feat: add Daytona and Islo providers
This commit is contained in:
parent
6ba12e4872
commit
e0a85bc780
@ -12,6 +12,8 @@
|
||||
- Added `.crabboxignore` for repo-local sync-only exclude patterns shared by `run` and `sync-plan`.
|
||||
- Documented the prebaked runner image boundary: provider-owned AMIs/snapshots hold machine capabilities while repo/runtime caches stay in QA workflows or warm leases.
|
||||
- Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation.
|
||||
- Added `provider: daytona` for Daytona sandbox leases using Daytona's SDK/toolbox for sync and command execution, with short-lived SSH access available through `crabbox ssh`.
|
||||
- Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
21
README.md
21
README.md
@ -12,7 +12,7 @@ Crabbox is an open-source remote testbox runner for maintainers and AI agents. L
|
||||
crabbox run -- pnpm test
|
||||
```
|
||||
|
||||
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud or AWS EC2. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, or use `provider: ssh` for existing macOS and Windows targets.
|
||||
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud or AWS EC2. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
|
||||
|
||||
---
|
||||
|
||||
@ -76,6 +76,7 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
|
||||
- **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner and AWS EC2 are first-class managed providers; AWS also owns managed Windows and EC2 Mac targets. Linux defaults to Spot unless capacity config says otherwise. Providers fall back across compatible instance families when capacity or quota rejects a request.
|
||||
- **macOS and Windows static hosts.** `provider: ssh` reuses existing machines; it does not create macOS or Windows Crabbox boxes. macOS and Windows WSL2 use the POSIX rsync path; native Windows uses PowerShell plus tar archive sync.
|
||||
- **Blacksmith Testbox wrapper.** Set `provider: blacksmith-testbox` to delegate warmup/run/list/status/stop to the Blacksmith CLI while Crabbox keeps local slugs, repo claims, timing summaries, and config conventions.
|
||||
- **Daytona and Islo sandboxes.** Set `provider: daytona` for Daytona SDK/toolbox execution from a snapshot with explicit SSH access when needed, or `provider: islo` for delegated Islo sandbox execution through the Islo Go SDK.
|
||||
- **Trusted AWS images.** Operators can create AMIs from active brokered AWS leases and promote a known-good image as the coordinator default.
|
||||
- **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type.
|
||||
- **GitHub Actions hydration.** `crabbox actions hydrate` registers a leased box as an ephemeral Actions runner, so the repo's own workflow installs runtimes, services, and secrets. Crabbox does not parse Actions YAML.
|
||||
@ -155,6 +156,24 @@ blacksmith:
|
||||
idleTimeout: 90m
|
||||
```
|
||||
|
||||
Optional Daytona sandbox:
|
||||
|
||||
```yaml
|
||||
provider: daytona
|
||||
daytona:
|
||||
snapshot: crabbox-ready
|
||||
workRoot: /home/daytona/crabbox
|
||||
```
|
||||
|
||||
Optional Islo sandbox:
|
||||
|
||||
```yaml
|
||||
provider: islo
|
||||
islo:
|
||||
image: docker.io/library/ubuntu:24.04
|
||||
workdir: crabbox
|
||||
```
|
||||
|
||||
Optional static macOS or Windows target:
|
||||
|
||||
```yaml
|
||||
|
||||
@ -78,7 +78,7 @@ Pick whichever matches your intent:
|
||||
|
||||
- **Get the mental model:** [How Crabbox Works](how-it-works.md), [Architecture](architecture.md), [Orchestrator](orchestrator.md).
|
||||
- **Use the CLI:** [CLI](cli.md), [Commands](commands/README.md), [Features](features/README.md), [Actions hydration](features/actions-hydration.md).
|
||||
- **Pick or add a target:** [Providers](features/providers.md), [Provider backends](provider-backends.md), [AWS](features/aws.md), [Hetzner](features/hetzner.md), [Blacksmith Testbox](features/blacksmith-testbox.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
|
||||
- **Pick or add a target:** [Providers](features/providers.md), [Provider backends](provider-backends.md), [AWS](features/aws.md), [Hetzner](features/hetzner.md), [Blacksmith Testbox](features/blacksmith-testbox.md), [Daytona](features/daytona.md), [Islo](features/islo.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
|
||||
- **Operate it:** [Operations](operations.md), [Observability](observability.md), [Troubleshooting](troubleshooting.md), [Performance](performance.md).
|
||||
- **Set it up or audit it:** [Infrastructure](infrastructure.md), [Security](security.md), [Source Map](source-map.md), [MVP Plan](mvp-plan.md).
|
||||
|
||||
|
||||
14
docs/cli.md
14
docs/cli.md
@ -33,8 +33,8 @@ crabbox init [--force]
|
||||
crabbox config show [--json]
|
||||
crabbox config path
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
|
||||
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
|
||||
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
|
||||
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
|
||||
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
|
||||
crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] [--webvnc] [--open] [-- <command...>]
|
||||
crabbox code --id <lease-id-or-slug> [--open]
|
||||
crabbox media preview --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
|
||||
@ -247,7 +247,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug> reuse an existing lease
|
||||
--provider <name> hetzner, aws, ssh, or blacksmith-testbox
|
||||
--provider <name> hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
|
||||
--target <name> linux, macos, or windows
|
||||
--windows-mode <mode> normal or wsl2
|
||||
--static-host <host> existing SSH host for provider=ssh
|
||||
@ -290,6 +290,14 @@ Crabbox stores local lease claims under its state directory. `warmup` and first
|
||||
|
||||
With `provider: blacksmith-testbox`, Crabbox delegates machine setup, sync, and command transport to the Blacksmith CLI. `--sync-only` is unsupported, sync timing is reported as `sync=delegated`, and Blacksmith auth is handled by `blacksmith auth login`, not `crabbox login`.
|
||||
|
||||
With `provider: daytona`, Crabbox creates Daytona sandboxes from
|
||||
`daytona.snapshot`, uploads workspaces through Daytona toolbox file APIs, and
|
||||
runs commands through Daytona toolbox process APIs. `crabbox ssh` mints
|
||||
short-lived Daytona SSH tokens and redacts those tokens from output. With
|
||||
`provider: islo`, Crabbox delegates sandbox setup and command execution to the
|
||||
Islo Go SDK; sync is delegated and `--sync-only`, `--checksum`, and
|
||||
`--force-sync-large` are unsupported.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
```text
|
||||
|
||||
@ -7,6 +7,8 @@ crabbox list
|
||||
crabbox list --provider aws
|
||||
crabbox list --provider ssh --target macos --static-host mac-studio.local
|
||||
crabbox list --provider blacksmith-testbox
|
||||
crabbox list --provider daytona
|
||||
crabbox list --provider islo
|
||||
crabbox list --json
|
||||
```
|
||||
|
||||
@ -19,10 +21,13 @@ same Crabbox list shape as other providers. `--json` keeps the compatibility
|
||||
shape parsed from the Blacksmith table: id, status, repo, workflow, job, ref,
|
||||
and created time when the upstream table exposes those columns.
|
||||
|
||||
In `daytona` and `islo` modes, rendering is core-owned: human output and `--json`
|
||||
use the normalized Crabbox lease view.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -13,6 +13,8 @@ crabbox run --desktop --browser --shell 'echo "$DISPLAY"; "$BROWSER" --version'
|
||||
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
|
||||
crabbox run --id cbx_abcdef123456 --junit junit.xml -- go test ./...
|
||||
crabbox run --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test -- pnpm test
|
||||
crabbox run --provider daytona --daytona-snapshot crabbox-ready -- pnpm test
|
||||
crabbox run --provider islo --islo-image docker.io/library/ubuntu:24.04 -- pnpm test
|
||||
crabbox run --provider ssh --target macos --static-host mac-studio.local -- xcodebuild test
|
||||
crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local -- dotnet test
|
||||
crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local --shell 'Write-Output ("BROWSER=" + $env:BROWSER)'
|
||||
@ -23,6 +25,16 @@ If `--id` is omitted, Crabbox creates a fresh non-kept lease and releases it whe
|
||||
|
||||
With `--provider blacksmith-testbox`, `--id` accepts a Blacksmith `tbx_...` ID or a local Crabbox slug. Crabbox forwards the command to `blacksmith testbox run`, delegates sync to Blacksmith, and prints `sync=delegated` in the final timing summary.
|
||||
|
||||
With `--provider daytona`, `--id` accepts a Daytona-backed Crabbox `cbx_...` ID
|
||||
or local slug. Crabbox uploads the sync archive through Daytona toolbox file
|
||||
APIs, extracts it in the sandbox, and runs the command through Daytona toolbox
|
||||
process APIs. The final timing summary reports `sync=delegated`.
|
||||
|
||||
With `--provider islo`, `--id` accepts an `isb_<crabbox-sandbox-name>` lease ID,
|
||||
a Crabbox-created sandbox name, or a local Crabbox slug. Islo owns sandbox
|
||||
workspace setup and command execution, so sync is delegated and the final timing
|
||||
summary reports `sync=delegated`.
|
||||
|
||||
When the lease has been hydrated by `crabbox actions hydrate`, `run` reads the remote marker under `$HOME/.crabbox/actions`, syncs into the workflow's `$GITHUB_WORKSPACE`, and sources the non-secret env file written by the workflow. That preserves the setup the workflow performed: checkout path, installed dependencies, service containers, caches, runner temp/toolcache paths, and any project-specific preparation. GitHub secrets and OIDC request tokens remain workflow-step scoped unless the project explicitly persists its own short-lived credentials.
|
||||
|
||||
If a configured Actions hydration workflow exists and a package-manager command such as `pnpm`, `npm`, `node`, or `corepack` is run before a hydration marker exists, Crabbox warns that the raw box may not have the project runtime installed. Hydrate first for CI-like setup, or include the runtime setup explicitly in the command.
|
||||
@ -77,7 +89,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -5,16 +5,17 @@
|
||||
```sh
|
||||
crabbox ssh --id blue-lobster
|
||||
crabbox ssh --id blue-lobster --network tailscale
|
||||
crabbox ssh --provider daytona --id blue-lobster
|
||||
crabbox ssh --provider ssh --target macos --static-host mac-studio.local
|
||||
```
|
||||
|
||||
The output includes the per-lease private key path when Crabbox created one. Printing an SSH command touches coordinator leases because it signals intended manual use. In `provider=ssh` mode it resolves the configured static target.
|
||||
The output includes the per-lease private key path when Crabbox created one. Printing an SSH command touches coordinator leases because it signals intended manual use. In `provider=ssh` mode it resolves the configured static target. In `provider=daytona` mode the short-lived SSH token is redacted by default; pass `--show-secret` only when you need a pasteable command in a trusted terminal.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|ssh
|
||||
--provider hetzner|aws|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
@ -23,6 +24,7 @@ Flags:
|
||||
--static-work-root <path>
|
||||
--network auto|tailscale|public
|
||||
--reclaim
|
||||
--show-secret
|
||||
```
|
||||
|
||||
`ssh` touches the lease and validates the local repo claim. Use `--reclaim` when intentionally taking over a lease from another repo.
|
||||
|
||||
@ -7,12 +7,17 @@ crabbox status --id blue-lobster
|
||||
crabbox status --id blue-lobster --network tailscale
|
||||
crabbox status --id blue-lobster --wait --wait-timeout 10m
|
||||
crabbox status --id blue-lobster --json
|
||||
crabbox status --provider daytona --id blue-lobster
|
||||
crabbox status --provider islo --id blue-lobster
|
||||
crabbox status --provider ssh --target macos --static-host mac-studio.local
|
||||
```
|
||||
|
||||
`--id` accepts the canonical `cbx_...` ID or active slug. In
|
||||
`blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and derives a
|
||||
normalized Crabbox status view from `blacksmith testbox list --all`. In
|
||||
`daytona` mode it resolves Crabbox labels and sandbox state through Daytona
|
||||
APIs. In `islo` mode it accepts an `isb_...` ID, Crabbox-created sandbox name,
|
||||
or local slug and renders SDK status through the core status view. In
|
||||
`provider=ssh` mode `--id` is optional and resolves the configured static target
|
||||
or local claim. Plain status is read-only; `--wait` touches the lease while
|
||||
waiting for Crabbox brokered leases.
|
||||
@ -21,7 +26,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -4,16 +4,18 @@
|
||||
|
||||
```sh
|
||||
crabbox stop blue-lobster
|
||||
crabbox stop --provider daytona blue-lobster
|
||||
crabbox stop --provider islo blue-lobster
|
||||
crabbox stop --provider ssh --static-host mac-studio.local mac-studio.local
|
||||
```
|
||||
|
||||
`crabbox release` remains as a compatibility alias.
|
||||
The argument accepts the stable `cbx_...` ID or an active friendly slug. In `blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and forwards to `blacksmith testbox stop`. In `provider=ssh` mode it removes the local claim for the configured static target; it never deletes the host.
|
||||
The argument accepts the stable `cbx_...` ID or an active friendly slug. In `blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and forwards to `blacksmith testbox stop`. In `daytona` mode it deletes the Daytona sandbox. In `islo` mode it accepts an `isb_...` ID, Crabbox-created sandbox name, or local slug and deletes the Islo sandbox. In `provider=ssh` mode it removes the local claim for the configured static target; it never deletes the host.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -12,6 +12,8 @@ crabbox warmup --provider aws --target windows --desktop
|
||||
crabbox warmup --provider aws --target macos --desktop --market on-demand --type mac2.metal
|
||||
crabbox warmup --actions-runner
|
||||
crabbox warmup --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test
|
||||
crabbox warmup --provider daytona --daytona-snapshot crabbox-ready
|
||||
crabbox warmup --provider islo --islo-image docker.io/library/ubuntu:24.04
|
||||
crabbox warmup --provider ssh --target macos --static-host mac-studio.local
|
||||
crabbox warmup --provider ssh --target windows --windows-mode normal --static-host win-dev.local --static-work-root 'C:\crabbox' --browser
|
||||
```
|
||||
@ -20,6 +22,15 @@ The command returns a stable `cbx_...` lease ID and a friendly slug. Reuse eithe
|
||||
|
||||
With `--provider blacksmith-testbox`, the canonical ID is the Blacksmith `tbx_...` ID returned by `blacksmith testbox warmup`; Crabbox still assigns and stores a local slug for reuse.
|
||||
|
||||
With `--provider daytona`, the canonical ID is a Crabbox `cbx_...` lease backed
|
||||
by a Daytona sandbox created from `daytona.snapshot`. `run` uses Daytona
|
||||
SDK/toolbox APIs; `ssh` mints short-lived Daytona SSH access tokens and redacts
|
||||
them from output.
|
||||
|
||||
With `--provider islo`, the canonical ID is
|
||||
`isb_<crabbox-sandbox-name>`. Crabbox stores a local slug, but Islo owns sandbox
|
||||
setup and command execution.
|
||||
|
||||
With `--provider ssh`, warmup claims an existing static SSH host instead of
|
||||
creating cloud capacity. Use `--target macos`, `--target windows
|
||||
--windows-mode normal`, or `--target windows --windows-mode wsl2` to select the
|
||||
@ -58,7 +69,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -17,6 +17,8 @@ Core features:
|
||||
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
|
||||
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior, classes, and cleanup.
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
|
||||
- [Daytona](daytona.md): Daytona SDK/toolbox sandbox leases with optional short-lived SSH access.
|
||||
- [Islo](islo.md): delegated Islo sandbox runs using the Islo Go SDK.
|
||||
- [Tailscale](tailscale.md): optional tailnet reachability for managed Linux leases and static hosts.
|
||||
- [Runner bootstrap](runner-bootstrap.md): cloud-init, installed tools, SSH port, and readiness.
|
||||
- [Prebaked runner images](prebaked-images.md): provider-owned image storage and the image/cache/state boundary.
|
||||
|
||||
75
docs/features/daytona.md
Normal file
75
docs/features/daytona.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Daytona
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing `provider: daytona`;
|
||||
- configuring Daytona auth, snapshots, or SSH access;
|
||||
- reviewing Daytona provider behavior.
|
||||
|
||||
`provider: daytona` provisions Daytona sandboxes from snapshots. `run` and
|
||||
`warmup` use Daytona's SDK/toolbox for workspace upload and command execution;
|
||||
`ssh` mints a short-lived Daytona SSH token only when interactive shell access is
|
||||
requested.
|
||||
|
||||
## Auth
|
||||
|
||||
Set one of:
|
||||
|
||||
```sh
|
||||
export DAYTONA_API_KEY=...
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```sh
|
||||
export DAYTONA_JWT_TOKEN=...
|
||||
export DAYTONA_ORGANIZATION_ID=...
|
||||
```
|
||||
|
||||
`DAYTONA_ORGANIZATION_ID` is required when JWT auth is used. `DAYTONA_API_URL`
|
||||
or `daytona.apiUrl` can override the default `https://app.daytona.io/api`.
|
||||
|
||||
## Config
|
||||
|
||||
Daytona's first Crabbox integration is snapshot-first. The snapshot owns CPU,
|
||||
memory, disk, and installed tooling. Crabbox does not expose Daytona resource
|
||||
flags in this mode.
|
||||
|
||||
```yaml
|
||||
provider: daytona
|
||||
target: linux
|
||||
daytona:
|
||||
snapshot: crabbox-ready
|
||||
target: ""
|
||||
user: daytona
|
||||
workRoot: /home/daytona/crabbox
|
||||
sshGatewayHost: ssh.app.daytona.io # fallback when the API omits sshCommand
|
||||
sshAccessMinutes: 30
|
||||
```
|
||||
|
||||
Equivalent flags:
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider daytona --daytona-snapshot crabbox-ready
|
||||
crabbox run --provider daytona --id <slug> -- pnpm test
|
||||
crabbox stop --provider daytona <slug>
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- `warmup` creates a Daytona sandbox from `daytona.snapshot`, waits for the
|
||||
sandbox, records Crabbox labels, then prints a normal Crabbox lease ID and
|
||||
slug.
|
||||
- `run --id` resolves a Daytona sandbox, uploads a Crabbox manifest archive
|
||||
through Daytona toolbox file APIs, extracts it in the sandbox, and executes the
|
||||
command through Daytona toolbox process APIs.
|
||||
- `list`, `status`, and `stop` use Daytona sandbox labels to find Crabbox-owned
|
||||
sandboxes.
|
||||
- `ssh` mints a fresh Daytona SSH token, parses the host and port returned by
|
||||
Daytona's `sshCommand`, and redacts the token as `<token>` unless
|
||||
`--show-secret` is used.
|
||||
|
||||
Daytona is a hybrid backend: core rendering, lease labels, sync manifests, and
|
||||
repo claim checks stay Crabbox-owned, while the actual `run` transport is
|
||||
Daytona SDK/toolbox. Actions runner hydration is not supported for Daytona
|
||||
warmup because it requires a normal long-lived SSH runner host.
|
||||
62
docs/features/islo.md
Normal file
62
docs/features/islo.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Islo
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing `provider: islo`;
|
||||
- configuring Islo sandbox image, sizing, or gateway profile;
|
||||
- reviewing delegated provider behavior.
|
||||
|
||||
`provider: islo` delegates sandbox setup and command execution to Islo. Crabbox
|
||||
uses the Islo Go SDK for auth, sandbox lifecycle, list, status, and stop. The
|
||||
SDK's current exec stream helper coalesces output, so Crabbox keeps a small SSE
|
||||
reader for `POST /sandboxes/{name}/exec/stream` while still using the SDK auth
|
||||
provider.
|
||||
|
||||
## Auth
|
||||
|
||||
```sh
|
||||
export ISLO_API_KEY=ak_...
|
||||
```
|
||||
|
||||
`ISLO_BASE_URL` or `islo.baseUrl` can override the default
|
||||
`https://api.islo.dev`.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
provider: islo
|
||||
target: linux
|
||||
islo:
|
||||
image: docker.io/library/ubuntu:24.04
|
||||
workdir: crabbox
|
||||
gatewayProfile: ""
|
||||
snapshotName: ""
|
||||
vcpus: 2
|
||||
memoryMB: 4096
|
||||
diskGB: 20
|
||||
```
|
||||
|
||||
Equivalent flags:
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider islo --islo-image docker.io/library/ubuntu:24.04
|
||||
crabbox run --provider islo -- pnpm test
|
||||
crabbox status --provider islo --id <slug>
|
||||
crabbox stop --provider islo <slug>
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- `warmup` creates a `crabbox-...` Islo sandbox and stores a local lease ID of
|
||||
the form `isb_<crabbox-sandbox-name>` plus a Crabbox slug.
|
||||
- `run` creates or reuses a sandbox, streams stdout/stderr from Islo's SSE exec
|
||||
endpoint, and returns the remote exit code.
|
||||
- Sync is delegated to Islo. `--sync-only`, `--checksum`, and
|
||||
`--force-sync-large` are rejected because Crabbox cannot honor those local
|
||||
rsync options.
|
||||
- `list`, `status`, and `stop` use the Islo SDK and return core-rendered
|
||||
Crabbox views for Crabbox-created sandboxes only.
|
||||
|
||||
Islo is not an SSH lease backend today. Commands that require a Crabbox SSH
|
||||
target, such as `ssh`, `vnc`, `code`, and Actions runner hydration, should use
|
||||
Hetzner, AWS, static SSH, or Daytona instead.
|
||||
@ -21,11 +21,20 @@ still exists for reusing existing macOS and Windows machines:
|
||||
ssh Existing SSH host selected by static.host
|
||||
```
|
||||
|
||||
Direct provider backends can also run without the Crabbox coordinator:
|
||||
|
||||
```text
|
||||
daytona Daytona sandboxes with SDK/toolbox run and short-lived SSH access
|
||||
islo Islo sandboxes with delegated command execution
|
||||
```
|
||||
|
||||
## Provider Pages
|
||||
|
||||
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
|
||||
- [Hetzner](hetzner.md): Linux-only managed provider behavior, classes, and cleanup.
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
|
||||
- [Daytona](daytona.md): Daytona SDK/toolbox sandbox leases.
|
||||
- [Islo](islo.md): delegated Islo sandbox execution.
|
||||
- [Provider backends](../provider-backends.md): implementation guide for adding a new provider/backend/plugin.
|
||||
|
||||
## Hetzner Summary
|
||||
@ -118,6 +127,15 @@ brokered Worker path.
|
||||
|
||||
Crabbox can also wrap Blacksmith Testboxes with `provider: blacksmith-testbox`. That backend does not use the Crabbox broker or direct cloud credentials. It shells out to the authenticated Blacksmith CLI for `testbox warmup`, `run`, `status`, `list`, and `stop`, while Crabbox keeps local slugs, repo claims, config, and timing summaries. See [Blacksmith Testbox](blacksmith-testbox.md).
|
||||
|
||||
Crabbox can use Daytona sandboxes with `provider: daytona`. Crabbox creates a
|
||||
sandbox from `daytona.snapshot`, syncs and executes `run` through Daytona's
|
||||
SDK/toolbox APIs, and mints short-lived SSH tokens only for explicit `ssh`
|
||||
access. See [Daytona](daytona.md).
|
||||
|
||||
Crabbox can use Islo sandboxes with `provider: islo`. Islo is a delegated run
|
||||
backend: the Islo Go SDK owns sandbox lifecycle and Crabbox streams command
|
||||
output from Islo's exec SSE endpoint. See [Islo](islo.md).
|
||||
|
||||
Static SSH targets:
|
||||
|
||||
```yaml
|
||||
@ -158,5 +176,7 @@ Related docs:
|
||||
- [Hetzner](hetzner.md)
|
||||
- [Tailscale](tailscale.md)
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md)
|
||||
- [Daytona](daytona.md)
|
||||
- [Islo](islo.md)
|
||||
- [Runner bootstrap](runner-bootstrap.md)
|
||||
- [Cost and usage](cost-usage.md)
|
||||
|
||||
@ -30,7 +30,6 @@ Examples:
|
||||
- Hetzner Cloud
|
||||
- AWS EC2
|
||||
- static SSH hosts
|
||||
- a future Daytona sandbox if it exposes stable SSH access
|
||||
|
||||
Crabbox core owns the normal workflow after acquisition:
|
||||
|
||||
@ -69,7 +68,9 @@ Crabbox-managed SSH.
|
||||
Examples:
|
||||
|
||||
- Blacksmith Testbox
|
||||
- a future Islo backend if it owns workspace setup and command streaming
|
||||
- Islo sandboxes, where Islo owns workspace setup and command streaming
|
||||
- Daytona sandboxes for `run`, where Daytona toolbox owns file upload and
|
||||
process execution while `crabbox ssh` still uses short-lived SSH tokens
|
||||
- a future external runner service that accepts a command and streams output
|
||||
|
||||
The delegated backend owns warmup, command execution, output streaming, and
|
||||
@ -152,6 +153,8 @@ internal/providers/hetzner
|
||||
internal/providers/aws
|
||||
internal/providers/ssh
|
||||
internal/providers/blacksmith
|
||||
internal/providers/daytona
|
||||
internal/providers/islo
|
||||
```
|
||||
|
||||
Each provider package owns registration, provider name, aliases, spec,
|
||||
@ -176,6 +179,9 @@ internal/cli/provider_hetzner.go # Hetzner SSH lease backend implementation
|
||||
internal/cli/provider_static.go # static SSH lease backend implementation
|
||||
internal/cli/provider_coordinator.go # brokered coordinator lease backend
|
||||
internal/cli/provider_blacksmith.go # existing delegated Blacksmith backend
|
||||
internal/cli/provider_daytona.go # Daytona SSH access backend implementation
|
||||
internal/cli/provider_daytona_delegated.go # Daytona SDK/toolbox run backend
|
||||
internal/cli/provider_islo.go # Islo delegated backend implementation
|
||||
```
|
||||
|
||||
This split is intentional. Existing built-ins still use a broad set of
|
||||
|
||||
@ -16,15 +16,15 @@ context and migration notes; the authoring guide is the handrail for new code.
|
||||
|
||||
Crabbox has two real execution models.
|
||||
|
||||
The first model is SSH lease execution. Hetzner, AWS, static SSH, and Daytona
|
||||
produce a machine or sandbox reachable through SSH. Crabbox owns the workflow:
|
||||
claim, sync, command wrapping, stdout/stderr streaming, result collection,
|
||||
timing, heartbeat, and release.
|
||||
The first model is SSH lease execution. Hetzner, AWS, and static SSH produce a
|
||||
machine reachable through SSH. Crabbox owns the workflow: claim, sync, command
|
||||
wrapping, stdout/stderr streaming, result collection, timing, heartbeat, and
|
||||
release.
|
||||
|
||||
The second model is delegated execution. Blacksmith Testboxes and Islo own
|
||||
machine setup, file/workspace state, command execution, and output streaming.
|
||||
Crabbox keeps provider selection, config, local claims/slugs, and timing
|
||||
summaries, but it does not rsync into these providers.
|
||||
The second model is delegated execution. Blacksmith Testboxes, Daytona `run`,
|
||||
and Islo own machine setup or file/workspace transport, command execution, and
|
||||
output streaming. Crabbox keeps provider selection, config, local claims/slugs,
|
||||
and timing summaries, but it does not rsync into these providers.
|
||||
|
||||
Relevant pull requests:
|
||||
|
||||
@ -34,13 +34,12 @@ Relevant pull requests:
|
||||
|
||||
SDK/source checks:
|
||||
|
||||
- Daytona upstream has an official Go SDK at
|
||||
`github.com/daytonaio/daytona/libs/sdk-go`, plus a lower-level generated API
|
||||
client. The official SDK is large and brings more dependency surface than the
|
||||
provider needs. The generated API client exposes the exact REST calls Crabbox
|
||||
needs: create sandbox, create SSH access, list sandboxes, update labels, and
|
||||
update last activity. Prefer a tiny Crabbox-owned REST client unless the SDK
|
||||
becomes meaningfully simpler.
|
||||
- Daytona upstream ships a generated Go API client at
|
||||
`github.com/daytonaio/daytona/libs/api-client-go` and a toolbox SDK at
|
||||
`github.com/daytonaio/daytona/libs/sdk-go`. Use both through narrow
|
||||
Crabbox-owned adapters: the generated client for list/get/start/delete,
|
||||
labels, last activity, and SSH access; the SDK/toolbox for sandbox create,
|
||||
file upload, and command execution.
|
||||
- Daytona snapshot creation does not accept CPU/memory/disk resources. Resource
|
||||
fields live on image creation. Snapshot-only mode must not expose resource
|
||||
flags that become no-ops.
|
||||
@ -331,7 +330,7 @@ provider kind coordinator features
|
||||
hetzner ssh-lease supported ssh, crabbox-sync, cleanup, tailscale
|
||||
aws ssh-lease supported ssh, crabbox-sync, cleanup, desktop, browser, code
|
||||
ssh ssh-lease never ssh, crabbox-sync, desktop, browser, code
|
||||
daytona ssh-lease never ssh, crabbox-sync, cleanup
|
||||
daytona ssh-lease never ssh, crabbox-sync
|
||||
blacksmith-testbox delegated-run never delegated execution
|
||||
islo delegated-run never delegated execution
|
||||
```
|
||||
@ -353,7 +352,7 @@ validation:
|
||||
```text
|
||||
provider=daytona managed provisioning supports target=linux only
|
||||
desktop/VNC is not supported for provider=islo; islo sandboxes are headless
|
||||
--actions-runner is not supported for provider=daytona
|
||||
--actions-runner requires an SSH lease provider with target=linux
|
||||
```
|
||||
|
||||
## Registry
|
||||
@ -510,7 +509,7 @@ Provider-specific flags:
|
||||
--daytona-target
|
||||
--daytona-user
|
||||
--daytona-work-root
|
||||
--daytona-ssh-token-minutes
|
||||
--daytona-ssh-access-minutes
|
||||
|
||||
--islo-image
|
||||
--islo-workdir
|
||||
@ -856,8 +855,8 @@ Daytona target example:
|
||||
```go
|
||||
SSHTarget{
|
||||
User: token,
|
||||
Host: "ssh.app.daytona.io",
|
||||
Port: "22",
|
||||
Host: parsedHostFromSSHCommand,
|
||||
Port: parsedPortFromSSHCommand,
|
||||
Key: "",
|
||||
TargetOS: "linux",
|
||||
ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null",
|
||||
@ -869,7 +868,7 @@ SSHTarget{
|
||||
Normal output:
|
||||
|
||||
```text
|
||||
ready ssh=<redacted>@ssh.app.daytona.io:22 network=public workroot=/home/daytona/crabbox
|
||||
ready ssh=<redacted>@<daytona-ssh-host>:<daytona-ssh-port> network=public workroot=/home/daytona/crabbox
|
||||
```
|
||||
|
||||
The actual interactive `crabbox ssh --provider daytona --id ...` command may
|
||||
@ -1109,7 +1108,7 @@ Does not support provider cleanup or coordinator.
|
||||
|
||||
### Daytona
|
||||
|
||||
Backend: `SSHLeaseBackend`
|
||||
Backend: hybrid `SSHLeaseBackend` + `DelegatedRunBackend`
|
||||
|
||||
Spec:
|
||||
|
||||
@ -1117,24 +1116,26 @@ Spec:
|
||||
kind=ssh-lease
|
||||
coordinator=never
|
||||
targets=linux
|
||||
features=ssh, crabbox-sync, cleanup
|
||||
features=ssh, crabbox-sync
|
||||
```
|
||||
|
||||
Owns:
|
||||
|
||||
- REST API auth and organization header;
|
||||
- Daytona generated Go API client auth and organization header;
|
||||
- Daytona SDK/toolbox auth;
|
||||
- sandbox create/list/get/start/stop/delete;
|
||||
- labels and last-activity touch;
|
||||
- SSH access token minting;
|
||||
- toolbox archive upload and command execution for `run`;
|
||||
- Daytona sandbox to `Server` mapping;
|
||||
- secret SSH user and public relay target metadata.
|
||||
|
||||
Reuses core:
|
||||
|
||||
- SSH sync/run;
|
||||
- sync manifest and guardrails;
|
||||
- claims;
|
||||
- status rendering;
|
||||
- cleanup policy.
|
||||
- explicit release/stop.
|
||||
|
||||
Initial constraints:
|
||||
|
||||
@ -1142,15 +1143,15 @@ Initial constraints:
|
||||
- No coordinator.
|
||||
- No Tailscale.
|
||||
- No VNC/screenshot/desktop/browser/code portal.
|
||||
- No Actions runner.
|
||||
- Actions runner hydration is not supported for Daytona warmup.
|
||||
- Snapshot mode only unless image mode is implemented fully.
|
||||
|
||||
Rebase notes for https://github.com/openclaw/crabbox/pull/32:
|
||||
|
||||
- Implement `Provider.Configure` returning a Daytona `SSHLeaseBackend`.
|
||||
- Keep raw REST instead of the official Daytona Go SDK.
|
||||
- Keep the labels body fix: Daytona label update expects
|
||||
`{ "labels": { ... } }`.
|
||||
- Implement `Provider.Configure` returning a Daytona backend that supports
|
||||
delegated `run` plus explicit SSH access.
|
||||
- Use Daytona's generated Go API client and SDK/toolbox; do not duplicate REST
|
||||
plumbing in Crabbox.
|
||||
- Keep start-before-SSH for stopped sandboxes.
|
||||
- Require `DAYTONA_ORGANIZATION_ID` when JWT auth is used unless Daytona docs
|
||||
prove it is optional for the account shape.
|
||||
@ -1294,9 +1295,11 @@ Expected behavior change: none for existing configs.
|
||||
|
||||
### Phase 7: Rebase Daytona
|
||||
|
||||
- Rebase https://github.com/openclaw/crabbox/pull/32 onto `SSHLeaseBackend`.
|
||||
- Keep Daytona REST client isolated.
|
||||
- Add tests for acquire/resolve/list/release/touch via backend.
|
||||
- Rebase https://github.com/openclaw/crabbox/pull/32 onto a hybrid Daytona
|
||||
backend: delegated SDK/toolbox `run`, explicit SSH access for `ssh`.
|
||||
- Keep Daytona SDK access isolated behind the backend adapter.
|
||||
- Add tests for acquire/resolve/list/release/touch plus delegated backend
|
||||
selection.
|
||||
- Add redaction tests for secret SSH user output.
|
||||
- Add live smoke behind explicit env gates only.
|
||||
|
||||
@ -1370,8 +1373,8 @@ Daytona tests:
|
||||
- labels body shape;
|
||||
- snapshot mode omits unusable resource overrides;
|
||||
- stopped sandbox starts before SSH target creation;
|
||||
- SSH target uses relay host, empty key, secret user, public network, and ready
|
||||
check;
|
||||
- SSH target parses the API-returned `sshCommand`, uses empty key, secret user,
|
||||
public network, and ready check;
|
||||
- list/status/timing output, including JSON, redacts token-bearing user;
|
||||
- release removes local claim.
|
||||
|
||||
@ -1401,7 +1404,7 @@ Docs tests:
|
||||
- A fake SSH lease backend can be tested without editing command handlers.
|
||||
- A fake delegated backend can be tested without editing command handlers.
|
||||
- Hetzner/AWS still use the coordinator when configured.
|
||||
- Daytona can be rebased by implementing `SSHLeaseBackend`.
|
||||
- Daytona can be rebased by implementing the hybrid backend.
|
||||
- Islo can be rebased by implementing `DelegatedRunBackend`.
|
||||
- No new provider requires touching the main command flow unless it adds a new
|
||||
top-level Crabbox feature.
|
||||
|
||||
@ -37,20 +37,24 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
- Direct AWS provider: `internal/cli/aws.go`
|
||||
- Static SSH macOS/Windows provider: `internal/cli/static.go`
|
||||
- Blacksmith Testbox argument/parsing helpers: `internal/cli/blacksmith.go`
|
||||
- Daytona provider backend and SDK/toolbox wrapper: `internal/cli/provider_daytona.go`, `internal/cli/provider_daytona_delegated.go`, `internal/providers/daytona`
|
||||
- Islo delegated backend and SDK wrapper: `internal/cli/provider_islo.go`, `internal/providers/islo`
|
||||
- Provider backend interfaces, registry, and request/result types:
|
||||
`internal/cli/provider_backend.go`
|
||||
- Built-in provider registration packages:
|
||||
`internal/providers/hetzner`, `internal/providers/aws`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
`internal/providers/all`
|
||||
- Built-in provider backend implementations:
|
||||
`internal/cli/providers_common.go`, `internal/cli/provider_aws.go`,
|
||||
`internal/cli/provider_hetzner.go`, `internal/cli/provider_static.go`,
|
||||
`internal/cli/provider_coordinator.go`, `internal/cli/provider_blacksmith.go`
|
||||
`internal/cli/provider_coordinator.go`, `internal/cli/provider_blacksmith.go`,
|
||||
`internal/cli/provider_daytona.go`, `internal/cli/provider_islo.go`
|
||||
- Worker Hetzner provider: `worker/src/hetzner.ts`
|
||||
- Worker AWS EC2 provider: `worker/src/aws.ts`
|
||||
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
|
||||
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`
|
||||
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
|
||||
- Provider/backend authoring guide: `docs/provider-backends.md`
|
||||
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
|
||||
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
|
||||
|
||||
35
go.mod
35
go.mod
@ -9,24 +9,53 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1
|
||||
github.com/daytonaio/daytona/libs/api-client-go v0.172.0
|
||||
github.com/daytonaio/daytona/libs/sdk-go v0.172.0
|
||||
github.com/islo-labs/go-sdk v0.0.0-20260503172735-c8bd46c2d7d5
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
nhooyr.io/websocket v1.8.17
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
91
go.sum
91
go.sum
@ -6,6 +6,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
@ -22,8 +24,14 @@ github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1 h1:gQ9fSyFk3Y9Vm2fVbphBeJfXJlk
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
@ -34,23 +42,94 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOIt
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/daytonaio/daytona/libs/api-client-go v0.172.0 h1:JbKycJaayrP2nMRzq84/h+V3bWbzU+5r0euQClMLSSQ=
|
||||
github.com/daytonaio/daytona/libs/api-client-go v0.172.0/go.mod h1:FzTxtFuR9utJw43WBUVdX9AF4rALJlSKvx4bddZthxs=
|
||||
github.com/daytonaio/daytona/libs/sdk-go v0.172.0 h1:ickg8R4cCJ+16MQpDGkSpVD/oyY5FG77OUoSaEYzpHg=
|
||||
github.com/daytonaio/daytona/libs/sdk-go v0.172.0/go.mod h1:a/rpZfveDUrT0+R8jlzndfIrV1u/DJmbZn/hAXu8yCs=
|
||||
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0 h1:1MyfR0Eu6sX3jGHFeXK6B7paMTSvqMufDuJvqZpaBFU=
|
||||
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0/go.mod h1:Y/IJdiDMtmm3Nz6NBiTRJ3uEiRUqNAvEZPNNCFdgkzo=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/islo-labs/go-sdk v0.0.0-20260503172735-c8bd46c2d7d5 h1:Jb/g064nT2bk/uS87kIlHP7sxp/1RVZE8oJoqabxH3Q=
|
||||
github.com/islo-labs/go-sdk v0.0.0-20260503172735-c8bd46c2d7d5/go.mod h1:2REOOHkiu0f0vI8sybwSxjt3xT6d0h9Yop5fRgwOF84=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
|
||||
4
index.js
4
index.js
@ -23,7 +23,7 @@ const envSchema = {
|
||||
|
||||
const providerSchema = {
|
||||
type: "string",
|
||||
enum: ["aws", "hetzner"],
|
||||
enum: ["aws", "hetzner", "ssh", "blacksmith-testbox", "blacksmith", "daytona", "islo"],
|
||||
};
|
||||
|
||||
function readConfig(api) {
|
||||
@ -206,6 +206,7 @@ function registerRun(api, config) {
|
||||
description: "Crabbox lease ID or friendly slug.",
|
||||
},
|
||||
command: commandArraySchema,
|
||||
provider: providerSchema,
|
||||
env: envSchema,
|
||||
noSync: {
|
||||
type: "boolean",
|
||||
@ -246,6 +247,7 @@ function registerRun(api, config) {
|
||||
throw new Error("crabbox_run is disabled by plugin config");
|
||||
}
|
||||
const args = ["run", "--id", readString(params, "id")];
|
||||
maybePush(args, "--provider", readString(params, "provider"));
|
||||
maybePushBool(args, "--no-sync", params?.noSync);
|
||||
maybePushBool(args, "--sync-only", params?.syncOnly);
|
||||
maybePushBool(args, "--force-sync-large", params?.forceSyncLarge);
|
||||
|
||||
@ -68,6 +68,28 @@ test("crabbox_run executes the CLI without shell wrapping", async () => {
|
||||
assert.equal(JSON.parse(result.details.stdout).env.CRABBOX_TEST_VALUE, "present");
|
||||
});
|
||||
|
||||
test("crabbox_run passes selected provider", async () => {
|
||||
const fake = createFakeCrabbox();
|
||||
const tools = registerWithConfig({ binary: fake.file });
|
||||
const result = await getTool(tools, "crabbox_run").execute("call-1", {
|
||||
id: "blue-lobster",
|
||||
provider: "islo",
|
||||
command: ["go", "test", "./..."],
|
||||
});
|
||||
assert.equal(result.details.code, 0);
|
||||
assert.deepEqual(JSON.parse(result.details.stdout).argv, [
|
||||
"run",
|
||||
"--id",
|
||||
"blue-lobster",
|
||||
"--provider",
|
||||
"islo",
|
||||
"--",
|
||||
"go",
|
||||
"test",
|
||||
"./...",
|
||||
]);
|
||||
});
|
||||
|
||||
test("crabbox_status includes optional flags", async () => {
|
||||
const fake = createFakeCrabbox();
|
||||
const tools = registerWithConfig({ binary: fake.file });
|
||||
|
||||
@ -207,7 +207,7 @@ Environment:
|
||||
CRABBOX_ACCESS_CLIENT_ID Cloudflare Access service token client ID
|
||||
CRABBOX_ACCESS_CLIENT_SECRET Cloudflare Access service token client secret
|
||||
CRABBOX_ACCESS_TOKEN Cloudflare Access JWT for protected routes
|
||||
CRABBOX_PROVIDER hetzner, aws, ssh, or blacksmith-testbox
|
||||
CRABBOX_PROVIDER hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
|
||||
CRABBOX_TARGET linux, macos, or windows
|
||||
CRABBOX_WINDOWS_MODE normal or wsl2
|
||||
CRABBOX_DESKTOP Provision or require desktop/VNC capability
|
||||
|
||||
@ -51,6 +51,8 @@ type Config struct {
|
||||
Capacity CapacityConfig
|
||||
Actions ActionsConfig
|
||||
Blacksmith BlacksmithConfig
|
||||
Daytona DaytonaConfig
|
||||
Islo IsloConfig
|
||||
Tailscale TailscaleConfig
|
||||
Static StaticConfig
|
||||
Results ResultsConfig
|
||||
@ -100,6 +102,31 @@ type BlacksmithConfig struct {
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type DaytonaConfig struct {
|
||||
APIKey string
|
||||
JWTToken string
|
||||
OrganizationID string
|
||||
APIURL string
|
||||
Snapshot string
|
||||
Target string
|
||||
User string
|
||||
WorkRoot string
|
||||
SSHGatewayHost string
|
||||
SSHAccessMinutes int
|
||||
}
|
||||
|
||||
type IsloConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Image string
|
||||
Workdir string
|
||||
GatewayProfile string
|
||||
SnapshotName string
|
||||
VCPUs int
|
||||
MemoryMB int
|
||||
DiskGB int
|
||||
}
|
||||
|
||||
type StaticConfig struct {
|
||||
ID string
|
||||
Name string
|
||||
@ -207,6 +234,21 @@ func baseConfig() Config {
|
||||
RunnerVersion: "latest",
|
||||
Ephemeral: true,
|
||||
},
|
||||
Daytona: DaytonaConfig{
|
||||
APIURL: "https://app.daytona.io/api",
|
||||
User: "daytona",
|
||||
WorkRoot: "/home/daytona/crabbox",
|
||||
SSHGatewayHost: "ssh.app.daytona.io",
|
||||
SSHAccessMinutes: 30,
|
||||
},
|
||||
Islo: IsloConfig{
|
||||
BaseURL: "https://api.islo.dev",
|
||||
Image: "docker.io/library/ubuntu:24.04",
|
||||
Workdir: "crabbox",
|
||||
VCPUs: 2,
|
||||
MemoryMB: 4096,
|
||||
DiskGB: 20,
|
||||
},
|
||||
Tailscale: TailscaleConfig{
|
||||
Tags: []string{"tag:crabbox"},
|
||||
HostnameTemplate: "crabbox-{slug}",
|
||||
@ -245,6 +287,8 @@ type fileConfig struct {
|
||||
Capacity *fileCapacityConfig `yaml:"capacity,omitempty"`
|
||||
Actions *fileActionsConfig `yaml:"actions,omitempty"`
|
||||
Blacksmith *fileBlacksmithConfig `yaml:"blacksmith,omitempty"`
|
||||
Daytona *fileDaytonaConfig `yaml:"daytona,omitempty"`
|
||||
Islo *fileIsloConfig `yaml:"islo,omitempty"`
|
||||
Tailscale *fileTailscaleConfig `yaml:"tailscale,omitempty"`
|
||||
Static *fileStaticConfig `yaml:"static,omitempty"`
|
||||
Results *fileResultsConfig `yaml:"results,omitempty"`
|
||||
@ -345,6 +389,27 @@ type fileBlacksmithConfig struct {
|
||||
Debug *bool `yaml:"debug,omitempty"`
|
||||
}
|
||||
|
||||
type fileDaytonaConfig struct {
|
||||
APIURL string `yaml:"apiUrl,omitempty"`
|
||||
Snapshot string `yaml:"snapshot,omitempty"`
|
||||
Target string `yaml:"target,omitempty"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
WorkRoot string `yaml:"workRoot,omitempty"`
|
||||
SSHGatewayHost string `yaml:"sshGatewayHost,omitempty"`
|
||||
SSHAccessMinutes int `yaml:"sshAccessMinutes,omitempty"`
|
||||
}
|
||||
|
||||
type fileIsloConfig struct {
|
||||
BaseURL string `yaml:"baseUrl,omitempty"`
|
||||
Image string `yaml:"image,omitempty"`
|
||||
Workdir string `yaml:"workdir,omitempty"`
|
||||
GatewayProfile string `yaml:"gatewayProfile,omitempty"`
|
||||
SnapshotName string `yaml:"snapshotName,omitempty"`
|
||||
VCPUs int `yaml:"vcpus,omitempty"`
|
||||
MemoryMB int `yaml:"memoryMB,omitempty"`
|
||||
DiskGB int `yaml:"diskGB,omitempty"`
|
||||
}
|
||||
|
||||
type fileTailscaleConfig struct {
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
@ -704,6 +769,55 @@ func applyFileConfig(cfg *Config, file fileConfig) {
|
||||
cfg.Blacksmith.Debug = *file.Blacksmith.Debug
|
||||
}
|
||||
}
|
||||
if file.Daytona != nil {
|
||||
if file.Daytona.APIURL != "" {
|
||||
cfg.Daytona.APIURL = file.Daytona.APIURL
|
||||
}
|
||||
if file.Daytona.Snapshot != "" {
|
||||
cfg.Daytona.Snapshot = file.Daytona.Snapshot
|
||||
}
|
||||
if file.Daytona.Target != "" {
|
||||
cfg.Daytona.Target = file.Daytona.Target
|
||||
}
|
||||
if file.Daytona.User != "" {
|
||||
cfg.Daytona.User = file.Daytona.User
|
||||
}
|
||||
if file.Daytona.WorkRoot != "" {
|
||||
cfg.Daytona.WorkRoot = file.Daytona.WorkRoot
|
||||
}
|
||||
if file.Daytona.SSHGatewayHost != "" {
|
||||
cfg.Daytona.SSHGatewayHost = file.Daytona.SSHGatewayHost
|
||||
}
|
||||
if file.Daytona.SSHAccessMinutes > 0 {
|
||||
cfg.Daytona.SSHAccessMinutes = file.Daytona.SSHAccessMinutes
|
||||
}
|
||||
}
|
||||
if file.Islo != nil {
|
||||
if file.Islo.BaseURL != "" {
|
||||
cfg.Islo.BaseURL = file.Islo.BaseURL
|
||||
}
|
||||
if file.Islo.Image != "" {
|
||||
cfg.Islo.Image = file.Islo.Image
|
||||
}
|
||||
if file.Islo.Workdir != "" {
|
||||
cfg.Islo.Workdir = file.Islo.Workdir
|
||||
}
|
||||
if file.Islo.GatewayProfile != "" {
|
||||
cfg.Islo.GatewayProfile = file.Islo.GatewayProfile
|
||||
}
|
||||
if file.Islo.SnapshotName != "" {
|
||||
cfg.Islo.SnapshotName = file.Islo.SnapshotName
|
||||
}
|
||||
if file.Islo.VCPUs > 0 {
|
||||
cfg.Islo.VCPUs = file.Islo.VCPUs
|
||||
}
|
||||
if file.Islo.MemoryMB > 0 {
|
||||
cfg.Islo.MemoryMB = file.Islo.MemoryMB
|
||||
}
|
||||
if file.Islo.DiskGB > 0 {
|
||||
cfg.Islo.DiskGB = file.Islo.DiskGB
|
||||
}
|
||||
}
|
||||
if file.Tailscale != nil {
|
||||
if file.Tailscale.Enabled != nil {
|
||||
cfg.Tailscale.Enabled = *file.Tailscale.Enabled
|
||||
@ -841,6 +955,25 @@ func applyEnv(cfg *Config) {
|
||||
cfg.Blacksmith.Workflow = getenv("CRABBOX_BLACKSMITH_WORKFLOW", cfg.Blacksmith.Workflow)
|
||||
cfg.Blacksmith.Job = getenv("CRABBOX_BLACKSMITH_JOB", cfg.Blacksmith.Job)
|
||||
cfg.Blacksmith.Ref = getenv("CRABBOX_BLACKSMITH_REF", cfg.Blacksmith.Ref)
|
||||
cfg.Daytona.APIKey = getenv("CRABBOX_DAYTONA_API_KEY", getenv("DAYTONA_API_KEY", cfg.Daytona.APIKey))
|
||||
cfg.Daytona.JWTToken = getenv("CRABBOX_DAYTONA_JWT_TOKEN", getenv("DAYTONA_JWT_TOKEN", cfg.Daytona.JWTToken))
|
||||
cfg.Daytona.OrganizationID = getenv("CRABBOX_DAYTONA_ORGANIZATION_ID", getenv("DAYTONA_ORGANIZATION_ID", cfg.Daytona.OrganizationID))
|
||||
cfg.Daytona.APIURL = getenv("CRABBOX_DAYTONA_API_URL", getenv("DAYTONA_API_URL", cfg.Daytona.APIURL))
|
||||
cfg.Daytona.Snapshot = getenv("CRABBOX_DAYTONA_SNAPSHOT", getenv("DAYTONA_SNAPSHOT", cfg.Daytona.Snapshot))
|
||||
cfg.Daytona.Target = getenv("CRABBOX_DAYTONA_TARGET", getenv("DAYTONA_TARGET", cfg.Daytona.Target))
|
||||
cfg.Daytona.User = getenv("CRABBOX_DAYTONA_USER", cfg.Daytona.User)
|
||||
cfg.Daytona.WorkRoot = getenv("CRABBOX_DAYTONA_WORK_ROOT", cfg.Daytona.WorkRoot)
|
||||
cfg.Daytona.SSHGatewayHost = getenv("CRABBOX_DAYTONA_SSH_GATEWAY_HOST", cfg.Daytona.SSHGatewayHost)
|
||||
cfg.Daytona.SSHAccessMinutes = getenvInt("CRABBOX_DAYTONA_SSH_ACCESS_MINUTES", cfg.Daytona.SSHAccessMinutes)
|
||||
cfg.Islo.APIKey = getenv("CRABBOX_ISLO_API_KEY", getenv("ISLO_API_KEY", cfg.Islo.APIKey))
|
||||
cfg.Islo.BaseURL = getenv("CRABBOX_ISLO_BASE_URL", getenv("ISLO_BASE_URL", cfg.Islo.BaseURL))
|
||||
cfg.Islo.Image = getenv("CRABBOX_ISLO_IMAGE", cfg.Islo.Image)
|
||||
cfg.Islo.Workdir = getenv("CRABBOX_ISLO_WORKDIR", cfg.Islo.Workdir)
|
||||
cfg.Islo.GatewayProfile = getenv("CRABBOX_ISLO_GATEWAY_PROFILE", cfg.Islo.GatewayProfile)
|
||||
cfg.Islo.SnapshotName = getenv("CRABBOX_ISLO_SNAPSHOT_NAME", cfg.Islo.SnapshotName)
|
||||
cfg.Islo.VCPUs = getenvInt("CRABBOX_ISLO_VCPUS", cfg.Islo.VCPUs)
|
||||
cfg.Islo.MemoryMB = getenvInt("CRABBOX_ISLO_MEMORY_MB", cfg.Islo.MemoryMB)
|
||||
cfg.Islo.DiskGB = getenvInt("CRABBOX_ISLO_DISK_GB", cfg.Islo.DiskGB)
|
||||
if value, ok := getenvBool("CRABBOX_TAILSCALE"); ok {
|
||||
cfg.Tailscale.Enabled = value
|
||||
}
|
||||
@ -946,9 +1079,12 @@ func serverTypeForClass(class string) string {
|
||||
}
|
||||
|
||||
func serverTypeForConfig(cfg Config) string {
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) || cfg.Provider == "islo" {
|
||||
return ""
|
||||
}
|
||||
if cfg.Provider == "daytona" {
|
||||
return "snapshot"
|
||||
}
|
||||
if cfg.Provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForConfig(cfg)[0]
|
||||
}
|
||||
@ -956,9 +1092,12 @@ func serverTypeForConfig(cfg Config) string {
|
||||
}
|
||||
|
||||
func serverTypeForProviderClass(provider, class string) string {
|
||||
if isBlacksmithProvider(provider) || isStaticProvider(provider) {
|
||||
if isBlacksmithProvider(provider) || isStaticProvider(provider) || provider == "islo" {
|
||||
return ""
|
||||
}
|
||||
if provider == "daytona" {
|
||||
return "snapshot"
|
||||
}
|
||||
if provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForClass(class)[0]
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ type leaseCreateFlagValues struct {
|
||||
|
||||
func registerLeaseCreateFlags(fs *flag.FlagSet, defaults Config) leaseCreateFlagValues {
|
||||
return leaseCreateFlagValues{
|
||||
Provider: fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox"),
|
||||
Provider: fs.String("provider", defaults.Provider, providerHelpAll()),
|
||||
Profile: fs.String("profile", defaults.Profile, "profile"),
|
||||
Class: fs.String("class", defaults.Class, "machine class"),
|
||||
ServerType: fs.String("type", getenv("CRABBOX_SERVER_TYPE", ""), "provider server/instance type"),
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
func (a App) list(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("list", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpAll())
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
providerFlags := registerProviderFlags(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
|
||||
@ -309,6 +309,14 @@ func normalizeProviderName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func providerHelpAll() string {
|
||||
return "provider: hetzner, aws, ssh, blacksmith-testbox, daytona, or islo"
|
||||
}
|
||||
|
||||
func providerHelpSSH() string {
|
||||
return "provider: hetzner, aws, ssh, or daytona"
|
||||
}
|
||||
|
||||
type providerFlagValues map[string]any
|
||||
|
||||
func registerProviderFlags(fs *flag.FlagSet, defaults Config) providerFlagValues {
|
||||
@ -375,23 +383,24 @@ func backendCoordinator(backend Backend) *CoordinatorClient {
|
||||
|
||||
func leaseOptionsFromConfig(cfg Config) LeaseOptions {
|
||||
return LeaseOptions{
|
||||
TargetOS: cfg.TargetOS,
|
||||
WindowsMode: cfg.WindowsMode,
|
||||
Class: cfg.Class,
|
||||
ServerType: cfg.ServerType,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
TTL: cfg.TTL,
|
||||
Desktop: cfg.Desktop,
|
||||
Browser: cfg.Browser,
|
||||
Code: cfg.Code,
|
||||
Tailscale: cfg.Tailscale,
|
||||
WorkRoot: cfg.WorkRoot,
|
||||
SSHUser: cfg.SSHUser,
|
||||
SSHPort: cfg.SSHPort,
|
||||
SSHKey: cfg.SSHKey,
|
||||
Sync: cfg.Sync,
|
||||
Results: cfg.Results,
|
||||
EnvAllow: cfg.EnvAllow,
|
||||
TargetOS: cfg.TargetOS,
|
||||
WindowsMode: cfg.WindowsMode,
|
||||
Class: cfg.Class,
|
||||
ServerType: cfg.ServerType,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
TTL: cfg.TTL,
|
||||
Desktop: cfg.Desktop,
|
||||
Browser: cfg.Browser,
|
||||
Code: cfg.Code,
|
||||
Tailscale: cfg.Tailscale,
|
||||
WorkRoot: cfg.WorkRoot,
|
||||
SSHUser: cfg.SSHUser,
|
||||
SSHPort: cfg.SSHPort,
|
||||
SSHKey: cfg.SSHKey,
|
||||
Sync: cfg.Sync,
|
||||
Results: cfg.Results,
|
||||
EnvAllow: cfg.EnvAllow,
|
||||
ActionsRunner: cfg.Actions.Workflow != "" || len(cfg.Actions.RunnerLabels) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -22,7 +23,7 @@ func testRuntimeWithRunner(r CommandRunner) Runtime {
|
||||
}
|
||||
|
||||
func TestProviderRegistryCanonicalAndAliases(t *testing.T) {
|
||||
for _, name := range []string{"hetzner", "aws", "ssh", "static", "static-ssh", "blacksmith", "blacksmith-testbox"} {
|
||||
for _, name := range []string{"hetzner", "aws", "ssh", "static", "static-ssh", "blacksmith", "blacksmith-testbox", "daytona", "islo"} {
|
||||
if _, err := ProviderFor(name); err != nil {
|
||||
t.Fatalf("ProviderFor(%q): %v", name, err)
|
||||
}
|
||||
@ -141,6 +142,126 @@ func TestBlacksmithBackendUsesInjectedCommandRunnerForListAndStatus(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFlagsApplyDaytonaAndIsloWithoutCoreEdits(t *testing.T) {
|
||||
defaults := baseConfig()
|
||||
fs := newFlagSet("test", io.Discard)
|
||||
provider := fs.String("provider", defaults.Provider, "")
|
||||
values := registerProviderFlags(fs, defaults)
|
||||
if err := parseFlags(fs, []string{
|
||||
"--provider", "daytona",
|
||||
"--daytona-snapshot", "snap-crabbox",
|
||||
"--daytona-target", "us",
|
||||
"--daytona-work-root", "/home/daytona/work",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := defaults
|
||||
cfg.Provider = *provider
|
||||
if err := applyProviderFlags(&cfg, fs, values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Daytona.Snapshot != "snap-crabbox" || cfg.Daytona.Target != "us" || cfg.Daytona.WorkRoot != "/home/daytona/work" {
|
||||
t.Fatalf("daytona flags not applied: %#v", cfg.Daytona)
|
||||
}
|
||||
|
||||
fs = newFlagSet("test", io.Discard)
|
||||
provider = fs.String("provider", defaults.Provider, "")
|
||||
values = registerProviderFlags(fs, defaults)
|
||||
if err := parseFlags(fs, []string{
|
||||
"--provider", "islo",
|
||||
"--islo-image", "ubuntu:24.04",
|
||||
"--islo-vcpus", "4",
|
||||
"--islo-memory-mb", "8192",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg = defaults
|
||||
cfg.Provider = *provider
|
||||
if err := applyProviderFlags(&cfg, fs, values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Islo.Image != "ubuntu:24.04" || cfg.Islo.VCPUs != 4 || cfg.Islo.MemoryMB != 8192 {
|
||||
t.Fatalf("islo flags not applied: %#v", cfg.Islo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaAuthRequiresOrganizationForJWT(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = daytonaProvider
|
||||
cfg.Daytona.APIKey = ""
|
||||
cfg.Daytona.JWTToken = "jwt"
|
||||
cfg.Daytona.OrganizationID = ""
|
||||
_, err := newDaytonaClient(cfg, Runtime{})
|
||||
if err == nil || !strings.Contains(err.Error(), "DAYTONA_ORGANIZATION_ID") {
|
||||
t.Fatalf("err=%v, want organization requirement", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaSSHTargetUsesReturnedSSHCommand(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Daytona.SSHGatewayHost = "fallback.example"
|
||||
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{
|
||||
Token: "tok_live_secret",
|
||||
Command: "ssh -p 2222 tok_live_secret@region-ssh.example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.User != "tok_live_secret" || target.Host != "region-ssh.example.com" || target.Port != "2222" {
|
||||
t.Fatalf("target=%#v", target)
|
||||
}
|
||||
if target.Key != "" || !target.AuthSecret || target.NetworkKind != NetworkPublic {
|
||||
t.Fatalf("target auth/network=%#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaSSHTargetFallsBackWhenCommandMissing(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Daytona.SSHGatewayHost = "fallback.example"
|
||||
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{Token: "tok_live_secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.User != "tok_live_secret" || target.Host != "fallback.example" || target.Port != "22" {
|
||||
t.Fatalf("target=%#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaBackendIsHybridSDKRunAndSSHAccess(t *testing.T) {
|
||||
backend := NewDaytonaLeaseBackend(ProviderSpec{Name: daytonaProvider}, baseConfig(), Runtime{})
|
||||
if _, ok := backend.(DelegatedRunBackend); !ok {
|
||||
t.Fatal("daytona should use delegated SDK run path")
|
||||
}
|
||||
if _, ok := backend.(SSHLeaseBackend); !ok {
|
||||
t.Fatal("daytona should still expose explicit SSH access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaytonaCommandString(t *testing.T) {
|
||||
if got := daytonaCommandString([]string{"go", "test", "./..."}, false); got != "'go' 'test' './...'" {
|
||||
t.Fatalf("command=%q", got)
|
||||
}
|
||||
if got := daytonaCommandString([]string{"FOO=bar", "go", "test"}, false); !strings.Contains(got, "FOO=") || !strings.Contains(got, "go") {
|
||||
t.Fatalf("shell command=%q", got)
|
||||
}
|
||||
if got := daytonaCommandString([]string{"echo hello && pwd"}, true); got != "echo hello && pwd" {
|
||||
t.Fatalf("shell mode=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactedSSHUserOnlyForDaytona(t *testing.T) {
|
||||
target := SSHTarget{User: "tok_live_secret"}
|
||||
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, target); got != target.User {
|
||||
t.Fatalf("redactedSSHUser hetzner=%q", got)
|
||||
}
|
||||
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, SSHTarget{User: "secret", AuthSecret: true}); got != daytonaTokenRedacted {
|
||||
t.Fatalf("redactedSSHUser auth secret=%q", got)
|
||||
}
|
||||
if got := redactedSSHUser(Config{Provider: daytonaProvider}, Server{}, target); got != daytonaTokenRedacted {
|
||||
t.Fatalf("redactedSSHUser daytona=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlacksmithBackendListJSONKeepsParsedTableShape(t *testing.T) {
|
||||
runner := &recordingCommandRunner{
|
||||
result: LocalCommandResult{
|
||||
|
||||
455
internal/cli/provider_daytona.go
Normal file
455
internal/cli/provider_daytona.go
Normal file
@ -0,0 +1,455 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
daytona "github.com/daytonaio/daytona/libs/api-client-go"
|
||||
)
|
||||
|
||||
const (
|
||||
daytonaProvider = "daytona"
|
||||
daytonaTokenRedacted = "<token>"
|
||||
)
|
||||
|
||||
type daytonaFlagValues struct {
|
||||
APIURL *string
|
||||
Snapshot *string
|
||||
Target *string
|
||||
User *string
|
||||
WorkRoot *string
|
||||
SSHGatewayHost *string
|
||||
SSHAccessMinutes *int
|
||||
}
|
||||
|
||||
func RegisterDaytonaProviderFlags(fs *flag.FlagSet, defaults Config) any {
|
||||
return daytonaFlagValues{
|
||||
APIURL: fs.String("daytona-api-url", defaults.Daytona.APIURL, "Daytona API URL"),
|
||||
Snapshot: fs.String("daytona-snapshot", defaults.Daytona.Snapshot, "Daytona snapshot name"),
|
||||
Target: fs.String("daytona-target", defaults.Daytona.Target, "Daytona compute target"),
|
||||
User: fs.String("daytona-user", defaults.Daytona.User, "Daytona sandbox user"),
|
||||
WorkRoot: fs.String("daytona-work-root", defaults.Daytona.WorkRoot, "Daytona sandbox work root"),
|
||||
SSHGatewayHost: fs.String("daytona-ssh-gateway-host", defaults.Daytona.SSHGatewayHost, "Daytona SSH gateway host"),
|
||||
SSHAccessMinutes: fs.Int("daytona-ssh-access-minutes", defaults.Daytona.SSHAccessMinutes, "Daytona SSH access token TTL in minutes"),
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyDaytonaProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
|
||||
v, ok := values.(daytonaFlagValues)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if flagWasSet(fs, "daytona-api-url") {
|
||||
cfg.Daytona.APIURL = *v.APIURL
|
||||
}
|
||||
if flagWasSet(fs, "daytona-snapshot") {
|
||||
cfg.Daytona.Snapshot = *v.Snapshot
|
||||
}
|
||||
if flagWasSet(fs, "daytona-target") {
|
||||
cfg.Daytona.Target = *v.Target
|
||||
}
|
||||
if flagWasSet(fs, "daytona-user") {
|
||||
cfg.Daytona.User = *v.User
|
||||
}
|
||||
if flagWasSet(fs, "daytona-work-root") {
|
||||
cfg.Daytona.WorkRoot = *v.WorkRoot
|
||||
}
|
||||
if flagWasSet(fs, "daytona-ssh-gateway-host") {
|
||||
cfg.Daytona.SSHGatewayHost = *v.SSHGatewayHost
|
||||
}
|
||||
if flagWasSet(fs, "daytona-ssh-access-minutes") {
|
||||
cfg.Daytona.SSHAccessMinutes = *v.SSHAccessMinutes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewDaytonaLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
|
||||
cfg.Provider = daytonaProvider
|
||||
return &daytonaLeaseBackend{spec: spec, cfg: cfg, rt: rt}
|
||||
}
|
||||
|
||||
type daytonaLeaseBackend struct {
|
||||
spec ProviderSpec
|
||||
cfg Config
|
||||
rt Runtime
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) Spec() ProviderSpec { return b.spec }
|
||||
|
||||
func (b *daytonaLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
|
||||
if strings.TrimSpace(b.cfg.Daytona.Snapshot) == "" {
|
||||
return LeaseTarget{}, exit(2, "provider=daytona requires --daytona-snapshot or daytona.snapshot")
|
||||
}
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
leaseID := newLeaseID()
|
||||
existing, err := client.ListCrabboxSandboxes(ctx)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, daytonaError("list sandboxes", err)
|
||||
}
|
||||
slug := allocateDirectLeaseSlug(leaseID, daytonaSandboxesToServers(existing, b.cfg))
|
||||
cfg := b.cfg
|
||||
cfg.ServerType = "snapshot"
|
||||
cfg.WorkRoot = daytonaWorkRoot(cfg)
|
||||
cfg.SSHKey = ""
|
||||
cfg.SSHUser = daytonaUser(cfg)
|
||||
cfg.SSHPort = "22"
|
||||
now := time.Now().UTC()
|
||||
labels := directLeaseLabels(cfg, leaseID, slug, daytonaProvider, "", req.Keep, now)
|
||||
labels["lease_name"] = leaseProviderName(leaseID, slug)
|
||||
labels["work_root"] = cfg.WorkRoot
|
||||
create := daytona.NewCreateSandbox()
|
||||
create.SetName(labels["lease_name"])
|
||||
create.SetSnapshot(strings.TrimSpace(cfg.Daytona.Snapshot))
|
||||
create.SetLabels(labels)
|
||||
if target := strings.TrimSpace(cfg.Daytona.Target); target != "" {
|
||||
create.SetTarget(target)
|
||||
}
|
||||
if user := daytonaUser(cfg); user != "" {
|
||||
create.SetUser(user)
|
||||
}
|
||||
autoStop := int32(durationMinutesCeil(cfg.IdleTimeout))
|
||||
create.SetAutoStopInterval(autoStop)
|
||||
fmt.Fprintf(b.rt.Stderr, "provisioning provider=daytona lease=%s slug=%s snapshot=%s target=%s keep=%v\n", leaseID, slug, cfg.Daytona.Snapshot, blank(cfg.Daytona.Target, "-"), req.Keep)
|
||||
created, err := client.CreateSandbox(ctx, *create)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, daytonaError("create sandbox", err)
|
||||
}
|
||||
sandbox, err := waitForDaytonaReady(ctx, client, created.GetId(), 5*time.Minute)
|
||||
if err != nil {
|
||||
if !req.Keep {
|
||||
_ = client.DeleteSandbox(context.Background(), created.GetId())
|
||||
}
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
server := daytonaSandboxToServer(sandbox, cfg)
|
||||
server.Labels["state"] = "ready"
|
||||
if err := client.ReplaceLabels(ctx, server.CloudID, server.Labels); err != nil {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: set labels: %v\n", daytonaError("replace labels", err))
|
||||
}
|
||||
target, err := daytonaSSHTargetFor(ctx, client, cfg, server)
|
||||
if err != nil {
|
||||
if !req.Keep {
|
||||
_ = client.DeleteSandbox(context.Background(), server.CloudID)
|
||||
}
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "daytona ssh", bootstrapWaitTimeout(cfg)); err != nil {
|
||||
if !req.Keep {
|
||||
_ = client.DeleteSandbox(context.Background(), server.CloudID)
|
||||
}
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "provisioned lease=%s sandbox=%s state=%s\n", leaseID, server.CloudID, server.Status)
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
sandbox, leaseID, err := resolveDaytonaSandbox(ctx, client, b.cfg, req.ID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if !daytonaStateReady(daytonaSandboxState(sandbox)) {
|
||||
if daytonaStateFailed(daytonaSandboxState(sandbox)) {
|
||||
return LeaseTarget{}, exit(5, "daytona sandbox %s entered terminal state=%s", sandbox.GetId(), daytonaSandboxState(sandbox))
|
||||
}
|
||||
sandbox, err = client.StartSandbox(ctx, sandbox.GetId())
|
||||
if err != nil {
|
||||
return LeaseTarget{}, daytonaError("start sandbox", err)
|
||||
}
|
||||
sandbox, err = waitForDaytonaReady(ctx, client, sandbox.GetId(), 5*time.Minute)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
}
|
||||
server := daytonaSandboxToServer(sandbox, b.cfg)
|
||||
target, err := daytonaSSHTargetFor(ctx, client, b.cfg, server)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
|
||||
_ = req
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sandboxes, err := client.ListCrabboxSandboxes(ctx)
|
||||
if err != nil {
|
||||
return nil, daytonaError("list sandboxes", err)
|
||||
}
|
||||
return daytonaSandboxesToServers(sandboxes, b.cfg), nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Lease.Server.CloudID != "" {
|
||||
if err := client.DeleteSandbox(ctx, req.Lease.Server.CloudID); err != nil {
|
||||
return daytonaError("delete sandbox", err)
|
||||
}
|
||||
}
|
||||
removeLeaseClaim(req.Lease.LeaseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return req.Lease.Server, err
|
||||
}
|
||||
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())
|
||||
if server.CloudID != "" {
|
||||
if err := client.ReplaceLabels(ctx, server.CloudID, server.Labels); err != nil {
|
||||
return server, daytonaError("replace labels", err)
|
||||
}
|
||||
if err := client.UpdateLastActivity(ctx, server.CloudID); err != nil {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: daytona last-activity: %v\n", daytonaError("update last activity", err))
|
||||
}
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func waitForDaytonaReady(ctx context.Context, client daytonaAPI, id string, timeout time.Duration) (*daytona.Sandbox, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
sandbox, err := client.GetSandbox(ctx, id)
|
||||
if err != nil {
|
||||
return nil, daytonaError("get sandbox", err)
|
||||
}
|
||||
state := daytonaSandboxState(sandbox)
|
||||
if daytonaStateReady(state) {
|
||||
return sandbox, nil
|
||||
}
|
||||
if daytonaStateFailed(state) {
|
||||
return nil, exit(5, "daytona sandbox %s entered terminal state=%s", id, state)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, exit(5, "timed out waiting for daytona sandbox %s (state=%s)", id, state)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDaytonaSandbox(ctx context.Context, client daytonaAPI, cfg Config, id string) (*daytona.Sandbox, string, error) {
|
||||
if id == "" {
|
||||
return nil, "", exit(2, "provider=daytona requires --id <sandbox-id-or-slug>")
|
||||
}
|
||||
sandboxes, err := client.ListCrabboxSandboxes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", daytonaError("list sandboxes", err)
|
||||
}
|
||||
if isCanonicalLeaseID(id) {
|
||||
for i := range sandboxes {
|
||||
if sandboxes[i].Labels["lease"] == id {
|
||||
return &sandboxes[i], id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
slug := normalizeLeaseSlug(id)
|
||||
var matches []*daytona.Sandbox
|
||||
for i := range sandboxes {
|
||||
if slug != "" && normalizeLeaseSlug(sandboxes[i].Labels["slug"]) == slug {
|
||||
matches = append(matches, &sandboxes[i])
|
||||
}
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return nil, "", exit(4, "daytona slug %q matches multiple sandboxes", id)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], matches[0].Labels["lease"], nil
|
||||
}
|
||||
for i := range sandboxes {
|
||||
if sandboxes[i].GetId() == id || sandboxes[i].GetName() == id || sandboxes[i].Labels["lease_name"] == id {
|
||||
return &sandboxes[i], blank(sandboxes[i].Labels["lease"], id), nil
|
||||
}
|
||||
}
|
||||
if claim, ok, err := resolveLeaseClaim(id); err != nil {
|
||||
return nil, "", err
|
||||
} else if ok && claim.Provider == daytonaProvider {
|
||||
for i := range sandboxes {
|
||||
if sandboxes[i].Labels["lease"] == claim.LeaseID {
|
||||
return &sandboxes[i], claim.LeaseID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
sandbox, err := client.GetSandbox(ctx, id)
|
||||
if err == nil && sandbox != nil && sandbox.GetId() != "" {
|
||||
return sandbox, blank(sandbox.Labels["lease"], id), nil
|
||||
}
|
||||
_ = cfg
|
||||
return nil, "", exit(4, "daytona sandbox not found: %s", id)
|
||||
}
|
||||
|
||||
func daytonaSSHTargetFor(ctx context.Context, client daytonaAPI, cfg Config, server Server) (SSHTarget, error) {
|
||||
access, err := client.CreateSSHAccess(ctx, server.CloudID, time.Duration(daytonaSSHAccessMinutes(cfg))*time.Minute)
|
||||
if err != nil {
|
||||
return SSHTarget{}, daytonaError("create ssh access", err)
|
||||
}
|
||||
return daytonaSSHTargetFromAccess(cfg, access)
|
||||
}
|
||||
|
||||
func daytonaSSHTargetFromAccess(cfg Config, access daytonaSSHAccess) (SSHTarget, error) {
|
||||
user := strings.TrimSpace(access.Token)
|
||||
host := daytonaSSHGatewayHost(cfg)
|
||||
port := "22"
|
||||
if command := strings.TrimSpace(access.Command); command != "" {
|
||||
parsedUser, parsedHost, parsedPort, err := parseDaytonaSSHCommand(command)
|
||||
if err != nil {
|
||||
return SSHTarget{}, err
|
||||
}
|
||||
user = parsedUser
|
||||
host = parsedHost
|
||||
port = parsedPort
|
||||
}
|
||||
if user == "" {
|
||||
return SSHTarget{}, fmt.Errorf("daytona ssh access response missing token")
|
||||
}
|
||||
return SSHTarget{
|
||||
User: user,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Key: "",
|
||||
TargetOS: targetLinux,
|
||||
ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null",
|
||||
AuthSecret: true,
|
||||
NetworkKind: NetworkPublic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseDaytonaSSHCommand(command string) (string, string, string, error) {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 {
|
||||
return "", "", "", fmt.Errorf("daytona ssh command is empty")
|
||||
}
|
||||
if fields[0] == "ssh" {
|
||||
fields = fields[1:]
|
||||
}
|
||||
port := "22"
|
||||
destination := ""
|
||||
for i := 0; i < len(fields); i++ {
|
||||
field := fields[i]
|
||||
switch {
|
||||
case field == "-p":
|
||||
if i+1 >= len(fields) || strings.TrimSpace(fields[i+1]) == "" {
|
||||
return "", "", "", fmt.Errorf("daytona ssh command missing -p value: %q", command)
|
||||
}
|
||||
i++
|
||||
port = fields[i]
|
||||
case strings.HasPrefix(field, "-p") && len(field) > 2:
|
||||
port = strings.TrimPrefix(field, "-p")
|
||||
case strings.HasPrefix(field, "-"):
|
||||
return "", "", "", fmt.Errorf("daytona ssh command has unsupported option %q", field)
|
||||
default:
|
||||
destination = field
|
||||
}
|
||||
}
|
||||
user, host, ok := strings.Cut(destination, "@")
|
||||
if !ok || strings.TrimSpace(user) == "" || strings.TrimSpace(host) == "" {
|
||||
return "", "", "", fmt.Errorf("daytona ssh command missing user@host destination: %q", command)
|
||||
}
|
||||
return user, host, port, nil
|
||||
}
|
||||
|
||||
func daytonaSandboxesToServers(sandboxes []daytona.Sandbox, cfg Config) []Server {
|
||||
servers := make([]Server, 0, len(sandboxes))
|
||||
for i := range sandboxes {
|
||||
servers = append(servers, daytonaSandboxToServer(&sandboxes[i], cfg))
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func daytonaSandboxToServer(sandbox *daytona.Sandbox, cfg Config) Server {
|
||||
labels := map[string]string{}
|
||||
if sandbox != nil && sandbox.Labels != nil {
|
||||
for k, v := range sandbox.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
}
|
||||
server := Server{Provider: daytonaProvider, Labels: labels}
|
||||
if sandbox != nil {
|
||||
server.CloudID = sandbox.GetId()
|
||||
server.Name = sandbox.GetName()
|
||||
server.Status = daytonaSandboxState(sandbox)
|
||||
}
|
||||
if server.Name == "" {
|
||||
server.Name = blank(labels["lease_name"], server.CloudID)
|
||||
}
|
||||
server.ServerType.Name = blank(labels["server_type"], serverTypeForProviderClass(cfg.Provider, cfg.Class))
|
||||
return server
|
||||
}
|
||||
|
||||
func daytonaSandboxState(sandbox *daytona.Sandbox) string {
|
||||
if sandbox == nil || sandbox.State == nil {
|
||||
return ""
|
||||
}
|
||||
return string(*sandbox.State)
|
||||
}
|
||||
|
||||
func daytonaStateReady(state string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "started", "running", "ready", "active":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func daytonaStateFailed(state string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "error", "errored", "failed", "build_failed", "destroyed", "deleted":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func daytonaUser(cfg Config) string {
|
||||
return blank(strings.TrimSpace(cfg.Daytona.User), "daytona")
|
||||
}
|
||||
|
||||
func daytonaWorkRoot(cfg Config) string {
|
||||
return blank(strings.TrimSpace(cfg.Daytona.WorkRoot), "/home/"+daytonaUser(cfg)+"/crabbox")
|
||||
}
|
||||
|
||||
func daytonaSSHGatewayHost(cfg Config) string {
|
||||
return blank(strings.TrimSpace(cfg.Daytona.SSHGatewayHost), "ssh.app.daytona.io")
|
||||
}
|
||||
|
||||
func daytonaSSHAccessMinutes(cfg Config) int {
|
||||
if cfg.Daytona.SSHAccessMinutes > 0 {
|
||||
return cfg.Daytona.SSHAccessMinutes
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
func redactedSSHUser(cfg Config, server Server, target SSHTarget) string {
|
||||
if target.AuthSecret {
|
||||
return daytonaTokenRedacted
|
||||
}
|
||||
if cfg.Provider == daytonaProvider || server.Provider == daytonaProvider {
|
||||
return daytonaTokenRedacted
|
||||
}
|
||||
return target.User
|
||||
}
|
||||
201
internal/cli/provider_daytona_client.go
Normal file
201
internal/cli/provider_daytona_client.go
Normal file
@ -0,0 +1,201 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
daytona "github.com/daytonaio/daytona/libs/api-client-go"
|
||||
sdkdaytona "github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona"
|
||||
sdktypes "github.com/daytonaio/daytona/libs/sdk-go/pkg/types"
|
||||
)
|
||||
|
||||
type daytonaAPI interface {
|
||||
CreateSandbox(context.Context, daytona.CreateSandbox) (*daytona.Sandbox, error)
|
||||
GetSandbox(context.Context, string) (*daytona.Sandbox, error)
|
||||
ListCrabboxSandboxes(context.Context) ([]daytona.Sandbox, error)
|
||||
StartSandbox(context.Context, string) (*daytona.Sandbox, error)
|
||||
DeleteSandbox(context.Context, string) error
|
||||
ReplaceLabels(context.Context, string, map[string]string) error
|
||||
UpdateLastActivity(context.Context, string) error
|
||||
CreateSSHAccess(context.Context, string, time.Duration) (daytonaSSHAccess, error)
|
||||
}
|
||||
|
||||
type daytonaSSHAccess struct {
|
||||
Token string
|
||||
Command string
|
||||
}
|
||||
|
||||
type daytonaSDKClient struct {
|
||||
api *daytona.APIClient
|
||||
token string
|
||||
orgID string
|
||||
}
|
||||
|
||||
func newDaytonaClient(cfg Config, rt Runtime) (daytonaAPI, error) {
|
||||
auth, err := daytonaAuthConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL := strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/")
|
||||
apiCfg := daytona.NewConfiguration()
|
||||
apiCfg.Servers = daytona.ServerConfigurations{{URL: apiURL}}
|
||||
if rt.HTTP != nil {
|
||||
apiCfg.HTTPClient = rt.HTTP
|
||||
}
|
||||
return &daytonaSDKClient{api: daytona.NewAPIClient(apiCfg), token: auth.token(), orgID: auth.OrganizationID}, nil
|
||||
}
|
||||
|
||||
type daytonaAuth struct {
|
||||
APIKey string
|
||||
JWTToken string
|
||||
OrganizationID string
|
||||
}
|
||||
|
||||
func (a daytonaAuth) token() string {
|
||||
if a.APIKey != "" {
|
||||
return a.APIKey
|
||||
}
|
||||
return a.JWTToken
|
||||
}
|
||||
|
||||
func daytonaAuthConfig(cfg Config) (daytonaAuth, error) {
|
||||
auth := daytonaAuth{
|
||||
APIKey: strings.TrimSpace(cfg.Daytona.APIKey),
|
||||
JWTToken: strings.TrimSpace(cfg.Daytona.JWTToken),
|
||||
OrganizationID: strings.TrimSpace(cfg.Daytona.OrganizationID),
|
||||
}
|
||||
if auth.APIKey == "" && auth.JWTToken == "" {
|
||||
return daytonaAuth{}, exit(3, "provider=daytona requires DAYTONA_API_KEY or DAYTONA_JWT_TOKEN")
|
||||
}
|
||||
if auth.APIKey == "" && auth.JWTToken != "" && auth.OrganizationID == "" {
|
||||
return daytonaAuth{}, exit(3, "provider=daytona with DAYTONA_JWT_TOKEN requires DAYTONA_ORGANIZATION_ID")
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func newDaytonaToolboxClient(cfg Config) (*sdkdaytona.Client, error) {
|
||||
auth, err := daytonaAuthConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sdkdaytona.NewClientWithConfig(&sdktypes.DaytonaConfig{
|
||||
APIKey: auth.APIKey,
|
||||
JWTToken: auth.JWTToken,
|
||||
OrganizationID: auth.OrganizationID,
|
||||
APIUrl: strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/"),
|
||||
Target: strings.TrimSpace(cfg.Daytona.Target),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) ctx(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, daytona.ContextAccessToken, c.token)
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) CreateSandbox(ctx context.Context, body daytona.CreateSandbox) (*daytona.Sandbox, error) {
|
||||
req := c.api.SandboxAPI.CreateSandbox(c.ctx(ctx)).CreateSandbox(body)
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
out, _, err := req.Execute()
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) GetSandbox(ctx context.Context, id string) (*daytona.Sandbox, error) {
|
||||
req := c.api.SandboxAPI.GetSandbox(c.ctx(ctx), id).Verbose(true)
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
out, _, err := req.Execute()
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) ListCrabboxSandboxes(ctx context.Context) ([]daytona.Sandbox, error) {
|
||||
filter, _ := json.Marshal(map[string]string{"crabbox": "true"})
|
||||
var all []daytona.Sandbox
|
||||
for page := float32(1); ; page++ {
|
||||
req := c.api.SandboxAPI.ListSandboxesPaginated(c.ctx(ctx)).Page(page).Limit(100).Labels(string(filter))
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
out, _, err := req.Execute()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return all, nil
|
||||
}
|
||||
all = append(all, out.GetItems()...)
|
||||
if out.GetTotalPages() <= page || len(out.GetItems()) == 0 {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) StartSandbox(ctx context.Context, id string) (*daytona.Sandbox, error) {
|
||||
req := c.api.SandboxAPI.StartSandbox(c.ctx(ctx), id)
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
out, _, err := req.Execute()
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) DeleteSandbox(ctx context.Context, id string) error {
|
||||
req := c.api.SandboxAPI.DeleteSandbox(c.ctx(ctx), id)
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
_, _, err := req.Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) ReplaceLabels(ctx context.Context, id string, labels map[string]string) error {
|
||||
req := c.api.SandboxAPI.ReplaceLabels(c.ctx(ctx), id).SandboxLabels(*daytona.NewSandboxLabels(labels))
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
_, _, err := req.Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) UpdateLastActivity(ctx context.Context, id string) error {
|
||||
req := c.api.SandboxAPI.UpdateLastActivity(c.ctx(ctx), id)
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
_, err := req.Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *daytonaSDKClient) CreateSSHAccess(ctx context.Context, id string, ttl time.Duration) (daytonaSSHAccess, error) {
|
||||
req := c.api.SandboxAPI.CreateSshAccess(c.ctx(ctx), id).ExpiresInMinutes(float32(durationMinutesCeil(ttl)))
|
||||
if c.orgID != "" {
|
||||
req = req.XDaytonaOrganizationID(c.orgID)
|
||||
}
|
||||
out, _, err := req.Execute()
|
||||
if err != nil {
|
||||
return daytonaSSHAccess{}, err
|
||||
}
|
||||
if out == nil || out.GetToken() == "" {
|
||||
return daytonaSSHAccess{}, fmt.Errorf("daytona ssh access response missing token")
|
||||
}
|
||||
return daytonaSSHAccess{Token: out.GetToken(), Command: out.GetSshCommand()}, nil
|
||||
}
|
||||
|
||||
func daytonaError(action string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var apiErr *daytona.GenericOpenAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
body := strings.TrimSpace(summarizeJSON(apiErr.Body()))
|
||||
if body != "" {
|
||||
return fmt.Errorf("daytona %s: %s: %s", action, apiErr.Error(), body)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("daytona %s: %w", action, err)
|
||||
}
|
||||
424
internal/cli/provider_daytona_delegated.go
Normal file
424
internal/cli/provider_daytona_delegated.go
Normal file
@ -0,0 +1,424 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apidaytona "github.com/daytonaio/daytona/libs/api-client-go"
|
||||
sdkdaytona "github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona"
|
||||
sdkoptions "github.com/daytonaio/daytona/libs/sdk-go/pkg/options"
|
||||
sdktypes "github.com/daytonaio/daytona/libs/sdk-go/pkg/types"
|
||||
)
|
||||
|
||||
func (b *daytonaLeaseBackend) Warmup(ctx context.Context, req WarmupRequest) error {
|
||||
if req.ActionsRunner {
|
||||
return exit(2, "--actions-runner is not supported for provider=daytona SDK warmup")
|
||||
}
|
||||
started := time.Now()
|
||||
sandbox, leaseID, slug, err := b.createDaytonaToolboxSandbox(ctx, req.Repo, req.Keep, req.Reclaim)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stdout, "leased %s slug=%s provider=daytona sandbox=%s\n", leaseID, slug, sandbox.ID)
|
||||
fmt.Fprintf(b.rt.Stdout, "warmup complete total=%s\n", time.Since(started).Round(time.Millisecond))
|
||||
if req.TimingJSON {
|
||||
return writeTimingJSON(b.rt.Stderr, timingReport{
|
||||
Provider: daytonaProvider,
|
||||
LeaseID: leaseID,
|
||||
Slug: slug,
|
||||
TotalMs: time.Since(started).Milliseconds(),
|
||||
ExitCode: 0,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
started := time.Now()
|
||||
client, err := newDaytonaToolboxClient(b.cfg)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
var sandbox *sdkdaytona.Sandbox
|
||||
leaseID, slug := "", ""
|
||||
acquired := false
|
||||
if req.ID == "" {
|
||||
sandbox, leaseID, slug, err = b.createDaytonaToolboxSandboxWithClient(ctx, client, req.Repo, req.Keep, req.Reclaim)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "leased %s slug=%s provider=daytona sandbox=%s\n", leaseID, slug, sandbox.ID)
|
||||
acquired = true
|
||||
} else {
|
||||
sandbox, leaseID, err = b.resolveDaytonaToolboxSandbox(ctx, client, req.ID)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
slug = newLeaseSlug(leaseID)
|
||||
if claim, ok, claimErr := resolveLeaseClaim(req.ID); claimErr != nil {
|
||||
return RunResult{}, claimErr
|
||||
} else if ok && claim.Provider == daytonaProvider {
|
||||
slug = claim.Slug
|
||||
if err := claimLeaseForRepoConfig(claim.LeaseID, claim.Slug, b.cfg, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if acquired && !req.Keep {
|
||||
defer b.deleteDaytonaToolboxSandbox(context.Background(), sandbox.ID, leaseID)
|
||||
}
|
||||
cfg := b.cfg
|
||||
cfg.Provider = daytonaProvider
|
||||
cfg.WorkRoot = daytonaWorkRoot(cfg)
|
||||
workdir := remoteJoin(cfg, leaseID, req.Repo.Name)
|
||||
var syncDuration time.Duration
|
||||
var syncPhases []timingPhase
|
||||
if !req.NoSync {
|
||||
syncStarted := time.Now()
|
||||
syncPhases, err = b.syncDaytonaToolbox(ctx, sandbox, req, workdir)
|
||||
syncDuration = time.Since(syncStarted)
|
||||
if err != nil {
|
||||
return RunResult{Total: time.Since(started), SyncDelegated: true}, err
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "sync complete in %s\n", syncDuration.Round(time.Millisecond))
|
||||
} else {
|
||||
if _, err := sandbox.Process.ExecuteCommand(ctx, "mkdir -p "+shellQuote(workdir)); err != nil {
|
||||
return RunResult{}, fmt.Errorf("daytona create workdir: %w", err)
|
||||
}
|
||||
}
|
||||
if req.SyncOnly {
|
||||
result := RunResult{Total: time.Since(started), SyncDelegated: true}
|
||||
fmt.Fprintf(b.rt.Stdout, "synced %s\n", workdir)
|
||||
if req.TimingJSON {
|
||||
err := writeTimingJSON(b.rt.Stderr, timingReport{
|
||||
Provider: daytonaProvider,
|
||||
LeaseID: leaseID,
|
||||
Slug: slug,
|
||||
SyncMs: syncDuration.Milliseconds(),
|
||||
SyncPhases: syncPhases,
|
||||
SyncSkipped: req.NoSync,
|
||||
TotalMs: result.Total.Milliseconds(),
|
||||
ExitCode: 0,
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
command := daytonaCommandString(req.Command, req.ShellMode)
|
||||
if command == "" {
|
||||
return RunResult{}, exit(2, "missing command")
|
||||
}
|
||||
commandStarted := time.Now()
|
||||
fmt.Fprintf(b.rt.Stderr, "running on daytona %s\n", strings.Join(req.Command, " "))
|
||||
execOpts := []func(*sdkoptions.ExecuteCommand){sdkoptions.WithCwd(workdir)}
|
||||
if env := allowedEnv(req.Options.EnvAllow); len(env) > 0 {
|
||||
execOpts = append(execOpts, sdkoptions.WithCommandEnv(env))
|
||||
}
|
||||
response, err := sandbox.Process.ExecuteCommand(ctx, command, execOpts...)
|
||||
commandDuration := time.Since(commandStarted)
|
||||
result := RunResult{
|
||||
ExitCode: responseExitCode(response),
|
||||
Command: commandDuration,
|
||||
Total: time.Since(started),
|
||||
SyncDelegated: true,
|
||||
}
|
||||
if response != nil && response.Result != "" {
|
||||
fmt.Fprint(b.rt.Stdout, response.Result)
|
||||
if !strings.HasSuffix(response.Result, "\n") {
|
||||
fmt.Fprintln(b.rt.Stdout)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "daytona run summary sync=%s command=%s total=%s exit=%d\n", syncDuration.Round(time.Millisecond), result.Command.Round(time.Millisecond), result.Total.Round(time.Millisecond), result.ExitCode)
|
||||
if req.TimingJSON {
|
||||
if timingErr := writeTimingJSON(b.rt.Stderr, timingReport{
|
||||
Provider: daytonaProvider,
|
||||
LeaseID: leaseID,
|
||||
Slug: slug,
|
||||
SyncMs: syncDuration.Milliseconds(),
|
||||
SyncPhases: syncPhases,
|
||||
SyncSkipped: req.NoSync,
|
||||
CommandMs: commandDuration.Milliseconds(),
|
||||
TotalMs: result.Total.Milliseconds(),
|
||||
ExitCode: result.ExitCode,
|
||||
}); timingErr != nil {
|
||||
return result, timingErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return result, ExitError{Code: 1, Message: fmt.Sprintf("daytona run failed: %v", err)}
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
return result, ExitError{Code: result.ExitCode, Message: fmt.Sprintf("daytona run exited %d", result.ExitCode)}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) Status(ctx context.Context, req StatusRequest) (statusView, error) {
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return statusView{}, err
|
||||
}
|
||||
deadline := time.Now().Add(req.WaitTimeout)
|
||||
if req.WaitTimeout <= 0 {
|
||||
deadline = time.Now().Add(5 * time.Minute)
|
||||
}
|
||||
for {
|
||||
sandbox, leaseID, err := resolveDaytonaSandbox(ctx, client, b.cfg, req.ID)
|
||||
if err != nil {
|
||||
return statusView{}, err
|
||||
}
|
||||
view := daytonaStatusView(leaseID, sandbox, b.cfg)
|
||||
if !req.Wait || view.Ready {
|
||||
return view, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return statusView{}, exit(5, "timed out waiting for sandbox %s to become ready", req.ID)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return statusView{}, ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) Stop(ctx context.Context, req StopRequest) error {
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sandbox, leaseID, err := resolveDaytonaSandbox(ctx, client, b.cfg, req.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.DeleteSandbox(ctx, sandbox.GetId()); err != nil {
|
||||
return daytonaError("delete sandbox", err)
|
||||
}
|
||||
removeLeaseClaim(leaseID)
|
||||
fmt.Fprintf(b.rt.Stderr, "released lease=%s sandbox=%s\n", leaseID, sandbox.GetId())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) createDaytonaToolboxSandbox(ctx context.Context, repo Repo, keep, reclaim bool) (*sdkdaytona.Sandbox, string, string, error) {
|
||||
client, err := newDaytonaToolboxClient(b.cfg)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return b.createDaytonaToolboxSandboxWithClient(ctx, client, repo, keep, reclaim)
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) createDaytonaToolboxSandboxWithClient(ctx context.Context, client *sdkdaytona.Client, repo Repo, keep, reclaim bool) (*sdkdaytona.Sandbox, string, string, error) {
|
||||
if strings.TrimSpace(b.cfg.Daytona.Snapshot) == "" {
|
||||
return nil, "", "", exit(2, "provider=daytona requires --daytona-snapshot or daytona.snapshot")
|
||||
}
|
||||
apiClient, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
leaseID := newLeaseID()
|
||||
existing, err := apiClient.ListCrabboxSandboxes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", "", daytonaError("list sandboxes", err)
|
||||
}
|
||||
slug := allocateDirectLeaseSlug(leaseID, daytonaSandboxesToServers(existing, b.cfg))
|
||||
cfg := b.cfg
|
||||
cfg.Provider = daytonaProvider
|
||||
cfg.ServerType = "snapshot"
|
||||
cfg.WorkRoot = daytonaWorkRoot(cfg)
|
||||
cfg.SSHUser = daytonaUser(cfg)
|
||||
cfg.SSHPort = "22"
|
||||
labels := directLeaseLabels(cfg, leaseID, slug, daytonaProvider, "", keep, time.Now().UTC())
|
||||
labels["lease_name"] = leaseProviderName(leaseID, slug)
|
||||
labels["work_root"] = cfg.WorkRoot
|
||||
autoStop := durationMinutesCeil(cfg.IdleTimeout)
|
||||
fmt.Fprintf(b.rt.Stderr, "provisioning provider=daytona lease=%s slug=%s snapshot=%s target=%s keep=%v mode=sdk\n", leaseID, slug, cfg.Daytona.Snapshot, blank(cfg.Daytona.Target, "-"), keep)
|
||||
sandbox, err := client.Create(ctx, sdktypes.SnapshotParams{
|
||||
Snapshot: strings.TrimSpace(cfg.Daytona.Snapshot),
|
||||
SandboxBaseParams: sdktypes.SandboxBaseParams{
|
||||
Name: labels["lease_name"],
|
||||
User: daytonaUser(cfg),
|
||||
Labels: labels,
|
||||
Public: true,
|
||||
AutoStopInterval: &autoStop,
|
||||
},
|
||||
}, sdkoptions.WithTimeout(5*time.Minute))
|
||||
if err != nil {
|
||||
return nil, "", "", daytonaError("create sandbox", err)
|
||||
}
|
||||
if err := claimLeaseForRepoConfig(leaseID, slug, cfg, repo.Root, cfg.IdleTimeout, reclaim); err != nil {
|
||||
_ = sandbox.Delete(context.Background())
|
||||
return nil, "", "", err
|
||||
}
|
||||
labels["state"] = "ready"
|
||||
labels["last_touched_at"] = leaseLabelTime(time.Now().UTC())
|
||||
if err := apiClient.ReplaceLabels(ctx, sandbox.ID, labels); err != nil {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: set labels: %v\n", daytonaError("replace labels", err))
|
||||
}
|
||||
return sandbox, leaseID, slug, nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) resolveDaytonaToolboxSandbox(ctx context.Context, sdkClient *sdkdaytona.Client, id string) (*sdkdaytona.Sandbox, string, error) {
|
||||
apiClient, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
apiSandbox, leaseID, err := resolveDaytonaSandbox(ctx, apiClient, b.cfg, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !daytonaStateReady(daytonaSandboxState(apiSandbox)) {
|
||||
if _, err := apiClient.StartSandbox(ctx, apiSandbox.GetId()); err != nil {
|
||||
return nil, "", daytonaError("start sandbox", err)
|
||||
}
|
||||
if _, err := waitForDaytonaReady(ctx, apiClient, apiSandbox.GetId(), 5*time.Minute); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
sandbox, err := sdkClient.Get(ctx, apiSandbox.GetId())
|
||||
if err != nil {
|
||||
return nil, "", daytonaError("get sandbox", err)
|
||||
}
|
||||
return sandbox, leaseID, nil
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) deleteDaytonaToolboxSandbox(ctx context.Context, sandboxID, leaseID string) {
|
||||
client, err := newDaytonaClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: daytona stop failed for %s: %v\n", sandboxID, err)
|
||||
return
|
||||
}
|
||||
if err := client.DeleteSandbox(ctx, sandboxID); err != nil {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: daytona stop failed for %s: %v\n", sandboxID, daytonaError("delete sandbox", err))
|
||||
return
|
||||
}
|
||||
removeLeaseClaim(leaseID)
|
||||
}
|
||||
|
||||
func (b *daytonaLeaseBackend) syncDaytonaToolbox(ctx context.Context, sandbox *sdkdaytona.Sandbox, req RunRequest, workdir string) ([]timingPhase, error) {
|
||||
start := time.Now()
|
||||
excludes, err := syncExcludes(req.Repo.Root, b.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifestStarted := time.Now()
|
||||
manifest, err := syncManifest(req.Repo.Root, excludes)
|
||||
if err != nil {
|
||||
return nil, exit(6, "build sync file list: %v", err)
|
||||
}
|
||||
manifestDuration := time.Since(manifestStarted)
|
||||
preflightStarted := time.Now()
|
||||
if err := checkSyncPreflight(manifest, b.cfg, req.ForceSyncLarge, b.rt.Stderr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preflightDuration := time.Since(preflightStarted)
|
||||
archiveStarted := time.Now()
|
||||
archive, err := createDaytonaSyncArchive(ctx, req.Repo, manifest, b.rt.Stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archiveDuration := time.Since(archiveStarted)
|
||||
uploadStarted := time.Now()
|
||||
archivePath := path.Join("/tmp", "crabbox-"+newLeaseID()+".tgz")
|
||||
if err := sandbox.FileSystem.UploadFile(ctx, archive, archivePath); err != nil {
|
||||
return nil, fmt.Errorf("daytona upload archive: %w", err)
|
||||
}
|
||||
uploadDuration := time.Since(uploadStarted)
|
||||
extractStarted := time.Now()
|
||||
deletePrefix := ""
|
||||
if b.cfg.Sync.Delete {
|
||||
deletePrefix = "rm -rf " + shellQuote(workdir) + " && "
|
||||
}
|
||||
extractCommand := deletePrefix + "mkdir -p " + shellQuote(workdir) + " && tar -xzf " + shellQuote(archivePath) + " -C " + shellQuote(workdir) + " && rm -f " + shellQuote(archivePath)
|
||||
if response, err := sandbox.Process.ExecuteCommand(ctx, extractCommand); err != nil {
|
||||
return nil, fmt.Errorf("daytona extract archive: %w", err)
|
||||
} else if responseExitCode(response) != 0 {
|
||||
return nil, exit(responseExitCode(response), "daytona extract archive exited %d: %s", responseExitCode(response), response.Result)
|
||||
}
|
||||
extractDuration := time.Since(extractStarted)
|
||||
manifestWriteStarted := time.Now()
|
||||
metaDir := path.Join(workdir, ".crabbox")
|
||||
if err := sandbox.FileSystem.CreateFolder(ctx, metaDir); err != nil {
|
||||
return nil, fmt.Errorf("daytona create metadata dir: %w", err)
|
||||
}
|
||||
if err := sandbox.FileSystem.UploadFile(ctx, manifest.NUL(), path.Join(metaDir, "sync-manifest")); err != nil {
|
||||
return nil, fmt.Errorf("daytona upload sync manifest: %w", err)
|
||||
}
|
||||
manifestWriteDuration := time.Since(manifestWriteStarted)
|
||||
phases := []timingPhase{
|
||||
{Name: "manifest", Ms: manifestDuration.Milliseconds()},
|
||||
{Name: "preflight", Ms: preflightDuration.Milliseconds()},
|
||||
{Name: "archive", Ms: archiveDuration.Milliseconds()},
|
||||
{Name: "upload", Ms: uploadDuration.Milliseconds()},
|
||||
{Name: "extract", Ms: extractDuration.Milliseconds()},
|
||||
{Name: "manifest_write", Ms: manifestWriteDuration.Milliseconds()},
|
||||
{Name: "toolbox_sync", Ms: time.Since(start).Milliseconds()},
|
||||
}
|
||||
return phases, nil
|
||||
}
|
||||
|
||||
func createDaytonaSyncArchive(ctx context.Context, repo Repo, manifest SyncManifest, stderr anyWriter) ([]byte, error) {
|
||||
var input bytes.Buffer
|
||||
input.Write(manifest.NUL())
|
||||
cmd := exec.CommandContext(ctx, "tar", "-czf", "-", "-C", repo.Root, "--null", "-T", "-")
|
||||
cmd.Stdin = &input
|
||||
cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1")
|
||||
var archive bytes.Buffer
|
||||
cmd.Stdout = &archive
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, exit(6, "create sync archive: %v", err)
|
||||
}
|
||||
return archive.Bytes(), nil
|
||||
}
|
||||
|
||||
func daytonaCommandString(command []string, shellMode bool) string {
|
||||
if len(command) == 0 {
|
||||
return ""
|
||||
}
|
||||
if shellMode {
|
||||
return strings.Join(command, " ")
|
||||
}
|
||||
if shouldUseShell(command) || leadingEnvAssignment(command) {
|
||||
return shellScriptFromArgv(command)
|
||||
}
|
||||
return strings.Join(shellWords(command), " ")
|
||||
}
|
||||
|
||||
func daytonaStatusView(leaseID string, sandbox *apidaytona.Sandbox, cfg Config) statusView {
|
||||
server := daytonaSandboxToServer(sandbox, cfg)
|
||||
state := server.Status
|
||||
if !daytonaStateReady(state) {
|
||||
state = blank(server.Labels["state"], state)
|
||||
}
|
||||
return statusView{
|
||||
ID: leaseID,
|
||||
Slug: serverSlug(server),
|
||||
Provider: daytonaProvider,
|
||||
TargetOS: targetLinux,
|
||||
State: state,
|
||||
ServerID: server.DisplayID(),
|
||||
ServerType: server.ServerType.Name,
|
||||
Network: NetworkPublic,
|
||||
Ready: daytonaStateReady(state) || daytonaStateReady(server.Status),
|
||||
HasHost: true,
|
||||
LastTouchedAt: blank(leaseLabelTimeDisplay(server.Labels["last_touched_at"]),
|
||||
server.Labels["last_touched_at"]),
|
||||
IdleFor: idleForString(server.Labels["last_touched_at"], time.Now()),
|
||||
IdleTimeout: leaseLabelDurationDisplay(server.Labels["idle_timeout_secs"], server.Labels["idle_timeout"]),
|
||||
ExpiresAt: blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]),
|
||||
Labels: server.Labels,
|
||||
}
|
||||
}
|
||||
|
||||
func responseExitCode(response *sdktypes.ExecuteResponse) int {
|
||||
if response == nil {
|
||||
return 1
|
||||
}
|
||||
return response.ExitCode
|
||||
}
|
||||
434
internal/cli/provider_islo.go
Normal file
434
internal/cli/provider_islo.go
Normal file
@ -0,0 +1,434 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gosdk "github.com/islo-labs/go-sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
isloProvider = "islo"
|
||||
isloLeasePrefix = "isb_"
|
||||
isloNamePrefix = "crabbox-"
|
||||
)
|
||||
|
||||
type isloFlagValues struct {
|
||||
BaseURL *string
|
||||
Image *string
|
||||
Workdir *string
|
||||
GatewayProfile *string
|
||||
SnapshotName *string
|
||||
VCPUs *int
|
||||
MemoryMB *int
|
||||
DiskGB *int
|
||||
}
|
||||
|
||||
func RegisterIsloProviderFlags(fs *flag.FlagSet, defaults Config) any {
|
||||
return isloFlagValues{
|
||||
BaseURL: fs.String("islo-base-url", defaults.Islo.BaseURL, "Islo API base URL"),
|
||||
Image: fs.String("islo-image", defaults.Islo.Image, "Islo sandbox image"),
|
||||
Workdir: fs.String("islo-workdir", defaults.Islo.Workdir, "Islo sandbox working directory under /workspace"),
|
||||
GatewayProfile: fs.String("islo-gateway-profile", defaults.Islo.GatewayProfile, "Islo gateway profile name or id"),
|
||||
SnapshotName: fs.String("islo-snapshot-name", defaults.Islo.SnapshotName, "Islo snapshot name"),
|
||||
VCPUs: fs.Int("islo-vcpus", defaults.Islo.VCPUs, "Islo sandbox vCPUs"),
|
||||
MemoryMB: fs.Int("islo-memory-mb", defaults.Islo.MemoryMB, "Islo sandbox memory in MB"),
|
||||
DiskGB: fs.Int("islo-disk-gb", defaults.Islo.DiskGB, "Islo sandbox disk in GB"),
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyIsloProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
|
||||
v, ok := values.(isloFlagValues)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if flagWasSet(fs, "islo-base-url") {
|
||||
cfg.Islo.BaseURL = *v.BaseURL
|
||||
}
|
||||
if flagWasSet(fs, "islo-image") {
|
||||
cfg.Islo.Image = *v.Image
|
||||
}
|
||||
if flagWasSet(fs, "islo-workdir") {
|
||||
cfg.Islo.Workdir = *v.Workdir
|
||||
}
|
||||
if flagWasSet(fs, "islo-gateway-profile") {
|
||||
cfg.Islo.GatewayProfile = *v.GatewayProfile
|
||||
}
|
||||
if flagWasSet(fs, "islo-snapshot-name") {
|
||||
cfg.Islo.SnapshotName = *v.SnapshotName
|
||||
}
|
||||
if flagWasSet(fs, "islo-vcpus") {
|
||||
cfg.Islo.VCPUs = *v.VCPUs
|
||||
}
|
||||
if flagWasSet(fs, "islo-memory-mb") {
|
||||
cfg.Islo.MemoryMB = *v.MemoryMB
|
||||
}
|
||||
if flagWasSet(fs, "islo-disk-gb") {
|
||||
cfg.Islo.DiskGB = *v.DiskGB
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewIsloBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
|
||||
cfg.Provider = isloProvider
|
||||
return &isloBackend{spec: spec, cfg: cfg, rt: rt}
|
||||
}
|
||||
|
||||
type isloBackend struct {
|
||||
spec ProviderSpec
|
||||
cfg Config
|
||||
rt Runtime
|
||||
}
|
||||
|
||||
func (b *isloBackend) Spec() ProviderSpec { return b.spec }
|
||||
|
||||
func (b *isloBackend) Warmup(ctx context.Context, req WarmupRequest) error {
|
||||
started := b.now()
|
||||
client, err := newIsloClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaseID, name, slug, err := b.createSandbox(ctx, client, req.Repo, req.Reclaim)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stdout, "leased %s slug=%s provider=islo sandbox=%s\n", leaseID, slug, name)
|
||||
if !req.Keep {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: islo warmup keeps the sandbox until explicit stop\n")
|
||||
}
|
||||
total := b.now().Sub(started)
|
||||
fmt.Fprintf(b.rt.Stdout, "warmup complete total=%s\n", total.Round(time.Millisecond))
|
||||
if req.TimingJSON {
|
||||
return writeTimingJSON(b.rt.Stderr, timingReport{
|
||||
Provider: isloProvider,
|
||||
LeaseID: leaseID,
|
||||
Slug: slug,
|
||||
TotalMs: total.Milliseconds(),
|
||||
ExitCode: 0,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *isloBackend) Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
if err := rejectDelegatedSyncOptions(isloProvider, req); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
started := b.now()
|
||||
client, err := newIsloClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
leaseID, name := "", ""
|
||||
acquired := false
|
||||
if req.ID == "" {
|
||||
var slug string
|
||||
leaseID, name, slug, err = b.createSandbox(ctx, client, req.Repo, req.Reclaim)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "leased %s slug=%s provider=islo sandbox=%s\n", leaseID, slug, name)
|
||||
acquired = true
|
||||
} else {
|
||||
leaseID, name, err = resolveIsloLeaseID(req.ID, req.Repo.Root, req.Reclaim)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
}
|
||||
if acquired && !req.Keep {
|
||||
defer func() {
|
||||
if err := client.DeleteSandbox(context.Background(), name); err != nil {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: islo stop failed for %s: %v\n", name, err)
|
||||
return
|
||||
}
|
||||
removeLeaseClaim(leaseID)
|
||||
}()
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "provider=islo lease=%s sandbox=%s\n", leaseID, name)
|
||||
commandStart := b.now()
|
||||
exitCode, runErr := b.exec(ctx, client, name, req.Command, req.ShellMode)
|
||||
commandDuration := b.now().Sub(commandStart)
|
||||
result := RunResult{
|
||||
ExitCode: exitCode,
|
||||
Command: commandDuration,
|
||||
Total: b.now().Sub(started),
|
||||
SyncDelegated: true,
|
||||
}
|
||||
fmt.Fprintf(b.rt.Stderr, "islo run summary command=%s total=%s exit=%d\n", result.Command.Round(time.Millisecond), result.Total.Round(time.Millisecond), exitCode)
|
||||
if req.TimingJSON {
|
||||
if err := writeTimingJSON(b.rt.Stderr, timingReport{
|
||||
Provider: isloProvider,
|
||||
LeaseID: leaseID,
|
||||
SyncDelegated: true,
|
||||
SyncPhases: []timingPhase{{Name: "delegated", Skipped: true, Reason: "islo owns sandbox state"}},
|
||||
CommandMs: result.Command.Milliseconds(),
|
||||
TotalMs: result.Total.Milliseconds(),
|
||||
ExitCode: exitCode,
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if runErr != nil {
|
||||
return result, ExitError{Code: 1, Message: fmt.Sprintf("islo run failed: %v", runErr)}
|
||||
}
|
||||
if exitCode != 0 {
|
||||
return result, ExitError{Code: exitCode, Message: fmt.Sprintf("islo run exited %d", exitCode)}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *isloBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
|
||||
_ = req
|
||||
client, err := newIsloClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sandboxes, err := client.ListSandboxes(ctx)
|
||||
if err != nil {
|
||||
return nil, isloError("list sandboxes", err)
|
||||
}
|
||||
servers := make([]Server, 0, len(sandboxes))
|
||||
for _, sandbox := range sandboxes {
|
||||
if sandbox == nil || !isCrabboxIsloSandboxName(sandbox.GetName()) {
|
||||
continue
|
||||
}
|
||||
servers = append(servers, isloSandboxToServer(sandbox))
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func (b *isloBackend) Status(ctx context.Context, req StatusRequest) (statusView, error) {
|
||||
client, err := newIsloClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return statusView{}, err
|
||||
}
|
||||
leaseID, name, err := resolveIsloLeaseID(req.ID, "", false)
|
||||
if err != nil {
|
||||
return statusView{}, err
|
||||
}
|
||||
deadline := b.now().Add(req.WaitTimeout)
|
||||
if req.WaitTimeout <= 0 {
|
||||
deadline = b.now().Add(5 * time.Minute)
|
||||
}
|
||||
for {
|
||||
sandbox, err := client.GetSandbox(ctx, name)
|
||||
if err != nil {
|
||||
return statusView{}, isloError("get sandbox", err)
|
||||
}
|
||||
view := isloStatusView(leaseID, sandbox)
|
||||
if !req.Wait || view.Ready {
|
||||
return view, nil
|
||||
}
|
||||
if b.now().After(deadline) {
|
||||
return statusView{}, exit(5, "timed out waiting for sandbox %s to become ready", name)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return statusView{}, ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *isloBackend) Stop(ctx context.Context, req StopRequest) error {
|
||||
client, err := newIsloClient(b.cfg, b.rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaseID, name, err := resolveIsloLeaseID(req.ID, "", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.DeleteSandbox(ctx, name); err != nil {
|
||||
return isloError("delete sandbox", err)
|
||||
}
|
||||
removeLeaseClaim(leaseID)
|
||||
fmt.Fprintf(b.rt.Stderr, "released lease=%s sandbox=%s\n", leaseID, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *isloBackend) createSandbox(ctx context.Context, client isloAPI, repo Repo, reclaim bool) (string, string, string, error) {
|
||||
name := newIsloSandboxName(repo)
|
||||
create := &gosdk.SandboxCreate{Name: stringValue(name)}
|
||||
if b.cfg.Islo.Image != "" {
|
||||
create.Image = stringValue(b.cfg.Islo.Image)
|
||||
}
|
||||
if b.cfg.Islo.Workdir != "" {
|
||||
create.Workdir = stringValue(b.cfg.Islo.Workdir)
|
||||
}
|
||||
if b.cfg.Islo.GatewayProfile != "" {
|
||||
create.GatewayProfile = stringValue(b.cfg.Islo.GatewayProfile)
|
||||
}
|
||||
if b.cfg.Islo.SnapshotName != "" {
|
||||
create.SnapshotName = stringValue(b.cfg.Islo.SnapshotName)
|
||||
}
|
||||
if b.cfg.Islo.VCPUs > 0 {
|
||||
create.Vcpus = intValue(b.cfg.Islo.VCPUs)
|
||||
}
|
||||
if b.cfg.Islo.MemoryMB > 0 {
|
||||
create.MemoryMb = intValue(b.cfg.Islo.MemoryMB)
|
||||
}
|
||||
if b.cfg.Islo.DiskGB > 0 {
|
||||
create.DiskGb = intValue(b.cfg.Islo.DiskGB)
|
||||
}
|
||||
sandbox, err := client.CreateSandbox(ctx, create)
|
||||
if err != nil {
|
||||
return "", "", "", isloError("create sandbox", err)
|
||||
}
|
||||
if sandbox == nil || sandbox.GetName() == "" {
|
||||
return "", "", "", exit(5, "islo create sandbox returned no name")
|
||||
}
|
||||
leaseID := isloLeasePrefix + sandbox.GetName()
|
||||
slug := newLeaseSlug(leaseID)
|
||||
if err := claimLeaseForRepoProvider(leaseID, slug, isloProvider, repo.Root, b.cfg.IdleTimeout, reclaim); err != nil {
|
||||
_ = client.DeleteSandbox(context.Background(), sandbox.GetName())
|
||||
return "", "", "", err
|
||||
}
|
||||
return leaseID, sandbox.GetName(), slug, nil
|
||||
}
|
||||
|
||||
func (b *isloBackend) exec(ctx context.Context, client isloAPI, name string, command []string, shellMode bool) (int, error) {
|
||||
if len(command) == 0 {
|
||||
return 2, errors.New("missing command")
|
||||
}
|
||||
execCommand := command
|
||||
if shellMode || shouldUseShell(command) || leadingEnvAssignment(command) {
|
||||
execCommand = []string{"bash", "-lc", shellScriptFromArgv(command)}
|
||||
}
|
||||
req := &gosdk.ExecRequest{Command: execCommand}
|
||||
if b.cfg.Islo.Workdir != "" {
|
||||
req.Workdir = stringValue(b.cfg.Islo.Workdir)
|
||||
}
|
||||
return client.ExecStream(ctx, name, req, b.rt.Stdout, b.rt.Stderr)
|
||||
}
|
||||
|
||||
func resolveIsloLeaseID(id, repoRoot string, reclaim bool) (string, string, error) {
|
||||
if id == "" {
|
||||
return "", "", exit(2, "provider=islo requires a Crabbox-created sandbox name, lease id, or slug")
|
||||
}
|
||||
if strings.HasPrefix(id, isloLeasePrefix) {
|
||||
name := strings.TrimPrefix(id, isloLeasePrefix)
|
||||
if !isCrabboxIsloSandboxName(name) {
|
||||
return "", "", exit(4, "islo lease %q is not a Crabbox-owned sandbox", id)
|
||||
}
|
||||
return id, name, nil
|
||||
}
|
||||
if claim, ok, err := resolveLeaseClaim(id); err != nil {
|
||||
return "", "", err
|
||||
} else if ok && claim.Provider == isloProvider {
|
||||
if repoRoot != "" {
|
||||
if err := claimLeaseForRepoProvider(claim.LeaseID, claim.Slug, isloProvider, repoRoot, time.Duration(claim.IdleTimeoutSeconds)*time.Second, reclaim); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return claim.LeaseID, strings.TrimPrefix(claim.LeaseID, isloLeasePrefix), nil
|
||||
}
|
||||
if !isCrabboxIsloSandboxName(id) {
|
||||
return "", "", exit(4, "islo sandbox %q is not claimed by Crabbox; use a Crabbox slug or %s<crabbox-sandbox-name>", id, isloLeasePrefix)
|
||||
}
|
||||
return isloLeasePrefix + id, id, nil
|
||||
}
|
||||
|
||||
func isloSandboxToServer(sandbox *gosdk.SandboxResponse) Server {
|
||||
if sandbox == nil {
|
||||
return Server{Provider: isloProvider, Labels: map[string]string{"provider": isloProvider}}
|
||||
}
|
||||
labels := map[string]string{
|
||||
"provider": isloProvider,
|
||||
"lease": isloLeasePrefix + sandbox.GetName(),
|
||||
"slug": newLeaseSlug(isloLeasePrefix + sandbox.GetName()),
|
||||
"target": targetLinux,
|
||||
"state": sandbox.GetStatus(),
|
||||
}
|
||||
return Server{
|
||||
Provider: isloProvider,
|
||||
CloudID: sandbox.GetID(),
|
||||
Name: sandbox.GetName(),
|
||||
Status: sandbox.GetStatus(),
|
||||
Labels: labels,
|
||||
}
|
||||
}
|
||||
|
||||
func isloStatusView(leaseID string, sandbox *gosdk.SandboxResponse) statusView {
|
||||
name := strings.TrimPrefix(leaseID, isloLeasePrefix)
|
||||
status := ""
|
||||
image := ""
|
||||
if sandbox != nil {
|
||||
name = sandbox.GetName()
|
||||
status = sandbox.GetStatus()
|
||||
image = sandbox.GetImage()
|
||||
}
|
||||
return statusView{
|
||||
ID: leaseID,
|
||||
Slug: newLeaseSlug(leaseID),
|
||||
Provider: isloProvider,
|
||||
TargetOS: targetLinux,
|
||||
State: status,
|
||||
ServerID: name,
|
||||
ServerType: image,
|
||||
Network: NetworkPublic,
|
||||
Ready: isloStatusReady(status),
|
||||
Labels: map[string]string{
|
||||
"provider": isloProvider,
|
||||
"lease": leaseID,
|
||||
"state": status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isloStatusReady(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "ready", "running", "started", "active":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func newIsloSandboxName(repo Repo) string {
|
||||
base := normalizeLeaseSlug(repo.Name)
|
||||
if base == "" {
|
||||
base = "crabbox"
|
||||
}
|
||||
base = strings.TrimPrefix(base, strings.TrimSuffix(isloNamePrefix, "-")+"-")
|
||||
return isloNamePrefix + base + "-" + isloRandomSuffix()
|
||||
}
|
||||
|
||||
func isCrabboxIsloSandboxName(name string) bool {
|
||||
return strings.HasPrefix(normalizeLeaseSlug(name), isloNamePrefix)
|
||||
}
|
||||
|
||||
func isloRandomSuffix() string {
|
||||
var b [3]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())[:6]
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func leadingEnvAssignment(command []string) bool {
|
||||
return len(command) > 1 && strings.Contains(command[0], "=") && !strings.HasPrefix(command[0], "-")
|
||||
}
|
||||
|
||||
func stringValue(v string) *string { return &v }
|
||||
func intValue(v int) *int { return &v }
|
||||
|
||||
func isloError(action string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("islo %s: %w", action, err)
|
||||
}
|
||||
|
||||
func (b *isloBackend) now() time.Time {
|
||||
if b.rt.Clock != nil {
|
||||
return b.rt.Clock.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
172
internal/cli/provider_islo_client.go
Normal file
172
internal/cli/provider_islo_client.go
Normal file
@ -0,0 +1,172 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gosdk "github.com/islo-labs/go-sdk"
|
||||
"github.com/islo-labs/go-sdk/client"
|
||||
"github.com/islo-labs/go-sdk/customauth"
|
||||
"github.com/islo-labs/go-sdk/option"
|
||||
)
|
||||
|
||||
type isloAPI interface {
|
||||
CreateSandbox(context.Context, *gosdk.SandboxCreate) (*gosdk.SandboxResponse, error)
|
||||
GetSandbox(context.Context, string) (*gosdk.SandboxResponse, error)
|
||||
ListSandboxes(context.Context) ([]*gosdk.SandboxResponse, error)
|
||||
DeleteSandbox(context.Context, string) error
|
||||
ExecStream(context.Context, string, *gosdk.ExecRequest, io.Writer, io.Writer) (int, error)
|
||||
}
|
||||
|
||||
type isloSDKClient struct {
|
||||
sdk *client.Client
|
||||
auth *customauth.Provider
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newIsloClient(cfg Config, rt Runtime) (isloAPI, error) {
|
||||
apiKey := strings.TrimSpace(cfg.Islo.APIKey)
|
||||
if apiKey == "" {
|
||||
return nil, exit(2, "provider=islo requires ISLO_API_KEY")
|
||||
}
|
||||
baseURL := strings.TrimRight(blank(cfg.Islo.BaseURL, "https://api.islo.dev"), "/")
|
||||
httpClient := rt.HTTP
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
auth := customauth.NewProvider(baseURL, apiKey, 0, httpClient)
|
||||
var baseTransport http.RoundTripper
|
||||
var timeout time.Duration
|
||||
if httpClient != nil {
|
||||
baseTransport = httpClient.Transport
|
||||
timeout = httpClient.Timeout
|
||||
}
|
||||
sdkHTTPClient := &http.Client{
|
||||
Transport: customauth.NewTransport(baseTransport, auth),
|
||||
Timeout: timeout,
|
||||
}
|
||||
sdk := client.NewClient(option.WithBaseURL(baseURL), option.WithHTTPClient(sdkHTTPClient))
|
||||
return &isloSDKClient{sdk: sdk, auth: auth, baseURL: baseURL, httpClient: httpClient}, nil
|
||||
}
|
||||
|
||||
func (c *isloSDKClient) CreateSandbox(ctx context.Context, req *gosdk.SandboxCreate) (*gosdk.SandboxResponse, error) {
|
||||
return c.sdk.Sandboxes.CreateSandbox(ctx, req)
|
||||
}
|
||||
|
||||
func (c *isloSDKClient) GetSandbox(ctx context.Context, name string) (*gosdk.SandboxResponse, error) {
|
||||
return c.sdk.Sandboxes.GetSandbox(ctx, &gosdk.GetSandboxRequest{SandboxName: name})
|
||||
}
|
||||
|
||||
func (c *isloSDKClient) ListSandboxes(ctx context.Context) ([]*gosdk.SandboxResponse, error) {
|
||||
limit := 100
|
||||
var all []*gosdk.SandboxResponse
|
||||
for offset := 0; ; offset += limit {
|
||||
page, err := c.sdk.Sandboxes.ListSandboxes(ctx, &gosdk.ListSandboxesRequest{Limit: &limit, Offset: &offset})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if page == nil {
|
||||
return all, nil
|
||||
}
|
||||
items := page.GetItems()
|
||||
all = append(all, items...)
|
||||
if len(items) < limit {
|
||||
return all, nil
|
||||
}
|
||||
if total := page.GetTotal(); total > 0 && offset+len(items) >= total {
|
||||
return all, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *isloSDKClient) DeleteSandbox(ctx context.Context, name string) error {
|
||||
_, err := c.sdk.Sandboxes.DeleteSandbox(ctx, &gosdk.DeleteSandboxRequest{SandboxName: name})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *isloSDKClient) ExecStream(ctx context.Context, name string, req *gosdk.ExecRequest, stdout, stderr io.Writer) (int, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("encode exec request: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sandboxes/"+name+"/exec/stream", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
token, err := c.auth.Token(ctx)
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("islo auth: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+token)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return 1, fmt.Errorf("islo exec stream %s: %s", resp.Status, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
return parseIsloSSE(resp.Body, stdout, stderr)
|
||||
}
|
||||
|
||||
func parseIsloSSE(r io.Reader, stdout, stderr io.Writer) (int, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
exitCode := 0
|
||||
event := ""
|
||||
var data []string
|
||||
flush := func() {
|
||||
if event == "" && len(data) == 0 {
|
||||
return
|
||||
}
|
||||
payload := strings.Join(data, "\n")
|
||||
switch event {
|
||||
case "stdout":
|
||||
_, _ = stdout.Write([]byte(payload))
|
||||
case "stderr":
|
||||
_, _ = stderr.Write([]byte(payload))
|
||||
case "exit":
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(payload)); err == nil {
|
||||
exitCode = n
|
||||
}
|
||||
}
|
||||
event = ""
|
||||
data = data[:0]
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, ":") {
|
||||
continue
|
||||
}
|
||||
field, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
field = line
|
||||
value = ""
|
||||
}
|
||||
value = strings.TrimPrefix(value, " ")
|
||||
switch field {
|
||||
case "event":
|
||||
event = value
|
||||
case "data":
|
||||
data = append(data, value)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return exitCode, scanner.Err()
|
||||
}
|
||||
132
internal/cli/provider_islo_test.go
Normal file
132
internal/cli/provider_islo_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseIsloSSE(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"event: stdout",
|
||||
"data: hello",
|
||||
"",
|
||||
"event: stderr",
|
||||
"data: warn",
|
||||
"",
|
||||
"event: exit",
|
||||
"data: 7",
|
||||
"",
|
||||
}, "\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code, err := parseIsloSSE(strings.NewReader(body), &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != 7 || stdout.String() != "hello" || stderr.String() != "warn" {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeadingEnvAssignmentUsesShell(t *testing.T) {
|
||||
if !leadingEnvAssignment([]string{"FOO=bar", "pnpm", "test"}) {
|
||||
t.Fatal("expected leading env assignment to require shell")
|
||||
}
|
||||
if leadingEnvAssignment([]string{"pnpm", "test"}) {
|
||||
t.Fatal("plain argv should not require shell")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsloStatusReady(t *testing.T) {
|
||||
for _, status := range []string{"ready", "running", "started", "active"} {
|
||||
if !isloStatusReady(status) {
|
||||
t.Fatalf("expected %q ready", status)
|
||||
}
|
||||
}
|
||||
if isloStatusReady("stopped") {
|
||||
t.Fatal("stopped should not be ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIsloLeaseIDRejectsUnclaimedRawSandbox(t *testing.T) {
|
||||
if _, _, err := resolveIsloLeaseID("production", "", false); err == nil {
|
||||
t.Fatal("expected raw non-Crabbox sandbox to be rejected")
|
||||
}
|
||||
leaseID, name, err := resolveIsloLeaseID("crabbox-repo-abcdef", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if leaseID != "isb_crabbox-repo-abcdef" || name != "crabbox-repo-abcdef" {
|
||||
t.Fatalf("lease=%q name=%q", leaseID, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIsloSandboxNameUsesCrabboxPrefix(t *testing.T) {
|
||||
name := newIsloSandboxName(Repo{Name: "repo"})
|
||||
if !strings.HasPrefix(name, "crabbox-repo-") {
|
||||
t.Fatalf("name=%q", name)
|
||||
}
|
||||
if !isCrabboxIsloSandboxName(name) {
|
||||
t.Fatalf("expected %q to be recognized as Crabbox-owned", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsloSDKClientListUsesInjectedHTTPAndPaginates(t *testing.T) {
|
||||
authHits := 0
|
||||
listHits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/auth/token":
|
||||
authHits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"session_token": "jwt-from-test",
|
||||
"cookie_max_age": 3600,
|
||||
})
|
||||
case "/sandboxes/":
|
||||
listHits++
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer jwt-from-test" {
|
||||
t.Fatalf("Authorization=%q", got)
|
||||
}
|
||||
offset := r.URL.Query().Get("offset")
|
||||
offsetValue, _ := strconv.Atoi(offset)
|
||||
items := []map[string]any{}
|
||||
if offset == "0" {
|
||||
for i := 0; i < 100; i++ {
|
||||
items = append(items, map[string]any{"id": "id", "name": "crabbox-a", "status": "running", "image": "ubuntu"})
|
||||
}
|
||||
} else if offset == "100" {
|
||||
items = append(items, map[string]any{"id": "id", "name": "crabbox-b", "status": "running", "image": "ubuntu"})
|
||||
} else {
|
||||
t.Fatalf("unexpected offset=%q", offset)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"items": items,
|
||||
"total": 101,
|
||||
"limit": 100,
|
||||
"offset": offsetValue,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
api, err := newIsloClient(Config{Islo: IsloConfig{APIKey: "ak_test", BaseURL: srv.URL}}, Runtime{HTTP: srv.Client()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := api.ListSandboxes(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 101 {
|
||||
t.Fatalf("items=%d", len(items))
|
||||
}
|
||||
if authHits != 1 || listHits != 2 {
|
||||
t.Fatalf("authHits=%d listHits=%d", authHits, listHits)
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@ func init() {
|
||||
RegisterProvider(testAWSProvider{})
|
||||
RegisterProvider(testStaticSSHProvider{})
|
||||
RegisterProvider(testBlacksmithProvider{})
|
||||
RegisterProvider(testDaytonaProvider{})
|
||||
RegisterProvider(testIsloProvider{})
|
||||
}
|
||||
|
||||
type testHetznerProvider struct{}
|
||||
@ -108,3 +110,49 @@ func (testBlacksmithProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values a
|
||||
func (p testBlacksmithProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
|
||||
return NewBlacksmithBackend(p.Spec(), cfg, rt), nil
|
||||
}
|
||||
|
||||
type testDaytonaProvider struct{}
|
||||
|
||||
func (testDaytonaProvider) Name() string { return "daytona" }
|
||||
func (testDaytonaProvider) Aliases() []string { return nil }
|
||||
func (testDaytonaProvider) Spec() ProviderSpec {
|
||||
return ProviderSpec{
|
||||
Name: daytonaProvider,
|
||||
Kind: ProviderKindSSHLease,
|
||||
Targets: []TargetSpec{{OS: targetLinux}},
|
||||
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync},
|
||||
Coordinator: CoordinatorNever,
|
||||
}
|
||||
}
|
||||
func (testDaytonaProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
|
||||
return RegisterDaytonaProviderFlags(fs, defaults)
|
||||
}
|
||||
func (testDaytonaProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
|
||||
return ApplyDaytonaProviderFlags(cfg, fs, values)
|
||||
}
|
||||
func (p testDaytonaProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
|
||||
return NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
|
||||
}
|
||||
|
||||
type testIsloProvider struct{}
|
||||
|
||||
func (testIsloProvider) Name() string { return isloProvider }
|
||||
func (testIsloProvider) Aliases() []string { return nil }
|
||||
func (testIsloProvider) Spec() ProviderSpec {
|
||||
return ProviderSpec{
|
||||
Name: isloProvider,
|
||||
Kind: ProviderKindDelegatedRun,
|
||||
Targets: []TargetSpec{{OS: targetLinux}},
|
||||
Features: nil,
|
||||
Coordinator: CoordinatorNever,
|
||||
}
|
||||
}
|
||||
func (testIsloProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
|
||||
return RegisterIsloProviderFlags(fs, defaults)
|
||||
}
|
||||
func (testIsloProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
|
||||
return ApplyIsloProviderFlags(cfg, fs, values)
|
||||
}
|
||||
func (p testIsloProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
|
||||
return NewIsloBackend(p.Spec(), cfg, rt), nil
|
||||
}
|
||||
|
||||
@ -103,17 +103,14 @@ func (a App) warmup(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
|
||||
}
|
||||
}
|
||||
network := NetworkPublic
|
||||
if target.Host != server.PublicNet.IPv4.IP && target.Host != "" {
|
||||
network = NetworkTailscale
|
||||
}
|
||||
network := readyNetworkDisplay(cfg, server, target)
|
||||
meta := serverTailscaleMetadata(server)
|
||||
tailscaleSummary := ""
|
||||
if meta.Enabled {
|
||||
tailscaleSummary = " tailscale=" + blank(tailscaleTargetHost(meta), blank(meta.State, "requested"))
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "leased %s slug=%s provider=%s server=%s type=%s ip=%s%s idle_timeout=%s expires=%s\n", leaseID, blank(serverSlug(server), "-"), cfg.Provider, server.DisplayID(), server.ServerType.Name, server.PublicNet.IPv4.IP, tailscaleSummary, cfg.IdleTimeout, blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]))
|
||||
fmt.Fprintf(a.Stdout, "ready ssh=%s@%s:%s network=%s workroot=%s\n", target.User, target.Host, target.Port, network, cfg.WorkRoot)
|
||||
fmt.Fprintf(a.Stdout, "ready ssh=%s@%s:%s network=%s workroot=%s\n", redactedSSHUser(cfg, server, target), target.Host, target.Port, network, cfg.WorkRoot)
|
||||
if *actionsRunner {
|
||||
ghRepo, err := resolveGitHubRepo(repo, cfg.Actions.Repo)
|
||||
if err != nil {
|
||||
@ -708,6 +705,22 @@ func applyResolvedServerConfig(cfg *Config, server Server) {
|
||||
if server.ServerType.Name != "" {
|
||||
cfg.ServerType = server.ServerType.Name
|
||||
}
|
||||
if root := server.Labels["work_root"]; root != "" {
|
||||
cfg.WorkRoot = root
|
||||
}
|
||||
}
|
||||
|
||||
func readyNetworkDisplay(cfg Config, server Server, target SSHTarget) NetworkMode {
|
||||
if target.NetworkKind != "" {
|
||||
return target.NetworkKind
|
||||
}
|
||||
if cfg.Provider == "daytona" || server.Provider == "daytona" {
|
||||
return NetworkPublic
|
||||
}
|
||||
if target.Host != server.PublicNet.IPv4.IP && target.Host != "" {
|
||||
return NetworkTailscale
|
||||
}
|
||||
return NetworkPublic
|
||||
}
|
||||
|
||||
func coordinatorFallbackSummary(lease CoordinatorLease) string {
|
||||
@ -924,7 +937,7 @@ func findServerByAlias(servers []Server, id string) (Server, string, error) {
|
||||
func (a App) stop(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("stop", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpAll())
|
||||
providerFlags := registerProviderFlags(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
|
||||
@ -28,6 +28,8 @@ type SSHTarget struct {
|
||||
TargetOS string
|
||||
WindowsMode string
|
||||
ReadyCheck string
|
||||
AuthSecret bool
|
||||
NetworkKind NetworkMode
|
||||
}
|
||||
|
||||
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
|
||||
@ -285,9 +287,7 @@ func sshBaseArgs(target SSHTarget) []string {
|
||||
}
|
||||
|
||||
func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts string) []string {
|
||||
return []string{
|
||||
"-i", target.Key,
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
args := []string{
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UserKnownHostsFile=" + sshConfigFileValue(knownHostsFile(target)),
|
||||
@ -295,11 +295,21 @@ func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts
|
||||
"-o", "ConnectionAttempts=" + connectionAttempts,
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=2",
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPersist=60s",
|
||||
"-o", "ControlPath=" + sshControlPath(target),
|
||||
"-p", target.Port,
|
||||
}
|
||||
if target.AuthSecret {
|
||||
args = append(args, "-o", "ControlMaster=no")
|
||||
} else {
|
||||
args = append(args,
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPersist=60s",
|
||||
"-o", "ControlPath="+sshControlPath(target),
|
||||
)
|
||||
}
|
||||
if target.Key != "" {
|
||||
args = append([]string{"-i", target.Key, "-o", "IdentitiesOnly=yes"}, args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func minDuration(left, right time.Duration) time.Duration {
|
||||
|
||||
@ -3,14 +3,16 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a App) ssh(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("ssh", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
showSecret := fs.Bool("show-secret", false, "print secret auth material for token-based SSH providers")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -31,6 +33,19 @@ func (a App) ssh(ctx context.Context, args []string) error {
|
||||
if err := a.claimAndTouchLeaseTarget(ctx, cfg, server, leaseID, *reclaim); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "ssh -i %s -p %s %s@%s\n", target.Key, target.Port, target.User, target.Host)
|
||||
if target.AuthSecret && !*showSecret {
|
||||
fmt.Fprintf(a.Stderr, "warning: ssh auth user is secret; rerun with --show-secret to print a pasteable command\n")
|
||||
}
|
||||
fmt.Fprintln(a.Stdout, sshCommandLine(target, target.AuthSecret && !*showSecret))
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshCommandLine(target SSHTarget, redactSecret bool) string {
|
||||
renderTarget := target
|
||||
if redactSecret {
|
||||
renderTarget.User = daytonaTokenRedacted
|
||||
}
|
||||
args := append([]string{"ssh"}, sshBaseArgs(renderTarget)...)
|
||||
args = append(args, renderTarget.User+"@"+renderTarget.Host)
|
||||
return strings.Join(shellWords(args), " ")
|
||||
}
|
||||
|
||||
@ -228,6 +228,61 @@ func TestSSHArgsIncludeReliabilityOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHArgsAllowTokenUserWithoutIdentityFile(t *testing.T) {
|
||||
t.Setenv("HOME", "/tmp/crabbox-home")
|
||||
got := strings.Join(sshArgs(SSHTarget{
|
||||
User: "tok_live_secret",
|
||||
Host: "ssh.app.daytona.io",
|
||||
Port: "22",
|
||||
}, "true"), "\n")
|
||||
for _, unwanted := range []string{"-i\n", "IdentitiesOnly=yes"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("sshArgs() should omit key-only option %q when target key is empty: %q", unwanted, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "tok_live_secret@ssh.app.daytona.io") {
|
||||
t.Fatalf("sshArgs() missing token user target: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHArgsAuthSecretDisablesControlMaster(t *testing.T) {
|
||||
t.Setenv("HOME", "/tmp/crabbox-home")
|
||||
got := strings.Join(sshArgs(SSHTarget{
|
||||
User: "tok_live_secret",
|
||||
Host: "ssh.app.daytona.io",
|
||||
Port: "22",
|
||||
AuthSecret: true,
|
||||
}, "true"), "\n")
|
||||
for _, unwanted := range []string{"ControlMaster=auto", "ControlPersist=", "ControlPath="} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("sshArgs() should omit mux option %q for secret auth target: %q", unwanted, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "ControlMaster=no") {
|
||||
t.Fatalf("sshArgs() missing ControlMaster=no for secret auth target: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
|
||||
target := SSHTarget{
|
||||
User: "tok_live_secret",
|
||||
Host: "ssh.app.daytona.io",
|
||||
Port: "22",
|
||||
AuthSecret: true,
|
||||
}
|
||||
redacted := sshCommandLine(target, true)
|
||||
if strings.Contains(redacted, target.User) {
|
||||
t.Fatalf("redacted command leaked token: %q", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, daytonaTokenRedacted+"@ssh.app.daytona.io") {
|
||||
t.Fatalf("redacted command missing placeholder user: %q", redacted)
|
||||
}
|
||||
full := sshCommandLine(target, false)
|
||||
if !strings.Contains(full, target.User+"@ssh.app.daytona.io") {
|
||||
t.Fatalf("full command missing token user: %q", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHTransportProbeDoesNotRequireCrabboxReady(t *testing.T) {
|
||||
got := sshTransportProbeCommand(SSHTarget{Host: "100.64.0.10", Port: "2222"})
|
||||
if strings.Contains(got, "crabbox-ready") || strings.Contains(got, "git --version") || strings.Contains(got, "/work/crabbox") {
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
func (a App) status(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("status", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpAll())
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
wait := fs.Bool("wait", false, "wait until ready")
|
||||
waitTimeout := fs.Duration("wait-timeout", 5*time.Minute, "maximum wait duration")
|
||||
@ -87,6 +87,12 @@ func statusViewFromLeaseTarget(ctx context.Context, cfg Config, lease LeaseTarge
|
||||
server := lease.Server
|
||||
target := lease.SSH
|
||||
hasHost := server.PublicNet.IPv4.IP != ""
|
||||
if target.NetworkKind == NetworkPublic && target.Host != "" {
|
||||
hasHost = true
|
||||
}
|
||||
if (cfg.Provider == "daytona" || server.Provider == "daytona") && target.Host != "" {
|
||||
hasHost = true
|
||||
}
|
||||
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
|
||||
if err != nil {
|
||||
return statusView{}, err
|
||||
@ -112,7 +118,7 @@ func statusViewFromLeaseTarget(ctx context.Context, cfg Config, lease LeaseTarge
|
||||
Network: resolved.Network,
|
||||
Tailscale: tailscale,
|
||||
SSHHost: target.Host,
|
||||
SSHUser: target.User,
|
||||
SSHUser: redactedSSHUser(cfg, server, target),
|
||||
SSHPort: target.Port,
|
||||
SSHFallbackPorts: target.FallbackPorts,
|
||||
SSHKey: target.Key,
|
||||
|
||||
@ -3,6 +3,8 @@ package all
|
||||
import (
|
||||
_ "github.com/openclaw/crabbox/internal/providers/aws"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/blacksmith"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/daytona"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/hetzner"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/islo"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/ssh"
|
||||
)
|
||||
|
||||
36
internal/providers/daytona/provider.go
Normal file
36
internal/providers/daytona/provider.go
Normal file
@ -0,0 +1,36 @@
|
||||
package daytona
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterProvider(Provider{})
|
||||
}
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (Provider) Name() string { return "daytona" }
|
||||
func (Provider) Aliases() []string {
|
||||
return nil
|
||||
}
|
||||
func (Provider) Spec() cli.ProviderSpec {
|
||||
return cli.ProviderSpec{
|
||||
Name: "daytona",
|
||||
Kind: cli.ProviderKindSSHLease,
|
||||
Targets: []cli.TargetSpec{{OS: "linux"}},
|
||||
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync},
|
||||
Coordinator: cli.CoordinatorNever,
|
||||
}
|
||||
}
|
||||
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
|
||||
return cli.RegisterDaytonaProviderFlags(fs, defaults)
|
||||
}
|
||||
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
|
||||
return cli.ApplyDaytonaProviderFlags(cfg, fs, values)
|
||||
}
|
||||
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
|
||||
return cli.NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
|
||||
}
|
||||
36
internal/providers/islo/provider.go
Normal file
36
internal/providers/islo/provider.go
Normal file
@ -0,0 +1,36 @@
|
||||
package islo
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterProvider(Provider{})
|
||||
}
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (Provider) Name() string { return "islo" }
|
||||
func (Provider) Aliases() []string {
|
||||
return nil
|
||||
}
|
||||
func (Provider) Spec() cli.ProviderSpec {
|
||||
return cli.ProviderSpec{
|
||||
Name: "islo",
|
||||
Kind: cli.ProviderKindDelegatedRun,
|
||||
Targets: []cli.TargetSpec{{OS: "linux"}},
|
||||
Features: nil,
|
||||
Coordinator: cli.CoordinatorNever,
|
||||
}
|
||||
}
|
||||
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
|
||||
return cli.RegisterIsloProviderFlags(fs, defaults)
|
||||
}
|
||||
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
|
||||
return cli.ApplyIsloProviderFlags(cfg, fs, values)
|
||||
}
|
||||
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
|
||||
return cli.NewIsloBackend(p.Spec(), cfg, rt), nil
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user