From 6069888eca9d91edc5c96d810f14e10577777f82 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:26:43 -0700 Subject: [PATCH 01/15] feat(provider): add Coder SSH-lease provider Add a direct Coder provider that leases workspaces through the local Coder CLI and exposes them as proxy-backed SSH leases for Crabbox commands. Keep Coder auth in the native CLI store while making doctor, run, ssh, stop, and cleanup work with conservative stop-first lifecycle defaults. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 1 + docs/README.md | 1 + docs/commands/cleanup.md | 13 +- docs/commands/doctor.md | 4 + docs/commands/providers.md | 3 + docs/commands/run.md | 6 + docs/commands/ssh.md | 3 + docs/commands/status.md | 5 +- docs/commands/stop.md | 4 + docs/commands/warmup.md | 9 + docs/providers/README.md | 3 +- docs/providers/coder.md | 152 +++++ docs/providers/provider-metadata.json | 14 + internal/cli/config.go | 114 ++++ internal/cli/config_test.go | 39 ++ internal/cli/provider_categories_generated.go | 1 + internal/providers/all/all.go | 1 + internal/providers/all/all_test.go | 1 + internal/providers/coder/backend.go | 585 ++++++++++++++++++ internal/providers/coder/backend_test.go | 356 +++++++++++ internal/providers/coder/client.go | 335 ++++++++++ internal/providers/coder/core.go | 109 ++++ internal/providers/coder/flags.go | 173 ++++++ internal/providers/coder/provider.go | 51 ++ 24 files changed, 1980 insertions(+), 3 deletions(-) create mode 100644 docs/providers/coder.md create mode 100644 internal/providers/coder/backend.go create mode 100644 internal/providers/coder/backend_test.go create mode 100644 internal/providers/coder/client.go create mode 100644 internal/providers/coder/core.go create mode 100644 internal/providers/coder/flags.go create mode 100644 internal/providers/coder/provider.go diff --git a/README.md b/README.md index cc7e33581..5a473d252 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ from the CLI. | [Semaphore](docs/providers/semaphore.md) — `semaphore` (`sem`) | Linux · direct | A Semaphore CI job leased as a testbox. | | [Sprites](docs/providers/sprites.md) — `sprites` | Linux · direct | Sprites microVMs through `sprite proxy`. | | [Tenki](docs/providers/tenki.md) — `tenki` | Linux · direct | Tenki sandbox VMs through `tenki sandbox ssh-proxy`. | +| [Coder](docs/providers/coder.md) — `coder` | Linux · direct | Coder workspaces through `coder ssh --stdio`; stops by default, deletes only by opt-in. | | [Daytona](docs/providers/daytona.md) — `daytona` | Linux · direct | Daytona-managed dev sandbox over SSH. | | [Morph](docs/providers/morph.md) — `morph` | Linux · direct | Morph Cloud snapshot-backed instances over the shared SSH gateway. | | [RunPod](docs/providers/runpod.md) — `runpod` (`run-pod`, `runpodio`) | Linux · direct | RunPod GPU pods with public SSH. | diff --git a/docs/README.md b/docs/README.md index 65f1469be..d94c78d46 100644 --- a/docs/README.md +++ b/docs/README.md @@ -157,6 +157,7 @@ Pick whichever matches your intent: [Namespace Compute Instance](providers/namespace-instance.md), [Semaphore](providers/semaphore.md), [Sprites](providers/sprites.md), [Tenki](providers/tenki.md), + [Coder](providers/coder.md), [Daytona](providers/daytona.md), [Islo](providers/islo.md), [E2B](providers/e2b.md), [Modal](providers/modal.md), [Agent Sandbox](providers/agent-sandbox.md), diff --git a/docs/commands/cleanup.md b/docs/commands/cleanup.md index 06c68a2c4..b5cf88d36 100644 --- a/docs/commands/cleanup.md +++ b/docs/commands/cleanup.md @@ -10,6 +10,7 @@ crabbox cleanup crabbox cleanup --provider namespace-devbox --dry-run crabbox cleanup --provider namespace-devbox crabbox cleanup --provider hostinger --dry-run +crabbox cleanup --provider coder --dry-run ``` `crabbox machine cleanup` is preserved as a compatibility alias and behaves @@ -55,6 +56,10 @@ What cleanup does depends on the selected provider: provider scope. It deletes idle-expired Crabbox-owned sandboxes and keeps missing-or-inaccessible claims unless `--cloudflare-sandbox-forget-missing` is explicit. +- **`coder`** lists Coder workspaces and acts only on workspaces with Crabbox + ownership evidence: the configured workspace prefix or Crabbox labels in + Coder JSON. It stops by default and deletes only with `coder.deleteOnRelease` + or `--coder-delete-on-release`. - Providers that have nothing to sweep return an error rather than acting. For example `provider=ssh` (static / bring-your-own hosts) reports: @@ -123,10 +128,16 @@ When no matching files exist: namespace ssh cleanup no crabbox files found ``` +Coder cleanup prints one line per owned workspace: + +```text +coder cleanup stop workspace=crabbox-blue dry_run=true +``` + ## Flags ```text ---provider hetzner|aws|azure|gcp|proxmox|xcp-ng|hostinger|namespace-devbox|cloudflare|cloudflare-dynamic-workers|cloudflare-sandbox|blaxel|multipass|vercel-sandbox +--provider hetzner|aws|azure|gcp|proxmox|xcp-ng|hostinger|namespace-devbox|namespace-instance|coder|cloudflare|cloudflare-dynamic-workers|cloudflare-sandbox|blaxel|multipass|vercel-sandbox provider to sweep (default from config) --dry-run print decisions without making provider calls ``` diff --git a/docs/commands/doctor.md b/docs/commands/doctor.md index 1aebf3609..bd650c910 100644 --- a/docs/commands/doctor.md +++ b/docs/commands/doctor.md @@ -91,6 +91,10 @@ Provider readiness validates the selected provider without creating a lease. auth/inventory access, project scoping readiness, and local `vsbx_...` inventory without creating resources. Blacksmith Testbox reports runtime as provider-hydrated because GitHub Actions hydration is owned by Testbox. +- `provider=coder` runs only non-mutating Coder CLI checks: `coder version`, + `coder whoami -o json`, and workspace inventory. Missing login is reported as + `auth=missing_login` with `mutation=false`; doctor does not create, start, + stop, or delete Coder workspaces. - Providers with no direct doctor print `skip provider ... direct_doctor=unsupported`. The provider check is bounded to a 10s timeout. A failure adds a `class` diff --git a/docs/commands/providers.md b/docs/commands/providers.md index 4dbae4697..bfea5eae8 100644 --- a/docs/commands/providers.md +++ b/docs/commands/providers.md @@ -363,6 +363,9 @@ Direct self-hosted SSH-lease providers such as `firecracker`, `proxmox`, and ] ``` +`coder` appears as an SSH-lease provider with Linux target, `ssh`, +`crabbox-sync`, and `cleanup` features, and `coordinator: never`. + ## Fields - `provider`: canonical provider name (the value you pass to `--provider`). diff --git a/docs/commands/run.md b/docs/commands/run.md index 892b706c1..9adc1459c 100644 --- a/docs/commands/run.md +++ b/docs/commands/run.md @@ -126,6 +126,12 @@ Crabbox prints the exact `crabbox stop --provider docker-sandbox ` command for manual cleanup. Reused `--id` Docker Sandbox runs keep their existing lifecycle behavior. +`--provider coder` remains on the normal SSH-run path. Crabbox asks the local +`coder` CLI to create or start a Linux workspace, syncs into +`coder.workRoot`, runs the command over `coder ssh --stdio`, and then stops the +workspace by default for one-shot cleanup. Set `--coder-delete-on-release` only +for disposable workspaces that should be deleted instead of stopped. + ## Sync Sync builds a file manifest with `git ls-files --cached --others diff --git a/docs/commands/ssh.md b/docs/commands/ssh.md index 02e7ff0be..849718031 100644 --- a/docs/commands/ssh.md +++ b/docs/commands/ssh.md @@ -56,6 +56,9 @@ providers resolve access lazily or wrap the connection: - **`provider=xcp-ng`** resolves the VM IPv4 address from XCP-ng guest metrics during provisioning, then prints the normal per-lease SSH command for the cloud-init user. +- **`provider=coder`** prints a proxy-backed SSH command that uses + `coder ssh --stdio --wait `. Coder owns authentication and + tunneling; Crabbox does not print or pass Coder tokens on argv. - **Provider-routed direct providers** accept the same provider-specific routing flags here as `status` and `stop`; for example `--kubevirt-context` or `--external-routing-file` can select the exact lease backend when config diff --git a/docs/commands/status.md b/docs/commands/status.md index 36c47424f..972e275e5 100644 --- a/docs/commands/status.md +++ b/docs/commands/status.md @@ -39,6 +39,9 @@ addition to the Crabbox lease ID and local slug: - `sprites` — resolves local claims, Sprites labels, and SSH readiness through `sprite proxy`. - `daytona` — resolves Crabbox labels and sandbox state through the Daytona API. +- `coder` — accepts a Crabbox lease ID, local slug, Coder workspace name, or + `owner/workspace`; plain status reads Coder inventory without starting stopped + workspaces. - `islo` — accepts an `isb_...` ID, a Crabbox-created sandbox name, or a local slug. - `e2b` — accepts a lease ID, local slug, or a Crabbox-owned E2B sandbox ID in @@ -64,7 +67,7 @@ from idling out. ```text --id ---provider hetzner|aws|azure|azure-dynamic-sessions|gcp|proxmox|ssh|exe-dev|blacksmith-testbox|blaxel|namespace-devbox|semaphore|sprites|daytona|islo|e2b|vercel-sandbox +--provider hetzner|aws|azure|azure-dynamic-sessions|gcp|proxmox|ssh|exe-dev|blacksmith-testbox|blaxel|namespace-devbox|semaphore|sprites|tenki|coder|daytona|islo|e2b|vercel-sandbox --target linux|macos|windows --windows-mode normal|wsl2 --static-host diff --git a/docs/commands/stop.md b/docs/commands/stop.md index 76adb0086..23a6eac93 100644 --- a/docs/commands/stop.md +++ b/docs/commands/stop.md @@ -46,6 +46,9 @@ Crabbox lease ID and local slug: - `semaphore` — stops the Semaphore CI job and removes the local claim. - `sprites` — deletes the Sprites sprite and removes the local claim. - `daytona` — deletes the Daytona sandbox. +- `coder` — stops the Coder workspace by default and removes the local claim. + Set `coder.deleteOnRelease` or pass `--coder-delete-on-release` to delete the + workspace instead. - `islo` — accepts an `isb_...` ID, a Crabbox-created sandbox name, or a local slug and deletes the Islo sandbox. - `e2b` — accepts a Crabbox lease ID, a local slug, or a Crabbox-owned E2B @@ -126,6 +129,7 @@ Each provider also registers its own flags; the ones relevant to `stop` include: ```text --namespace-delete-on-release delete the Namespace Devbox instead of shutting it down +--coder-delete-on-release delete the Coder workspace instead of stopping it --exe-dev-control-host exe.dev SSH API host --sprites-api-url Sprites API URL --e2b-api-url E2B API URL diff --git a/docs/commands/warmup.md b/docs/commands/warmup.md index 25ff888a3..dba1d0f09 100644 --- a/docs/commands/warmup.md +++ b/docs/commands/warmup.md @@ -151,6 +151,15 @@ username, and password must come from config or environment before warmup can create a lease. Keep the XAPI endpoint on a management network or VPN, prefer trusted certificates, and treat `--xcp-ng-insecure-tls` as private-lab only. +### coder + +`--provider coder` creates or reuses a Linux Coder workspace through the local +`coder` CLI. New workspaces require `--coder-template` or `coder.template`. +Crabbox connects through `coder ssh --stdio`, so no raw public host or provider +SSH key is needed. Run `crabbox doctor --provider coder` first; if the local +Coder CLI is not logged in, doctor reports `auth=missing_login` without +mutating workspaces. + ### aws — Windows `--provider aws --target windows --windows-mode normal --desktop` creates a real diff --git a/docs/providers/README.md b/docs/providers/README.md index f85306138..501039077 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -61,7 +61,7 @@ selection metadata. Regenerate it with `node scripts/generate-provider-matrix.mj `scripts/check-docs.sh` fails when provider registration, metadata, docs paths, or this generated table drift. -Current built-in surface: 66 providers (38 SSH lease, 26 delegated run, 2 service control). +Current built-in surface: 67 providers (39 SSH lease, 26 delegated run, 2 service control). Access terms: @@ -86,6 +86,7 @@ Access terms: | [cloudflare](cloudflare.md) (`cf`) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync`, `cleanup`, `run-session` | `linux`; Cloudflare Container | `cloud`; GPU: no | Cloudflare Worker; container delete | Fast delegated Linux container execution | Requires Worker deployment and container availability | | [cloudflare-dynamic-workers](cloudflare-dynamic-workers.md) (`cf-dynamic`, `cfdw`) | built-in; `delegated-run` · delegated-sandbox | No SSH; `provider-owned` · direct only; features: `cleanup`, `module-run`, `run-session` | `worker-runtime`; Cloudflare Dynamic Worker | `cloud`; GPU: no | Cloudflare loader Worker; terminal metadata and local claim removal | Hosted Worker module execution | No shell, SSH, or filesystem sync; Dynamic Workers must be enabled | | [cloudflare-sandbox](cloudflare-sandbox.md) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync`, `cleanup` | `linux`; Cloudflare Sandbox bridge | `cloud`; GPU: no | Cloudflare Sandbox bridge; sandbox delete | Cloudflare Sandbox Linux command execution through a bridge | Requires a configured bridge URL; no SSH, browser, Tailscale, URL sessions, mounts, or checkpoints | +| [coder](coder.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `linux`; Coder workspace | `provider-managed`; GPU: unknown | Coder CLI; workspace stop or delete | Coder-backed Linux workspace over SSH proxy | Requires the coder CLI, login, template access, and workspace quota | | [codesandbox](codesandbox.md) (`csb`, `code-sandbox`) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync`, `cleanup`, `pause-resume`, `run-session` | `linux`; CodeSandbox SDK sandbox | `provider-managed`; GPU: no | CodeSandbox; sandbox delete | Managed CodeSandbox Linux development environments | Requires env-only SDK auth and a local Node bridge | | [daytona](daytona.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `archive-sync` · direct only; features: `ssh`, `crabbox-sync` | `linux`; Daytona sandbox | `provider-managed`; GPU: unknown | Daytona; sandbox delete | Managed development sandbox with delegated archive sync and execution | SSH access is short-lived; run and sync use Daytona toolbox APIs | | [digitalocean](digitalocean.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup`, `tailscale` | `linux`; DigitalOcean Droplet | `cloud`; GPU: optional | Crabbox; Droplet and key delete | Simple direct Linux VM | Direct-only; no coordinator scheduling | diff --git a/docs/providers/coder.md b/docs/providers/coder.md new file mode 100644 index 000000000..97a15b7b5 --- /dev/null +++ b/docs/providers/coder.md @@ -0,0 +1,152 @@ +# Coder + +`provider: coder` leases Linux Coder workspaces through the local `coder` CLI +and exposes them to Crabbox as normal SSH leases. Crabbox uses Coder for +workspace lifecycle, authentication, and tunneling, then runs its usual SSH +sync, command execution, status, and cleanup flow over `coder ssh --stdio`. + +Coder is direct-only. It never routes through the Crabbox coordinator and it +does not store Coder API tokens in Crabbox config. + +## Requirements + +- Coder CLI installed and on `PATH`, or set `coder.cliPath`. +- A local Coder login, usually from `coder login `. +- A Linux Coder template with `git`, `rsync`, and `tar` available in the + workspace. +- A template name for new workspaces, supplied by config or + `--coder-template`. + +Run a non-mutating preflight first: + +```sh +crabbox doctor --provider coder +crabbox doctor --provider coder --json +``` + +If the Coder CLI is not logged in, doctor fails with `auth=missing_login` and +`mutation=false`. It does not create, start, stop, or delete workspaces. + +## Config + +```yaml +provider: coder +coder: + cliPath: coder + template: go-dev + preset: large + workspacePrefix: crabbox- + workRoot: /home/coder/crabbox + wait: yes + useParameterDefaults: true + parameters: + - region=iad + - size=large + richParameterFile: ~/.config/coder/rich-parameters.yaml + deleteOnRelease: false +``` + +Environment overrides: + +```text +CRABBOX_CODER_CLI +CRABBOX_CODER_TEMPLATE +CRABBOX_CODER_PRESET +CRABBOX_CODER_WORKSPACE_PREFIX +CRABBOX_CODER_WORK_ROOT +CRABBOX_CODER_WAIT +CRABBOX_CODER_USE_PARAMETER_DEFAULTS +CRABBOX_CODER_PARAMETERS +CRABBOX_CODER_RICH_PARAMETER_FILE +CRABBOX_CODER_DELETE_ON_RELEASE +``` + +`CRABBOX_CODER_PARAMETERS` is comma-separated, for example +`region=iad,size=large`. Coder session tokens should stay in Coder's own login +store or supported Coder environment, not in Crabbox config and not on Crabbox +argv. + +## Flags + +```text +--coder-cli +--coder-template +--coder-preset +--coder-workspace-prefix +--coder-work-root +--coder-wait yes|no|auto +--coder-use-parameter-defaults +--coder-parameter name=value[,name=value] +--coder-rich-parameter-file +--coder-delete-on-release +``` + +`--class` and `--type` are not supported for `provider=coder`; choose sizing in +the Coder template or preset. + +## Lifecycle + +New leases create a Coder workspace with a Coder-safe name derived from the +Crabbox slug and `coder.workspacePrefix`. Workspace names are lowercase, +hyphenated, 1-32 characters, and avoid Coder's reserved `new` and `create` +names. + +Crabbox can resolve a Coder lease by Crabbox lease ID, local slug, Coder +workspace name, or `owner/workspace` when the Coder inventory contains a unique +match. + +Release is conservative: + +- By default, `crabbox stop` runs `coder stop --yes ` and removes the + local Crabbox claim. +- Deletion requires `coder.deleteOnRelease: true` or + `--coder-delete-on-release`, which runs `coder delete --yes `. + +Cleanup is also conservative. It only acts on workspaces that look +Crabbox-owned through the configured workspace prefix or Crabbox labels in +Coder JSON. `crabbox cleanup --provider coder --dry-run` prints the intended +stop/delete actions without mutating workspaces. + +## SSH + +Coder workspaces use OpenSSH proxy mode: + +```text +ProxyCommand coder ssh --stdio --wait yes +``` + +Crabbox marks the target as proxy-backed instead of trying to discover a raw +host or port. The ready check verifies the standard Crabbox sync prerequisites: +`git`, `rsync`, and `tar`. + +## Examples + +```sh +crabbox warmup --provider coder --coder-template go-dev --slug testbox +crabbox run --provider coder --coder-template go-dev -- pnpm test +crabbox ssh --provider coder testbox +crabbox status --provider coder testbox +crabbox stop --provider coder testbox +``` + +Delete only when the workspace is disposable: + +```sh +crabbox stop --provider coder --coder-delete-on-release testbox +``` + +## Live smoke + +Run live smoke only after `coder whoami -o json` succeeds and you have selected +a safe disposable template: + +```sh +crabbox doctor --provider coder +crabbox warmup --provider coder --coder-template go-dev --slug coder-smoke +crabbox run --provider coder --id coder-smoke -- bash -lc 'command -v git && command -v rsync && command -v tar && echo ok' +crabbox stop --provider coder coder-smoke +``` + +If login, template, or quota is unavailable, classify the live smoke as +`environment_blocked` instead of treating deterministic unit tests as live +proof. diff --git a/docs/providers/provider-metadata.json b/docs/providers/provider-metadata.json index 2c0e07b51..d8f001dbe 100644 --- a/docs/providers/provider-metadata.json +++ b/docs/providers/provider-metadata.json @@ -223,6 +223,20 @@ "caveat": "Requires env-only SDK auth and a local Node bridge", "docs": "codesandbox.md" }, + "coder": { + "status": "built-in", + "category": "direct-cloud", + "substrate": "Coder workspace", + "location": "provider-managed", + "ssh": "crabbox-managed", + "sync": "crabbox-sync", + "gpu": "unknown", + "lifecycle": "Coder CLI", + "cleanup": "workspace stop or delete", + "bestFit": "Coder-backed Linux workspace over SSH proxy", + "caveat": "Requires the coder CLI, login, template access, and workspace quota", + "docs": "coder.md" + }, "daytona": { "status": "built-in", "category": "direct-cloud", diff --git a/internal/cli/config.go b/internal/cli/config.go index 8a0d22712..f83433dc4 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -155,6 +155,7 @@ type Config struct { NamespaceInstance NamespaceInstanceConfig Phala PhalaConfig phalaTypeExplicitOrder uint64 + Coder CoderConfig Morph MorphConfig Daytona DaytonaConfig E2B E2BConfig @@ -497,6 +498,19 @@ type PhalaConfig struct { Attest *bool } +type CoderConfig struct { + CLIPath string + Template string + Preset string + WorkspacePrefix string + WorkRoot string + DeleteOnRelease bool + Wait string + UseParameterDefaults bool + Parameters []string + RichParameterFile string +} + type MorphConfig struct { APIKey string APIURL string @@ -2460,6 +2474,12 @@ func baseConfig() Config { // writable. /var/volatile is a writable tmpfs on every dstack guest. WorkRoot: "/var/volatile/crabbox", }, + Coder: CoderConfig{ + CLIPath: "coder", + WorkspacePrefix: "crabbox-", + WorkRoot: "/home/coder/crabbox", + Wait: "yes", + }, Morph: MorphConfig{ APIURL: "https://cloud.morph.so", SSHGatewayHost: "ssh.cloud.morph.so", @@ -2802,6 +2822,7 @@ type fileConfig struct { Namespace *fileNamespaceConfig `yaml:"namespace,omitempty"` NamespaceInstance *fileNamespaceInstanceConfig `yaml:"namespaceInstance,omitempty"` Phala *filePhalaConfig `yaml:"phala,omitempty"` + Coder *fileCoderConfig `yaml:"coder,omitempty"` Morph *fileMorphConfig `yaml:"morph,omitempty"` Daytona *fileDaytonaConfig `yaml:"daytona,omitempty"` E2B *fileE2BConfig `yaml:"e2b,omitempty"` @@ -3253,6 +3274,47 @@ type filePhalaConfig struct { Attest *bool `yaml:"attest,omitempty"` } +type fileCoderConfig struct { + CLIPath string `yaml:"cliPath,omitempty"` + Template string `yaml:"template,omitempty"` + Preset string `yaml:"preset,omitempty"` + WorkspacePrefix string `yaml:"workspacePrefix,omitempty"` + WorkRoot string `yaml:"workRoot,omitempty"` + DeleteOnRelease *bool `yaml:"deleteOnRelease,omitempty"` + Wait string `yaml:"wait,omitempty"` + UseParameterDefaults *bool `yaml:"useParameterDefaults,omitempty"` + Parameters []string `yaml:"parameters,omitempty"` + RichParameterFile string `yaml:"richParameterFile,omitempty"` +} + +func (c *fileCoderConfig) UnmarshalYAML(node *yaml.Node) error { + type plain fileCoderConfig + var out plain + if err := node.Decode(&out); err != nil { + return err + } + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i].Value + value := node.Content[i+1] + if key != "parameters" { + continue + } + switch value.Kind { + case yaml.SequenceNode: + out.Parameters = out.Parameters[:0] + for _, item := range value.Content { + if strings.TrimSpace(item.Value) != "" { + out.Parameters = append(out.Parameters, strings.TrimSpace(item.Value)) + } + } + case yaml.ScalarNode: + out.Parameters = splitCommaList(value.Value) + } + } + *c = fileCoderConfig(out) + return nil +} + type fileMorphConfig struct { APIKey string `yaml:"apiKey,omitempty"` APIURL string `yaml:"apiUrl,omitempty"` @@ -5203,6 +5265,38 @@ func applyFileConfigWithTrust(cfg *Config, file fileConfig, trusted bool) error cfg.Phala.Attest = &value } } + if file.Coder != nil { + if file.Coder.CLIPath != "" { + cfg.Coder.CLIPath = expandUserPath(file.Coder.CLIPath) + } + if file.Coder.Template != "" { + cfg.Coder.Template = file.Coder.Template + } + if file.Coder.Preset != "" { + cfg.Coder.Preset = file.Coder.Preset + } + if file.Coder.WorkspacePrefix != "" { + cfg.Coder.WorkspacePrefix = file.Coder.WorkspacePrefix + } + if file.Coder.WorkRoot != "" { + cfg.Coder.WorkRoot = file.Coder.WorkRoot + } + if file.Coder.DeleteOnRelease != nil { + cfg.Coder.DeleteOnRelease = *file.Coder.DeleteOnRelease + } + if file.Coder.Wait != "" { + cfg.Coder.Wait = file.Coder.Wait + } + if file.Coder.UseParameterDefaults != nil { + cfg.Coder.UseParameterDefaults = *file.Coder.UseParameterDefaults + } + if len(file.Coder.Parameters) > 0 { + cfg.Coder.Parameters = normalizeList(file.Coder.Parameters) + } + if file.Coder.RichParameterFile != "" { + cfg.Coder.RichParameterFile = expandUserPath(file.Coder.RichParameterFile) + } + } if file.Morph != nil { if file.Morph.APIKey != "" { cfg.Morph.APIKey = file.Morph.APIKey @@ -7147,6 +7241,26 @@ func applyEnv(cfg *Config) error { cfg.Morph.APIURL = value cfg.credentialProvenance.morphAPIURL = credentialSourceEnvironment } + cfg.Coder.CLIPath = expandUserPath(getenv("CRABBOX_CODER_CLI", cfg.Coder.CLIPath)) + cfg.Coder.Template = getenv("CRABBOX_CODER_TEMPLATE", cfg.Coder.Template) + cfg.Coder.Preset = getenv("CRABBOX_CODER_PRESET", cfg.Coder.Preset) + cfg.Coder.WorkspacePrefix = getenv("CRABBOX_CODER_WORKSPACE_PREFIX", cfg.Coder.WorkspacePrefix) + cfg.Coder.WorkRoot = getenv("CRABBOX_CODER_WORK_ROOT", cfg.Coder.WorkRoot) + if value, ok := getenvBool("CRABBOX_CODER_DELETE_ON_RELEASE"); ok { + cfg.Coder.DeleteOnRelease = value + } + cfg.Coder.Wait = getenv("CRABBOX_CODER_WAIT", cfg.Coder.Wait) + if value, ok := getenvBool("CRABBOX_CODER_USE_PARAMETER_DEFAULTS"); ok { + cfg.Coder.UseParameterDefaults = value + } + if paramsEnv := os.Getenv("CRABBOX_CODER_PARAMETERS"); strings.TrimSpace(paramsEnv) != "" { + params := splitCommaList(paramsEnv) + if strings.EqualFold(strings.TrimSpace(paramsEnv), "none") { + params = []string{} + } + cfg.Coder.Parameters = params + } + cfg.Coder.RichParameterFile = expandUserPath(getenv("CRABBOX_CODER_RICH_PARAMETER_FILE", cfg.Coder.RichParameterFile)) cfg.Morph.Snapshot = getenv("CRABBOX_MORPH_SNAPSHOT", cfg.Morph.Snapshot) if value := os.Getenv("CRABBOX_MORPH_SSH_GATEWAY_HOST"); value != "" { cfg.Morph.SSHGatewayHost = value diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 204d4ee87..00c3062a5 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -399,6 +399,16 @@ func clearConfigEnv(t *testing.T) { "CRABBOX_NAMESPACE_AUTO_STOP_IDLE_TIMEOUT", "CRABBOX_NAMESPACE_WORK_ROOT", "CRABBOX_NAMESPACE_DELETE_ON_RELEASE", + "CRABBOX_CODER_CLI", + "CRABBOX_CODER_TEMPLATE", + "CRABBOX_CODER_PRESET", + "CRABBOX_CODER_WORKSPACE_PREFIX", + "CRABBOX_CODER_WORK_ROOT", + "CRABBOX_CODER_DELETE_ON_RELEASE", + "CRABBOX_CODER_WAIT", + "CRABBOX_CODER_USE_PARAMETER_DEFAULTS", + "CRABBOX_CODER_PARAMETERS", + "CRABBOX_CODER_RICH_PARAMETER_FILE", "CRABBOX_MORPH_API_KEY", "MORPH_API_KEY", "CRABBOX_MORPH_API_URL", @@ -4540,6 +4550,19 @@ tenki: cpus: 4 memoryMB: 8192 diskGB: 40 +coder: + cliPath: /usr/local/bin/coder + template: go-dev + preset: large + workspacePrefix: cbx- + workRoot: /home/coder/test + deleteOnRelease: true + wait: auto + useParameterDefaults: true + parameters: + - region=iad + - size=large + richParameterFile: ~/.config/coder/params.yaml tensorlake: apiUrl: https://api.tensorlake.example.test cliPath: /usr/local/bin/tl @@ -4766,6 +4789,9 @@ ssh: if cfg.Tenki.CLIPath != "/usr/local/bin/tenki" || cfg.Tenki.Endpoint != "https://api.tenki.example.test" || cfg.Tenki.Gateway != "wss://gateway.tenki.example.test" || cfg.Tenki.Workspace != "ws_file" || cfg.Tenki.Project != "proj_file" || cfg.Tenki.Image != "ubuntu:tenki" || cfg.Tenki.WorkRoot != "/home/tenki/test" || cfg.Tenki.CPUs != 4 || cfg.Tenki.MemoryMB != 8192 || cfg.Tenki.DiskGB != 40 { t.Fatalf("tenki config not loaded: %#v", cfg.Tenki) } + if cfg.Coder.CLIPath != "/usr/local/bin/coder" || cfg.Coder.Template != "go-dev" || cfg.Coder.Preset != "large" || cfg.Coder.WorkspacePrefix != "cbx-" || cfg.Coder.WorkRoot != "/home/coder/test" || !cfg.Coder.DeleteOnRelease || cfg.Coder.Wait != "auto" || !cfg.Coder.UseParameterDefaults || len(cfg.Coder.Parameters) != 2 || cfg.Coder.Parameters[1] != "size=large" || cfg.Coder.RichParameterFile != filepath.Join(home, ".config", "coder", "params.yaml") { + t.Fatalf("coder config not loaded: %#v", cfg.Coder) + } if cfg.Tensorlake.APIURL != "https://api.tensorlake.example.test" || cfg.Tensorlake.CLIPath != "/usr/local/bin/tl" || cfg.Tensorlake.Image != "ubuntu-22.04" || cfg.Tensorlake.Snapshot != "snap-tl" || cfg.Tensorlake.OrganizationID != "org-tl" || cfg.Tensorlake.ProjectID != "proj-tl" || cfg.Tensorlake.Namespace != "ns-tl" || cfg.Tensorlake.Workdir != "/workspace/crabbox-test" || cfg.Tensorlake.CPUs != 4 || cfg.Tensorlake.MemoryMB != 8192 || cfg.Tensorlake.DiskMB != 30000 || cfg.Tensorlake.TimeoutSecs != 1800 || !cfg.Tensorlake.NoInternet { t.Fatalf("tensorlake config not loaded: %#v", cfg.Tensorlake) } @@ -5230,6 +5256,16 @@ func TestEnvOverridesConfig(t *testing.T) { t.Setenv("CRABBOX_NAMESPACE_AUTO_STOP_IDLE_TIMEOUT", "4h") t.Setenv("CRABBOX_NAMESPACE_WORK_ROOT", "/workspaces/env") t.Setenv("CRABBOX_NAMESPACE_DELETE_ON_RELEASE", "true") + t.Setenv("CRABBOX_CODER_CLI", "/opt/coder/bin/coder") + t.Setenv("CRABBOX_CODER_TEMPLATE", "python-dev") + t.Setenv("CRABBOX_CODER_PRESET", "gpu") + t.Setenv("CRABBOX_CODER_WORKSPACE_PREFIX", "env-") + t.Setenv("CRABBOX_CODER_WORK_ROOT", "/home/coder/env") + t.Setenv("CRABBOX_CODER_DELETE_ON_RELEASE", "true") + t.Setenv("CRABBOX_CODER_WAIT", "no") + t.Setenv("CRABBOX_CODER_USE_PARAMETER_DEFAULTS", "true") + t.Setenv("CRABBOX_CODER_PARAMETERS", "region=sfo,size=xl") + t.Setenv("CRABBOX_CODER_RICH_PARAMETER_FILE", "~/coder-rich.yaml") t.Setenv("CRABBOX_BLACKSMITH_IDLE_TIMEOUT", "2h") t.Setenv("CRABBOX_BLACKSMITH_DEBUG", "true") t.Setenv("CRABBOX_ACTIONS_RUNNER_LABELS", "crabbox,linux-large") @@ -5341,6 +5377,9 @@ func TestEnvOverridesConfig(t *testing.T) { if cfg.Tenki.CLIPath != "/opt/tenki/bin/tenki" || cfg.Tenki.Endpoint != "https://api.tenki-env.example" || cfg.Tenki.Gateway != "wss://gateway.tenki-env.example" || cfg.Tenki.Workspace != "ws_env" || cfg.Tenki.Project != "proj_env" || cfg.Tenki.Image != "ubuntu:tenki-env" || cfg.Tenki.Snapshot != "snap-env" || cfg.Tenki.WorkRoot != "/home/tenki/env" || cfg.Tenki.CPUs != 8 || cfg.Tenki.MemoryMB != 16384 || cfg.Tenki.DiskGB != 80 { t.Fatalf("unexpected tenki env: %#v", cfg.Tenki) } + if cfg.Coder.CLIPath != "/opt/coder/bin/coder" || cfg.Coder.Template != "python-dev" || cfg.Coder.Preset != "gpu" || cfg.Coder.WorkspacePrefix != "env-" || cfg.Coder.WorkRoot != "/home/coder/env" || !cfg.Coder.DeleteOnRelease || cfg.Coder.Wait != "no" || !cfg.Coder.UseParameterDefaults || len(cfg.Coder.Parameters) != 2 || cfg.Coder.Parameters[0] != "region=sfo" || cfg.Coder.RichParameterFile != filepath.Join(home, "coder-rich.yaml") { + t.Fatalf("unexpected coder env: %#v", cfg.Coder) + } if cfg.Tensorlake.APIKey != "tl-api-env" || cfg.Tensorlake.APIURL != "https://api.tl-env.example" || cfg.Tensorlake.CLIPath != "/opt/tl/bin/tensorlake" || cfg.Tensorlake.Image != "ubuntu:tl-env" || cfg.Tensorlake.Snapshot != "snap-tl-env" || cfg.Tensorlake.OrganizationID != "org-tl-env" || cfg.Tensorlake.ProjectID != "proj-tl-env" || cfg.Tensorlake.Namespace != "ns-tl-env" || cfg.Tensorlake.Workdir != "/workspace/tl-env" || cfg.Tensorlake.CPUs != 2.5 || cfg.Tensorlake.MemoryMB != 4096 || cfg.Tensorlake.DiskMB != 20480 || cfg.Tensorlake.TimeoutSecs != 900 || !cfg.Tensorlake.NoInternet { t.Fatalf("unexpected tensorlake env: %#v", cfg.Tensorlake) } diff --git a/internal/cli/provider_categories_generated.go b/internal/cli/provider_categories_generated.go index 4f64b79f3..ffa9baad1 100644 --- a/internal/cli/provider_categories_generated.go +++ b/internal/cli/provider_categories_generated.go @@ -18,6 +18,7 @@ var benchmarkProviderCategories = map[string]string{ "cloudflare": "delegated-sandbox", "cloudflare-dynamic-workers": "delegated-sandbox", "cloudflare-sandbox": "delegated-sandbox", + "coder": "direct-cloud", "codesandbox": "delegated-sandbox", "daytona": "direct-cloud", "digitalocean": "direct-cloud", diff --git a/internal/providers/all/all.go b/internal/providers/all/all.go index 96f802736..951183bff 100644 --- a/internal/providers/all/all.go +++ b/internal/providers/all/all.go @@ -16,6 +16,7 @@ import ( _ "github.com/openclaw/crabbox/internal/providers/cloudflare" _ "github.com/openclaw/crabbox/internal/providers/cloudflaredynamicworkers" _ "github.com/openclaw/crabbox/internal/providers/cloudflaresandbox" + _ "github.com/openclaw/crabbox/internal/providers/coder" _ "github.com/openclaw/crabbox/internal/providers/codesandbox" _ "github.com/openclaw/crabbox/internal/providers/daytona" _ "github.com/openclaw/crabbox/internal/providers/digitalocean" diff --git a/internal/providers/all/all_test.go b/internal/providers/all/all_test.go index ac3d54d1e..467abee5c 100644 --- a/internal/providers/all/all_test.go +++ b/internal/providers/all/all_test.go @@ -1139,6 +1139,7 @@ func allBuiltInProviderNames() []string { "cloudflare-dynamic-workers", "cloudflare-sandbox", "codesandbox", + "coder", "daytona", "digitalocean", "docker-sandbox", diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go new file mode 100644 index 000000000..c9f088cf8 --- /dev/null +++ b/internal/providers/coder/backend.go @@ -0,0 +1,585 @@ +package coder + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" +) + +type coderLeaseBackend struct { + spec ProviderSpec + cfg Config + rt Runtime +} + +func NewCoderLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) (Backend, error) { + if strings.TrimSpace(cfg.Coder.CLIPath) == "" { + cfg.Coder.CLIPath = "coder" + } + if strings.TrimSpace(cfg.Coder.WorkspacePrefix) == "" { + cfg.Coder.WorkspacePrefix = "crabbox-" + } + if strings.TrimSpace(cfg.Coder.WorkRoot) == "" { + cfg.Coder.WorkRoot = "/home/coder/crabbox" + } + if strings.TrimSpace(cfg.Coder.Wait) == "" { + cfg.Coder.Wait = "yes" + } + cfg.Provider = coderProvider + cfg.TargetOS = targetLinux + cfg.SSHUser = "coder" + cfg.SSHPort = "22" + cfg.SSHFallbackPorts = nil + cfg.Network = networkPublic + cfg.WorkRoot = coderWorkRoot(cfg) + if err := validateCoderConfig(cfg); err != nil { + return nil, err + } + return &coderLeaseBackend{spec: spec, cfg: cfg, rt: rt}, nil +} + +func (b *coderLeaseBackend) Spec() ProviderSpec { return b.spec } + +func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) { + if strings.TrimSpace(b.cfg.Coder.Template) == "" { + return LeaseTarget{}, exit(2, "provider=coder requires --coder-template or coder.template to create a workspace") + } + client, err := newCoderClient(b.cfg, b.rt) + if err != nil { + return LeaseTarget{}, err + } + existing, err := client.list(ctx) + if err != nil { + return LeaseTarget{}, err + } + leaseID := newLeaseID() + slug, err := allocateDirectLeaseSlug(leaseID, req.RequestedSlug, coderWorkspacesToServers(existing, b.cfg)) + if err != nil { + return LeaseTarget{}, err + } + workspaceName, err := coderWorkspaceName(b.cfg.Coder.WorkspacePrefix, slug, leaseID) + if err != nil { + return LeaseTarget{}, err + } + fmt.Fprintf(b.rt.Stderr, "provisioning provider=coder lease=%s slug=%s workspace=%s template=%s keep=%v\n", leaseID, slug, workspaceName, b.cfg.Coder.Template, req.Keep) + if err := client.create(ctx, b.cfg, workspaceName); err != nil { + return LeaseTarget{}, err + } + workspaces, err := client.list(ctx) + if err != nil { + if !req.Keep { + _ = client.stop(context.Background(), workspaceName) + } + return LeaseTarget{}, err + } + workspace, ok := findCoderWorkspace(workspaces, workspaceName) + if !ok { + if !req.Keep { + _ = client.stop(context.Background(), workspaceName) + } + return LeaseTarget{}, exit(5, "coder workspace %s was created but not found in coder list", workspaceName) + } + server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, req.Keep) + target := coderSSHTarget(b.cfg, workspaceName) + if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "coder ssh", bootstrapWaitTimeout(b.cfg)); err != nil { + if !req.Keep { + _ = client.stop(context.Background(), workspaceName) + } + return LeaseTarget{}, err + } + server.Status = "ready" + server.Labels["state"] = "ready" + if err := claimLeaseForRepoProvider(leaseID, slug, coderProvider, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil { + if !req.Keep { + _ = client.stop(context.Background(), workspaceName) + } + return LeaseTarget{}, err + } + _ = updateLeaseClaimEndpoint(leaseID, server, target) + return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil +} + +func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) { + client, err := newCoderClient(b.cfg, b.rt) + if err != nil { + return LeaseTarget{}, err + } + listFn := client.list + if strings.Contains(strings.TrimSpace(req.ID), "/") { + listFn = client.listAll + } + workspaces, err := listFn(ctx) + if err != nil { + return LeaseTarget{}, err + } + workspace, leaseID, slug, err := b.resolveWorkspace(req.ID, workspaces) + if err != nil { + return LeaseTarget{}, err + } + server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, true) + workspaceRef := coderWorkspaceCommandName(workspace) + if req.ReleaseOnly || req.StatusOnly { + lease := LeaseTarget{Server: server, LeaseID: leaseID} + if !req.ReadyProbe || !coderWorkspaceReady(workspace) { + return lease, nil + } + lease.SSH = coderSSHTarget(b.cfg, workspaceRef) + return lease, nil + } + if !coderWorkspaceReady(workspace) { + if err := client.start(ctx, workspaceRef); err != nil { + return LeaseTarget{}, err + } + workspaces, err = client.list(ctx) + if err != nil { + return LeaseTarget{}, err + } + if refreshed, found := findCoderWorkspace(workspaces, workspaceRef); found { + workspace = refreshed + server = coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, true) + workspaceRef = coderWorkspaceCommandName(workspace) + } + } + target := coderSSHTarget(b.cfg, workspaceRef) + if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "coder ssh", bootstrapWaitTimeout(b.cfg)); err != nil { + return LeaseTarget{}, err + } + if req.Repo.Root != "" && leaseID != "" { + if err := claimLeaseForRepoProvider(leaseID, slug, coderProvider, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil { + return LeaseTarget{}, err + } + _ = updateLeaseClaimEndpoint(leaseID, server, target) + } + return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil +} + +func (b *coderLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) { + client, err := newCoderClient(b.cfg, b.rt) + if err != nil { + return nil, err + } + workspaces, err := client.list(ctx) + if err != nil { + return nil, err + } + servers := make([]Server, 0, len(workspaces)) + for _, workspace := range workspaces { + leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) + if !owned && !req.All { + continue + } + servers = append(servers, coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, true)) + } + return servers, nil +} + +func (b *coderLeaseBackend) Doctor(ctx context.Context, _ DoctorRequest) (DoctorResult, error) { + client, err := newCoderClient(b.cfg, b.rt) + if err != nil { + return DoctorResult{}, err + } + checks := []DoctorCheck{} + if err := client.version(ctx); err != nil { + checks = append(checks, DoctorCheck{Status: "fail", Check: "cli", Message: err.Error(), Details: map[string]string{"mutation": "false"}}) + return DoctorResult{Provider: coderProvider, Status: "fail", Message: "cli=missing auth=unchecked inventory=unchecked mutation=false", Checks: checks}, err + } + checks = append(checks, DoctorCheck{Status: "pass", Check: "cli", Message: "coder CLI available", Details: map[string]string{"mutation": "false"}}) + if err := client.whoami(ctx); err != nil { + checks = append(checks, DoctorCheck{Status: "fail", Check: "auth", Message: err.Error(), Details: map[string]string{"mutation": "false", "classification": "missing_login"}}) + return DoctorResult{Provider: coderProvider, Status: "fail", Message: "cli=ready auth=missing_login inventory=unchecked mutation=false", Checks: checks}, err + } + checks = append(checks, DoctorCheck{Status: "pass", Check: "auth", Message: "coder login ready", Details: map[string]string{"mutation": "false"}}) + servers, err := b.List(ctx, ListRequest{}) + if err != nil { + checks = append(checks, DoctorCheck{Status: "fail", Check: "inventory", Message: err.Error(), Details: map[string]string{"mutation": "false"}}) + return DoctorResult{Provider: coderProvider, Status: "fail", Message: "cli=ready auth=ready inventory=failed mutation=false"}, err + } + checks = append(checks, DoctorCheck{Status: "pass", Check: "inventory", Message: fmt.Sprintf("listed %d Crabbox-owned Coder workspaces", len(servers)), Details: map[string]string{"mutation": "false"}}) + return DoctorResult{Provider: coderProvider, Status: "pass", Message: fmt.Sprintf("cli=ready auth=ready inventory=ready api=list mutation=false leases=%d runtime=unchecked", len(servers)), Checks: checks}, nil +} + +func (b *coderLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error { + client, err := newCoderClient(b.cfg, b.rt) + if err != nil { + return err + } + name := strings.TrimSpace(req.Lease.Server.Labels["coder_workspace_ref"]) + if name == "" { + name = strings.TrimSpace(req.Lease.Server.Labels["coder_workspace"]) + } + if name == "" { + name = strings.TrimSpace(req.Lease.Server.Name) + } + if name == "" { + name = strings.TrimSpace(req.Lease.Server.CloudID) + } + if name == "" { + return exit(2, "coder release requires a workspace name") + } + if b.cfg.Coder.DeleteOnRelease { + err = client.delete(ctx, name) + } else { + err = client.stop(ctx, name) + } + if err != nil { + return err + } + removeLeaseClaim(req.Lease.LeaseID) + return nil +} + +func (b *coderLeaseBackend) ReleaseLeaseMessage(lease LeaseTarget) string { + action := "stopped" + if b.cfg.Coder.DeleteOnRelease { + action = "deleted" + } + return fmt.Sprintf("%s coder workspace lease=%s workspace=%s", action, lease.LeaseID, lease.Server.Name) +} + +func (b *coderLeaseBackend) Touch(_ context.Context, req TouchRequest) (Server, error) { + server := req.Lease.Server + if server.Labels == nil { + server.Labels = map[string]string{} + } + server.Labels = touchDirectLeaseLabels(server.Labels, b.cfg, req.State, time.Now().UTC()) + return server, nil +} + +func (b *coderLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) error { + client, err := newCoderClient(b.cfg, b.rt) + if err != nil { + return err + } + workspaces, err := client.list(ctx) + if err != nil { + return err + } + claims, err := listCoderClaimsByWorkspace(b.cfg) + if err != nil { + return err + } + now := time.Now().UTC() + for _, workspace := range workspaces { + leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) + if !owned { + continue + } + server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, false) + claim, hasClaim := claims[workspace.Name] + if leaseID == "" && hasClaim { + leaseID = claim.LeaseID + } + shouldAct, reason := shouldCleanupCoder(server, claim, hasClaim, now) + if !shouldAct { + fmt.Fprintf(b.rt.Stderr, "skip coder workspace=%s reason=%s\n", workspace.Name, reason) + continue + } + action := "stop" + if b.cfg.Coder.DeleteOnRelease { + action = "delete" + } + fmt.Fprintf(b.rt.Stdout, "coder cleanup %s workspace=%s lease=%s reason=%s dry_run=%t\n", action, workspace.Name, blank(leaseID, "-"), reason, req.DryRun) + if req.DryRun { + continue + } + if b.cfg.Coder.DeleteOnRelease { + if err := client.delete(ctx, workspace.Name); err != nil { + return err + } + } else if err := client.stop(ctx, workspace.Name); err != nil { + return err + } + if leaseID != "" { + removeLeaseClaim(leaseID) + } + } + return nil +} + +func listCoderClaimsByWorkspace(cfg Config) (map[string]LeaseClaim, error) { + claims, err := listLeaseClaims() + if err != nil { + return nil, err + } + out := map[string]LeaseClaim{} + for _, claim := range claims { + if claim.Provider != coderProvider { + continue + } + name := strings.TrimSpace(claim.Labels["coder_workspace"]) + if name == "" { + name = coderWorkspaceNameFromRef(claim.Labels["coder_workspace_ref"]) + } + if name == "" { + name, err = coderWorkspaceName(cfg.Coder.WorkspacePrefix, claim.Slug, claim.LeaseID) + if err != nil { + continue + } + } + if name != "" { + out[name] = claim + } + } + return out, nil +} + +func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time.Time) (bool, string) { + if strings.EqualFold(server.Labels["keep"], "true") { + return false, "keep=true" + } + if !coderServerRunning(server.Status) && server.Status != "ready" { + return true, "workspace state=" + blank(server.Status, "unknown") + } + if hasClaim { + lastUsed, err := time.Parse(time.RFC3339, strings.TrimSpace(claim.LastUsedAt)) + if err != nil || lastUsed.IsZero() { + return false, "claim active" + } + idle := time.Duration(claim.IdleTimeoutSeconds) * time.Second + if idle <= 0 { + return false, "claim active" + } + if now.After(lastUsed.Add(idle).Add(12 * time.Hour)) { + return true, "claim expired" + } + return false, "claim active" + } + return false, "missing claim" +} + +func coderWorkspaceHasCrabboxLabel(workspace coderWorkspace) bool { + for _, key := range []string{"crabbox", "provider", "lease", "slug", "crabbox_lease_id", "crabbox_slug"} { + value := strings.TrimSpace(workspace.Labels[key]) + if value == "" { + continue + } + if key == "provider" && value != coderProvider { + continue + } + return true + } + return false +} + +func coderServerRunning(status string) bool { + status = strings.ToLower(strings.TrimSpace(status)) + return status == "running" || status == "ready" || status == "starting" +} + +func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []coderWorkspace) (coderWorkspace, string, string, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return coderWorkspace{}, "", "", exit(2, "coder resolve requires a lease id, slug, workspace, or owner/workspace") + } + if claim, ok, err := resolveLeaseClaimForProvider(identifier, coderProvider); err != nil { + return coderWorkspace{}, "", "", err + } else if ok { + name := strings.TrimSpace(claim.Labels["coder_workspace_ref"]) + if name == "" { + name = strings.TrimSpace(claim.Labels["coder_workspace"]) + } + if name == "" { + var err error + name, err = coderWorkspaceName(b.cfg.Coder.WorkspacePrefix, claim.Slug, claim.LeaseID) + if err != nil { + return coderWorkspace{}, "", "", err + } + } + if workspace, found := findCoderWorkspace(workspaces, name); found { + return workspace, claim.LeaseID, claim.Slug, nil + } + } + normalized := normalizeCoderWorkspaceIdentifier(identifier) + matches := []coderWorkspace{} + for _, workspace := range workspaces { + if normalizeCoderWorkspaceIdentifier(workspace.Name) == normalized || normalizeCoderWorkspaceIdentifier(coderOwnerWorkspace(workspace)) == normalized { + matches = append(matches, workspace) + continue + } + if _, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg); owned && normalizeLeaseSlug(slug) == normalizeLeaseSlug(identifier) { + matches = append(matches, workspace) + } + } + if len(matches) == 0 { + return coderWorkspace{}, "", "", exit(5, "coder workspace %q not found", identifier) + } + if len(matches) > 1 { + return coderWorkspace{}, "", "", exit(5, "coder workspace %q is ambiguous", identifier) + } + leaseID, slug, _ := coderWorkspaceLeaseMetadata(matches[0], b.cfg) + return matches[0], leaseID, slug, nil +} + +func coderWorkspacesToServers(workspaces []coderWorkspace, cfg Config) []Server { + servers := make([]Server, 0, len(workspaces)) + for _, workspace := range workspaces { + leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, cfg) + if !owned { + continue + } + servers = append(servers, coderWorkspaceToServer(workspace, cfg, leaseID, slug, true)) + } + return servers +} + +func coderWorkspaceToServer(workspace coderWorkspace, cfg Config, leaseID, slug string, keep bool) Server { + if slug == "" { + slug = coderSlugFromWorkspace(workspace.Name, cfg.Coder.WorkspacePrefix) + } + labels := directLeaseLabels(cfg, leaseID, slug, coderProvider, "", keep, time.Now().UTC()) + if labels == nil { + labels = map[string]string{} + } + labels["coder_workspace"] = workspace.Name + labels["coder_workspace_ref"] = coderWorkspaceCommandName(workspace) + labels["state"] = coderWorkspaceState(workspace) + server := Server{CloudID: workspace.Name, Provider: coderProvider, Name: workspace.Name, Status: labels["state"], Labels: labels} + server.ServerType.Name = blank(workspace.Template, "coder-workspace") + return server +} + +func coderWorkspaceLeaseMetadata(workspace coderWorkspace, cfg Config) (string, string, bool) { + leaseID := strings.TrimSpace(workspace.Labels["crabbox_lease_id"]) + slug := normalizeLeaseSlug(workspace.Labels["crabbox_slug"]) + if leaseID != "" || slug != "" { + return leaseID, slug, true + } + slug = coderSlugFromWorkspace(workspace.Name, cfg.Coder.WorkspacePrefix) + if slug == "" { + return "", "", false + } + return "", slug, true +} + +func coderSlugFromWorkspace(name, prefix string) string { + cleanPrefix, err := cleanCoderWorkspacePrefix(prefix) + if err != nil { + return "" + } + name = strings.ToLower(strings.TrimSpace(name)) + if !strings.HasPrefix(name, cleanPrefix) { + return "" + } + return normalizeLeaseSlug(strings.TrimPrefix(name, cleanPrefix)) +} + +func coderSSHTarget(cfg Config, workspaceName string) SSHTarget { + return SSHTarget{ + User: "coder", + Host: workspaceName, + Port: "22", + TargetOS: targetLinux, + NetworkKind: networkPublic, + ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null", + SSHConfigProxy: true, + ProxyCommand: shellQuote(cfg.Coder.CLIPath) + " ssh --stdio --wait " + shellQuote(blank(cfg.Coder.Wait, "yes")) + " " + shellQuote(workspaceName), + } +} + +func coderWorkspaceReady(workspace coderWorkspace) bool { + state := coderWorkspaceState(workspace) + if state == "ready" || state == "running" { + return true + } + for _, agent := range workspace.Agents { + if strings.EqualFold(agent.OS, "linux") && (strings.EqualFold(agent.Status, "connected") || strings.EqualFold(agent.Status, "ready")) && (agent.Lifecycle == "" || strings.EqualFold(agent.Lifecycle, "ready")) { + return true + } + } + return false +} + +func coderWorkspaceState(workspace coderWorkspace) string { + for _, agent := range workspace.Agents { + if strings.EqualFold(agent.OS, "linux") && strings.EqualFold(agent.Status, "connected") && (agent.Lifecycle == "" || strings.EqualFold(agent.Lifecycle, "ready")) { + return "ready" + } + } + for _, value := range []string{workspace.Status, workspace.Transition} { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "running", "ready", "started", "start": + return "ready" + case "stopped", "stop", "stopping": + return "stopped" + case "starting", "pending": + return "starting" + case "failed", "error", "canceled", "cancelled": + return value + } + } + return blank(strings.ToLower(strings.TrimSpace(workspace.Status)), "unknown") +} + +func findCoderWorkspace(workspaces []coderWorkspace, name string) (coderWorkspace, bool) { + for _, workspace := range workspaces { + if normalizeCoderWorkspaceIdentifier(workspace.Name) == normalizeCoderWorkspaceIdentifier(name) || normalizeCoderWorkspaceIdentifier(coderOwnerWorkspace(workspace)) == normalizeCoderWorkspaceIdentifier(name) { + return workspace, true + } + } + return coderWorkspace{}, false +} + +func coderOwnerWorkspace(workspace coderWorkspace) string { + if workspace.Owner == "" { + return workspace.Name + } + return workspace.Owner + "/" + workspace.Name +} + +func coderWorkspaceCommandName(workspace coderWorkspace) string { + return coderOwnerWorkspace(workspace) +} + +func coderWorkspaceNameFromRef(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + if _, name, ok := strings.Cut(ref, "/"); ok { + return strings.TrimSpace(name) + } + return ref +} + +func normalizeCoderWorkspaceIdentifier(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +var coderWorkspaceInvalidChars = regexp.MustCompile(`[^a-z0-9-]+`) + +func coderWorkspaceName(prefix, slug, leaseID string) (string, error) { + cleanPrefix, err := cleanCoderWorkspacePrefix(prefix) + if err != nil { + return "", err + } + base := strings.ToLower(normalizeLeaseSlug(slug)) + base = coderWorkspaceInvalidChars.ReplaceAllString(base, "-") + base = strings.Trim(base, "-") + if base == "" { + base = strings.Trim(strings.ToLower(strings.ReplaceAll(leaseID, "_", "-")), "-") + } + if base == "new" || base == "create" { + base = "cbx-" + base + } + maxBase := 32 - len(cleanPrefix) + if maxBase < 1 { + return "", exit(2, "coder.workspacePrefix %q leaves no room for a workspace name", cleanPrefix) + } + if len(base) > maxBase { + base = strings.Trim(base[:maxBase], "-") + } + name := strings.Trim(cleanPrefix+base, "-") + if len(name) < 1 || len(name) > 32 { + return "", exit(2, "coder workspace name %q must be 1-32 characters", name) + } + if name == "new" || name == "create" { + name = "cbx-" + name + } + if name[0] == '-' || name[len(name)-1] == '-' { + return "", exit(2, "coder workspace name %q must start and end with a letter or number", name) + } + return name, nil +} diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go new file mode 100644 index 000000000..4d686ed4f --- /dev/null +++ b/internal/providers/coder/backend_test.go @@ -0,0 +1,356 @@ +package coder + +import ( + "context" + "errors" + "flag" + "io" + "strings" + "testing" + "time" +) + +type fakeRunner struct { + calls []LocalCommandRequest + run func(LocalCommandRequest) (LocalCommandResult, error) +} + +func (r *fakeRunner) Run(_ context.Context, req LocalCommandRequest) (LocalCommandResult, error) { + r.calls = append(r.calls, req) + if r.run != nil { + return r.run(req) + } + return LocalCommandResult{}, nil +} + +func TestCoderProviderSpec(t *testing.T) { + spec := Provider{}.Spec() + if spec.Name != coderProvider || spec.Kind != "ssh-lease" || spec.Coordinator != "never" { + t.Fatalf("unexpected spec: %#v", spec) + } + for _, feature := range []Feature{Feature("ssh"), Feature("crabbox-sync"), Feature("cleanup")} { + if !spec.Features.Has(feature) { + t.Fatalf("features=%v missing %s", spec.Features, feature) + } + } +} + +func TestCoderFlagsApplyWithoutSecrets(t *testing.T) { + cfg := Config{Provider: coderProvider, TargetOS: targetLinux, Coder: CoderConfig{CLIPath: "coder", WorkRoot: "/home/coder/crabbox", WorkspacePrefix: "crabbox-", Wait: "yes"}} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + values := RegisterCoderProviderFlags(fs, cfg) + if err := fs.Parse([]string{"--coder-template", "go-dev", "--coder-preset", "large", "--coder-parameter", "region=iad,size=large", "--coder-delete-on-release"}); err != nil { + t.Fatal(err) + } + if err := ApplyCoderProviderFlags(&cfg, fs, values); err != nil { + t.Fatal(err) + } + if cfg.Coder.Template != "go-dev" || cfg.Coder.Preset != "large" || len(cfg.Coder.Parameters) != 2 || !cfg.Coder.DeleteOnRelease { + t.Fatalf("flags not applied: %#v", cfg.Coder) + } + fs.VisitAll(func(f *flag.Flag) { + if strings.Contains(f.Name, "token") || strings.Contains(f.Name, "session") { + t.Fatalf("coder provider must not expose token/session flags: %s", f.Name) + } + }) +} + +func TestCoderCreateCommandUsesTemplateParametersAndNoTokenArgv(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + args := strings.Join(req.Args, " ") + for _, forbidden := range []string{"CODER_SESSION_TOKEN", "token", "secret"} { + if strings.Contains(strings.ToLower(args), strings.ToLower(forbidden)) { + t.Fatalf("create argv leaked forbidden value %q: %s", forbidden, args) + } + } + if strings.Contains(args, "--wait ") { + t.Fatalf("create args must not use unsupported --wait flag:\n%s", args) + } + for _, want := range []string{"create", "--yes", "--template go-dev", "--preset large", "--no-wait", "--use-parameter-defaults", "--parameter region=iad", "--parameter size=large", "--rich-parameter-file /tmp/params.yaml", "crabbox-blue"} { + if !strings.Contains(args, want) { + t.Fatalf("create args missing %q:\n%s", want, args) + } + } + return LocalCommandResult{}, nil + } + client := &coderClient{cliPath: "coder", runner: runner, stdout: io.Discard, stderr: io.Discard} + cfg := Config{Coder: CoderConfig{Template: "go-dev", Preset: "large", Wait: "no", UseParameterDefaults: true, Parameters: []string{"region=iad", "size=large"}, RichParameterFile: "/tmp/params.yaml"}} + if err := client.create(context.Background(), cfg, "crabbox-blue"); err != nil { + t.Fatal(err) + } +} + +func TestCoderSSHTargetUsesProxyCommand(t *testing.T) { + target := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "/opt/Coder CLI/coder", Wait: "yes"}}, "crabbox-blue") + if !target.SSHConfigProxy || target.Host != "crabbox-blue" || target.User != "coder" || target.TargetOS != targetLinux { + t.Fatalf("unexpected target: %#v", target) + } + for _, want := range []string{"'/opt/Coder CLI/coder'", "ssh", "--stdio", "--wait", "'yes'", "'crabbox-blue'"} { + if !strings.Contains(target.ProxyCommand, want) { + t.Fatalf("proxy command %q missing %q", target.ProxyCommand, want) + } + } + if !strings.Contains(target.ReadyCheck, "command -v git") || !strings.Contains(target.ReadyCheck, "command -v rsync") || !strings.Contains(target.ReadyCheck, "command -v tar") { + t.Fatalf("ready check missing expected tools: %q", target.ReadyCheck) + } +} + +func TestCoderReleaseStopsByDefaultAndDeletesOnlyWhenConfigured(t *testing.T) { + for _, tc := range []struct { + name string + delete bool + wantArgs string + }{ + {name: "stop default", wantArgs: "stop --yes crabbox-blue"}, + {name: "delete opt in", delete: true, wantArgs: "delete --yes crabbox-blue"}, + } { + t.Run(tc.name, func(t *testing.T) { + runner := &fakeRunner{} + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: tc.delete}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + err = backend.(*coderLeaseBackend).ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: "cbx_123", Server: Server{Name: "crabbox-blue"}}}) + if err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 || strings.Join(runner.calls[0].Args, " ") != tc.wantArgs { + t.Fatalf("calls=%#v want %s", runner.calls, tc.wantArgs) + } + }) + } +} + +func TestCoderAcquireStopsWorkspaceWhenPostCreateInventoryMissesIt(t *testing.T) { + runner := &fakeRunner{} + listCalls := 0 + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + listCalls++ + return LocalCommandResult{Stdout: `[]`}, nil + case "create --yes --template go-dev crabbox-blue": + return LocalCommandResult{}, nil + case "stop --yes crabbox-blue": + return LocalCommandResult{}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + _, err = backend.(*coderLeaseBackend).Acquire(context.Background(), AcquireRequest{RequestedSlug: "blue", Repo: Repo{Root: t.TempDir()}}) + if err == nil || !strings.Contains(err.Error(), "created but not found") { + t.Fatalf("expected inventory miss error, got %v", err) + } + if listCalls != 2 { + t.Fatalf("list calls=%d want 2", listCalls) + } + if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != "stop --yes crabbox-blue" { + t.Fatalf("final rollback command=%q", got) + } +} + +func TestCoderDoctorClassifiesMissingLoginNonMutating(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "version": + return LocalCommandResult{Stdout: "Coder v2.33.5"}, nil + case "whoami -o json": + return LocalCommandResult{ExitCode: 1, Stderr: "You are not logged in"}, errors.New("exit 1") + default: + t.Fatalf("doctor must be non-mutating, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + result, err := backend.Doctor(context.Background(), DoctorRequest{}) + if err == nil { + t.Fatal("expected missing login error") + } + if !strings.Contains(result.Message, "auth=missing_login") || !strings.Contains(result.Message, "mutation=false") { + t.Fatalf("unexpected doctor result: %#v", result) + } + if len(runner.calls) != 2 { + t.Fatalf("calls=%d want 2", len(runner.calls)) + } +} + +func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { + installCoderClaimState(t) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[ + {"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}, + {"id":"ws2","name":"personal","template_name":"go-dev","latest_build":{"status":"running"}} + ]`}, nil + case "stop --yes crabbox-blue": + return LocalCommandResult{}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + servers, err := backend.(*coderLeaseBackend).List(context.Background(), ListRequest{}) + if err != nil { + t.Fatal(err) + } + if len(servers) != 1 || servers[0].Name != "crabbox-blue" || serverSlug(servers[0]) != "blue" { + t.Fatalf("unexpected servers: %#v", servers) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != "stop --yes crabbox-blue" { + t.Fatalf("cleanup final call=%q", got) + } +} + +func TestCoderCleanupSkipsActiveClaimedAndRunningUnclaimedWorkspaces(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_active", "active", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[ + {"id":"ws1","name":"crabbox-active","template_name":"go-dev","latest_build":{"status":"running","resources":[{"agents":[{"name":"main","operating_system":"linux","status":"connected","lifecycle_state":"ready"}]}]}}, + {"id":"ws2","name":"crabbox-unclaimed","template_name":"go-dev","latest_build":{"status":"running","resources":[{"agents":[{"name":"main","operating_system":"linux","status":"connected","lifecycle_state":"ready"}]}]}}, + {"id":"ws3","name":"personal","template_name":"go-dev","latest_build":{"status":"running"}} + ]`}, nil + default: + t.Fatalf("cleanup must skip active/running workspaces, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("cleanup made mutating calls: %#v", runner.calls) + } +} + +func TestCoderResolveStatusOnlyDoesNotStartOrSSH(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("status-only resolve must not mutate or prepare SSH, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.Server.Status != "stopped" || lease.SSH.Host != "" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderResolveClaimUsesStoredWorkspaceAcrossPrefixChanges(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_prefix", "blue", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "crabbox-blue", Labels: map[string]string{"coder_workspace": "crabbox-blue", "coder_workspace_ref": "crabbox-blue"}} + if err := updateLeaseClaimEndpoint("cbx_prefix", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("status-only resolve must only list, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "other-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "cbx_prefix", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.Server.Name != "crabbox-blue" || lease.LeaseID != "cbx_prefix" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderOwnerQualifiedResolveAndReleaseUseOwnerWorkspace(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"running","resources":[{"agents":[{"name":"main","operating_system":"linux","status":"connected","lifecycle_state":"ready"}]}]}}]`}, nil + case "stop --yes alice/shared": + return LocalCommandResult{}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "alice/shared", StatusOnly: true, ReadyProbe: true}) + if err != nil { + t.Fatal(err) + } + if lease.SSH.Host != "alice/shared" || lease.Server.Labels["coder_workspace_ref"] != "alice/shared" { + t.Fatalf("owner-qualified target not preserved: %#v", lease) + } + if err := backend.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: lease}); err != nil { + t.Fatal(err) + } + if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != "stop --yes alice/shared" { + t.Fatalf("release command=%q", got) + } +} + +func TestCoderWorkspaceNameRules(t *testing.T) { + for _, tc := range []struct { + prefix string + slug string + leaseID string + want string + }{ + {prefix: "crabbox-", slug: "Blue Workspace", leaseID: "cbx_123456abcdef", want: "crabbox-blue-workspace"}, + {prefix: "cbx", slug: "new", leaseID: "cbx_123456abcdef", want: "cbx-cbx-new"}, + {prefix: "crabbox-", slug: "this-name-is-much-longer-than-coder-allows", leaseID: "cbx_123456abcdef", want: "crabbox-this-name-is-much-longer"}, + } { + got, err := coderWorkspaceName(tc.prefix, tc.slug, tc.leaseID) + if err != nil { + t.Fatalf("coderWorkspaceName(%q,%q): %v", tc.prefix, tc.slug, err) + } + if got != tc.want { + t.Fatalf("coderWorkspaceName(%q,%q)=%q want %q", tc.prefix, tc.slug, got, tc.want) + } + } +} + +func installCoderClaimState(t *testing.T) { + t.Helper() + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("CRABBOX_CONFIG", t.TempDir()+"/missing.yaml") +} diff --git a/internal/providers/coder/client.go b/internal/providers/coder/client.go new file mode 100644 index 000000000..3667f2d98 --- /dev/null +++ b/internal/providers/coder/client.go @@ -0,0 +1,335 @@ +package coder + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +type coderClient struct { + cliPath string + runner interface { + Run(context.Context, LocalCommandRequest) (LocalCommandResult, error) + } + stdout io.Writer + stderr io.Writer +} + +func newCoderClient(cfg Config, rt Runtime) (*coderClient, error) { + if rt.Exec == nil { + return nil, exit(2, "coder provider requires command runner") + } + cliPath := strings.TrimSpace(cfg.Coder.CLIPath) + if cliPath == "" { + cliPath = "coder" + } + return &coderClient{cliPath: cliPath, runner: rt.Exec, stdout: rt.Stdout, stderr: rt.Stderr}, nil +} + +func (c *coderClient) run(ctx context.Context, args []string, stdout, stderr io.Writer) (LocalCommandResult, error) { + return c.runner.Run(ctx, LocalCommandRequest{Name: c.cliPath, Args: args, Stdout: stdout, Stderr: stderr}) +} + +func (c *coderClient) output(ctx context.Context, args []string) (string, error) { + result, err := c.run(ctx, args, nil, nil) + if err != nil { + msg := strings.TrimSpace(result.Stdout + result.Stderr) + if msg == "" { + msg = err.Error() + } + return "", ExitError{Code: result.ExitCode, Message: fmt.Sprintf("coder %s failed: %s", strings.Join(args, " "), msg)} + } + return result.Stdout, nil +} + +func (c *coderClient) version(ctx context.Context) error { + result, err := c.run(ctx, []string{"version"}, nil, nil) + if err != nil { + msg := strings.TrimSpace(result.Stdout + result.Stderr) + if msg == "" { + msg = err.Error() + } + return ExitError{Code: result.ExitCode, Message: "coder cli unavailable: " + msg} + } + return nil +} + +func (c *coderClient) whoami(ctx context.Context) error { + result, err := c.run(ctx, []string{"whoami", "-o", "json"}, nil, nil) + if err != nil { + msg := strings.TrimSpace(result.Stdout + result.Stderr) + if msg == "" { + msg = err.Error() + } + return ExitError{Code: result.ExitCode, Message: "coder credential unavailable: run `coder login `; mutation=false detail=" + msg} + } + return nil +} + +func (c *coderClient) list(ctx context.Context) ([]coderWorkspace, error) { + out, err := c.output(ctx, []string{"list", "-o", "json"}) + if err != nil { + return nil, err + } + return parseCoderWorkspaces(out) +} + +func (c *coderClient) listAll(ctx context.Context) ([]coderWorkspace, error) { + out, err := c.output(ctx, []string{"list", "--all", "-o", "json"}) + if err != nil { + return nil, err + } + return parseCoderWorkspaces(out) +} + +func (c *coderClient) create(ctx context.Context, cfg Config, name string) error { + args := []string{"create", "--yes", "--template", strings.TrimSpace(cfg.Coder.Template)} + if preset := strings.TrimSpace(cfg.Coder.Preset); preset != "" { + args = append(args, "--preset", preset) + } + if wait := strings.TrimSpace(cfg.Coder.Wait); strings.EqualFold(wait, "no") { + args = append(args, "--no-wait") + } + if cfg.Coder.UseParameterDefaults { + args = append(args, "--use-parameter-defaults") + } + for _, param := range cfg.Coder.Parameters { + args = append(args, "--parameter", strings.TrimSpace(param)) + } + if file := strings.TrimSpace(cfg.Coder.RichParameterFile); file != "" { + args = append(args, "--rich-parameter-file", file) + } + args = append(args, name) + result, err := c.run(ctx, args, c.stdout, c.stderr) + if err != nil { + return ExitError{Code: result.ExitCode, Message: fmt.Sprintf("coder create workspace %s failed: %s", name, strings.TrimSpace(result.Stdout+result.Stderr))} + } + return nil +} + +func (c *coderClient) start(ctx context.Context, name string) error { + result, err := c.run(ctx, []string{"start", "--yes", name}, c.stdout, c.stderr) + if err != nil { + return ExitError{Code: result.ExitCode, Message: fmt.Sprintf("coder start workspace %s failed: %s", name, strings.TrimSpace(result.Stdout+result.Stderr))} + } + return nil +} + +func (c *coderClient) stop(ctx context.Context, name string) error { + result, err := c.run(ctx, []string{"stop", "--yes", name}, c.stdout, c.stderr) + if err != nil { + return ExitError{Code: result.ExitCode, Message: fmt.Sprintf("coder stop workspace %s failed: %s", name, strings.TrimSpace(result.Stdout+result.Stderr))} + } + return nil +} + +func (c *coderClient) delete(ctx context.Context, name string) error { + result, err := c.run(ctx, []string{"delete", "--yes", name}, c.stdout, c.stderr) + if err != nil { + return ExitError{Code: result.ExitCode, Message: fmt.Sprintf("coder delete workspace %s failed: %s", name, strings.TrimSpace(result.Stdout+result.Stderr))} + } + return nil +} + +type coderWorkspace struct { + ID string + Name string + Owner string + Template string + Status string + Autostart string + Outdated bool + Transition string + Agents []coderAgent + Labels map[string]string +} + +type coderAgent struct { + Name string + OS string + Status string + Lifecycle string +} + +func parseCoderWorkspaces(out string) ([]coderWorkspace, error) { + var raw any + if err := json.Unmarshal([]byte(out), &raw); err != nil { + return nil, exit(5, "coder list returned invalid JSON: %v", err) + } + switch value := raw.(type) { + case []any: + return parseCoderWorkspaceArray(value) + case map[string]any: + for _, key := range []string{"workspaces", "items", "data"} { + if items, ok := value[key].([]any); ok { + return parseCoderWorkspaceArray(items) + } + } + return parseCoderWorkspaceArray([]any{value}) + default: + return nil, exit(5, "coder list returned unsupported JSON shape") + } +} + +func parseCoderWorkspaceArray(items []any) ([]coderWorkspace, error) { + workspaces := make([]coderWorkspace, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]any) + if !ok { + return nil, exit(5, "coder list workspace entry is not an object") + } + workspaces = append(workspaces, parseCoderWorkspaceObject(obj)) + } + return workspaces, nil +} + +func parseCoderWorkspaceObject(obj map[string]any) coderWorkspace { + workspace := coderWorkspace{ + ID: stringField(obj, "id"), + Name: firstStringField(obj, "name", "workspace_name"), + Status: firstStringField(obj, "status", "latest_build.status"), + Transition: firstStringField(obj, "transition", "latest_build.transition"), + Template: firstStringField(obj, "template_display_name", "template_name", "template.name"), + Labels: map[string]string{}, + } + if owner := objectField(obj, "owner"); owner != nil { + workspace.Owner = firstStringField(owner, "name", "username") + } + if workspace.Owner == "" { + workspace.Owner = firstStringField(obj, "owner_name", "owner") + } + if build := objectField(obj, "latest_build"); build != nil { + if workspace.Status == "" { + workspace.Status = firstStringField(build, "status") + } + if workspace.Transition == "" { + workspace.Transition = firstStringField(build, "transition") + } + workspace.Agents = append(workspace.Agents, parseCoderAgents(build)...) + } + workspace.Agents = append(workspace.Agents, parseCoderAgents(obj)...) + for k, v := range mapField(obj, "labels") { + workspace.Labels[k] = v + } + return workspace +} + +func parseCoderAgents(obj map[string]any) []coderAgent { + var out []coderAgent + for _, key := range []string{"agents", "workspace_agents"} { + if items, ok := obj[key].([]any); ok { + for _, item := range items { + agentObj, ok := item.(map[string]any) + if !ok { + continue + } + out = append(out, coderAgent{ + Name: firstStringField(agentObj, "name"), + OS: firstStringField(agentObj, "operating_system", "os"), + Status: firstStringField(agentObj, "status"), + Lifecycle: firstStringField(agentObj, "lifecycle_state", "lifecycle"), + }) + } + } + } + for _, key := range []string{"resources"} { + if items, ok := obj[key].([]any); ok { + for _, item := range items { + resource, ok := item.(map[string]any) + if !ok { + continue + } + out = append(out, parseCoderAgents(resource)...) + } + } + } + return out +} + +func objectField(obj map[string]any, path string) map[string]any { + current := obj + parts := strings.Split(path, ".") + for i, part := range parts { + next, ok := current[part] + if !ok { + return nil + } + value, ok := next.(map[string]any) + if !ok { + return nil + } + if i == len(parts)-1 { + return value + } + current = value + } + return nil +} + +func stringField(obj map[string]any, key string) string { + if value, ok := obj[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func firstStringField(obj map[string]any, keys ...string) string { + for _, key := range keys { + if strings.Contains(key, ".") { + if value, ok := nestedStringField(obj, key); ok { + return value + } + continue + } + if value := stringField(obj, key); value != "" { + return value + } + } + return "" +} + +func nestedStringField(obj map[string]any, path string) (string, bool) { + parts := strings.Split(path, ".") + current := obj + for i, part := range parts { + value, ok := current[part] + if !ok { + return "", false + } + if i == len(parts)-1 { + str, ok := value.(string) + return strings.TrimSpace(str), ok && strings.TrimSpace(str) != "" + } + next, ok := value.(map[string]any) + if !ok { + return "", false + } + current = next + } + return "", false +} + +func mapField(obj map[string]any, key string) map[string]string { + out := map[string]string{} + raw, ok := obj[key].(map[string]any) + if !ok { + return out + } + for k, v := range raw { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +func coderCommandError(err error) (ExitError, bool) { + var exitErr ExitError + if errors.As(err, &exitErr) { + return exitErr, true + } + return ExitError{}, false +} diff --git a/internal/providers/coder/core.go b/internal/providers/coder/core.go new file mode 100644 index 000000000..396b55669 --- /dev/null +++ b/internal/providers/coder/core.go @@ -0,0 +1,109 @@ +package coder + +import ( + "context" + "flag" + "io" + "time" + + core "github.com/openclaw/crabbox/internal/cli" +) + +type Config = core.Config +type CoderConfig = core.CoderConfig +type ProviderSpec = core.ProviderSpec +type Feature = core.Feature +type Runtime = core.Runtime +type Backend = core.Backend +type DoctorRequest = core.DoctorRequest +type DoctorResult = core.DoctorResult +type DoctorCheck = core.DoctorCheck +type AcquireRequest = core.AcquireRequest +type ResolveRequest = core.ResolveRequest +type ReleaseLeaseRequest = core.ReleaseLeaseRequest +type TouchRequest = core.TouchRequest +type ListRequest = core.ListRequest +type CleanupRequest = core.CleanupRequest +type LeaseClaim = core.LeaseClaim +type Repo = core.Repo +type LeaseView = core.LeaseView +type LeaseTarget = core.LeaseTarget +type Server = core.Server +type SSHTarget = core.SSHTarget +type LocalCommandRequest = core.LocalCommandRequest +type LocalCommandResult = core.LocalCommandResult +type ExitError = core.ExitError + +const ( + coderProvider = "coder" + targetLinux = core.TargetLinux + networkPublic = core.NetworkPublic +) + +func exit(code int, format string, args ...any) core.ExitError { + return core.Exit(code, format, args...) +} + +func flagWasSet(fs *flag.FlagSet, name string) bool { + return core.FlagWasSet(fs, name) +} + +func blank(value, fallback string) string { + return core.Blank(value, fallback) +} + +func newLeaseID() string { + return core.NewLeaseID() +} + +func allocateDirectLeaseSlug(leaseID, requested string, servers []Server) (string, error) { + return core.AllocateDirectLeaseSlug(leaseID, requested, servers) +} + +func normalizeLeaseSlug(value string) string { + return core.NormalizeLeaseSlug(value) +} + +func serverSlug(server Server) string { + return core.ServerSlug(server) +} + +func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep bool, now time.Time) map[string]string { + return core.DirectLeaseLabels(cfg, leaseID, slug, provider, market, keep, now) +} + +func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string { + return core.TouchDirectLeaseLabels(labels, cfg, state, now) +} + +func claimLeaseForRepoProvider(leaseID, slug, provider, repoRoot string, idleTimeout time.Duration, reclaim bool) error { + return core.ClaimLeaseForRepoProvider(leaseID, slug, provider, repoRoot, idleTimeout, reclaim) +} + +func updateLeaseClaimEndpoint(leaseID string, server Server, target SSHTarget) error { + return core.UpdateLeaseClaimEndpoint(leaseID, server, target) +} + +func resolveLeaseClaimForProvider(identifier, provider string) (core.LeaseClaim, bool, error) { + return core.ResolveLeaseClaimForProvider(identifier, provider) +} + +func listLeaseClaims() ([]core.LeaseClaim, error) { + return core.ListLeaseClaims() +} + +func removeLeaseClaim(leaseID string) { + core.RemoveLeaseClaim(leaseID) +} + +func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error { + return core.WaitForSSHReady(ctx, target, stderr, phase, timeout) +} + +func bootstrapWaitTimeout(cfg Config) time.Duration { + return core.BootstrapWaitTimeout(cfg) +} + +func shellQuote(s string) string { + return core.ShellQuote(s) +} diff --git a/internal/providers/coder/flags.go b/internal/providers/coder/flags.go new file mode 100644 index 000000000..4d7c7e8d1 --- /dev/null +++ b/internal/providers/coder/flags.go @@ -0,0 +1,173 @@ +package coder + +import ( + "flag" + "path" + "strings" +) + +type coderFlagValues struct { + CLIPath *string + Template *string + Preset *string + WorkspacePrefix *string + WorkRoot *string + DeleteOnRelease *bool + Wait *string + UseParameterDefaults *bool + Parameters *string + RichParameterFile *string +} + +func RegisterCoderProviderFlags(fs *flag.FlagSet, defaults Config) any { + return coderFlagValues{ + CLIPath: fs.String("coder-cli", defaults.Coder.CLIPath, "Coder CLI path"), + Template: fs.String("coder-template", defaults.Coder.Template, "Coder template for new workspaces"), + Preset: fs.String("coder-preset", defaults.Coder.Preset, "Coder template preset"), + WorkspacePrefix: fs.String("coder-workspace-prefix", defaults.Coder.WorkspacePrefix, "prefix for Crabbox-managed Coder workspace names"), + WorkRoot: fs.String("coder-work-root", defaults.Coder.WorkRoot, "Coder workspace Crabbox work root"), + DeleteOnRelease: fs.Bool("coder-delete-on-release", defaults.Coder.DeleteOnRelease, "delete Coder workspace on release instead of stopping it"), + Wait: fs.String("coder-wait", defaults.Coder.Wait, "Coder SSH startup wait mode: yes, no, or auto"), + UseParameterDefaults: fs.Bool("coder-use-parameter-defaults", defaults.Coder.UseParameterDefaults, "pass --use-parameter-defaults to coder create"), + Parameters: fs.String("coder-parameter", strings.Join(defaults.Coder.Parameters, ","), "comma-separated Coder parameter values name=value"), + RichParameterFile: fs.String("coder-rich-parameter-file", defaults.Coder.RichParameterFile, "Coder rich parameter file"), + } +} + +func ApplyCoderProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error { + if cfg.Provider == coderProvider { + if flagWasSet(fs, "class") { + return exit(2, "--class is not supported for provider=coder; choose size through the Coder template or --coder-preset") + } + if flagWasSet(fs, "type") { + return exit(2, "--type is not supported for provider=coder; choose a Coder template with --coder-template") + } + if cfg.TargetOS != "" && cfg.TargetOS != targetLinux { + return exit(2, "provider=coder supports target=linux only") + } + } + v, ok := values.(coderFlagValues) + if !ok { + return nil + } + if flagWasSet(fs, "coder-cli") { + cfg.Coder.CLIPath = *v.CLIPath + } + if flagWasSet(fs, "coder-template") { + cfg.Coder.Template = *v.Template + } + if flagWasSet(fs, "coder-preset") { + cfg.Coder.Preset = *v.Preset + } + if flagWasSet(fs, "coder-workspace-prefix") { + cfg.Coder.WorkspacePrefix = *v.WorkspacePrefix + } + if flagWasSet(fs, "coder-work-root") { + cfg.Coder.WorkRoot = *v.WorkRoot + cfg.WorkRoot = *v.WorkRoot + } + if flagWasSet(fs, "coder-delete-on-release") { + cfg.Coder.DeleteOnRelease = *v.DeleteOnRelease + } + if flagWasSet(fs, "coder-wait") { + cfg.Coder.Wait = *v.Wait + } + if flagWasSet(fs, "coder-use-parameter-defaults") { + cfg.Coder.UseParameterDefaults = *v.UseParameterDefaults + } + if flagWasSet(fs, "coder-parameter") { + cfg.Coder.Parameters = splitCommaList(*v.Parameters) + } + if flagWasSet(fs, "coder-rich-parameter-file") { + cfg.Coder.RichParameterFile = *v.RichParameterFile + } + if cfg.Provider == coderProvider { + return validateCoderConfig(*cfg) + } + return nil +} + +func validateCoderConfig(cfg Config) error { + if strings.TrimSpace(cfg.Coder.CLIPath) == "" { + return exit(2, "coder.cliPath must not be empty") + } + if err := validateCoderWait(cfg.Coder.Wait); err != nil { + return err + } + if _, err := cleanCoderWorkRoot(coderWorkRoot(cfg)); err != nil { + return err + } + if _, err := cleanCoderWorkspacePrefix(cfg.Coder.WorkspacePrefix); err != nil { + return err + } + for _, param := range cfg.Coder.Parameters { + if strings.TrimSpace(param) == "" { + return exit(2, "coder.parameters entries must not be empty") + } + if !strings.Contains(param, "=") { + return exit(2, "coder parameter %q must use name=value", param) + } + } + return nil +} + +func validateCoderWait(value string) error { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "yes", "no", "auto": + return nil + default: + return exit(2, "coder.wait must be yes, no, or auto") + } +} + +func coderWorkRoot(cfg Config) string { + if strings.TrimSpace(cfg.Coder.WorkRoot) != "" { + return strings.TrimSpace(cfg.Coder.WorkRoot) + } + return "/home/coder/crabbox" +} + +func cleanCoderWorkRoot(workRoot string) (string, error) { + clean := path.Clean(strings.TrimSpace(workRoot)) + if clean == "" || !strings.HasPrefix(clean, "/") { + return "", exit(2, "coder.workRoot %q must resolve to an absolute path", workRoot) + } + switch clean { + case "/", "/bin", "/dev", "/etc", "/home", "/lib", "/lib64", "/opt", "/proc", "/root", "/sbin", "/sys", "/tmp", "/usr", "/var", "/workspaces": + return "", exit(2, "coder.workRoot %q is too broad; choose a dedicated subdirectory", clean) + } + return clean, nil +} + +func cleanCoderWorkspacePrefix(prefix string) (string, error) { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + if prefix == "" { + prefix = "crabbox-" + } + prefix = strings.Trim(prefix, "-") + if prefix == "" { + return "", exit(2, "coder.workspacePrefix must include at least one letter or number") + } + for _, r := range prefix { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return "", exit(2, "coder.workspacePrefix must contain only letters, numbers, and hyphens") + } + return prefix + "-", nil +} + +func splitCommaList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} diff --git a/internal/providers/coder/provider.go b/internal/providers/coder/provider.go new file mode 100644 index 000000000..17e742d74 --- /dev/null +++ b/internal/providers/coder/provider.go @@ -0,0 +1,51 @@ +package coder + +import ( + "flag" + + core "github.com/openclaw/crabbox/internal/cli" +) + +func init() { + core.RegisterProvider(Provider{}) +} + +type Provider struct{} + +func (Provider) Name() string { return coderProvider } +func (Provider) Aliases() []string { return nil } + +func (Provider) Spec() core.ProviderSpec { + return core.ProviderSpec{ + Name: coderProvider, + Family: coderProvider, + Kind: core.ProviderKindSSHLease, + Targets: []core.TargetSpec{{OS: core.TargetLinux}}, + Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup}, + Coordinator: core.CoordinatorNever, + } +} + +func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any { + return RegisterCoderProviderFlags(fs, defaults) +} + +func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error { + return ApplyCoderProviderFlags(cfg, fs, values) +} + +func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) { + return NewCoderLeaseBackend(p.Spec(), cfg, rt) +} + +func (p Provider) ConfigureDoctor(cfg core.Config, rt core.Runtime) (core.DoctorBackend, error) { + backend, err := p.Configure(cfg, rt) + if err != nil { + return nil, err + } + doctor, ok := backend.(core.DoctorBackend) + if !ok { + return nil, core.Exit(2, "coder doctor backend unavailable") + } + return doctor, nil +} From 1566b4c764f2d3b6d7784934eceb2980436b66ed Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:49:56 -0700 Subject: [PATCH 02/15] fix(provider): tighten Coder ownership checks Require explicit Crabbox markers or legacy Crabbox labels before generic lease metadata is trusted, while keeping prefix-based ownership and legacy label resolution working. This prevents cleanup or resolve from acting on unrelated Coder workspaces after the provider branch is merged. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 36 ++++-- internal/providers/coder/backend_test.go | 154 +++++++++++++++++++++++ 2 files changed, 180 insertions(+), 10 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index c9f088cf8..91dc4b3c7 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -350,17 +350,10 @@ func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time } func coderWorkspaceHasCrabboxLabel(workspace coderWorkspace) bool { - for _, key := range []string{"crabbox", "provider", "lease", "slug", "crabbox_lease_id", "crabbox_slug"} { - value := strings.TrimSpace(workspace.Labels[key]) - if value == "" { - continue - } - if key == "provider" && value != coderProvider { - continue - } + if strings.EqualFold(strings.TrimSpace(workspace.Labels["crabbox"]), "true") { return true } - return false + return strings.EqualFold(strings.TrimSpace(workspace.Labels["created_by"]), "crabbox") } func coderServerRunning(status string) bool { @@ -392,13 +385,19 @@ func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []cod } } normalized := normalizeCoderWorkspaceIdentifier(identifier) + normalizedSlug := normalizeLeaseSlug(identifier) matches := []coderWorkspace{} for _, workspace := range workspaces { + leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) if normalizeCoderWorkspaceIdentifier(workspace.Name) == normalized || normalizeCoderWorkspaceIdentifier(coderOwnerWorkspace(workspace)) == normalized { matches = append(matches, workspace) continue } - if _, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg); owned && normalizeLeaseSlug(slug) == normalizeLeaseSlug(identifier) { + if owned && leaseID != "" && leaseID == identifier { + matches = append(matches, workspace) + continue + } + if owned && normalizedSlug != "" && normalizeLeaseSlug(slug) == normalizedSlug { matches = append(matches, workspace) } } @@ -441,11 +440,28 @@ func coderWorkspaceToServer(workspace coderWorkspace, cfg Config, leaseID, slug } func coderWorkspaceLeaseMetadata(workspace coderWorkspace, cfg Config) (string, string, bool) { + hasCrabboxLabel := coderWorkspaceHasCrabboxLabel(workspace) leaseID := strings.TrimSpace(workspace.Labels["crabbox_lease_id"]) slug := normalizeLeaseSlug(workspace.Labels["crabbox_slug"]) if leaseID != "" || slug != "" { + if leaseID == "" { + leaseID = strings.TrimSpace(workspace.Labels["lease"]) + } + if slug == "" { + slug = normalizeLeaseSlug(workspace.Labels["slug"]) + } return leaseID, slug, true } + leaseID = strings.TrimSpace(workspace.Labels["lease"]) + slug = normalizeLeaseSlug(workspace.Labels["slug"]) + if leaseID != "" || slug != "" { + if hasCrabboxLabel { + return leaseID, slug, true + } + } + if hasCrabboxLabel { + return "", "", true + } slug = coderSlugFromWorkspace(workspace.Name, cfg.Coder.WorkspacePrefix) if slug == "" { return "", "", false diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 4d686ed4f..0f5530344 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -248,6 +248,160 @@ func TestCoderCleanupSkipsActiveClaimedAndRunningUnclaimedWorkspaces(t *testing. } } +func TestCoderListAndResolveUseStandardCrabboxLabels(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"crabbox":"true","created_by":"crabbox","provider":"coder","lease":"cbx_label","slug":"blue-lobster"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + servers, err := backend.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatal(err) + } + if len(servers) != 1 || servers[0].Name != "team-workspace" || serverSlug(servers[0]) != "blue-lobster" { + t.Fatalf("unexpected servers: %#v", servers) + } + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "cbx_label", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.LeaseID != "cbx_label" || lease.Server.Name != "team-workspace" || serverSlug(lease.Server) != "blue-lobster" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderListAndResolveUseLegacyCrabboxLabels(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"crabbox_lease_id":"cbx_legacy","crabbox_slug":"legacy-lobster"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + servers, err := backend.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatal(err) + } + if len(servers) != 1 || servers[0].Name != "team-workspace" || serverSlug(servers[0]) != "legacy-lobster" { + t.Fatalf("unexpected servers: %#v", servers) + } + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "cbx_legacy", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.LeaseID != "cbx_legacy" || lease.Server.Name != "team-workspace" || serverSlug(lease.Server) != "legacy-lobster" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderCleanupSkipsProviderLabelWithoutCrabboxOwnership(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"provider":"coder"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("cleanup must not act on provider-only labels, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + if err := backend.Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("cleanup made mutating calls: %#v", runner.calls) + } +} + +func TestCoderCleanupSkipsProviderAndSlugWithoutCrabboxMarker(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"provider":"coder","slug":"blue-lobster"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("cleanup must not act on provider+slug labels without Crabbox markers, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + if err := backend.Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("cleanup made mutating calls: %#v", runner.calls) + } +} + +func TestCoderCleanupSkipsGenericSlugWithoutCrabboxMarker(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"slug":"blue-lobster"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("cleanup must not act on generic slug labels, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + if err := backend.Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("cleanup made mutating calls: %#v", runner.calls) + } +} + +func TestCoderListUsesPrefixOwnershipDespiteUnrelatedProviderLabel(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","labels":{"provider":"terraform"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + servers, err := backend.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatal(err) + } + if len(servers) != 1 || servers[0].Name != "crabbox-blue" || serverSlug(servers[0]) != "blue" { + t.Fatalf("unexpected servers: %#v", servers) + } +} + +func TestCoderResolveRejectsEmptyNormalizedSlugMatch(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"crabbox":"true","created_by":"crabbox"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + if _, err := backend.Resolve(context.Background(), ResolveRequest{ID: "!!!", StatusOnly: true}); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found, got %v", err) + } +} + func TestCoderResolveStatusOnlyDoesNotStartOrSSH(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { From e2edbb4ea08d2321590774f3c9acaa0015751191 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:55:27 -0700 Subject: [PATCH 03/15] fix(provider): keep claimed Coder workspaces reusable Check claim freshness before stopped-state cleanup so opted-in delete cleanup cannot remove still-claimed Coder workspaces. Add a regression test for stopped active claims to keep resolve-on-demand leases reusable. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 6 ++--- internal/providers/coder/backend_test.go | 29 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index 91dc4b3c7..90ee15ff6 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -329,9 +329,6 @@ func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time if strings.EqualFold(server.Labels["keep"], "true") { return false, "keep=true" } - if !coderServerRunning(server.Status) && server.Status != "ready" { - return true, "workspace state=" + blank(server.Status, "unknown") - } if hasClaim { lastUsed, err := time.Parse(time.RFC3339, strings.TrimSpace(claim.LastUsedAt)) if err != nil || lastUsed.IsZero() { @@ -346,6 +343,9 @@ func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time } return false, "claim active" } + if !coderServerRunning(server.Status) && server.Status != "ready" { + return true, "workspace state=" + blank(server.Status, "unknown") + } return false, "missing claim" } diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 0f5530344..478b747f0 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -248,6 +248,35 @@ func TestCoderCleanupSkipsActiveClaimedAndRunningUnclaimedWorkspaces(t *testing. } } +func TestCoderCleanupSkipsStoppedActiveClaim(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_stopped", "stopped", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[ + {"id":"ws1","name":"crabbox-stopped","template_name":"go-dev","latest_build":{"status":"stopped"}} + ]`}, nil + default: + t.Fatalf("cleanup must not act on a stopped active claim, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: true}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("cleanup made mutating calls: %#v", runner.calls) + } +} + func TestCoderListAndResolveUseStandardCrabboxLabels(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { From 99783949c36014a388af515e05f4d2bcfc3c41fa Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:04:05 -0700 Subject: [PATCH 04/15] fix(provider): roll back failed Coder acquires safely Use the configured stop-vs-delete release policy for post-create rollback paths, but only after verifying the workspace exists when coder create itself fails. This keeps disposable Coder workspaces cleaned up while preserving the original create error for failures that never produced a workspace. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 50 +++++++++++-- internal/providers/coder/backend_test.go | 92 ++++++++++++++++-------- 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index 90ee15ff6..f7e4e1143 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -2,6 +2,7 @@ package coder import ( "context" + "errors" "fmt" "regexp" "strings" @@ -65,19 +66,23 @@ func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (Le } fmt.Fprintf(b.rt.Stderr, "provisioning provider=coder lease=%s slug=%s workspace=%s template=%s keep=%v\n", leaseID, slug, workspaceName, b.cfg.Coder.Template, req.Keep) if err := client.create(ctx, b.cfg, workspaceName); err != nil { + if !req.Keep { + err = b.rollbackCreateError(workspaceName, client, err) + } return LeaseTarget{}, err } workspaces, err := client.list(ctx) if err != nil { if !req.Keep { - _ = client.stop(context.Background(), workspaceName) + err = b.rollbackCreatedWorkspace(workspaceName, client, err) } return LeaseTarget{}, err } workspace, ok := findCoderWorkspace(workspaces, workspaceName) if !ok { if !req.Keep { - _ = client.stop(context.Background(), workspaceName) + err = b.rollbackCreatedWorkspace(workspaceName, client, exit(5, "coder workspace %s was created but not found in coder list", workspaceName)) + return LeaseTarget{}, err } return LeaseTarget{}, exit(5, "coder workspace %s was created but not found in coder list", workspaceName) } @@ -85,7 +90,7 @@ func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (Le target := coderSSHTarget(b.cfg, workspaceName) if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "coder ssh", bootstrapWaitTimeout(b.cfg)); err != nil { if !req.Keep { - _ = client.stop(context.Background(), workspaceName) + err = b.rollbackCreatedWorkspace(workspaceName, client, err) } return LeaseTarget{}, err } @@ -93,7 +98,7 @@ func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (Le server.Labels["state"] = "ready" if err := claimLeaseForRepoProvider(leaseID, slug, coderProvider, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil { if !req.Keep { - _ = client.stop(context.Background(), workspaceName) + err = b.rollbackCreatedWorkspace(workspaceName, client, err) } return LeaseTarget{}, err } @@ -101,6 +106,43 @@ func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (Le return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil } +func (b *coderLeaseBackend) rollbackCreatedWorkspace(name string, client *coderClient, cause error) error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := b.releaseWorkspace(cleanupCtx, client, name); err != nil { + return exit(coderExitCode(cause), "%v; coder cleanup failed for workspace %s; manual cleanup: crabbox stop --provider coder %s: %v", cause, name, name, err) + } + return cause +} + +func (b *coderLeaseBackend) rollbackCreateError(name string, client *coderClient, cause error) error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + workspaces, err := client.list(cleanupCtx) + if err != nil { + return cause + } + if _, ok := findCoderWorkspace(workspaces, name); !ok { + return cause + } + return b.rollbackCreatedWorkspace(name, client, cause) +} + +func (b *coderLeaseBackend) releaseWorkspace(ctx context.Context, client *coderClient, name string) error { + if b.cfg.Coder.DeleteOnRelease { + return client.delete(ctx, name) + } + return client.stop(ctx, name) +} + +func coderExitCode(err error) int { + var exitErr ExitError + if errors.As(err, &exitErr) && exitErr.Code != 0 { + return exitErr.Code + } + return 1 +} + func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) { client, err := newCoderClient(b.cfg, b.rt) if err != nil { diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 478b747f0..0330570e7 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -122,36 +122,68 @@ func TestCoderReleaseStopsByDefaultAndDeletesOnlyWhenConfigured(t *testing.T) { } } -func TestCoderAcquireStopsWorkspaceWhenPostCreateInventoryMissesIt(t *testing.T) { - runner := &fakeRunner{} - listCalls := 0 - runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { - switch strings.Join(req.Args, " ") { - case "list -o json": - listCalls++ - return LocalCommandResult{Stdout: `[]`}, nil - case "create --yes --template go-dev crabbox-blue": - return LocalCommandResult{}, nil - case "stop --yes crabbox-blue": - return LocalCommandResult{}, nil - default: - t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) - } - return LocalCommandResult{}, nil - } - backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) - if err != nil { - t.Fatal(err) - } - _, err = backend.(*coderLeaseBackend).Acquire(context.Background(), AcquireRequest{RequestedSlug: "blue", Repo: Repo{Root: t.TempDir()}}) - if err == nil || !strings.Contains(err.Error(), "created but not found") { - t.Fatalf("expected inventory miss error, got %v", err) - } - if listCalls != 2 { - t.Fatalf("list calls=%d want 2", listCalls) - } - if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != "stop --yes crabbox-blue" { - t.Fatalf("final rollback command=%q", got) +func TestCoderAcquireRollbackUsesReleasePolicy(t *testing.T) { + for _, tc := range []struct { + name string + delete bool + createErr error + postCreateList string + wantErr string + wantAction string + wantListCalls int + }{ + {name: "inventory miss stops by default", wantErr: "created but not found", wantAction: "stop --yes crabbox-blue", wantListCalls: 2}, + {name: "inventory miss deletes when configured", delete: true, wantErr: "created but not found", wantAction: "delete --yes crabbox-blue", wantListCalls: 2}, + {name: "create failure without workspace skips rollback", createErr: errors.New("build failed"), postCreateList: `[]`, wantErr: "build failed", wantListCalls: 2}, + {name: "create failure rolls back when workspace exists", createErr: errors.New("build failed"), postCreateList: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`, wantErr: "build failed", wantAction: "stop --yes crabbox-blue", wantListCalls: 2}, + {name: "create failure rollback honors delete policy", delete: true, createErr: errors.New("build failed"), postCreateList: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`, wantErr: "build failed", wantAction: "delete --yes crabbox-blue", wantListCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + runner := &fakeRunner{} + listCalls := 0 + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + listCalls++ + if tc.createErr != nil && listCalls == 2 { + return LocalCommandResult{Stdout: tc.postCreateList}, nil + } + return LocalCommandResult{Stdout: `[]`}, nil + case "create --yes --template go-dev crabbox-blue": + if tc.createErr != nil { + return LocalCommandResult{ExitCode: 1, Stderr: tc.createErr.Error()}, tc.createErr + } + return LocalCommandResult{}, nil + case "stop --yes crabbox-blue", "delete --yes crabbox-blue": + return LocalCommandResult{}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: tc.delete}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + _, err = backend.(*coderLeaseBackend).Acquire(context.Background(), AcquireRequest{RequestedSlug: "blue", Repo: Repo{Root: t.TempDir()}}) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected %q error, got %v", tc.wantErr, err) + } + if listCalls != tc.wantListCalls { + t.Fatalf("list calls=%d want %d", listCalls, tc.wantListCalls) + } + if tc.wantAction == "" { + for _, call := range runner.calls { + if strings.HasPrefix(strings.Join(call.Args, " "), "stop --yes") || strings.HasPrefix(strings.Join(call.Args, " "), "delete --yes") { + t.Fatalf("unexpected rollback call: %#v", runner.calls) + } + } + return + } + if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != tc.wantAction { + t.Fatalf("final rollback command=%q want %q", got, tc.wantAction) + } + }) } } From 575e662489dfeaeb873f087b7e4eac06a31e6e1f Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:15:01 -0700 Subject: [PATCH 05/15] fix(provider): avoid Coder long-slug workspace collisions Hash overlong Coder workspace names before truncation and fall back to a lease-hash slug suffix when an existing workspace already occupies the derived name. This keeps long requested slugs stable enough for humans while avoiding deterministic create collisions. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 68 +++++++++++++++++++++++- internal/providers/coder/backend_test.go | 43 ++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index f7e4e1143..b40ff2cee 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -2,6 +2,8 @@ package coder import ( "context" + "crypto/sha1" + "encoding/hex" "errors" "fmt" "regexp" @@ -60,7 +62,7 @@ func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (Le if err != nil { return LeaseTarget{}, err } - workspaceName, err := coderWorkspaceName(b.cfg.Coder.WorkspacePrefix, slug, leaseID) + slug, workspaceName, err := coderUniqueWorkspaceName(existing, b.cfg.Coder.WorkspacePrefix, slug, leaseID) if err != nil { return LeaseTarget{}, err } @@ -608,6 +610,37 @@ func normalizeCoderWorkspaceIdentifier(value string) string { var coderWorkspaceInvalidChars = regexp.MustCompile(`[^a-z0-9-]+`) +const coderMaxRequestedSlugLength = 41 +const coderWorkspaceHashLength = 6 + +func coderUniqueWorkspaceName(workspaces []coderWorkspace, prefix, slug, leaseID string) (string, string, error) { + name, err := coderWorkspaceName(prefix, slug, leaseID) + if err != nil { + return "", "", err + } + if !coderWorkspaceNameExists(workspaces, name) { + return slug, name, nil + } + candidateSlug := coderCollisionSlug(slug, leaseID) + name, err = coderWorkspaceName(prefix, candidateSlug, leaseID) + if err != nil { + return "", "", err + } + if coderWorkspaceNameExists(workspaces, name) { + return "", "", exit(5, "coder workspace name %q collides with existing inventory", name) + } + return candidateSlug, name, nil +} + +func coderWorkspaceNameExists(workspaces []coderWorkspace, name string) bool { + for _, workspace := range workspaces { + if normalizeCoderWorkspaceIdentifier(workspace.Name) == normalizeCoderWorkspaceIdentifier(name) { + return true + } + } + return false +} + func coderWorkspaceName(prefix, slug, leaseID string) (string, error) { cleanPrefix, err := cleanCoderWorkspacePrefix(prefix) if err != nil { @@ -627,7 +660,17 @@ func coderWorkspaceName(prefix, slug, leaseID string) (string, error) { return "", exit(2, "coder.workspacePrefix %q leaves no room for a workspace name", cleanPrefix) } if len(base) > maxBase { - base = strings.Trim(base[:maxBase], "-") + hash := coderWorkspaceHash(base) + if maxBase <= len(hash)+1 { + base = hash[:maxBase] + } else { + prefixPart := strings.Trim(base[:maxBase-len(hash)-1], "-") + if prefixPart == "" { + base = hash[:maxBase] + } else { + base = prefixPart + "-" + hash + } + } } name := strings.Trim(cleanPrefix+base, "-") if len(name) < 1 || len(name) > 32 { @@ -641,3 +684,24 @@ func coderWorkspaceName(prefix, slug, leaseID string) (string, error) { } return name, nil } + +func coderCollisionSlug(slug, leaseID string) string { + slug = normalizeLeaseSlug(slug) + suffix := coderWorkspaceHash(leaseID) + maxBase := coderMaxRequestedSlugLength - len(suffix) - 1 + if maxBase < 1 { + return suffix[:coderMaxRequestedSlugLength] + } + if len(slug) > maxBase { + slug = strings.Trim(slug[:maxBase], "-") + } + if slug == "" { + return suffix + } + return slug + "-" + suffix +} + +func coderWorkspaceHash(value string) string { + sum := sha1.Sum([]byte(value)) + return hex.EncodeToString(sum[:])[:coderWorkspaceHashLength] +} diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 0330570e7..edbbda81d 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "io" + "regexp" "strings" "testing" "time" @@ -551,7 +552,6 @@ func TestCoderWorkspaceNameRules(t *testing.T) { }{ {prefix: "crabbox-", slug: "Blue Workspace", leaseID: "cbx_123456abcdef", want: "crabbox-blue-workspace"}, {prefix: "cbx", slug: "new", leaseID: "cbx_123456abcdef", want: "cbx-cbx-new"}, - {prefix: "crabbox-", slug: "this-name-is-much-longer-than-coder-allows", leaseID: "cbx_123456abcdef", want: "crabbox-this-name-is-much-longer"}, } { got, err := coderWorkspaceName(tc.prefix, tc.slug, tc.leaseID) if err != nil { @@ -561,6 +561,47 @@ func TestCoderWorkspaceNameRules(t *testing.T) { t.Fatalf("coderWorkspaceName(%q,%q)=%q want %q", tc.prefix, tc.slug, got, tc.want) } } + got, err := coderWorkspaceName("crabbox-", "this-name-is-much-longer-than-coder-allows", "cbx_123456abcdef") + if err != nil { + t.Fatal(err) + } + if matched := regexp.MustCompile(`^crabbox-this-name-is-much-[0-9a-f]{6}$`).MatchString(got); !matched { + t.Fatalf("unexpected long workspace name %q", got) + } +} + +func TestCoderWorkspaceNameDisambiguatesLongSlugs(t *testing.T) { + first, err := coderWorkspaceName("crabbox-", "this-name-is-much-longer-than-coder-allows-alpha", "cbx_first") + if err != nil { + t.Fatal(err) + } + second, err := coderWorkspaceName("crabbox-", "this-name-is-much-longer-than-coder-allows-bravo", "cbx_second") + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatalf("long slugs collided: %q", first) + } +} + +func TestCoderUniqueWorkspaceNameFallsBackOnExistingNameCollision(t *testing.T) { + existingName, err := coderWorkspaceName("crabbox-", "this-name-is-much-longer-than-coder-allows", "cbx_existing") + if err != nil { + t.Fatal(err) + } + slug, name, err := coderUniqueWorkspaceName([]coderWorkspace{{Name: existingName}}, "crabbox-", "this-name-is-much-longer-than-coder-allows", "cbx_123456abcdef") + if err != nil { + t.Fatal(err) + } + if slug == "this-name-is-much-longer-than-coder-allows" { + t.Fatalf("expected collision slug, got %q", slug) + } + if name == existingName { + t.Fatalf("expected unique workspace name, got %q", name) + } + if !strings.HasSuffix(slug, "-"+coderWorkspaceHash("cbx_123456abcdef")) { + t.Fatalf("collision slug %q missing lease hash suffix", slug) + } } func installCoderClaimState(t *testing.T) { From bc2340b656afd3081c87abd8cac4818f22aa57d9 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:23:42 -0700 Subject: [PATCH 06/15] fix(provider): resolve claimed owner-qualified Coder workspaces Decide between coder list and coder list --all from the original request or stored claim reference before resolving and keep that same scope for post-start refreshes. This lets lease-id and slug based commands keep working when a claim points at an owner-qualified workspace. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 37 ++++++++++++--- internal/providers/coder/backend_test.go | 58 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index b40ff2cee..a262c77f8 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -151,7 +151,11 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le return LeaseTarget{}, err } listFn := client.list - if strings.Contains(strings.TrimSpace(req.ID), "/") { + useListAll, err := b.resolveNeedsListAll(req.ID) + if err != nil { + return LeaseTarget{}, err + } + if useListAll { listFn = client.listAll } workspaces, err := listFn(ctx) @@ -176,7 +180,11 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le if err := client.start(ctx, workspaceRef); err != nil { return LeaseTarget{}, err } - workspaces, err = client.list(ctx) + refreshList := client.list + if useListAll { + refreshList = client.listAll + } + workspaces, err = refreshList(ctx) if err != nil { return LeaseTarget{}, err } @@ -199,6 +207,18 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil } +func (b *coderLeaseBackend) resolveNeedsListAll(identifier string) (bool, error) { + identifier = strings.TrimSpace(identifier) + if strings.Contains(identifier, "/") { + return true, nil + } + claim, ok, err := resolveLeaseClaimForProvider(identifier, coderProvider) + if err != nil || !ok { + return false, err + } + return strings.Contains(coderClaimWorkspaceRef(claim), "/"), nil +} + func (b *coderLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) { client, err := newCoderClient(b.cfg, b.rt) if err != nil { @@ -413,10 +433,7 @@ func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []cod if claim, ok, err := resolveLeaseClaimForProvider(identifier, coderProvider); err != nil { return coderWorkspace{}, "", "", err } else if ok { - name := strings.TrimSpace(claim.Labels["coder_workspace_ref"]) - if name == "" { - name = strings.TrimSpace(claim.Labels["coder_workspace"]) - } + name := coderClaimWorkspaceRef(claim) if name == "" { var err error name, err = coderWorkspaceName(b.cfg.Coder.WorkspacePrefix, claim.Slug, claim.LeaseID) @@ -455,6 +472,14 @@ func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []cod return matches[0], leaseID, slug, nil } +func coderClaimWorkspaceRef(claim LeaseClaim) string { + name := strings.TrimSpace(claim.Labels["coder_workspace_ref"]) + if name == "" { + name = strings.TrimSpace(claim.Labels["coder_workspace"]) + } + return name +} + func coderWorkspacesToServers(workspaces []coderWorkspace, cfg Config) []Server { servers := make([]Server, 0, len(workspaces)) for _, workspace := range workspaces { diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index edbbda81d..c7d2fa5b0 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -514,6 +514,64 @@ func TestCoderResolveClaimUsesStoredWorkspaceAcrossPrefixChanges(t *testing.T) { } } +func TestCoderResolveClaimUsesListAllForOwnerQualifiedWorkspaceRef(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_owner", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("expected owner-qualified claim resolve to use list --all, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "cbx_owner", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.LeaseID != "cbx_owner" || lease.Server.Labels["coder_workspace_ref"] != "alice/shared" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderResolveNeedsListAllUsesOnlyOwnerQualifiedRequestOrClaim(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_owner", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}} + tests := []struct { + id string + want bool + }{ + {id: "alice/shared", want: true}, + {id: "cbx_owner", want: true}, + {id: "blue", want: false}, + } + for _, tc := range tests { + got, err := backend.resolveNeedsListAll(tc.id) + if err != nil { + t.Fatalf("resolveNeedsListAll(%q): %v", tc.id, err) + } + if got != tc.want { + t.Fatalf("resolveNeedsListAll(%q)=%v want %v", tc.id, got, tc.want) + } + } +} + func TestCoderOwnerQualifiedResolveAndReleaseUseOwnerWorkspace(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { From b27d29a451b35041e6246df716637a1e36f0104d Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:29:06 -0700 Subject: [PATCH 07/15] fix(provider): report ready Coder status accurately Attach the proxy SSH target to ready status-only leases and preserve accumulated doctor checks when inventory listing fails. This keeps status JSON honest for ready workspaces and retains inventory diagnostics in failing doctor output. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 7 ++-- internal/providers/coder/backend_test.go | 46 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index a262c77f8..baa30b390 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -170,10 +170,9 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le workspaceRef := coderWorkspaceCommandName(workspace) if req.ReleaseOnly || req.StatusOnly { lease := LeaseTarget{Server: server, LeaseID: leaseID} - if !req.ReadyProbe || !coderWorkspaceReady(workspace) { - return lease, nil + if coderWorkspaceReady(workspace) && (req.StatusOnly || req.ReadyProbe) { + lease.SSH = coderSSHTarget(b.cfg, workspaceRef) } - lease.SSH = coderSSHTarget(b.cfg, workspaceRef) return lease, nil } if !coderWorkspaceReady(workspace) { @@ -258,7 +257,7 @@ func (b *coderLeaseBackend) Doctor(ctx context.Context, _ DoctorRequest) (Doctor servers, err := b.List(ctx, ListRequest{}) if err != nil { checks = append(checks, DoctorCheck{Status: "fail", Check: "inventory", Message: err.Error(), Details: map[string]string{"mutation": "false"}}) - return DoctorResult{Provider: coderProvider, Status: "fail", Message: "cli=ready auth=ready inventory=failed mutation=false"}, err + return DoctorResult{Provider: coderProvider, Status: "fail", Message: "cli=ready auth=ready inventory=failed mutation=false", Checks: checks}, err } checks = append(checks, DoctorCheck{Status: "pass", Check: "inventory", Message: fmt.Sprintf("listed %d Crabbox-owned Coder workspaces", len(servers)), Details: map[string]string{"mutation": "false"}}) return DoctorResult{Provider: coderProvider, Status: "pass", Message: fmt.Sprintf("cli=ready auth=ready inventory=ready api=list mutation=false leases=%d runtime=unchecked", len(servers)), Checks: checks}, nil diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index c7d2fa5b0..2e9b5c7e1 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -214,6 +214,31 @@ func TestCoderDoctorClassifiesMissingLoginNonMutating(t *testing.T) { } } +func TestCoderDoctorPreservesChecksOnInventoryFailure(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "version": + return LocalCommandResult{Stdout: "Coder v2.33.5"}, nil + case "whoami -o json": + return LocalCommandResult{Stdout: `{"username":"alice"}`}, nil + case "list -o json": + return LocalCommandResult{ExitCode: 1, Stderr: "inventory unavailable"}, errors.New("inventory unavailable") + default: + t.Fatalf("doctor must only read inventory, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + result, err := backend.Doctor(context.Background(), DoctorRequest{}) + if err == nil || !strings.Contains(err.Error(), "inventory unavailable") { + t.Fatalf("expected inventory error, got %v", err) + } + if len(result.Checks) != 3 || result.Checks[2].Check != "inventory" || result.Checks[2].Status != "fail" { + t.Fatalf("unexpected doctor checks: %#v", result.Checks) + } +} + func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { installCoderClaimState(t) runner := &fakeRunner{} @@ -485,6 +510,27 @@ func TestCoderResolveStatusOnlyDoesNotStartOrSSH(t *testing.T) { } } +func TestCoderResolveStatusOnlyIncludesSSHForReadyWorkspace(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"running","resources":[{"agents":[{"name":"main","operating_system":"linux","status":"connected","lifecycle_state":"ready"}]}]}}]`}, nil + default: + t.Fatalf("status-only ready resolve must only list, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.SSH.Host != "crabbox-blue" || !lease.SSH.SSHConfigProxy { + t.Fatalf("expected status-only ready lease to include SSH target, got %#v", lease) + } +} + func TestCoderResolveClaimUsesStoredWorkspaceAcrossPrefixChanges(t *testing.T) { installCoderClaimState(t) if err := claimLeaseForRepoProvider("cbx_prefix", "blue", coderProvider, t.TempDir(), time.Hour, false); err != nil { From daece7e48bf318716fce5163a0cdca41327aacb8 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:42:18 -0700 Subject: [PATCH 08/15] fix(provider): honor Coder work roots and SSH aliases Propagate Coder work-root defaults through normal config loading, stamp resolved Coder servers with that work root, and use unique slash-free SSH host aliases for owner-qualified workspaces while keeping the full ref in the proxy command. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/cli/config.go | 24 ++++++++++++++++++- internal/cli/config_test.go | 30 ++++++++++++++++++++++++ internal/providers/coder/backend.go | 24 ++++++++++++++++++- internal/providers/coder/backend_test.go | 23 +++++++++++++++++- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/internal/cli/config.go b/internal/cli/config.go index f83433dc4..815ac8278 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -1889,13 +1889,35 @@ func applyProviderConfigDefaults(cfg *Config) error { } return nil } + if cfg.Provider == "coder" { + base := baseConfig() + if cfg.SSHUser == "" || cfg.SSHUser == base.SSHUser { + cfg.SSHUser = "coder" + } + if cfg.SSHPort == "" || cfg.SSHPort == base.SSHPort { + cfg.SSHPort = "22" + } + cfg.SSHFallbackPorts = nil + if !isDefaultWorkRoot(cfg.WorkRoot) && (cfg.Coder.WorkRoot == "" || cfg.Coder.WorkRoot == base.Coder.WorkRoot) { + cfg.Coder.WorkRoot = cfg.WorkRoot + } else if cfg.Coder.WorkRoot == "" { + cfg.Coder.WorkRoot = base.Coder.WorkRoot + } + if cfg.Coder.WorkRoot != "" { + cfg.WorkRoot = cfg.Coder.WorkRoot + } + if cfg.TargetOS == "" { + cfg.TargetOS = targetLinux + } + return nil + } if cfg.Provider == "firecracker" { base := baseConfig() if cfg.Firecracker.User != "" && (cfg.SSHUser == "" || cfg.SSHUser == base.SSHUser || cfg.Firecracker.User != base.Firecracker.User) { cfg.SSHUser = cfg.Firecracker.User } if cfg.SSHPort == "" || cfg.SSHPort == base.SSHPort { - cfg.SSHPort = "22" + cfg.SSHPort = blank(cfg.Firecracker.SSHPort, "22") } cfg.SSHFallbackPorts = nil if cfg.Firecracker.WorkRoot != "" && (isDefaultWorkRoot(cfg.WorkRoot) || cfg.Firecracker.WorkRoot != base.Firecracker.WorkRoot) { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 00c3062a5..6ba65021a 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -2811,6 +2811,36 @@ func TestTartConfigDefaultsFileAndEnv(t *testing.T) { } } +func TestCoderConfigDefaultsSetWorkRoot(t *testing.T) { + clearConfigEnv(t) + cfg := baseConfig() + cfg.Provider = "coder" + if err := applyProviderConfigDefaults(&cfg); err != nil { + t.Fatal(err) + } + if cfg.WorkRoot != "/home/coder/crabbox" { + t.Fatalf("WorkRoot=%q want /home/coder/crabbox", cfg.WorkRoot) + } + cfg = baseConfig() + cfg.Provider = "coder" + cfg.Coder.WorkRoot = "/home/coder/custom" + if err := applyProviderConfigDefaults(&cfg); err != nil { + t.Fatal(err) + } + if cfg.WorkRoot != "/home/coder/custom" { + t.Fatalf("custom WorkRoot=%q want /home/coder/custom", cfg.WorkRoot) + } + cfg = baseConfig() + cfg.Provider = "coder" + cfg.WorkRoot = "/tmp/explicit" + if err := applyProviderConfigDefaults(&cfg); err != nil { + t.Fatal(err) + } + if cfg.Coder.WorkRoot != "/tmp/explicit" || cfg.WorkRoot != "/tmp/explicit" { + t.Fatalf("explicit top-level work root not propagated: coder=%q work=%q", cfg.Coder.WorkRoot, cfg.WorkRoot) + } +} + func TestIncusConfigDefaultsFileAndEnv(t *testing.T) { clearConfigEnv(t) cfg := baseConfig() diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index baa30b390..df3c583c3 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -501,6 +501,7 @@ func coderWorkspaceToServer(workspace coderWorkspace, cfg Config, leaseID, slug } labels["coder_workspace"] = workspace.Name labels["coder_workspace_ref"] = coderWorkspaceCommandName(workspace) + labels["work_root"] = coderWorkRoot(cfg) labels["state"] = coderWorkspaceState(workspace) server := Server{CloudID: workspace.Name, Provider: coderProvider, Name: workspace.Name, Status: labels["state"], Labels: labels} server.ServerType.Name = blank(workspace.Template, "coder-workspace") @@ -550,9 +551,10 @@ func coderSlugFromWorkspace(name, prefix string) string { } func coderSSHTarget(cfg Config, workspaceName string) SSHTarget { + host := coderWorkspaceSSHHost(workspaceName) return SSHTarget{ User: "coder", - Host: workspaceName, + Host: host, Port: "22", TargetOS: targetLinux, NetworkKind: networkPublic, @@ -562,6 +564,26 @@ func coderSSHTarget(cfg Config, workspaceName string) SSHTarget { } } +func coderWorkspaceSSHHost(ref string) string { + ref = strings.TrimSpace(ref) + if !strings.Contains(ref, "/") { + return blank(coderWorkspaceNameFromRef(ref), "coder-workspace") + } + base := normalizeLeaseSlug(ref) + hash := coderWorkspaceHash(ref) + maxBase := 63 - len("coder-") - len(hash) - 1 + if maxBase < 1 { + return "coder-" + hash + } + if len(base) > maxBase { + base = strings.Trim(base[:maxBase], "-") + } + if base == "" { + return "coder-" + hash + } + return "coder-" + base + "-" + hash +} + func coderWorkspaceReady(workspace coderWorkspace) bool { state := coderWorkspaceState(workspace) if state == "ready" || state == "running" { diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 2e9b5c7e1..654d8406f 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -97,6 +97,24 @@ func TestCoderSSHTargetUsesProxyCommand(t *testing.T) { } } +func TestCoderSSHTargetUsesValidHostForOwnerQualifiedWorkspace(t *testing.T) { + target := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "alice/shared") + if !regexp.MustCompile(`^coder-alice-shared-[0-9a-f]{6}$`).MatchString(target.Host) { + t.Fatalf("Host=%q want unique owner-qualified alias", target.Host) + } + if !strings.Contains(target.ProxyCommand, "'alice/shared'") { + t.Fatalf("proxy command %q missing owner-qualified ref", target.ProxyCommand) + } +} + +func TestCoderSSHTargetKeepsOwnerQualifiedHostsUnique(t *testing.T) { + alice := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "alice/shared") + bob := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "bob/shared") + if alice.Host == bob.Host { + t.Fatalf("owner-qualified SSH hosts collided: %q", alice.Host) + } +} + func TestCoderReleaseStopsByDefaultAndDeletesOnlyWhenConfigured(t *testing.T) { for _, tc := range []struct { name string @@ -273,6 +291,9 @@ func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != "stop --yes crabbox-blue" { t.Fatalf("cleanup final call=%q", got) } + if servers[0].Labels["work_root"] != "/home/coder/crabbox" { + t.Fatalf("work_root label=%q", servers[0].Labels["work_root"]) + } } func TestCoderCleanupSkipsActiveClaimedAndRunningUnclaimedWorkspaces(t *testing.T) { @@ -636,7 +657,7 @@ func TestCoderOwnerQualifiedResolveAndReleaseUseOwnerWorkspace(t *testing.T) { if err != nil { t.Fatal(err) } - if lease.SSH.Host != "alice/shared" || lease.Server.Labels["coder_workspace_ref"] != "alice/shared" { + if !regexp.MustCompile(`^coder-alice-shared-[0-9a-f]{6}$`).MatchString(lease.SSH.Host) || !strings.Contains(lease.SSH.ProxyCommand, "'alice/shared'") || lease.Server.Labels["coder_workspace_ref"] != "alice/shared" { t.Fatalf("owner-qualified target not preserved: %#v", lease) } if err := backend.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: lease}); err != nil { From 8da3b927102b2267ad1ddb156ca273433400f8d9 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:54:11 -0700 Subject: [PATCH 09/15] fix(provider): preserve explicit kept Coder leases Reuse persisted keep metadata from local claims when resolving, listing, and cleaning Coder workspaces so explicit keep requests survive later status and run flows without making ordinary claims immortal. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- internal/providers/coder/backend.go | 31 +++++++++++++-- internal/providers/coder/backend_test.go | 48 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index df3c583c3..26472e087 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -166,7 +166,11 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le if err != nil { return LeaseTarget{}, err } - server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, true) + keep, err := b.resolveKeepLabel(leaseID) + if err != nil { + return LeaseTarget{}, err + } + server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, keep) workspaceRef := coderWorkspaceCommandName(workspace) if req.ReleaseOnly || req.StatusOnly { lease := LeaseTarget{Server: server, LeaseID: leaseID} @@ -189,7 +193,7 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le } if refreshed, found := findCoderWorkspace(workspaces, workspaceRef); found { workspace = refreshed - server = coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, true) + server = coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, keep) workspaceRef = coderWorkspaceCommandName(workspace) } } @@ -218,6 +222,17 @@ func (b *coderLeaseBackend) resolveNeedsListAll(identifier string) (bool, error) return strings.Contains(coderClaimWorkspaceRef(claim), "/"), nil } +func (b *coderLeaseBackend) resolveKeepLabel(leaseID string) (bool, error) { + if leaseID == "" { + return false, nil + } + claim, ok, err := resolveLeaseClaimForProvider(leaseID, coderProvider) + if err != nil || !ok { + return false, err + } + return coderClaimKeep(claim), nil +} + func (b *coderLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) { client, err := newCoderClient(b.cfg, b.rt) if err != nil { @@ -227,13 +242,17 @@ func (b *coderLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseV if err != nil { return nil, err } + claims, err := listCoderClaimsByWorkspace(b.cfg) + if err != nil { + return nil, err + } servers := make([]Server, 0, len(workspaces)) for _, workspace := range workspaces { leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) if !owned && !req.All { continue } - servers = append(servers, coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, true)) + servers = append(servers, coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, coderClaimKeep(claims[workspace.Name]))) } return servers, nil } @@ -389,7 +408,7 @@ func listCoderClaimsByWorkspace(cfg Config) (map[string]LeaseClaim, error) { } func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time.Time) (bool, string) { - if strings.EqualFold(server.Labels["keep"], "true") { + if strings.EqualFold(server.Labels["keep"], "true") || (hasClaim && coderClaimKeep(claim)) { return false, "keep=true" } if hasClaim { @@ -479,6 +498,10 @@ func coderClaimWorkspaceRef(claim LeaseClaim) string { return name } +func coderClaimKeep(claim LeaseClaim) bool { + return strings.EqualFold(claim.Labels["keep"], "true") +} + func coderWorkspacesToServers(workspaces []coderWorkspace, cfg Config) []Server { servers := make([]Server, 0, len(workspaces)) for _, workspace := range workspaces { diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 654d8406f..4b60798e8 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -296,6 +296,33 @@ func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { } } +func TestCoderCleanupSkipsKeptClaims(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_keep", "blue", coderProvider, t.TempDir(), time.Hour, true); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("kept cleanup should not mutate, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected cleanup to stop after inventory for keep=true claim, calls=%#v", runner.calls) + } +} + func TestCoderCleanupSkipsActiveClaimedAndRunningUnclaimedWorkspaces(t *testing.T) { installCoderClaimState(t) if err := claimLeaseForRepoProvider("cbx_active", "active", coderProvider, t.TempDir(), time.Hour, false); err != nil { @@ -581,6 +608,27 @@ func TestCoderResolveClaimUsesStoredWorkspaceAcrossPrefixChanges(t *testing.T) { } } +func TestCoderResolveKeepLabelUsesClaimKeepMetadata(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_keepflag", "blue", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + if err := claimLeaseForRepoProvider("cbx_keeptrue", "green", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "crabbox-green", Labels: map[string]string{"keep": "true"}} + if err := updateLeaseClaimEndpoint("cbx_keeptrue", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}} + if keep, err := backend.resolveKeepLabel("cbx_keepflag"); err != nil || keep { + t.Fatalf("ordinary resolveKeepLabel keep=%v err=%v", keep, err) + } + if keep, err := backend.resolveKeepLabel("cbx_keeptrue"); err != nil || !keep { + t.Fatalf("kept resolveKeepLabel keep=%v err=%v", keep, err) + } +} + func TestCoderResolveClaimUsesListAllForOwnerQualifiedWorkspaceRef(t *testing.T) { installCoderClaimState(t) if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { From 88b7d1c42a6591a9109910e7d553b110d3966c66 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:22:38 -0700 Subject: [PATCH 10/15] fix(provider): harden Coder lease lifecycle Persist Coder release intent in local claims, make workspace names lease-unique, and restrict cleanup to claimed workspaces so Crabbox does not mutate unrelated Coder environments. Also preserve owner-qualified workspace resolution, add safer acquisition rollback, isolate Coder known_hosts state, and include the delete-on-release flag in generated stop commands. Refs: #265 --- docs/commands/cleanup.md | 12 +- docs/providers/coder.md | 32 +- internal/cli/profiles_test.go | 19 + internal/cli/run.go | 2 + internal/providers/coder/backend.go | 489 ++++++++++--- internal/providers/coder/backend_test.go | 893 +++++++++++++++++++++-- internal/providers/coder/client.go | 11 +- 7 files changed, 1297 insertions(+), 161 deletions(-) diff --git a/docs/commands/cleanup.md b/docs/commands/cleanup.md index b5cf88d36..7a73e1a07 100644 --- a/docs/commands/cleanup.md +++ b/docs/commands/cleanup.md @@ -56,10 +56,12 @@ What cleanup does depends on the selected provider: provider scope. It deletes idle-expired Crabbox-owned sandboxes and keeps missing-or-inaccessible claims unless `--cloudflare-sandbox-forget-missing` is explicit. -- **`coder`** lists Coder workspaces and acts only on workspaces with Crabbox - ownership evidence: the configured workspace prefix or Crabbox labels in - Coder JSON. It stops by default and deletes only with `coder.deleteOnRelease` - or `--coder-delete-on-release`. +- **`coder`** lists workspaces with Crabbox ownership evidence, such as the + configured workspace prefix or Crabbox labels in Coder JSON, but mutates only + workspaces that also have a local Crabbox claim with cleanup metadata. It + uses the release action persisted in each local claim: new delete-on-release + claims delete, stop-on-release claims stop, and older claims without that + metadata default to stop. - Providers that have nothing to sweep return an error rather than acting. For example `provider=ssh` (static / bring-your-own hosts) reports: @@ -128,7 +130,7 @@ When no matching files exist: namespace ssh cleanup no crabbox files found ``` -Coder cleanup prints one line per owned workspace: +Coder cleanup prints one line per cleanup-eligible claimed workspace: ```text coder cleanup stop workspace=crabbox-blue dry_run=true diff --git a/docs/providers/coder.md b/docs/providers/coder.md index 97a15b7b5..c88b11015 100644 --- a/docs/providers/coder.md +++ b/docs/providers/coder.md @@ -87,9 +87,11 @@ the Coder template or preset. ## Lifecycle New leases create a Coder workspace with a Coder-safe name derived from the -Crabbox slug and `coder.workspacePrefix`. Workspace names are lowercase, -hyphenated, 1-32 characters, and avoid Coder's reserved `new` and `create` -names. +Crabbox slug, `coder.workspacePrefix`, and a short lease hash suffix. The local +Crabbox slug remains the friendly lookup handle, while the Coder workspace name +is lease-unique so failed provisioning rollback cannot stop or delete another +same-slug workspace. Workspace names are lowercase, hyphenated, 1-32 characters, +and avoid Coder's reserved `new` and `create` names. Crabbox can resolve a Coder lease by Crabbox lease ID, local slug, Coder workspace name, or `owner/workspace` when the Coder inventory contains a unique @@ -101,11 +103,19 @@ Release is conservative: local Crabbox claim. - Deletion requires `coder.deleteOnRelease: true` or `--coder-delete-on-release`, which runs `coder delete --yes `. - -Cleanup is also conservative. It only acts on workspaces that look -Crabbox-owned through the configured workspace prefix or Crabbox labels in -Coder JSON. `crabbox cleanup --provider coder --dry-run` prints the intended -stop/delete actions without mutating workspaces. + New local claims persist that release action so later cleanup does not turn an + originally stop-on-release workspace into a delete-on-cleanup workspace after + a config change. + +Cleanup is also conservative. It lists workspaces that look Crabbox-owned +through the configured workspace prefix or Crabbox labels in Coder JSON, but it +only stops or deletes workspaces that also have a local Crabbox claim with +cleanup metadata. Prefix-owned workspaces without a local claim are skipped, +because stopped Coder workspaces are normal reusable environments and Coder does +not expose a generic `coder create` label flag for Crabbox-owned lifecycle +metadata. `crabbox cleanup --provider coder --dry-run` prints the intended +stop/delete actions without mutating workspaces. Older local claims that do not +record a Coder release action default to stop during cleanup. ## SSH @@ -119,6 +129,12 @@ Crabbox marks the target as proxy-backed instead of trying to discover a raw host or port. The ready check verifies the standard Crabbox sync prerequisites: `git`, `rsync`, and `tar`. +Coder SSH targets use stable synthetic host aliases for SSH config reuse, but +their `known_hosts` entries are isolated under Crabbox's Coder config directory +and keyed by the Coder workspace identity when available. This avoids stale +global host keys when a disposable workspace is deleted and later recreated with +the same name. + ## Examples ```sh diff --git a/internal/cli/profiles_test.go b/internal/cli/profiles_test.go index 46b5bb77d..f566dddec 100644 --- a/internal/cli/profiles_test.go +++ b/internal/cli/profiles_test.go @@ -740,6 +740,25 @@ func TestRunStopCommandIncludesSemaphoreRoutingFlags(t *testing.T) { } } +func TestRunStopCommandIncludesCoderDeleteFlag(t *testing.T) { + got := runStopCommand(Config{ + Provider: "coder", + TargetOS: targetLinux, + Coder: CoderConfig{ + DeleteOnRelease: true, + }, + }, "cbx_123") + for _, want := range []string{ + "--provider coder", + "--coder-delete-on-release=true", + "--id cbx_123", + } { + if !strings.Contains(got, want) { + t.Fatalf("stop command missing %q:\n%s", want, got) + } + } +} + func TestRunStopCommandIncludesExeDevRoutingFlags(t *testing.T) { got := runStopCommand(Config{ Provider: "exe-dev", diff --git a/internal/cli/run.go b/internal/cli/run.go index 524697e72..34a86f5ec 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -1894,6 +1894,8 @@ func appendProviderStopRoutingArgs(args []string, cfg Config, id string) []strin if DeleteOnReleaseExplicit(cfg, "namespace-devbox") { args = append(args, fmt.Sprintf("--namespace-delete-on-release=%t", cfg.Namespace.DeleteOnRelease)) } + case "coder": + args = append(args, fmt.Sprintf("--coder-delete-on-release=%t", cfg.Coder.DeleteOnRelease)) case "daytona": if strings.TrimSpace(cfg.Daytona.APIURL) != "" { args = append(args, "--daytona-api-url", cfg.Daytona.APIURL) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index 26472e087..daac94e06 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -6,11 +6,19 @@ import ( "encoding/hex" "errors" "fmt" + "os" + "path/filepath" "regexp" "strings" "time" ) +const ( + coderReleaseActionLabel = "coder_release_action" + coderReleaseActionStop = "stop" + coderReleaseActionDelete = "delete" +) + type coderLeaseBackend struct { spec ProviderSpec cfg Config @@ -66,68 +74,68 @@ func (b *coderLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (Le if err != nil { return LeaseTarget{}, err } + if err := claimLeaseForRepoProvider(leaseID, slug, coderProvider, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil { + return LeaseTarget{}, err + } + _ = updateLeaseClaimEndpoint(leaseID, coderWorkspaceToServer(coderWorkspace{Name: workspaceName}, b.cfg, leaseID, slug, req.Keep), SSHTarget{}) fmt.Fprintf(b.rt.Stderr, "provisioning provider=coder lease=%s slug=%s workspace=%s template=%s keep=%v\n", leaseID, slug, workspaceName, b.cfg.Coder.Template, req.Keep) if err := client.create(ctx, b.cfg, workspaceName); err != nil { if !req.Keep { - err = b.rollbackCreateError(workspaceName, client, err) + err = b.rollbackCreateError(workspaceName, leaseID, client, err) } return LeaseTarget{}, err } workspaces, err := client.list(ctx) if err != nil { if !req.Keep { - err = b.rollbackCreatedWorkspace(workspaceName, client, err) + err = b.rollbackCreatedWorkspace(workspaceName, leaseID, client, err) } return LeaseTarget{}, err } workspace, ok := findCoderWorkspace(workspaces, workspaceName) if !ok { if !req.Keep { - err = b.rollbackCreatedWorkspace(workspaceName, client, exit(5, "coder workspace %s was created but not found in coder list", workspaceName)) + err = b.rollbackCreatedWorkspace(workspaceName, leaseID, client, exit(5, "coder workspace %s was created but not found in coder list", workspaceName)) return LeaseTarget{}, err } return LeaseTarget{}, exit(5, "coder workspace %s was created but not found in coder list", workspaceName) } server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, req.Keep) - target := coderSSHTarget(b.cfg, workspaceName) + target := coderSSHTarget(b.cfg, workspaceName, workspace.ID) if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "coder ssh", bootstrapWaitTimeout(b.cfg)); err != nil { if !req.Keep { - err = b.rollbackCreatedWorkspace(workspaceName, client, err) + err = b.rollbackCreatedWorkspace(workspaceName, leaseID, client, err) } return LeaseTarget{}, err } server.Status = "ready" server.Labels["state"] = "ready" - if err := claimLeaseForRepoProvider(leaseID, slug, coderProvider, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil { - if !req.Keep { - err = b.rollbackCreatedWorkspace(workspaceName, client, err) - } - return LeaseTarget{}, err - } _ = updateLeaseClaimEndpoint(leaseID, server, target) return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil } -func (b *coderLeaseBackend) rollbackCreatedWorkspace(name string, client *coderClient, cause error) error { +func (b *coderLeaseBackend) rollbackCreateError(name, leaseID string, client *coderClient, cause error) error { cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := b.releaseWorkspace(cleanupCtx, client, name); err != nil { - return exit(coderExitCode(cause), "%v; coder cleanup failed for workspace %s; manual cleanup: crabbox stop --provider coder %s: %v", cause, name, name, err) + workspaces, err := client.list(cleanupCtx) + if err != nil { + return cause } + if _, ok := findCoderWorkspace(workspaces, name); ok { + return b.rollbackCreatedWorkspace(name, leaseID, client, cause) + } + removeLeaseClaim(leaseID) return cause } -func (b *coderLeaseBackend) rollbackCreateError(name string, client *coderClient, cause error) error { +func (b *coderLeaseBackend) rollbackCreatedWorkspace(name, leaseID string, client *coderClient, cause error) error { cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - workspaces, err := client.list(cleanupCtx) - if err != nil { - return cause + if err := client.delete(cleanupCtx, name); err != nil { + return exit(coderExitCode(cause), "%v; coder cleanup failed for workspace %s; manual cleanup: crabbox stop --provider coder --coder-delete-on-release --id %s: %v", cause, name, name, err) } - if _, ok := findCoderWorkspace(workspaces, name); !ok { - return cause - } - return b.rollbackCreatedWorkspace(name, client, cause) + removeLeaseClaim(leaseID) + return cause } func (b *coderLeaseBackend) releaseWorkspace(ctx context.Context, client *coderClient, name string) error { @@ -150,11 +158,18 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le if err != nil { return LeaseTarget{}, err } - listFn := client.list useListAll, err := b.resolveNeedsListAll(req.ID) if err != nil { return LeaseTarget{}, err } + claims, err := listCoderClaimsByWorkspace(b.cfg) + if err != nil { + return LeaseTarget{}, err + } + if coderClaimsNeedListAllForIdentifier(claims, req.ID) { + useListAll = true + } + listFn := client.list if useListAll { listFn = client.listAll } @@ -162,20 +177,27 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le if err != nil { return LeaseTarget{}, err } - workspace, leaseID, slug, err := b.resolveWorkspace(req.ID, workspaces) + nameCounts := coderWorkspaceNameCounts(workspaces) + workspace, leaseID, slug, err := b.resolveWorkspace(req.ID, workspaces, claims, nameCounts) if err != nil { + if req.ReleaseOnly { + if claim, ok := coderClaimForIdentifier(claims, req.ID); ok { + return coderStaleClaimLeaseTarget(b.cfg, claim), nil + } + } return LeaseTarget{}, err } keep, err := b.resolveKeepLabel(leaseID) if err != nil { return LeaseTarget{}, err } - server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, keep) + claim, hasClaim := coderClaimForWorkspaceInInventory(claims, workspace, nameCounts) + server := coderWorkspaceToServerWithClaim(workspace, b.cfg, leaseID, slug, keep, claim, hasClaim) workspaceRef := coderWorkspaceCommandName(workspace) if req.ReleaseOnly || req.StatusOnly { lease := LeaseTarget{Server: server, LeaseID: leaseID} - if coderWorkspaceReady(workspace) && (req.StatusOnly || req.ReadyProbe) { - lease.SSH = coderSSHTarget(b.cfg, workspaceRef) + if coderWorkspaceReady(workspace) { + lease.SSH = coderSSHTarget(b.cfg, workspaceRef, workspace.ID) } return lease, nil } @@ -193,11 +215,13 @@ func (b *coderLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (Le } if refreshed, found := findCoderWorkspace(workspaces, workspaceRef); found { workspace = refreshed - server = coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, keep) + nameCounts = coderWorkspaceNameCounts(workspaces) + claim, hasClaim = coderClaimForWorkspaceInInventory(claims, workspace, nameCounts) + server = coderWorkspaceToServerWithClaim(workspace, b.cfg, leaseID, slug, keep, claim, hasClaim) workspaceRef = coderWorkspaceCommandName(workspace) } } - target := coderSSHTarget(b.cfg, workspaceRef) + target := coderSSHTarget(b.cfg, workspaceRef, workspace.ID) if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "coder ssh", bootstrapWaitTimeout(b.cfg)); err != nil { return LeaseTarget{}, err } @@ -238,21 +262,35 @@ func (b *coderLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseV if err != nil { return nil, err } - workspaces, err := client.list(ctx) + claims, err := listCoderClaimsByWorkspace(b.cfg) if err != nil { return nil, err } - claims, err := listCoderClaimsByWorkspace(b.cfg) + listFn := client.list + if coderClaimsNeedListAll(claims) { + listFn = client.listAll + } + workspaces, err := listFn(ctx) if err != nil { return nil, err } servers := make([]Server, 0, len(workspaces)) + nameCounts := coderWorkspaceNameCounts(workspaces) for _, workspace := range workspaces { leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) - if !owned && !req.All { + claim, hasClaim := coderClaimForWorkspaceInInventory(claims, workspace, nameCounts) + if !owned && !hasClaim && !req.All { continue } - servers = append(servers, coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, coderClaimKeep(claims[workspace.Name]))) + if hasClaim { + if leaseID == "" { + leaseID = claim.LeaseID + } + if slug == "" { + slug = claim.Slug + } + } + servers = append(servers, coderWorkspaceToServerWithClaim(workspace, b.cfg, leaseID, slug, hasClaim && coderClaimKeep(claim), claim, hasClaim)) } return servers, nil } @@ -269,8 +307,14 @@ func (b *coderLeaseBackend) Doctor(ctx context.Context, _ DoctorRequest) (Doctor } checks = append(checks, DoctorCheck{Status: "pass", Check: "cli", Message: "coder CLI available", Details: map[string]string{"mutation": "false"}}) if err := client.whoami(ctx); err != nil { - checks = append(checks, DoctorCheck{Status: "fail", Check: "auth", Message: err.Error(), Details: map[string]string{"mutation": "false", "classification": "missing_login"}}) - return DoctorResult{Provider: coderProvider, Status: "fail", Message: "cli=ready auth=missing_login inventory=unchecked mutation=false", Checks: checks}, err + authStatus := "failed" + classification := "auth_failed" + if coderWhoamiMissingLogin(err.Error()) { + authStatus = "missing_login" + classification = "missing_login" + } + checks = append(checks, DoctorCheck{Status: "fail", Check: "auth", Message: err.Error(), Details: map[string]string{"mutation": "false", "classification": classification}}) + return DoctorResult{Provider: coderProvider, Status: "fail", Message: fmt.Sprintf("cli=ready auth=%s inventory=unchecked mutation=false", authStatus), Checks: checks}, err } checks = append(checks, DoctorCheck{Status: "pass", Check: "auth", Message: "coder login ready", Details: map[string]string{"mutation": "false"}}) servers, err := b.List(ctx, ListRequest{}) @@ -300,12 +344,12 @@ func (b *coderLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRe if name == "" { return exit(2, "coder release requires a workspace name") } - if b.cfg.Coder.DeleteOnRelease { - err = client.delete(ctx, name) - } else { - err = client.stop(ctx, name) - } + err = b.releaseWorkspace(ctx, client, name) if err != nil { + if coderWorkspaceMissingError(err) && req.Lease.LeaseID != "" { + removeLeaseClaim(req.Lease.LeaseID) + return nil + } return err } removeLeaseClaim(req.Lease.LeaseID) @@ -334,43 +378,48 @@ func (b *coderLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) err if err != nil { return err } - workspaces, err := client.list(ctx) + claims, err := listCoderClaimsByWorkspace(b.cfg) if err != nil { return err } - claims, err := listCoderClaimsByWorkspace(b.cfg) + listFn := client.list + if coderClaimsNeedListAll(claims) { + listFn = client.listAll + } + workspaces, err := listFn(ctx) if err != nil { return err } now := time.Now().UTC() + nameCounts := coderWorkspaceNameCounts(workspaces) for _, workspace := range workspaces { leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) - if !owned { + claim, hasClaim := coderClaimForWorkspaceInInventory(claims, workspace, nameCounts) + if !owned && !hasClaim { continue } - server := coderWorkspaceToServer(workspace, b.cfg, leaseID, slug, false) - claim, hasClaim := claims[workspace.Name] if leaseID == "" && hasClaim { leaseID = claim.LeaseID } + if slug == "" && hasClaim { + slug = claim.Slug + } + server := coderWorkspaceToServerWithClaim(workspace, b.cfg, leaseID, slug, false, claim, hasClaim) shouldAct, reason := shouldCleanupCoder(server, claim, hasClaim, now) if !shouldAct { fmt.Fprintf(b.rt.Stderr, "skip coder workspace=%s reason=%s\n", workspace.Name, reason) continue } - action := "stop" - if b.cfg.Coder.DeleteOnRelease { - action = "delete" - } + action := coderCleanupReleaseAction(claim, hasClaim) fmt.Fprintf(b.rt.Stdout, "coder cleanup %s workspace=%s lease=%s reason=%s dry_run=%t\n", action, workspace.Name, blank(leaseID, "-"), reason, req.DryRun) if req.DryRun { continue } - if b.cfg.Coder.DeleteOnRelease { - if err := client.delete(ctx, workspace.Name); err != nil { + if action == coderReleaseActionDelete { + if err := client.delete(ctx, coderWorkspaceCommandName(workspace)); err != nil { return err } - } else if err := client.stop(ctx, workspace.Name); err != nil { + } else if err := client.stop(ctx, coderWorkspaceCommandName(workspace)); err != nil { return err } if leaseID != "" { @@ -390,23 +439,82 @@ func listCoderClaimsByWorkspace(cfg Config) (map[string]LeaseClaim, error) { if claim.Provider != coderProvider { continue } - name := strings.TrimSpace(claim.Labels["coder_workspace"]) - if name == "" { - name = coderWorkspaceNameFromRef(claim.Labels["coder_workspace_ref"]) - } + name := coderClaimWorkspaceRef(claim) if name == "" { - name, err = coderWorkspaceName(cfg.Coder.WorkspacePrefix, claim.Slug, claim.LeaseID) + name, err = coderClaimWorkspaceName(cfg, claim) if err != nil { continue } } if name != "" { - out[name] = claim + out[coderClaimKey(name)] = claim } } return out, nil } +func coderClaimForWorkspace(claims map[string]LeaseClaim, workspace coderWorkspace) (LeaseClaim, bool) { + return coderClaimForWorkspaceInInventory(claims, workspace, nil) +} + +func coderClaimForWorkspaceInInventory(claims map[string]LeaseClaim, workspace coderWorkspace, nameCounts map[string]int) (LeaseClaim, bool) { + if claim, ok := claims[coderClaimKey(coderWorkspaceCommandName(workspace))]; ok { + return claim, true + } + if strings.TrimSpace(workspace.Owner) != "" { + if nameCounts == nil || nameCounts[coderClaimKey(workspace.Name)] != 1 { + return LeaseClaim{}, false + } + } + if claim, ok := claims[coderClaimKey(workspace.Name)]; ok { + return claim, true + } + return LeaseClaim{}, false +} + +func coderClaimKey(ref string) string { + return normalizeCoderWorkspaceIdentifier(strings.TrimSpace(ref)) +} + +func coderClaimsNeedListAll(claims map[string]LeaseClaim) bool { + for key := range claims { + if strings.Contains(key, "/") { + return true + } + } + return false +} + +func coderClaimsNeedListAllForIdentifier(claims map[string]LeaseClaim, identifier string) bool { + identifier = strings.TrimSpace(identifier) + normalized := normalizeCoderWorkspaceIdentifier(identifier) + normalizedSlug := normalizeLeaseSlug(identifier) + for _, claim := range claims { + ref := coderClaimWorkspaceRef(claim) + if !strings.Contains(ref, "/") { + continue + } + if normalizeCoderWorkspaceIdentifier(ref) == normalized || normalizeCoderWorkspaceIdentifier(coderWorkspaceNameFromRef(ref)) == normalized { + return true + } + if claim.LeaseID == identifier { + return true + } + if normalizedSlug != "" && normalizeLeaseSlug(claim.Slug) == normalizedSlug { + return true + } + } + return false +} + +func coderWorkspaceNameCounts(workspaces []coderWorkspace) map[string]int { + counts := map[string]int{} + for _, workspace := range workspaces { + counts[coderClaimKey(workspace.Name)]++ + } + return counts +} + func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time.Time) (bool, string) { if strings.EqualFold(server.Labels["keep"], "true") || (hasClaim && coderClaimKeep(claim)) { return false, "keep=true" @@ -425,9 +533,6 @@ func shouldCleanupCoder(server Server, claim LeaseClaim, hasClaim bool, now time } return false, "claim active" } - if !coderServerRunning(server.Status) && server.Status != "ready" { - return true, "workspace state=" + blank(server.Status, "unknown") - } return false, "missing claim" } @@ -443,35 +548,49 @@ func coderServerRunning(status string) bool { return status == "running" || status == "ready" || status == "starting" } -func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []coderWorkspace) (coderWorkspace, string, string, error) { +func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []coderWorkspace, claims map[string]LeaseClaim, nameCounts map[string]int) (coderWorkspace, string, string, error) { identifier = strings.TrimSpace(identifier) if identifier == "" { return coderWorkspace{}, "", "", exit(2, "coder resolve requires a lease id, slug, workspace, or owner/workspace") } + if claim, ok := coderClaimByLeaseID(claims, identifier); ok { + if workspace, found, err := b.resolveClaimWorkspace(claim, workspaces, nameCounts); err != nil { + return coderWorkspace{}, "", "", err + } else if found { + return workspace, claim.LeaseID, claim.Slug, nil + } + } if claim, ok, err := resolveLeaseClaimForProvider(identifier, coderProvider); err != nil { return coderWorkspace{}, "", "", err } else if ok { - name := coderClaimWorkspaceRef(claim) - if name == "" { - var err error - name, err = coderWorkspaceName(b.cfg.Coder.WorkspacePrefix, claim.Slug, claim.LeaseID) - if err != nil { - return coderWorkspace{}, "", "", err - } - } - if workspace, found := findCoderWorkspace(workspaces, name); found { + if workspace, found, err := b.resolveClaimWorkspace(claim, workspaces, nameCounts); err != nil { + return coderWorkspace{}, "", "", err + } else if found { return workspace, claim.LeaseID, claim.Slug, nil } } normalized := normalizeCoderWorkspaceIdentifier(identifier) normalizedSlug := normalizeLeaseSlug(identifier) - matches := []coderWorkspace{} + exactMatches := []coderWorkspace{} for _, workspace := range workspaces { - leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) if normalizeCoderWorkspaceIdentifier(workspace.Name) == normalized || normalizeCoderWorkspaceIdentifier(coderOwnerWorkspace(workspace)) == normalized { - matches = append(matches, workspace) - continue + exactMatches = append(exactMatches, workspace) } + } + if len(exactMatches) > 1 { + return coderWorkspace{}, "", "", exit(5, "coder workspace %q is ambiguous", identifier) + } + if len(exactMatches) == 1 { + workspace := exactMatches[0] + if claim, ok := coderClaimForWorkspaceInInventory(claims, workspace, nameCounts); ok { + return workspace, claim.LeaseID, claim.Slug, nil + } + leaseID, slug, _ := coderWorkspaceLeaseMetadata(workspace, b.cfg) + return workspace, leaseID, slug, nil + } + matches := []coderWorkspace{} + for _, workspace := range workspaces { + leaseID, slug, owned := coderWorkspaceLeaseMetadata(workspace, b.cfg) if owned && leaseID != "" && leaseID == identifier { matches = append(matches, workspace) continue @@ -486,10 +605,93 @@ func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []cod if len(matches) > 1 { return coderWorkspace{}, "", "", exit(5, "coder workspace %q is ambiguous", identifier) } + if claim, ok := coderClaimForWorkspaceInInventory(claims, matches[0], nameCounts); ok { + return matches[0], claim.LeaseID, claim.Slug, nil + } leaseID, slug, _ := coderWorkspaceLeaseMetadata(matches[0], b.cfg) return matches[0], leaseID, slug, nil } +func (b *coderLeaseBackend) resolveClaimWorkspace(claim LeaseClaim, workspaces []coderWorkspace, nameCounts map[string]int) (coderWorkspace, bool, error) { + name := coderClaimWorkspaceRef(claim) + if name == "" { + var err error + name, err = coderClaimWorkspaceName(b.cfg, claim) + if err != nil { + return coderWorkspace{}, false, err + } + } + if !strings.Contains(name, "/") { + counts := nameCounts + if counts == nil { + counts = coderWorkspaceNameCounts(workspaces) + } + if counts[coderClaimKey(name)] > 1 { + return coderWorkspace{}, false, exit(5, "coder workspace %q is ambiguous", name) + } + } + workspace, found := findCoderWorkspace(workspaces, name) + return workspace, found, nil +} + +func coderClaimByLeaseID(claims map[string]LeaseClaim, leaseID string) (LeaseClaim, bool) { + for _, claim := range claims { + if claim.LeaseID == leaseID { + return claim, true + } + } + return LeaseClaim{}, false +} + +func coderClaimForIdentifier(claims map[string]LeaseClaim, identifier string) (LeaseClaim, bool) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return LeaseClaim{}, false + } + if claim, ok := coderClaimByLeaseID(claims, identifier); ok { + return claim, true + } + normalized := normalizeCoderWorkspaceIdentifier(identifier) + normalizedSlug := normalizeLeaseSlug(identifier) + for _, claim := range claims { + if normalizeCoderWorkspaceIdentifier(coderClaimWorkspaceRef(claim)) == normalized { + return claim, true + } + if normalizedSlug != "" && normalizeLeaseSlug(claim.Slug) == normalizedSlug { + return claim, true + } + } + return LeaseClaim{}, false +} + +func coderStaleClaimLeaseTarget(cfg Config, claim LeaseClaim) LeaseTarget { + name := coderClaimWorkspaceRef(claim) + if name == "" { + generated, err := coderWorkspaceName(cfg.Coder.WorkspacePrefix, claim.Slug, claim.LeaseID) + if err == nil { + name = generated + } + } + labels := map[string]string{} + for k, v := range claim.Labels { + labels[k] = v + } + if name != "" { + labels["coder_workspace_ref"] = name + labels["coder_workspace"] = coderWorkspaceNameFromRef(name) + } + if claim.Slug != "" { + labels["slug"] = claim.Slug + } + if claim.LeaseID != "" { + labels["lease"] = claim.LeaseID + } + return LeaseTarget{ + Server: Server{CloudID: name, Provider: coderProvider, Name: coderWorkspaceNameFromRef(name), Status: "missing", Labels: labels}, + LeaseID: claim.LeaseID, + } +} + func coderClaimWorkspaceRef(claim LeaseClaim) string { name := strings.TrimSpace(claim.Labels["coder_workspace_ref"]) if name == "" { @@ -498,10 +700,41 @@ func coderClaimWorkspaceRef(claim LeaseClaim) string { return name } +func coderClaimWorkspaceName(cfg Config, claim LeaseClaim) (string, error) { + return coderWorkspaceName(cfg.Coder.WorkspacePrefix, coderCollisionSlug(claim.Slug, claim.LeaseID), claim.LeaseID) +} + func coderClaimKeep(claim LeaseClaim) bool { return strings.EqualFold(claim.Labels["keep"], "true") } +func coderReleaseActionFromConfig(cfg Config) string { + if cfg.Coder.DeleteOnRelease { + return coderReleaseActionDelete + } + return coderReleaseActionStop +} + +func coderReleaseAction(value string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case coderReleaseActionDelete, "true": + return coderReleaseActionDelete, true + case coderReleaseActionStop, "false": + return coderReleaseActionStop, true + default: + return "", false + } +} + +func coderCleanupReleaseAction(claim LeaseClaim, hasClaim bool) string { + if hasClaim { + if action, ok := coderReleaseAction(claim.Labels[coderReleaseActionLabel]); ok { + return action + } + } + return coderReleaseActionStop +} + func coderWorkspacesToServers(workspaces []coderWorkspace, cfg Config) []Server { servers := make([]Server, 0, len(workspaces)) for _, workspace := range workspaces { @@ -515,6 +748,10 @@ func coderWorkspacesToServers(workspaces []coderWorkspace, cfg Config) []Server } func coderWorkspaceToServer(workspace coderWorkspace, cfg Config, leaseID, slug string, keep bool) Server { + return coderWorkspaceToServerWithClaim(workspace, cfg, leaseID, slug, keep, LeaseClaim{}, false) +} + +func coderWorkspaceToServerWithClaim(workspace coderWorkspace, cfg Config, leaseID, slug string, keep bool, claim LeaseClaim, hasClaim bool) Server { if slug == "" { slug = coderSlugFromWorkspace(workspace.Name, cfg.Coder.WorkspacePrefix) } @@ -522,11 +759,30 @@ func coderWorkspaceToServer(workspace coderWorkspace, cfg Config, leaseID, slug if labels == nil { labels = map[string]string{} } + labels[coderReleaseActionLabel] = coderReleaseActionFromConfig(cfg) + for k, v := range workspace.Labels { + if strings.TrimSpace(v) != "" { + labels[k] = v + } + } + if hasClaim { + for k, v := range claim.Labels { + if strings.TrimSpace(v) != "" { + labels[k] = v + } + } + } + if leaseID != "" { + labels["lease"] = leaseID + } + if slug != "" { + labels["slug"] = slug + } labels["coder_workspace"] = workspace.Name labels["coder_workspace_ref"] = coderWorkspaceCommandName(workspace) labels["work_root"] = coderWorkRoot(cfg) labels["state"] = coderWorkspaceState(workspace) - server := Server{CloudID: workspace.Name, Provider: coderProvider, Name: workspace.Name, Status: labels["state"], Labels: labels} + server := Server{CloudID: coderWorkspaceCommandName(workspace), Provider: coderProvider, Name: workspace.Name, Status: labels["state"], Labels: labels} server.ServerType.Name = blank(workspace.Template, "coder-workspace") return server } @@ -542,23 +798,34 @@ func coderWorkspaceLeaseMetadata(workspace coderWorkspace, cfg Config) (string, if slug == "" { slug = normalizeLeaseSlug(workspace.Labels["slug"]) } + if leaseID == "" { + leaseID = coderAdoptedWorkspaceLeaseID(workspace) + } return leaseID, slug, true } leaseID = strings.TrimSpace(workspace.Labels["lease"]) slug = normalizeLeaseSlug(workspace.Labels["slug"]) if leaseID != "" || slug != "" { if hasCrabboxLabel { + if leaseID == "" { + leaseID = coderAdoptedWorkspaceLeaseID(workspace) + } return leaseID, slug, true } } if hasCrabboxLabel { - return "", "", true + return coderAdoptedWorkspaceLeaseID(workspace), "", true } slug = coderSlugFromWorkspace(workspace.Name, cfg.Coder.WorkspacePrefix) if slug == "" { return "", "", false } - return "", slug, true + return coderAdoptedWorkspaceLeaseID(workspace), slug, true +} + +func coderAdoptedWorkspaceLeaseID(workspace coderWorkspace) string { + sum := sha1.Sum([]byte("coder:" + coderWorkspaceCommandName(workspace))) + return "cbx_" + hex.EncodeToString(sum[:])[:12] } func coderSlugFromWorkspace(name, prefix string) string { @@ -573,12 +840,13 @@ func coderSlugFromWorkspace(name, prefix string) string { return normalizeLeaseSlug(strings.TrimPrefix(name, cleanPrefix)) } -func coderSSHTarget(cfg Config, workspaceName string) SSHTarget { +func coderSSHTarget(cfg Config, workspaceName, workspaceID string) SSHTarget { host := coderWorkspaceSSHHost(workspaceName) return SSHTarget{ User: "coder", Host: host, Port: "22", + KnownHostsFile: coderKnownHostsFile(workspaceName, workspaceID), TargetOS: targetLinux, NetworkKind: networkPublic, ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null", @@ -587,6 +855,21 @@ func coderSSHTarget(cfg Config, workspaceName string) SSHTarget { } } +func coderKnownHostsFile(workspaceName, workspaceID string) string { + configDir, err := os.UserConfigDir() + if err != nil || strings.TrimSpace(configDir) == "" { + configDir = filepath.Join(os.Getenv("HOME"), ".config") + } + dir := filepath.Join(configDir, "crabbox", coderProvider, "known_hosts.d") + _ = os.MkdirAll(dir, 0o700) + identity := strings.TrimSpace(workspaceID) + if identity == "" { + identity = strings.TrimSpace(workspaceName) + } + sum := sha1.Sum([]byte(strings.TrimSpace(workspaceName) + "\x00" + identity)) + return filepath.Join(dir, hex.EncodeToString(sum[:])[:12]) +} + func coderWorkspaceSSHHost(ref string) string { ref = strings.TrimSpace(ref) if !strings.Contains(ref, "/") { @@ -608,10 +891,6 @@ func coderWorkspaceSSHHost(ref string) string { } func coderWorkspaceReady(workspace coderWorkspace) bool { - state := coderWorkspaceState(workspace) - if state == "ready" || state == "running" { - return true - } for _, agent := range workspace.Agents { if strings.EqualFold(agent.OS, "linux") && (strings.EqualFold(agent.Status, "connected") || strings.EqualFold(agent.Status, "ready")) && (agent.Lifecycle == "" || strings.EqualFold(agent.Lifecycle, "ready")) { return true @@ -629,12 +908,14 @@ func coderWorkspaceState(workspace coderWorkspace) string { for _, value := range []string{workspace.Status, workspace.Transition} { value = strings.ToLower(strings.TrimSpace(value)) switch value { - case "running", "ready", "started", "start": + case "ready": return "ready" + case "running", "started": + return "running" + case "starting", "pending", "start": + return "starting" case "stopped", "stop", "stopping": return "stopped" - case "starting", "pending": - return "starting" case "failed", "error", "canceled", "cancelled": return value } @@ -683,22 +964,15 @@ const coderMaxRequestedSlugLength = 41 const coderWorkspaceHashLength = 6 func coderUniqueWorkspaceName(workspaces []coderWorkspace, prefix, slug, leaseID string) (string, string, error) { - name, err := coderWorkspaceName(prefix, slug, leaseID) - if err != nil { - return "", "", err - } - if !coderWorkspaceNameExists(workspaces, name) { - return slug, name, nil - } - candidateSlug := coderCollisionSlug(slug, leaseID) - name, err = coderWorkspaceName(prefix, candidateSlug, leaseID) + workspaceSlug := coderCollisionSlug(slug, leaseID) + name, err := coderWorkspaceName(prefix, workspaceSlug, leaseID) if err != nil { return "", "", err } if coderWorkspaceNameExists(workspaces, name) { return "", "", exit(5, "coder workspace name %q collides with existing inventory", name) } - return candidateSlug, name, nil + return slug, name, nil } func coderWorkspaceNameExists(workspaces []coderWorkspace, name string) bool { @@ -774,3 +1048,26 @@ func coderWorkspaceHash(value string) string { sum := sha1.Sum([]byte(value)) return hex.EncodeToString(sum[:])[:coderWorkspaceHashLength] } + +func coderWorkspaceMissingError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, needle := range []string{"not found", "does not exist", "no such workspace", "unknown workspace"} { + if strings.Contains(msg, needle) { + return true + } + } + return false +} + +func coderWhoamiMissingLogin(value string) bool { + msg := strings.ToLower(value) + for _, needle := range []string{"not logged in", "not authenticated", "no active session", "login required", "please log in"} { + if strings.Contains(msg, needle) { + return true + } + } + return false +} diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 4b60798e8..3fed75bfe 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -4,7 +4,10 @@ import ( "context" "errors" "flag" + "fmt" "io" + "os" + "path/filepath" "regexp" "strings" "testing" @@ -82,8 +85,21 @@ func TestCoderCreateCommandUsesTemplateParametersAndNoTokenArgv(t *testing.T) { } } +func TestParseCoderWorkspacesTreatsEmptyInventoryAsEmptyList(t *testing.T) { + for _, out := range []string{"", "No workspaces found!", " No workspaces found!\n"} { + workspaces, err := parseCoderWorkspaces(out) + if err != nil { + t.Fatalf("parseCoderWorkspaces(%q): %v", out, err) + } + if len(workspaces) != 0 { + t.Fatalf("parseCoderWorkspaces(%q) returned %#v, want empty", out, workspaces) + } + } +} + func TestCoderSSHTargetUsesProxyCommand(t *testing.T) { - target := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "/opt/Coder CLI/coder", Wait: "yes"}}, "crabbox-blue") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + target := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "/opt/Coder CLI/coder", Wait: "yes"}}, "crabbox-blue", "ws1") if !target.SSHConfigProxy || target.Host != "crabbox-blue" || target.User != "coder" || target.TargetOS != targetLinux { t.Fatalf("unexpected target: %#v", target) } @@ -95,10 +111,17 @@ func TestCoderSSHTargetUsesProxyCommand(t *testing.T) { if !strings.Contains(target.ReadyCheck, "command -v git") || !strings.Contains(target.ReadyCheck, "command -v rsync") || !strings.Contains(target.ReadyCheck, "command -v tar") { t.Fatalf("ready check missing expected tools: %q", target.ReadyCheck) } + if target.KnownHostsFile == "" || !strings.Contains(target.KnownHostsFile, filepath.Join("crabbox", coderProvider, "known_hosts.d")) { + t.Fatalf("known_hosts file should be isolated under crabbox config dir: %q", target.KnownHostsFile) + } + if info, err := os.Stat(filepath.Dir(target.KnownHostsFile)); err != nil || !info.IsDir() || info.Mode().Perm() != 0o700 { + t.Fatalf("known_hosts dir not prepared securely: info=%#v err=%v", info, err) + } } func TestCoderSSHTargetUsesValidHostForOwnerQualifiedWorkspace(t *testing.T) { - target := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "alice/shared") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + target := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "alice/shared", "ws1") if !regexp.MustCompile(`^coder-alice-shared-[0-9a-f]{6}$`).MatchString(target.Host) { t.Fatalf("Host=%q want unique owner-qualified alias", target.Host) } @@ -108,21 +131,37 @@ func TestCoderSSHTargetUsesValidHostForOwnerQualifiedWorkspace(t *testing.T) { } func TestCoderSSHTargetKeepsOwnerQualifiedHostsUnique(t *testing.T) { - alice := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "alice/shared") - bob := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "bob/shared") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + alice := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "alice/shared", "ws1") + bob := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "bob/shared", "ws2") if alice.Host == bob.Host { t.Fatalf("owner-qualified SSH hosts collided: %q", alice.Host) } } +func TestCoderSSHTargetKnownHostsChangesWithWorkspaceID(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + first := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "crabbox-blue", "ws1") + second := coderSSHTarget(Config{Coder: CoderConfig{CLIPath: "coder", Wait: "yes"}}, "crabbox-blue", "ws2") + if first.Host != second.Host { + t.Fatalf("workspace aliases should stay stable for SSH config reuse: %q vs %q", first.Host, second.Host) + } + if first.KnownHostsFile == second.KnownHostsFile { + t.Fatalf("known_hosts file should change with workspace identity: %q", first.KnownHostsFile) + } +} + func TestCoderReleaseStopsByDefaultAndDeletesOnlyWhenConfigured(t *testing.T) { for _, tc := range []struct { name string delete bool + labels map[string]string wantArgs string }{ {name: "stop default", wantArgs: "stop --yes crabbox-blue"}, {name: "delete opt in", delete: true, wantArgs: "delete --yes crabbox-blue"}, + {name: "current delete config overrides persisted stop", delete: true, labels: map[string]string{"coder_release_action": "stop"}, wantArgs: "delete --yes crabbox-blue"}, + {name: "current stop config overrides persisted delete", labels: map[string]string{"coder_release_action": "delete"}, wantArgs: "stop --yes crabbox-blue"}, } { t.Run(tc.name, func(t *testing.T) { runner := &fakeRunner{} @@ -130,7 +169,7 @@ func TestCoderReleaseStopsByDefaultAndDeletesOnlyWhenConfigured(t *testing.T) { if err != nil { t.Fatal(err) } - err = backend.(*coderLeaseBackend).ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: "cbx_123", Server: Server{Name: "crabbox-blue"}}}) + err = backend.(*coderLeaseBackend).ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: "cbx_123", Server: Server{Name: "crabbox-blue", Labels: tc.labels}}}) if err != nil { t.Fatal(err) } @@ -141,46 +180,94 @@ func TestCoderReleaseStopsByDefaultAndDeletesOnlyWhenConfigured(t *testing.T) { } } -func TestCoderAcquireRollbackUsesReleasePolicy(t *testing.T) { +func TestCoderReleaseRemovesClaimWhenRemoteWorkspaceAlreadyGone(t *testing.T) { + installCoderClaimState(t) + writeCoderClaim(t, "cbx_gone", `{ + "leaseID":"cbx_gone", + "slug":"blue", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{"coder_workspace_ref":"crabbox-blue","coder_workspace":"crabbox-blue"} + }`) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[]`}, nil + case "stop --yes crabbox-blue": + return LocalCommandResult{ExitCode: 1, Stderr: "workspace not found"}, errors.New("workspace not found") + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + lease, err := backend.(*coderLeaseBackend).Resolve(context.Background(), ResolveRequest{ID: "cbx_gone", ReleaseOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.Server.Status != "missing" || lease.Server.Labels["coder_workspace_ref"] != "crabbox-blue" { + t.Fatalf("stale claim target not preserved: %#v", lease) + } + if err := backend.(*coderLeaseBackend).ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: lease}); err != nil { + t.Fatal(err) + } + claims, err := listLeaseClaims() + if err != nil { + t.Fatal(err) + } + if len(claims) != 0 { + t.Fatalf("stale claim was not removed: %#v", claims) + } +} + +func TestCoderAcquireRollbackUsesStopByDefaultAndSkipsCreateFailureRollback(t *testing.T) { for _, tc := range []struct { - name string - delete bool - createErr error - postCreateList string - wantErr string - wantAction string - wantListCalls int + name string + createErr error + createErrWorkspaceFound bool + wantErr string + wantRollback bool + wantListCalls int }{ - {name: "inventory miss stops by default", wantErr: "created but not found", wantAction: "stop --yes crabbox-blue", wantListCalls: 2}, - {name: "inventory miss deletes when configured", delete: true, wantErr: "created but not found", wantAction: "delete --yes crabbox-blue", wantListCalls: 2}, - {name: "create failure without workspace skips rollback", createErr: errors.New("build failed"), postCreateList: `[]`, wantErr: "build failed", wantListCalls: 2}, - {name: "create failure rolls back when workspace exists", createErr: errors.New("build failed"), postCreateList: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`, wantErr: "build failed", wantAction: "stop --yes crabbox-blue", wantListCalls: 2}, - {name: "create failure rollback honors delete policy", delete: true, createErr: errors.New("build failed"), postCreateList: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`, wantErr: "build failed", wantAction: "delete --yes crabbox-blue", wantListCalls: 2}, + {name: "inventory miss deletes created workspace", wantErr: "created but not found", wantRollback: true, wantListCalls: 2}, + {name: "create failure without workspace removes claim only", createErr: errors.New("build failed"), wantErr: "build failed", wantListCalls: 2}, + {name: "create failure with workspace deletes created workspace", createErr: errors.New("build failed"), createErrWorkspaceFound: true, wantErr: "build failed", wantRollback: true, wantListCalls: 2}, } { t.Run(tc.name, func(t *testing.T) { + installCoderClaimState(t) runner := &fakeRunner{} listCalls := 0 + createdName := "" runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { - switch strings.Join(req.Args, " ") { - case "list -o json": + command := strings.Join(req.Args, " ") + switch { + case command == "list -o json": listCalls++ - if tc.createErr != nil && listCalls == 2 { - return LocalCommandResult{Stdout: tc.postCreateList}, nil + if tc.createErr != nil && listCalls == 2 && tc.createErrWorkspaceFound { + return LocalCommandResult{Stdout: fmt.Sprintf(`[{"id":"ws1","name":%q,"template_name":"go-dev","latest_build":{"status":"stopped"}}]`, createdName)}, nil } return LocalCommandResult{Stdout: `[]`}, nil - case "create --yes --template go-dev crabbox-blue": + case strings.HasPrefix(command, "create --yes --template go-dev crabbox-blue"): + createdName = req.Args[len(req.Args)-1] if tc.createErr != nil { return LocalCommandResult{ExitCode: 1, Stderr: tc.createErr.Error()}, tc.createErr } return LocalCommandResult{}, nil - case "stop --yes crabbox-blue", "delete --yes crabbox-blue": + case strings.HasPrefix(command, "delete --yes crabbox-blue"): return LocalCommandResult{}, nil default: - t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + t.Fatalf("unexpected command: %s", command) } return LocalCommandResult{}, nil } - backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: tc.delete}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) if err != nil { t.Fatal(err) } @@ -191,16 +278,108 @@ func TestCoderAcquireRollbackUsesReleasePolicy(t *testing.T) { if listCalls != tc.wantListCalls { t.Fatalf("list calls=%d want %d", listCalls, tc.wantListCalls) } - if tc.wantAction == "" { + if !tc.wantRollback { for _, call := range runner.calls { - if strings.HasPrefix(strings.Join(call.Args, " "), "stop --yes") || strings.HasPrefix(strings.Join(call.Args, " "), "delete --yes") { + command := strings.Join(call.Args, " ") + if strings.HasPrefix(command, "delete --yes") { t.Fatalf("unexpected rollback call: %#v", runner.calls) } } return } - if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != tc.wantAction { - t.Fatalf("final rollback command=%q want %q", got, tc.wantAction) + wantAction := "delete --yes " + createdName + if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != wantAction { + t.Fatalf("final rollback command=%q want %q", got, wantAction) + } + }) + } +} + +func TestCoderAcquireKeepFailurePersistsWorkspaceRefBeforeReady(t *testing.T) { + installCoderClaimState(t) + runner := &fakeRunner{} + listCalls := 0 + createdName := "" + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + command := strings.Join(req.Args, " ") + switch { + case command == "list -o json": + listCalls++ + if listCalls == 1 { + return LocalCommandResult{Stdout: `[{"id":"existing","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + } + return LocalCommandResult{ExitCode: 1, Stderr: "inventory unavailable"}, errors.New("inventory unavailable") + case strings.HasPrefix(command, "create --yes --template go-dev crabbox-blue-"): + createdName = req.Args[len(req.Args)-1] + return LocalCommandResult{}, nil + default: + t.Fatalf("unexpected command: %s", command) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + _, err = backend.(*coderLeaseBackend).Acquire(context.Background(), AcquireRequest{RequestedSlug: "blue", Keep: true, Repo: Repo{Root: t.TempDir()}}) + if err == nil || !strings.Contains(err.Error(), "inventory unavailable") { + t.Fatalf("expected inventory error, got %v", err) + } + if createdName == "" { + t.Fatal("create was not called") + } + claims, err := listLeaseClaims() + if err != nil { + t.Fatal(err) + } + if len(claims) != 1 { + t.Fatalf("claims=%#v want one kept failed claim", claims) + } + if claims[0].Labels["coder_workspace_ref"] != createdName || claims[0].Labels["coder_workspace"] != createdName { + t.Fatalf("workspace ref not persisted before readiness failure: claim=%#v created=%q", claims[0], createdName) + } +} + +func TestCoderAcquireRollbackFailureHintMatchesReleasePolicy(t *testing.T) { + for _, tc := range []struct { + name string + delete bool + }{ + {name: "stop default"}, + {name: "delete configured", delete: true}, + } { + t.Run(tc.name, func(t *testing.T) { + installCoderClaimState(t) + runner := &fakeRunner{} + listCalls := 0 + createdName := "" + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + command := strings.Join(req.Args, " ") + switch { + case command == "list -o json": + listCalls++ + if listCalls == 1 { + return LocalCommandResult{Stdout: `[]`}, nil + } + return LocalCommandResult{Stdout: `[]`}, nil + case strings.HasPrefix(command, "create --yes --template go-dev crabbox-blue"): + createdName = req.Args[len(req.Args)-1] + return LocalCommandResult{}, nil + case strings.HasPrefix(command, "delete --yes crabbox-blue"): + return LocalCommandResult{ExitCode: 1, Stderr: "release failed"}, errors.New("release failed") + default: + t.Fatalf("unexpected command: %s", command) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: tc.delete}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + _, err = backend.(*coderLeaseBackend).Acquire(context.Background(), AcquireRequest{RequestedSlug: "blue", Repo: Repo{Root: t.TempDir()}}) + want := "manual cleanup: crabbox stop --provider coder --coder-delete-on-release --id " + createdName + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("rollback hint missing %q: %v", want, err) } }) } @@ -232,6 +411,32 @@ func TestCoderDoctorClassifiesMissingLoginNonMutating(t *testing.T) { } } +func TestCoderDoctorDoesNotClassifyServerFailureAsMissingLogin(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "version": + return LocalCommandResult{Stdout: "Coder v2.33.5"}, nil + case "whoami -o json": + return LocalCommandResult{ExitCode: 1, Stderr: "dial tcp: connection refused"}, errors.New("exit 1") + default: + t.Fatalf("doctor must be non-mutating, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + result, err := backend.Doctor(context.Background(), DoctorRequest{}) + if err == nil { + t.Fatal("expected auth failure") + } + if !strings.Contains(result.Message, "auth=failed") || strings.Contains(result.Message, "auth=missing_login") { + t.Fatalf("unexpected doctor result: %#v", result) + } + if got := result.Checks[1].Details["classification"]; got != "auth_failed" { + t.Fatalf("classification=%q want auth_failed; checks=%#v", got, result.Checks) + } +} + func TestCoderDoctorPreservesChecksOnInventoryFailure(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { @@ -257,7 +462,7 @@ func TestCoderDoctorPreservesChecksOnInventoryFailure(t *testing.T) { } } -func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { +func TestCoderListIncludesButCleanupSkipsUnclaimedStoppedWorkspaces(t *testing.T) { installCoderClaimState(t) runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { @@ -265,12 +470,10 @@ func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { case "list -o json": return LocalCommandResult{Stdout: `[ {"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}, - {"id":"ws2","name":"personal","template_name":"go-dev","latest_build":{"status":"running"}} - ]`}, nil - case "stop --yes crabbox-blue": - return LocalCommandResult{}, nil + {"id":"ws2","name":"personal","template_name":"go-dev","latest_build":{"status":"running"}} + ]`}, nil default: - t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + t.Fatalf("cleanup must not mutate unclaimed Coder workspaces, got: %s", strings.Join(req.Args, " ")) } return LocalCommandResult{}, nil } @@ -288,14 +491,27 @@ func TestCoderListAndCleanupFilterCrabboxOwnedStoppedWorkspaces(t *testing.T) { if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { t.Fatal(err) } - if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != "stop --yes crabbox-blue" { - t.Fatalf("cleanup final call=%q", got) + if len(runner.calls) != 2 { + t.Fatalf("expected list and cleanup inventory calls only, calls=%#v", runner.calls) } if servers[0].Labels["work_root"] != "/home/coder/crabbox" { t.Fatalf("work_root label=%q", servers[0].Labels["work_root"]) } } +func TestShouldCleanupCoderRequiresLocalClaimForStoppedWorkspace(t *testing.T) { + server := Server{Name: "crabbox-blue", Status: "stopped", Labels: map[string]string{"slug": "blue"}} + ok, reason := shouldCleanupCoder(server, LeaseClaim{}, false, time.Now()) + if ok || reason != "missing claim" { + t.Fatalf("cleanup=%v reason=%q; stopped unclaimed Coder workspaces must be preserved", ok, reason) + } + expired := LeaseClaim{LeaseID: "cbx_expired", LastUsedAt: time.Now().Add(-48 * time.Hour).Format(time.RFC3339), IdleTimeoutSeconds: int((30 * time.Minute).Seconds())} + ok, reason = shouldCleanupCoder(server, expired, true, time.Now()) + if !ok || reason != "claim expired" { + t.Fatalf("cleanup=%v reason=%q; expired local claim should be cleanup-eligible", ok, reason) + } +} + func TestCoderCleanupSkipsKeptClaims(t *testing.T) { installCoderClaimState(t) if err := claimLeaseForRepoProvider("cbx_keep", "blue", coderProvider, t.TempDir(), time.Hour, true); err != nil { @@ -383,6 +599,207 @@ func TestCoderCleanupSkipsStoppedActiveClaim(t *testing.T) { } } +func TestCoderCleanupUsesListAllForExpiredOwnerQualifiedClaim(t *testing.T) { + installCoderClaimState(t) + writeCoderClaim(t, "cbx_owner_expired", `{ + "leaseID":"cbx_owner_expired", + "slug":"shared", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{"coder_workspace_ref":"alice/shared"} + }`) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + case "stop --yes alice/shared": + return LocalCommandResult{}, nil + default: + t.Fatalf("cleanup must use owner-qualified inventory/ref, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 2 { + t.Fatalf("calls=%#v want list --all then stop", runner.calls) + } + if got := strings.Join(runner.calls[1].Args, " "); got != "stop --yes alice/shared" { + t.Fatalf("cleanup final call=%q", got) + } +} + +func TestCoderCleanupUsesPersistedReleasePolicy(t *testing.T) { + for _, tc := range []struct { + name string + claimLabels string + configDelete bool + wantAction string + }{ + { + name: "old claims default to stop despite delete config", + claimLabels: `"labels":{"coder_workspace":"crabbox-blue"}`, + configDelete: true, + wantAction: "stop --yes crabbox-blue", + }, + { + name: "delete claim persists delete action", + claimLabels: `"labels":{"coder_workspace":"crabbox-blue","coder_release_action":"delete"}`, + wantAction: "delete --yes crabbox-blue", + }, + } { + t.Run(tc.name, func(t *testing.T) { + installCoderClaimState(t) + writeCoderClaim(t, "cbx_expired", `{ + "leaseID":"cbx_expired", + "slug":"blue", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + `+tc.claimLabels+` + }`) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + case "stop --yes crabbox-blue", "delete --yes crabbox-blue": + return LocalCommandResult{}, nil + default: + t.Fatalf("unexpected cleanup command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: tc.configDelete}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != tc.wantAction { + t.Fatalf("cleanup action=%q want %q", got, tc.wantAction) + } + }) + } +} + +func TestCoderCleanupDoesNotApplyBareClaimToOwnerQualifiedInventory(t *testing.T) { + installCoderClaimState(t) + writeCoderClaim(t, "cbx_owner_expired", `{ + "leaseID":"cbx_owner_expired", + "slug":"shared", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{"coder_workspace_ref":"alice/shared"} + }`) + writeCoderClaim(t, "cbx_bare_expired", `{ + "leaseID":"cbx_bare_expired", + "slug":"shared", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{"coder_workspace":"shared"} + }`) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[ + {"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}}, + {"id":"ws2","name":"shared","owner_name":"bob","template_name":"go-dev","latest_build":{"status":"stopped"}} + ]`}, nil + case "stop --yes alice/shared": + return LocalCommandResult{}, nil + default: + t.Fatalf("cleanup must not apply bare claim to owner-qualified row, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 2 { + t.Fatalf("calls=%#v want list --all and only alice stop", runner.calls) + } +} + +func TestCoderCleanupAppliesBareClaimToUniqueOwnerQualifiedInventory(t *testing.T) { + installCoderClaimState(t) + writeCoderClaim(t, "cbx_other_kept", `{ + "leaseID":"cbx_other_kept", + "slug":"other", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{"coder_workspace_ref":"alice/other","keep":"true"} + }`) + writeCoderClaim(t, "cbx_bare_expired", `{ + "leaseID":"cbx_bare_expired", + "slug":"shared", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{"coder_workspace":"shared"} + }`) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[ + {"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}}, + {"id":"ws2","name":"other","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}} + ]`}, nil + case "stop --yes alice/shared": + return LocalCommandResult{}, nil + default: + t.Fatalf("unique owner-qualified row should accept bare claim, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + if err != nil { + t.Fatal(err) + } + claims, err := listCoderClaimsByWorkspace(Config{Coder: CoderConfig{WorkspacePrefix: "crabbox-"}}) + if err != nil { + t.Fatal(err) + } + if !coderClaimsNeedListAll(claims) { + t.Fatal("owner-qualified claim should force cleanup list --all") + } + if err := backend.(*coderLeaseBackend).Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if len(runner.calls) != 2 { + t.Fatalf("calls=%#v want list --all and only shared stop", runner.calls) + } +} + func TestCoderListAndResolveUseStandardCrabboxLabels(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { @@ -411,6 +828,55 @@ func TestCoderListAndResolveUseStandardCrabboxLabels(t *testing.T) { } } +func TestCoderResolveAdoptedWorkspaceSynthesizesStableLeaseID(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + first, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + second, err := backend.Resolve(context.Background(), ResolveRequest{ID: "crabbox-blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^cbx_[a-f0-9]{12}$`).MatchString(first.LeaseID) || first.LeaseID != second.LeaseID { + t.Fatalf("adopted workspace lease IDs must be stable canonical IDs, first=%q second=%q", first.LeaseID, second.LeaseID) + } + if serverSlug(first.Server) != "blue" { + t.Fatalf("adopted workspace slug=%q want blue", serverSlug(first.Server)) + } +} + +func TestCoderResolveCrabboxMarkerWorkspaceSynthesizesLeaseID(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"team-workspace","template_name":"go-dev","labels":{"crabbox":"true","created_by":"crabbox"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "team-workspace", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^cbx_[a-f0-9]{12}$`).MatchString(lease.LeaseID) { + t.Fatalf("marker-owned workspace lease ID=%q want stable canonical ID", lease.LeaseID) + } +} + func TestCoderListAndResolveUseLegacyCrabboxLabels(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { @@ -549,7 +1015,7 @@ func TestCoderResolveStatusOnlyDoesNotStartOrSSH(t *testing.T) { return LocalCommandResult{}, nil } backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} - lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true}) + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true, ReadyProbe: true}) if err != nil { t.Fatal(err) } @@ -579,6 +1045,30 @@ func TestCoderResolveStatusOnlyIncludesSSHForReadyWorkspace(t *testing.T) { } } +func TestCoderResolveRunningWorkspaceWithoutReadyAgentDoesNotPrepareSSH(t *testing.T) { + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"running","resources":[{"agents":[{"name":"main","operating_system":"linux","status":"connecting","lifecycle_state":"starting"}]}]}}]`}, nil + default: + t.Fatalf("status-only ready probe must only list, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true, ReadyProbe: true}) + if err != nil { + t.Fatal(err) + } + if lease.Server.Status != "running" || lease.Server.Labels["state"] != "running" { + t.Fatalf("running workspace should not be reported ready: %#v", lease) + } + if lease.SSH.Host != "" || lease.SSH.ProxyCommand != "" { + t.Fatalf("running workspace without ready agent should not prepare SSH: %#v", lease) + } +} + func TestCoderResolveClaimUsesStoredWorkspaceAcrossPrefixChanges(t *testing.T) { installCoderClaimState(t) if err := claimLeaseForRepoProvider("cbx_prefix", "blue", coderProvider, t.TempDir(), time.Hour, false); err != nil { @@ -606,6 +1096,185 @@ func TestCoderResolveClaimUsesStoredWorkspaceAcrossPrefixChanges(t *testing.T) { if lease.Server.Name != "crabbox-blue" || lease.LeaseID != "cbx_prefix" { t.Fatalf("unexpected lease: %#v", lease) } + lease, err = backend.Resolve(context.Background(), ResolveRequest{ID: "crabbox-blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.Server.Name != "crabbox-blue" || lease.LeaseID != "cbx_prefix" { + t.Fatalf("workspace-name resolve ignored stored claim: %#v", lease) + } +} + +func TestCoderResolveDoesNotListAllForUnrelatedOwnerQualifiedClaim(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_owner", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unrelated owner-qualified claim must not force list --all, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.Server.Name != "crabbox-blue" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderResolveBareWorkspaceNameUsesListAllForMatchingOwnerQualifiedClaim(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_owner", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("matching owner-qualified claim should force list --all, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "shared", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.LeaseID != "cbx_owner" || lease.Server.CloudID != "alice/shared" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + +func TestCoderListUsesListAllForOwnerQualifiedClaims(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_owner", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("owner-qualified claim should make list use list --all, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + servers, err := backend.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatal(err) + } + if len(servers) != 1 || servers[0].CloudID != "alice/shared" || servers[0].Labels["lease"] != "cbx_owner" { + t.Fatalf("unexpected servers: %#v", servers) + } +} + +func TestCoderResolvePreservesClaimTimingLabels(t *testing.T) { + installCoderClaimState(t) + writeCoderClaim(t, "cbx_timing", `{ + "leaseID":"cbx_timing", + "slug":"blue", + "provider":"coder", + "repoRoot":"/tmp/repo", + "claimedAt":"2026-01-01T00:00:00Z", + "lastUsedAt":"2026-01-01T00:00:00Z", + "idleTimeoutSeconds":1800, + "labels":{ + "coder_workspace":"crabbox-blue", + "created_at":"1767225600", + "last_touched_at":"1767225600", + "idle_timeout":"1800", + "idle_timeout_secs":"1800", + "expires_at":"1767227400" + } + }`) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "crabbox-blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "lease": "cbx_timing", + "slug": "blue", + "created_at": "1767225600", + "last_touched_at": "1767225600", + "idle_timeout": "1800", + "idle_timeout_secs": "1800", + "expires_at": "1767227400", + } { + if got := lease.Server.Labels[key]; got != want { + t.Fatalf("label %s=%q want %q; labels=%#v", key, got, want, lease.Server.Labels) + } + } + if lease.Server.Labels["state"] != "stopped" { + t.Fatalf("state label should still reflect current workspace state, labels=%#v", lease.Server.Labels) + } +} + +func TestCoderResolvePreservesRemoteWorkspaceLabels(t *testing.T) { + installCoderClaimState(t) + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list -o json": + return LocalCommandResult{Stdout: `[{"id":"ws1","name":"crabbox-blue","template_name":"go-dev","labels":{"crabbox":"true","created_by":"crabbox","lease":"cbx_abcdef123456","slug":"blue","keep":"true","created_at":"1767225600","last_touched_at":"1767225601","idle_timeout":"1800","idle_timeout_secs":"1800","expires_at":"1767227400"},"latest_build":{"status":"stopped"}}]`}, nil + default: + t.Fatalf("unexpected command: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "crabbox-blue", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "lease": "cbx_abcdef123456", + "slug": "blue", + "keep": "true", + "created_at": "1767225600", + "last_touched_at": "1767225601", + "idle_timeout": "1800", + "idle_timeout_secs": "1800", + "expires_at": "1767227400", + } { + if got := lease.Server.Labels[key]; got != want { + t.Fatalf("label %s=%q want %q; labels=%#v", key, got, want, lease.Server.Labels) + } + } } func TestCoderResolveKeepLabelUsesClaimKeepMetadata(t *testing.T) { @@ -658,6 +1327,38 @@ func TestCoderResolveClaimUsesListAllForOwnerQualifiedWorkspaceRef(t *testing.T) } } +func TestCoderResolveSlugClaimDisambiguatesOwnerQualifiedWorkspaces(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + server := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_owner", server, SSHTarget{}); err != nil { + t.Fatal(err) + } + runner := &fakeRunner{} + runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { + switch strings.Join(req.Args, " ") { + case "list --all -o json": + return LocalCommandResult{Stdout: `[ + {"id":"ws1","name":"shared","owner_name":"bob","template_name":"go-dev","latest_build":{"status":"stopped"}}, + {"id":"ws2","name":"shared","owner_name":"alice","template_name":"go-dev","latest_build":{"status":"stopped"}} + ]`}, nil + default: + t.Fatalf("expected owner-qualified claim resolve to use list --all, got: %s", strings.Join(req.Args, " ")) + } + return LocalCommandResult{}, nil + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{CLIPath: "coder", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, rt: Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}} + lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "shared", StatusOnly: true}) + if err != nil { + t.Fatal(err) + } + if lease.LeaseID != "cbx_owner" || lease.Server.CloudID != "alice/shared" { + t.Fatalf("unexpected lease: %#v", lease) + } +} + func TestCoderResolveNeedsListAllUsesOnlyOwnerQualifiedRequestOrClaim(t *testing.T) { installCoderClaimState(t) if err := claimLeaseForRepoProvider("cbx_owner", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { @@ -687,6 +1388,78 @@ func TestCoderResolveNeedsListAllUsesOnlyOwnerQualifiedRequestOrClaim(t *testing } } +func TestCoderClaimLookupPreservesOwnerQualifiedWorkspaceRefs(t *testing.T) { + installCoderClaimState(t) + if err := claimLeaseForRepoProvider("cbx_alice", "shared", coderProvider, t.TempDir(), time.Hour, true); err != nil { + t.Fatal(err) + } + aliceServer := Server{Name: "shared", Labels: map[string]string{"coder_workspace_ref": "alice/shared"}} + if err := updateLeaseClaimEndpoint("cbx_alice", aliceServer, SSHTarget{}); err != nil { + t.Fatal(err) + } + if err := claimLeaseForRepoProvider("cbx_local", "shared", coderProvider, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + localServer := Server{Name: "shared", Labels: map[string]string{"coder_workspace": "shared"}} + if err := updateLeaseClaimEndpoint("cbx_local", localServer, SSHTarget{}); err != nil { + t.Fatal(err) + } + claims, err := listCoderClaimsByWorkspace(Config{Coder: CoderConfig{WorkspacePrefix: "crabbox-"}}) + if err != nil { + t.Fatal(err) + } + aliceClaim, ok := coderClaimForWorkspace(claims, coderWorkspace{Name: "shared", Owner: "alice"}) + if !ok || aliceClaim.LeaseID != "cbx_alice" { + t.Fatalf("owner-qualified workspace resolved wrong claim: ok=%v claim=%#v", ok, aliceClaim) + } + bobClaim, ok := coderClaimForWorkspace(claims, coderWorkspace{Name: "shared", Owner: "bob"}) + if ok { + t.Fatalf("owner-qualified workspace without exact claim matched %#v", bobClaim) + } + localClaim, ok := coderClaimForWorkspace(claims, coderWorkspace{Name: "shared"}) + if !ok || localClaim.LeaseID != "cbx_local" || coderClaimKeep(localClaim) { + t.Fatalf("bare workspace resolved wrong claim: ok=%v claim=%#v", ok, localClaim) + } +} + +func TestCoderResolveRejectsAmbiguousBareClaimWorkspace(t *testing.T) { + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: Config{Coder: CoderConfig{WorkspacePrefix: "crabbox-"}}} + workspaces := []coderWorkspace{ + {Name: "shared", Owner: "alice", Template: "go-dev"}, + {Name: "shared", Owner: "bob", Template: "go-dev"}, + } + claims := map[string]LeaseClaim{ + coderClaimKey("shared"): { + LeaseID: "cbx_local", + Slug: "shared", + Labels: map[string]string{"coder_workspace": "shared"}, + }, + } + _, _, _, err := backend.resolveWorkspace("cbx_local", workspaces, claims, coderWorkspaceNameCounts(workspaces)) + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous bare claim error, got %v", err) + } +} + +func TestCoderResolveClaimFallbackUsesLeaseSuffixedWorkspaceName(t *testing.T) { + cfg := Config{Coder: CoderConfig{WorkspacePrefix: "crabbox-"}} + claim := LeaseClaim{LeaseID: "cbx_123456abcdef", Slug: "blue", Labels: map[string]string{}} + workspaceName, err := coderClaimWorkspaceName(cfg, claim) + if err != nil { + t.Fatal(err) + } + backend := &coderLeaseBackend{spec: Provider{}.Spec(), cfg: cfg} + workspace, leaseID, slug, err := backend.resolveWorkspace(claim.LeaseID, []coderWorkspace{{Name: workspaceName, Template: "go-dev"}}, map[string]LeaseClaim{ + coderClaimKey(workspaceName): claim, + }, map[string]int{coderClaimKey(workspaceName): 1}) + if err != nil { + t.Fatal(err) + } + if workspace.Name != workspaceName || leaseID != claim.LeaseID || slug != claim.Slug { + t.Fatalf("unexpected fallback resolution workspace=%#v lease=%q slug=%q", workspace, leaseID, slug) + } +} + func TestCoderOwnerQualifiedResolveAndReleaseUseOwnerWorkspace(t *testing.T) { runner := &fakeRunner{} runner.run = func(req LocalCommandRequest) (LocalCommandResult, error) { @@ -705,7 +1478,7 @@ func TestCoderOwnerQualifiedResolveAndReleaseUseOwnerWorkspace(t *testing.T) { if err != nil { t.Fatal(err) } - if !regexp.MustCompile(`^coder-alice-shared-[0-9a-f]{6}$`).MatchString(lease.SSH.Host) || !strings.Contains(lease.SSH.ProxyCommand, "'alice/shared'") || lease.Server.Labels["coder_workspace_ref"] != "alice/shared" { + if !regexp.MustCompile(`^coder-alice-shared-[0-9a-f]{6}$`).MatchString(lease.SSH.Host) || !strings.Contains(lease.SSH.ProxyCommand, "'alice/shared'") || lease.Server.Labels["coder_workspace_ref"] != "alice/shared" || lease.Server.CloudID != "alice/shared" { t.Fatalf("owner-qualified target not preserved: %#v", lease) } if err := backend.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: lease}); err != nil { @@ -757,23 +1530,32 @@ func TestCoderWorkspaceNameDisambiguatesLongSlugs(t *testing.T) { } } -func TestCoderUniqueWorkspaceNameFallsBackOnExistingNameCollision(t *testing.T) { - existingName, err := coderWorkspaceName("crabbox-", "this-name-is-much-longer-than-coder-allows", "cbx_existing") +func TestCoderUniqueWorkspaceNameKeepsFriendlySlugWhenAvailable(t *testing.T) { + slug, name, err := coderUniqueWorkspaceName(nil, "crabbox-", "blue", "cbx_123456abcdef") if err != nil { t.Fatal(err) } - slug, name, err := coderUniqueWorkspaceName([]coderWorkspace{{Name: existingName}}, "crabbox-", "this-name-is-much-longer-than-coder-allows", "cbx_123456abcdef") + if slug != "blue" { + t.Fatalf("friendly slug=%q want blue", slug) + } + wantSuffix := "-" + coderWorkspaceHash("cbx_123456abcdef") + if !strings.HasPrefix(name, "crabbox-blue-") || !strings.HasSuffix(name, wantSuffix) { + t.Fatalf("workspace name=%q should include lease hash suffix %q", name, wantSuffix) + } +} + +func TestCoderUniqueWorkspaceNameErrorsOnLeaseSuffixedCollision(t *testing.T) { + collisionSlug := coderCollisionSlug("blue", "cbx_123456abcdef") + existingName, err := coderWorkspaceName("crabbox-", collisionSlug, "cbx_123456abcdef") if err != nil { t.Fatal(err) } - if slug == "this-name-is-much-longer-than-coder-allows" { - t.Fatalf("expected collision slug, got %q", slug) - } - if name == existingName { - t.Fatalf("expected unique workspace name, got %q", name) + slug, name, err := coderUniqueWorkspaceName([]coderWorkspace{{Name: existingName}}, "crabbox-", "blue", "cbx_123456abcdef") + if err == nil { + t.Fatalf("expected collision error, got slug=%q name=%q", slug, name) } - if !strings.HasSuffix(slug, "-"+coderWorkspaceHash("cbx_123456abcdef")) { - t.Fatalf("collision slug %q missing lease hash suffix", slug) + if !strings.Contains(err.Error(), "collides") { + t.Fatalf("expected collision error, got %v", err) } } @@ -783,3 +1565,14 @@ func installCoderClaimState(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("CRABBOX_CONFIG", t.TempDir()+"/missing.yaml") } + +func writeCoderClaim(t *testing.T, leaseID, body string) { + t.Helper() + path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "crabbox", "claims", leaseID+".json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(strings.TrimSpace(body)+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/providers/coder/client.go b/internal/providers/coder/client.go index 3667f2d98..1e8a7c4ab 100644 --- a/internal/providers/coder/client.go +++ b/internal/providers/coder/client.go @@ -64,7 +64,10 @@ func (c *coderClient) whoami(ctx context.Context) error { if msg == "" { msg = err.Error() } - return ExitError{Code: result.ExitCode, Message: "coder credential unavailable: run `coder login `; mutation=false detail=" + msg} + if coderWhoamiMissingLogin(msg) { + return ExitError{Code: result.ExitCode, Message: "coder credential unavailable: run `coder login `; mutation=false detail=" + msg} + } + return ExitError{Code: result.ExitCode, Message: "coder credential check failed: mutation=false detail=" + msg} } return nil } @@ -155,8 +158,12 @@ type coderAgent struct { } func parseCoderWorkspaces(out string) ([]coderWorkspace, error) { + trimmed := strings.TrimSpace(out) + if trimmed == "" || strings.EqualFold(trimmed, "No workspaces found!") { + return nil, nil + } var raw any - if err := json.Unmarshal([]byte(out), &raw); err != nil { + if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { return nil, exit(5, "coder list returned invalid JSON: %v", err) } switch value := raw.(type) { From 57a5390d668f459fad712b8b0dcf278c1d8864eb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 13:57:35 +0800 Subject: [PATCH 11/15] fix(provider): restore firecracker ssh default --- internal/cli/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/config.go b/internal/cli/config.go index 815ac8278..888dd3ca5 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -1917,7 +1917,7 @@ func applyProviderConfigDefaults(cfg *Config) error { cfg.SSHUser = cfg.Firecracker.User } if cfg.SSHPort == "" || cfg.SSHPort == base.SSHPort { - cfg.SSHPort = blank(cfg.Firecracker.SSHPort, "22") + cfg.SSHPort = "22" } cfg.SSHFallbackPorts = nil if cfg.Firecracker.WorkRoot != "" && (isDefaultWorkRoot(cfg.WorkRoot) || cfg.Firecracker.WorkRoot != base.Firecracker.WorkRoot) { From b22034c8a7b5bd9dc66c6a5f1b3957994325e800 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 13:57:35 +0800 Subject: [PATCH 12/15] fix(coder): remove unused helper wrappers --- internal/providers/coder/backend.go | 5 ----- internal/providers/coder/client.go | 9 --------- 2 files changed, 14 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index daac94e06..3e03501a1 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -543,11 +543,6 @@ func coderWorkspaceHasCrabboxLabel(workspace coderWorkspace) bool { return strings.EqualFold(strings.TrimSpace(workspace.Labels["created_by"]), "crabbox") } -func coderServerRunning(status string) bool { - status = strings.ToLower(strings.TrimSpace(status)) - return status == "running" || status == "ready" || status == "starting" -} - func (b *coderLeaseBackend) resolveWorkspace(identifier string, workspaces []coderWorkspace, claims map[string]LeaseClaim, nameCounts map[string]int) (coderWorkspace, string, string, error) { identifier = strings.TrimSpace(identifier) if identifier == "" { diff --git a/internal/providers/coder/client.go b/internal/providers/coder/client.go index 1e8a7c4ab..bfda95e6c 100644 --- a/internal/providers/coder/client.go +++ b/internal/providers/coder/client.go @@ -3,7 +3,6 @@ package coder import ( "context" "encoding/json" - "errors" "fmt" "io" "strings" @@ -332,11 +331,3 @@ func mapField(obj map[string]any, key string) map[string]string { } return out } - -func coderCommandError(err error) (ExitError, bool) { - var exitErr ExitError - if errors.As(err, &exitErr) { - return exitErr, true - } - return ExitError{}, false -} From 6cc11f38472ee6b20c1a65656cd525bf8a74cb20 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 16:30:23 +0800 Subject: [PATCH 13/15] fix(coder): honor stop-first rollback policy --- internal/providers/coder/backend.go | 11 +++++++++-- internal/providers/coder/backend_test.go | 23 +++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/internal/providers/coder/backend.go b/internal/providers/coder/backend.go index 3e03501a1..dab6aa896 100644 --- a/internal/providers/coder/backend.go +++ b/internal/providers/coder/backend.go @@ -131,8 +131,8 @@ func (b *coderLeaseBackend) rollbackCreateError(name, leaseID string, client *co func (b *coderLeaseBackend) rollbackCreatedWorkspace(name, leaseID string, client *coderClient, cause error) error { cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := client.delete(cleanupCtx, name); err != nil { - return exit(coderExitCode(cause), "%v; coder cleanup failed for workspace %s; manual cleanup: crabbox stop --provider coder --coder-delete-on-release --id %s: %v", cause, name, name, err) + if err := b.releaseWorkspace(cleanupCtx, client, name); err != nil { + return exit(coderExitCode(cause), "%v; coder rollback %s failed for workspace %s; manual cleanup: %s: %v", cause, coderReleaseActionFromConfig(b.cfg), name, coderManualCleanupCommand(b.cfg, name), err) } removeLeaseClaim(leaseID) return cause @@ -710,6 +710,13 @@ func coderReleaseActionFromConfig(cfg Config) string { return coderReleaseActionStop } +func coderManualCleanupCommand(cfg Config, name string) string { + if cfg.Coder.DeleteOnRelease { + return fmt.Sprintf("crabbox stop --provider coder --coder-delete-on-release --id %s", name) + } + return fmt.Sprintf("crabbox stop --provider coder --id %s", name) +} + func coderReleaseAction(value string) (string, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case coderReleaseActionDelete, "true": diff --git a/internal/providers/coder/backend_test.go b/internal/providers/coder/backend_test.go index 3fed75bfe..591e282c1 100644 --- a/internal/providers/coder/backend_test.go +++ b/internal/providers/coder/backend_test.go @@ -232,13 +232,17 @@ func TestCoderAcquireRollbackUsesStopByDefaultAndSkipsCreateFailureRollback(t *t name string createErr error createErrWorkspaceFound bool + deleteOnRelease bool wantErr string wantRollback bool + wantAction string wantListCalls int }{ - {name: "inventory miss deletes created workspace", wantErr: "created but not found", wantRollback: true, wantListCalls: 2}, + {name: "inventory miss stops created workspace by default", wantErr: "created but not found", wantRollback: true, wantAction: "stop --yes", wantListCalls: 2}, + {name: "inventory miss deletes created workspace when configured", deleteOnRelease: true, wantErr: "created but not found", wantRollback: true, wantAction: "delete --yes", wantListCalls: 2}, {name: "create failure without workspace removes claim only", createErr: errors.New("build failed"), wantErr: "build failed", wantListCalls: 2}, - {name: "create failure with workspace deletes created workspace", createErr: errors.New("build failed"), createErrWorkspaceFound: true, wantErr: "build failed", wantRollback: true, wantListCalls: 2}, + {name: "create failure with workspace stops created workspace by default", createErr: errors.New("build failed"), createErrWorkspaceFound: true, wantErr: "build failed", wantRollback: true, wantAction: "stop --yes", wantListCalls: 2}, + {name: "create failure with workspace deletes created workspace when configured", createErr: errors.New("build failed"), createErrWorkspaceFound: true, deleteOnRelease: true, wantErr: "build failed", wantRollback: true, wantAction: "delete --yes", wantListCalls: 2}, } { t.Run(tc.name, func(t *testing.T) { installCoderClaimState(t) @@ -260,6 +264,8 @@ func TestCoderAcquireRollbackUsesStopByDefaultAndSkipsCreateFailureRollback(t *t return LocalCommandResult{ExitCode: 1, Stderr: tc.createErr.Error()}, tc.createErr } return LocalCommandResult{}, nil + case strings.HasPrefix(command, "stop --yes crabbox-blue"): + return LocalCommandResult{}, nil case strings.HasPrefix(command, "delete --yes crabbox-blue"): return LocalCommandResult{}, nil default: @@ -267,7 +273,7 @@ func TestCoderAcquireRollbackUsesStopByDefaultAndSkipsCreateFailureRollback(t *t } return LocalCommandResult{}, nil } - backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) + backend, err := NewCoderLeaseBackend(Provider{}.Spec(), Config{IdleTimeout: time.Hour, Coder: CoderConfig{CLIPath: "coder", Template: "go-dev", WorkspacePrefix: "crabbox-", WorkRoot: "/home/coder/crabbox", Wait: "yes", DeleteOnRelease: tc.deleteOnRelease}}, Runtime{Stdout: io.Discard, Stderr: io.Discard, Exec: runner}) if err != nil { t.Fatal(err) } @@ -281,13 +287,13 @@ func TestCoderAcquireRollbackUsesStopByDefaultAndSkipsCreateFailureRollback(t *t if !tc.wantRollback { for _, call := range runner.calls { command := strings.Join(call.Args, " ") - if strings.HasPrefix(command, "delete --yes") { + if strings.HasPrefix(command, "delete --yes") || strings.HasPrefix(command, "stop --yes") { t.Fatalf("unexpected rollback call: %#v", runner.calls) } } return } - wantAction := "delete --yes " + createdName + wantAction := tc.wantAction + " " + createdName if got := strings.Join(runner.calls[len(runner.calls)-1].Args, " "); got != wantAction { t.Fatalf("final rollback command=%q want %q", got, wantAction) } @@ -365,6 +371,8 @@ func TestCoderAcquireRollbackFailureHintMatchesReleasePolicy(t *testing.T) { case strings.HasPrefix(command, "create --yes --template go-dev crabbox-blue"): createdName = req.Args[len(req.Args)-1] return LocalCommandResult{}, nil + case strings.HasPrefix(command, "stop --yes crabbox-blue"): + return LocalCommandResult{ExitCode: 1, Stderr: "release failed"}, errors.New("release failed") case strings.HasPrefix(command, "delete --yes crabbox-blue"): return LocalCommandResult{ExitCode: 1, Stderr: "release failed"}, errors.New("release failed") default: @@ -377,7 +385,10 @@ func TestCoderAcquireRollbackFailureHintMatchesReleasePolicy(t *testing.T) { t.Fatal(err) } _, err = backend.(*coderLeaseBackend).Acquire(context.Background(), AcquireRequest{RequestedSlug: "blue", Repo: Repo{Root: t.TempDir()}}) - want := "manual cleanup: crabbox stop --provider coder --coder-delete-on-release --id " + createdName + want := "manual cleanup: crabbox stop --provider coder --id " + createdName + if tc.delete { + want = "manual cleanup: crabbox stop --provider coder --coder-delete-on-release --id " + createdName + } if err == nil || !strings.Contains(err.Error(), want) { t.Fatalf("rollback hint missing %q: %v", want, err) } From a8a0f6a2b452e120eb25c538dfefa68423b7c0e1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 19:28:16 +0800 Subject: [PATCH 14/15] docs(coder): align provider lifecycle guidance --- docs/features/provider-landscape.md | 4 ++-- docs/features/provider-selection.md | 2 +- docs/providers/coder.md | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/features/provider-landscape.md b/docs/features/provider-landscape.md index 853cc1b3b..e4b23bc18 100644 --- a/docs/features/provider-landscape.md +++ b/docs/features/provider-landscape.md @@ -37,7 +37,7 @@ That means: | --- | --- | --- | --- | | CI proof runners | Blacksmith Testbox, Semaphore, GitHub Actions-style runners | Strong fit. Crabbox already separates proof-runner semantics from generic VM semantics through `ci-proof-runner` providers and `providers recommend ci-proof`. | Keep run proof, artifacts, downloads, and status records normalized across providers. | | Hosted agent sandboxes | E2B, Vercel Sandbox, Modal Sandboxes, Cloudflare Sandbox SDK, OpenSandbox, smolvm, Upstash Box | Good fit when the provider owns process execution and files. These map to `delegated-run` better than SSH leases. | Improve artifact/download parity, preview URL reporting, timeout/error taxonomy, and optional MCP attachment routing. | -| Remote developer environments | Daytona, Namespace Devbox, CodeSandbox, Morph, OpenComputer, Codespaces-like tools | Good fit when Crabbox can either SSH into the workspace or delegate a command with archive sync. `providers recommend remote-dev` is the routing surface. | Add clearer live smoke docs per provider, surface pause/resume support, and keep local-editor, remote-compute flows distinct from CI proof. | +| Remote developer environments | Coder, Daytona, Namespace Devbox, CodeSandbox, Morph, OpenComputer, Codespaces-like tools | Good fit when Crabbox can either SSH into the workspace or delegate a command with archive sync. `providers recommend remote-dev` is the routing surface. | Add clearer live smoke docs per provider, surface pause/resume support, and keep local-editor, remote-compute flows distinct from CI proof. | | Forkable/versioned workspaces | Mitos, Firecracker snapshot systems, local-container, Parallels | Partial fit. Crabbox already has provider-neutral checkpoint/fork/restore capability names and checkpoint fan-out via `checkpoint fork --count`, but only local providers advertise the fast path today. | Harden `versioned-workspace` behavior before adding any runtime-specific fork API. Do not add Mitos-only flags. | | Worker and module runtimes | Cloudflare Dynamic Workers, Cloudflare Sandbox SDK, Vercel/edge-adjacent runtimes | Narrow fit. `cloudflare-dynamic-workers` is a module-run provider; generic container sandboxes need a separate lifecycle and file/process contract. | Keep worker-runtime separate from Linux sandbox execution unless the provider can expose files, process status, logs, preview URLs, and cleanup. | | Self-hosted virtualization | Proxmox, XCP-ng, Incus, KubeVirt, local VMs | Strong fit when Crabbox gets a normal SSH lease and lifecycle hooks. | Keep provider-specific reconciliation behind adapters; no provider-specific branching in core. | @@ -97,7 +97,7 @@ an opt-in live smoke. Until then, document it as an observed adjacent system. | Morph | Supported as `morph`. | Managed SSH lease fits local-editor, remote-compute workflows. | | OpenComputer | Supported as `opencomputer`. | Delegated Linux execution fits remote-dev and sandbox routing, but evidence parity should improve. | | DevPod | Do not support directly. | It is already a provider-agnostic dev environment layer. Use the resulting SSH/container target through `ssh`, `local-container`, or `external`. | -| Coder | Do not support directly by default. | A Coder workspace should enter Crabbox as a stable host or external provider unless there is a narrow lifecycle contract to own. | +| Coder | Supported as `coder`. | The built-in contract is intentionally narrow: local Coder CLI auth, Linux SSH proxy execution, stop-by-default release, delete only by opt-in, and cleanup only for locally claimed workspaces. Use `ssh` or `external` for existing workspaces that Crabbox should not manage. | | Kubernetes Agent Sandbox | Supported as `agent-sandbox`. | SandboxClaim-style delegated execution belongs behind the existing Kubernetes-native adapter. | | OpenSandbox, Microsandbox, Moru-like runtimes | Candidate only. | Add only when one has a stable lifecycle and evidence contract that is meaningfully different from existing delegated sandboxes. | diff --git a/docs/features/provider-selection.md b/docs/features/provider-selection.md index 712cbbf43..e23179220 100644 --- a/docs/features/provider-selection.md +++ b/docs/features/provider-selection.md @@ -105,7 +105,7 @@ first-class providers just because they are adjacent. | [Modal](../providers/modal.md) | Already supported as delegated run. Use it for provider-owned container execution, especially Python/GPU-shaped jobs. | | [Morph](../providers/morph.md) | Already supported as an SSH lease. Use it when a managed Linux VM with provider-side state reuse fits better than a pure delegated sandbox. | | [Kubernetes Agent Sandbox](../providers/agent-sandbox.md) | Already supported as delegated run. Use it for Kubernetes-hosted SandboxClaim workflows. | -| [Coder](https://github.com/coder/coder) | Do not mirror as a first-class provider unless there is a narrow lifecycle contract. For now, connect through `ssh` or an `external` provider when a Coder workspace exposes a stable host contract. | +| [Coder](../providers/coder.md) | Supported as a narrow direct SSH-lease provider. Crabbox uses the local `coder` CLI, stops claimed workspaces by default, deletes only by opt-in, and mutates cleanup-eligible workspaces only when a local Crabbox claim exists. Use `ssh` or `external` instead when you want to target an existing Coder workspace without Crabbox lifecycle ownership. | | [DevPod](https://github.com/loft-sh/devpod) | Do not mirror as a first-class provider. It is already a provider-agnostic dev environment layer; use its resulting SSH/container target through `ssh`, `local-container`, or `external` when needed. | | [Cloudflare Sandbox SDK](https://developers.cloudflare.com/sandbox/) | Keep separate from the existing Cloudflare providers until the runtime contract maps cleanly to a Crabbox backend. Prefer the current Cloudflare providers for built-in Worker/container flows. | diff --git a/docs/providers/coder.md b/docs/providers/coder.md index c88b11015..15f186341 100644 --- a/docs/providers/coder.md +++ b/docs/providers/coder.md @@ -1,9 +1,10 @@ # Coder -`provider: coder` leases Linux Coder workspaces through the local `coder` CLI -and exposes them to Crabbox as normal SSH leases. Crabbox uses Coder for -workspace lifecycle, authentication, and tunneling, then runs its usual SSH -sync, command execution, status, and cleanup flow over `coder ssh --stdio`. +`provider: coder` is a narrow direct SSH-lease integration for Linux Coder +workspaces. Crabbox asks the local `coder` CLI to create, start, stop, or +optionally delete Crabbox-claimed workspaces, while Coder keeps authentication, +workspace policy, and tunneling ownership. Crabbox then runs its usual SSH sync, +command execution, status, and cleanup flow over `coder ssh --stdio`. Coder is direct-only. It never routes through the Crabbox coordinator and it does not store Coder API tokens in Crabbox config. From a9d8eecbe929e446e641913a28c73c04f0c3e883 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 20:05:30 +0800 Subject: [PATCH 15/15] test(coder): add live smoke coverage --- docs/operations.md | 7 +++ docs/providers/coder.md | 12 ++-- scripts/live-smoke.sh | 110 +++++++++++++++++++++++++++++++++++ scripts/live-smoke.test.js | 116 +++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 4 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index c47258631..90e42e393 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -52,6 +52,7 @@ CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=hetzner CRABBOX_LIVE_REPO=/path/ CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=blacksmith-testbox CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=e2b CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=modal CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh +CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=coder CRABBOX_LIVE_COORDINATOR=0 CRABBOX_LIVE_CODER_TEMPLATE=go-dev CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=daytona CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=namespace-devbox CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=namespace-instance CRABBOX_LIVE_COORDINATOR=0 CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh @@ -103,6 +104,12 @@ Per-provider smoke prerequisites: the configured Python binary can import the Modal client, then creates one sandbox, waits for status, runs one no-sync command, lists normalized inventory, and stops the lease. +- **Coder** — authenticated `coder` CLI on `PATH` plus an explicit disposable + template from `CRABBOX_LIVE_CODER_TEMPLATE`, `CRABBOX_CODER_TEMPLATE`, or + `coder.template`. `scripts/live-smoke.sh` refuses to mutate Coder until a + template is selected, then proves doctor, dry-run cleanup, stop-by-default + warmup/run/stop/status, delete-on-release warmup/run/stop, list, and final + dry-run cleanup. - **Semaphore** — `CRABBOX_SEMAPHORE_HOST`, `CRABBOX_SEMAPHORE_PROJECT`, and `CRABBOX_SEMAPHORE_TOKEN`, or the equivalent user config. `scripts/live-smoke.sh` refuses to call Semaphore until those values are diff --git a/docs/providers/coder.md b/docs/providers/coder.md index 15f186341..dc3c3c489 100644 --- a/docs/providers/coder.md +++ b/docs/providers/coder.md @@ -158,12 +158,16 @@ Run live smoke only after `coder whoami -o json` succeeds and you have selected a safe disposable template: ```sh -crabbox doctor --provider coder -crabbox warmup --provider coder --coder-template go-dev --slug coder-smoke -crabbox run --provider coder --id coder-smoke -- bash -lc 'command -v git && command -v rsync && command -v tar && echo ok' -crabbox stop --provider coder coder-smoke +CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=coder CRABBOX_LIVE_COORDINATOR=0 \ + CRABBOX_LIVE_CODER_TEMPLATE=go-dev \ + CRABBOX_LIVE_REPO=/path/to/my-app scripts/live-smoke.sh ``` +The shared smoke runs `doctor`, `cleanup --dry-run`, stop-by-default warmup, +`status --wait`, `inspect`, SSH command rendering, a synced command, history/log +capture, stop, stopped-workspace status, delete-on-release warmup/run/stop, list, +and a final dry-run cleanup. + If login, template, or quota is unavailable, classify the live smoke as `environment_blocked` instead of treating deterministic unit tests as live proof. diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 47c859d24..5e9e22e7e 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -141,6 +141,10 @@ extract_tenki_session() { sed -n 's/.*tenki_session=\([^ ]*\).*/\1/p' | tail -1 } +extract_coder_workspace() { + sed -n 's/.*workspace=\([^ ]*\).*/\1/p' | tail -1 +} + stop_lease() { local id="$1" local slug="${2:-}" @@ -352,6 +356,108 @@ modal_smoke() { lease="" } +coder_stop_lease() { + local action="$1" + local id="$2" + local slug="${3:-}" + local args=(stop --provider coder) + if [[ "$action" == "delete" ]]; then + args+=(--coder-delete-on-release) + fi + if [[ -n "$slug" ]]; then + run_in_repo "$cb" "${args[@]}" "$slug" || run_in_repo "$cb" "${args[@]}" "$id" || true + else + run_in_repo "$cb" "${args[@]}" "$id" || true + fi +} + +coder_smoke() { + need_tool jq + need_tool rg + + local template="${CRABBOX_LIVE_CODER_TEMPLATE:-${CRABBOX_CODER_TEMPLATE:-$(config_value coder.template || true)}}" + if [[ -z "$template" ]]; then + echo "coder smoke requires CRABBOX_LIVE_CODER_TEMPLATE, CRABBOX_CODER_TEMPLATE, or coder.template" >&2 + return 2 + fi + + local ttl="${CRABBOX_LIVE_CODER_TTL:-15m}" + local idle_timeout="${CRABBOX_LIVE_CODER_IDLE_TIMEOUT:-5m}" + local slug_prefix="${CRABBOX_LIVE_CODER_SLUG_PREFIX:-coder-smoke-$$}" + local stop_slug="${slug_prefix}-stop" + local delete_slug="${slug_prefix}-delete" + local coder_args=(--provider coder --coder-template "$template" --ttl "$ttl" --idle-timeout "$idle_timeout") + if [[ -n "${CRABBOX_LIVE_CODER_PRESET:-}" ]]; then + coder_args+=(--coder-preset "$CRABBOX_LIVE_CODER_PRESET") + fi + + local lease="" + local slug="" + local delete_lease="" + local delete_workspace="" + cleanup() { + trap - RETURN ERR + if [[ -n "$delete_lease" ]]; then + coder_stop_lease delete "$delete_lease" "$delete_slug" + delete_lease="" + delete_workspace="" + fi + if [[ -n "$lease" ]]; then + coder_stop_lease stop "$lease" "$slug" + lease="" + slug="" + fi + } + trap cleanup RETURN ERR + + run_in_repo "$cb" doctor --provider coder + run_in_repo "$cb" cleanup --provider coder --dry-run + + local out + log_step "coder warmup stop_on_release slug=$stop_slug" + capture_run_live out run_in_repo "$cb" warmup "${coder_args[@]}" --slug "$stop_slug" --timing-json + lease="$(printf '%s\n' "$out" | extract_lease)" + slug="$(printf '%s\n' "$out" | extract_slug)" + local workspace + workspace="$(printf '%s\n' "$out" | extract_coder_workspace)" + test -n "$lease" + test -n "$slug" + test -n "$workspace" + + run_in_repo "$cb" status --provider coder --id "$slug" --wait --wait-timeout 120s + run_in_repo "$cb" inspect --provider coder --id "$slug" --json | jq '{id,slug,provider,state,serverType,host,ready,lastTouchedAt,expiresAt,labels}' + run_in_repo "$cb" ssh --provider coder --id "$slug" + local runout + capture_run_live runout run_in_repo "$cb" run --provider coder --id "$slug" --shell -- "$live_command" + local runid + runid="$(printf '%s\n' "$runout" | rg -o 'run_[a-f0-9]{12}' | tail -1 || true)" + run_in_repo "$cb" history --lease "$lease" --limit 5 + if [[ -n "$runid" ]]; then + run_in_repo "$cb" logs "$runid" | tail -80 + fi + log_step "coder stop stop_on_release slug=$slug workspace=$workspace" + run_in_repo "$cb" stop --provider coder "$slug" || run_in_repo "$cb" stop --provider coder "$lease" + lease="" + slug="" + run_in_repo "$cb" status --provider coder --id "$workspace" --json | jq '{id,slug,provider,state,ready,labels}' + + log_step "coder warmup delete_on_release slug=$delete_slug" + capture_run_live out run_in_repo "$cb" warmup "${coder_args[@]}" --coder-delete-on-release --slug "$delete_slug" --timing-json + delete_lease="$(printf '%s\n' "$out" | extract_lease)" + delete_workspace="$(printf '%s\n' "$out" | extract_coder_workspace)" + test -n "$delete_lease" + test -n "$delete_workspace" + run_in_repo "$cb" status --provider coder --id "$delete_slug" --wait --wait-timeout 120s + run_in_repo "$cb" run --provider coder --id "$delete_slug" --shell -- "$live_command" + log_step "coder stop delete_on_release slug=$delete_slug workspace=$delete_workspace" + run_in_repo "$cb" stop --provider coder --coder-delete-on-release "$delete_slug" || run_in_repo "$cb" stop --provider coder --coder-delete-on-release "$delete_lease" + delete_lease="" + delete_workspace="" + + run_in_repo "$cb" list --provider coder --json | jq 'map({id:(.id // .CloudID),slug:(.slug // .labels.slug),provider:(.provider // .Provider // .labels.provider),state:(.state // .labels.state // .status),host:(.host // .Host),workspace:(.labels.coder_workspace // .labels.coder_workspace_ref // null)})' + run_in_repo "$cb" cleanup --provider coder --dry-run +} + daytona_smoke() { need_tool jq @@ -956,6 +1062,10 @@ if has_provider modal; then modal_smoke fi +if has_provider coder; then + coder_smoke +fi + if has_provider daytona; then daytona_smoke fi diff --git a/scripts/live-smoke.test.js b/scripts/live-smoke.test.js index 9c4c11938..7d4281979 100644 --- a/scripts/live-smoke.test.js +++ b/scripts/live-smoke.test.js @@ -386,6 +386,122 @@ esac assert.doesNotMatch(calls, /^stop /m); }); +test("Coder live smoke proves stop and delete release actions", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-coder-")); + const fakeCrabbox = path.join(dir, "crabbox"); + const log = path.join(dir, "calls.log"); + writeExecutable( + fakeCrabbox, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >>"${log}" +has_arg() { + local needle="$1" + shift + for arg in "$@"; do + [[ "$arg" == "$needle" ]] && return 0 + done + return 1 +} +case "$1" in + config) + exit 0 + ;; + doctor) + printf 'ok provider=coder mutation=false\\n' + ;; + cleanup) + printf 'coder cleanup dry_run=true\\n' + ;; + warmup) + if [[ "\${2:-}" != "--provider" || "\${3:-}" != "coder" ]]; then + printf 'warmup missing coder provider: %s\\n' "$*" >&2 + exit 97 + fi + if ! has_arg "--coder-template" "$@"; then + printf 'warmup missing coder template: %s\\n' "$*" >&2 + exit 96 + fi + if has_arg "--coder-delete-on-release" "$@"; then + printf 'provisioning provider=coder lease=cbx_abcdef123456 slug=coder-smoke-test-delete workspace=crabbox-coder-smoke-test-delete-def456\\n' + printf 'provisioned lease=cbx_abcdef123456 slug=coder-smoke-test-delete state=ready workspace=crabbox-coder-smoke-test-delete-def456\\n' + else + printf 'provisioning provider=coder lease=cbx_123456789abc slug=coder-smoke-test-stop workspace=crabbox-coder-smoke-test-stop-abc123\\n' + printf 'provisioned lease=cbx_123456789abc slug=coder-smoke-test-stop state=ready workspace=crabbox-coder-smoke-test-stop-abc123\\n' + fi + ;; + status) + if has_arg "--json" "$@"; then + printf '{"id":"cbx_123456789abc","slug":"coder-smoke-test-stop","provider":"coder","state":"stopped","ready":false,"labels":{"coder_workspace":"crabbox-coder-smoke-test-stop-abc123"}}\\n' + else + printf 'lease=cbx_123456789abc slug=coder-smoke-test-stop provider=coder state=ready ready=true\\n' + fi + ;; + inspect) + printf '{"id":"cbx_123456789abc","slug":"coder-smoke-test-stop","provider":"coder","state":"ready","serverType":"coder","host":"coder-proxy","ready":true,"lastTouchedAt":"2026-06-24T00:00:00Z","expiresAt":"2026-06-24T00:15:00Z","labels":{"coder_workspace":"crabbox-coder-smoke-test-stop-abc123"}}\\n' + ;; + ssh) + exit 0 + ;; + run) + printf 'crabbox-live-ok\\nrun_deadbeef1234\\n' + ;; + history) + printf 'history ok\\n' + ;; + logs) + printf 'log ok\\n' + ;; + stop) + printf 'stopped %s\\n' "\${*: -1}" + ;; + list) + printf '[{"id":"cbx_123456789abc","slug":"coder-smoke-test-stop","provider":"coder","state":"stopped","host":"coder-proxy","labels":{"coder_workspace":"crabbox-coder-smoke-test-stop-abc123"}}]\\n' + ;; + *) + printf 'unexpected crabbox args: %s\\n' "$*" >&2 + exit 99 + ;; +esac +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + CRABBOX_BIN: fakeCrabbox, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_CODER_SLUG_PREFIX: "coder-smoke-test", + CRABBOX_LIVE_CODER_TEMPLATE: "go-dev", + CRABBOX_LIVE_PROVIDERS: "coder", + CRABBOX_LIVE_REPO: repoRoot, + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /crabbox-live-ok/); + assert.match(result.stderr, /admin active-lease check skipped/); + const calls = fs.readFileSync(log, "utf8"); + assert.match(calls, /^doctor --provider coder$/m); + assert.equal((calls.match(/^cleanup --provider coder --dry-run$/gm) ?? []).length, 2); + assert.match( + calls, + /^warmup --provider coder --coder-template go-dev --ttl 15m --idle-timeout 5m --slug coder-smoke-test-stop --timing-json$/m, + ); + assert.match(calls, /^stop --provider coder coder-smoke-test-stop$/m); + assert.match( + calls, + /^warmup --provider coder --coder-template go-dev --ttl 15m --idle-timeout 5m --coder-delete-on-release --slug coder-smoke-test-delete --timing-json$/m, + ); + assert.match(calls, /^stop --provider coder --coder-delete-on-release coder-smoke-test-delete$/m); + for (const command of ["status", "inspect", "ssh", "run", "history", "logs", "list"]) { + assert.match(calls, new RegExp(`^${command}(?: |$)`, "m")); + } +}); + test("Namespace Devbox live smoke requires the devbox CLI before provider mutation", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-namespace-")); const bin = path.join(dir, "bin");