Compare commits
5 Commits
main
...
feat/adapt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e8c998c3 | ||
|
|
218bfdeac8 | ||
|
|
b2eee1521b | ||
|
|
a8a8a95243 | ||
|
|
f399bf6d27 |
@ -79,8 +79,6 @@ crabbox desktop click --id <id-or-slug> --x 640 --y 420
|
||||
crabbox desktop paste --id <id-or-slug> --text "peter@example.com"
|
||||
crabbox desktop type --id <id-or-slug> --text "peter+qa@example.com"
|
||||
crabbox desktop key --id <id-or-slug> ctrl+l
|
||||
crabbox artifacts collect --id <id-or-slug> --all --output artifacts/<slug>
|
||||
crabbox artifacts publish --dir artifacts/<slug> --pr <number>
|
||||
crabbox sync-plan
|
||||
crabbox history --lease <id-or-slug>
|
||||
crabbox events <run_id> --json
|
||||
@ -107,13 +105,6 @@ bridge disconnected`, `problem: browser not launched`, `problem: input stack
|
||||
dead`, or similar will be followed by exact `rescue:` commands such as
|
||||
`crabbox webvnc status/reset` or `crabbox desktop doctor`.
|
||||
|
||||
For UI QA proof, use `crabbox artifacts collect` instead of ad hoc screenshots
|
||||
and shell recordings. It can bundle screenshots, MP4 recordings, trimmed GIFs,
|
||||
desktop doctor output, WebVNC status, run logs, and metadata, then
|
||||
`crabbox artifacts publish --pr <n>` can publish inline-ready Markdown through
|
||||
the configured coordinator artifact backend. Use explicit `--storage s3`,
|
||||
`--storage r2`, or `--storage local` only as a local fallback.
|
||||
|
||||
## Run Inspection Workflow
|
||||
|
||||
Use the CLI for durable run inspection; do not expect extra OpenClaw plugin
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@ -1,30 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added `provider: azure` for managed Azure Linux and native Windows SSH leases, including direct and brokered provisioning, shared Azure networking, SKU fallback, Azure docs, and cleanup support. Thanks @jwmoss.
|
||||
|
||||
## 0.7.0 - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added an authenticated coordinator control WebSocket for low-latency run attach streams and lease heartbeats, with HTTP polling/heartbeat fallback for older brokers.
|
||||
- Added mediated egress commands and browser wiring so Linux desktop leases can proxy selected app traffic through the operator machine via the coordinator bridge.
|
||||
- Added WebVNC portal clipboard controls for sending local clipboard text into the remote session and copying remote clipboard text back to the local browser.
|
||||
- Added rescue-first desktop/WebVNC failure output that names the failing layer and prints exact `rescue:` or native VNC fallback commands when bridges, viewers, browser launches, VNC targets, or input stacks hang.
|
||||
- Added lease sharing for individual users or the owning org, including `crabbox share`, `crabbox unshare`, API access checks, and a portal share control on lease detail pages.
|
||||
- Added collaborative WebVNC observer mode, with one active controller, read-only observers, and a portal takeover button that shows who is controlling the session.
|
||||
- Added first-class `crabbox artifacts` commands for desktop screenshots, MP4 recordings, trimmed GIFs, logs, metadata, Mantis/OpenClaw QA templates, and PR-ready publishing through broker-owned artifact storage, AWS S3, or Cloudflare R2.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed WebVNC portal sharing to open as an in-session modal, added a standalone share-page back action, and simplified collaboration controls into a single stateful control button.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `crabbox run` transport chatter by keeping SSH multiplexers alive longer, retrying fallback SSH ports for streaming commands, and batching stdout/stderr preview events into larger coordinator chunks.
|
||||
- Fixed `egress start --coordinator` so live public-route egress starts work when the local default coordinator is Cloudflare Access-protected.
|
||||
- Fixed macOS WebVNC cursor visibility by enabling noVNC's dot-cursor fallback when Screen Sharing sends a transparent or zero-sized cursor.
|
||||
- Fixed Tailscale exit-node bootstrap paths to prefer tailnet metadata and fail clearly when remote exit-node egress is not active.
|
||||
- Fixed `run --no-sync` timing summaries so they report `sync_skipped=true`.
|
||||
- Fixed native Windows command output so first-use PowerShell progress records do not leak CLIXML into run logs.
|
||||
@ -32,8 +21,6 @@
|
||||
- Fixed Code and WebVNC bridge websocket auth so upgraded brokers receive short-lived bridge tickets in the `Authorization` header instead of logging them in URL query strings, while preserving query fallback for older brokers.
|
||||
- Fixed managed AWS macOS desktop leases so readiness and WebVNC use a writable `ec2-user` work root, call `crabbox-ready` by absolute path, and read the generated Screen Sharing password via sudo.
|
||||
- Fixed managed AWS macOS bootstrap so VNC password generation does not abort under `pipefail` before Screen Sharing readiness is installed.
|
||||
- Fixed managed Linux bootstrap so SSH service activation cannot hang cloud-init before desktop/browser setup and readiness checks run.
|
||||
- Fixed WebVNC daemon start-by-slug so coordinator-backed leases use the resolved target OS in the background bridge command.
|
||||
- Fixed coordinator-backed `crabbox list` so a stale admin token no longer blocks normal logged-in users; the CLI now falls back to active user-visible leases instead of failing with `401 unauthorized`.
|
||||
- Fixed desktop, screenshot, VNC, and WebVNC SSH helpers so they retry live fallback ports when a coordinator lease advertises an SSH port that is not ready yet.
|
||||
|
||||
|
||||
23
README.md
23
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, AWS EC2, or Azure. Azure supports managed Linux and native Windows VMs. 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@ -53,7 +53,7 @@ Every lease has a stable `cbx_...` ID and a friendly crustacean slug (`blue-lobs
|
||||
```text
|
||||
your laptop Cloudflare Worker cloud provider
|
||||
------------- ------------------ --------------
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
|
||||
| lease + cost state |
|
||||
| |
|
||||
+------------ SSH + rsync to leased runner <--------------+
|
||||
@ -61,9 +61,9 @@ crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azur
|
||||
|
||||
- **CLI** — Go binary. Loads config, mints a per-lease SSH key, asks the broker for a lease, waits for SSH, seeds remote Git, rsyncs the dirty checkout (with fingerprint skip when nothing changed), runs the command, streams output, releases.
|
||||
- **Broker** — Cloudflare Worker at `crabbox.openclaw.ai` plus a single Durable Object. Owns provider credentials, serializes lease state, enforces active-lease and monthly spend caps, and expires stale leases by alarm. Auth is GitHub login or a shared bearer token.
|
||||
- **Runner** — a throwaway SSH machine prepared with SSH on the primary port, default `2222`, plus configured fallback ports and Crabbox's sync/run prerequisites. Linux uses Ubuntu with cloud-init and `/work/crabbox`; native Windows uses OpenSSH, Git for Windows, and `C:\crabbox`. No broker credentials live on the box. Project runtimes (Go, Node, Docker, services, secrets) come from your repo's GitHub Actions hydration, devcontainer, Nix, mise/asdf, or setup scripts — not from Crabbox.
|
||||
- **Runner** — vanilla Ubuntu prepared by cloud-init with SSH on the primary port, default `2222`, plus configured fallback ports, Git, rsync, curl, jq, and `/work/crabbox`. No broker credentials live on the box. Project runtimes (Go, Node, Docker, services, secrets) come from your repo's GitHub Actions hydration, devcontainer, Nix, mise/asdf, or setup scripts — not from Crabbox.
|
||||
|
||||
A direct-provider mode (`--provider hetzner|aws|azure` with local credentials) exists for debugging the broker itself; the brokered path is the default.
|
||||
A direct-provider mode (`--provider hetzner|aws` with local credentials) exists for debugging the broker itself; the brokered path is the default.
|
||||
|
||||
For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For the doc-to-code map, see [Source Map](docs/source-map.md).
|
||||
|
||||
@ -73,15 +73,14 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
|
||||
- **Run observability.** Every coordinator-backed run gets an early `run_...` handle. Use `crabbox attach <run-id>` while it is active, `crabbox events <run-id> --after <seq> --limit <n>` for durable lifecycle/output events, and `crabbox logs <run-id>` for retained output after completion.
|
||||
- **Stable timing records.** `--timing-json` on `run`, `warmup`, and `actions hydrate` gives scripts one machine-readable sync/command/total timing schema across AWS, Hetzner, and Blacksmith Testboxes.
|
||||
- **Local-first sync.** No clean-checkout requirement. Tracked + nonignored files only, fingerprint skip on no-op runs, sanity checks against suspicious mass deletions, optional shallow base-ref hydration for changed-test workflows.
|
||||
- **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner, AWS EC2, and Azure are managed providers; AWS also owns Windows WSL2 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.
|
||||
- **Azure Linux and native Windows.** `provider: azure` provisions Linux and native Windows VMs in a configurable Azure subscription using `DefaultAzureCredential` in direct mode or service-principal secrets in the broker. Crabbox creates a shared resource group, vnet, subnet, and NSG on first use, then per-lease public IPs, NICs, and VMs. Linux uses cloud-init; Windows uses VM Agent Custom Script Extension to install OpenSSH/Git and configure the Crabbox user.
|
||||
- **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, config conventions, and portal visibility for active external runners.
|
||||
- **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.
|
||||
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets. `crabbox desktop doctor` checks session, VNC, input tooling, browser, ffmpeg, screen size, screenshot capture, and WebVNC portal state; `desktop click/paste/type/key` provide first-class input helpers so agents do not hand-roll brittle `xdotool` snippets. QA systems such as Mantis own scenario logic, screenshots, and PR evidence. Azure native Windows is SSH/sync/run only; use AWS for managed Windows desktop/WSL2 or `provider: ssh` for an existing Windows host.
|
||||
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets. `crabbox desktop doctor` checks session, VNC, input tooling, browser, ffmpeg, screen size, screenshot capture, and WebVNC portal state; `desktop click/paste/type/key` provide first-class input helpers so agents do not hand-roll brittle `xdotool` snippets. Hetzner Windows is not a managed target; use AWS for managed Windows or `provider: ssh` for an existing Windows host.
|
||||
- **Authenticated web portal.** Browser login opens owner-scoped and explicitly shared lease/run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. `crabbox share` can grant a lease to one user or the owning org, and the lease page exposes the same sharing controls for owners/managers. WebVNC is preferred for human demos because it preloads the VNC password; `webvnc status` reports local daemon, tunnel, target reachability, bridge/viewer state, recent events, URL/password, and native VNC fallback, while `webvnc reset` restarts only the selected lease's WebVNC/input stack. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
|
||||
- **Hardened coordinator auth.** GitHub browser login, owner-scoped leases, admin-only routes, optional GitHub team allowlists, Cloudflare Access JWT verification, and service-token support keep normal use and operator automation separate.
|
||||
- **OpenClaw plugin.** The repo root is a native OpenClaw plugin for box lifecycle operations: `crabbox_run`, `crabbox_warmup`, `crabbox_status`, `crabbox_list`, and `crabbox_stop`. Run inspection stays in the CLI and Crabbox skill.
|
||||
@ -113,16 +112,6 @@ AWS WSL2 standard m8i.large, m8i-flex.large, c8i.large, r8i.large
|
||||
beast m8i.4xlarge, m8i-flex.4xlarge, c8i.4xlarge, r8i.4xlarge, m8i.2xlarge
|
||||
|
||||
AWS macOS all mac2.metal unless --type is set
|
||||
|
||||
Azure standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, then 16-vCPU fallbacks
|
||||
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, then 48/32-vCPU fallbacks
|
||||
large Standard_D96ads_v6, Standard_D96ds_v6, then 64/48-vCPU fallbacks
|
||||
beast Standard_D192ds_v6, Standard_D128ds_v6, then 96/64-vCPU fallbacks
|
||||
|
||||
Azure Win standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, Standard_D2as_v6
|
||||
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, Standard_D4as_v6
|
||||
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, Standard_D8as_v6
|
||||
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, Standard_D8ads_v6
|
||||
```
|
||||
|
||||
Override with `--type` or `CRABBOX_SERVER_TYPE` for a specific instance.
|
||||
|
||||
@ -13,13 +13,13 @@ A `crabbox run` command leases a brokered cloud machine or reuses a static SSH h
|
||||
```text
|
||||
your laptop Cloudflare Worker cloud provider
|
||||
------------- ------------------ --------------
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
|
||||
| lease + cost state |
|
||||
| |
|
||||
+------------ SSH + rsync to leased runner <--------------+
|
||||
```
|
||||
|
||||
The CLI is a Go binary. The broker is a Cloudflare Worker plus a single Durable Object. Brokered Linux runners are vanilla Ubuntu boxes prepared by cloud-init with SSH, Git, rsync, curl, jq, and `/work/crabbox`; AWS can also broker managed Windows/WSL2 and EC2 Mac desktop targets, while Azure can broker native Windows SSH/sync/run targets. Static hosts are existing SSH machines selected with `provider: ssh`. Project runtimes come from Actions hydration or repo-owned setup. Runners hold no broker credentials - they are leaf nodes.
|
||||
The CLI is a Go binary. The broker is a Cloudflare Worker plus a single Durable Object. Brokered Linux runners are vanilla Ubuntu boxes prepared by cloud-init with SSH, Git, rsync, curl, jq, and `/work/crabbox`; AWS can also broker managed Windows and EC2 Mac desktop targets. Static hosts are existing SSH machines selected with `provider: ssh`. Project runtimes come from Actions hydration or repo-owned setup. Runners hold no broker credentials - they are leaf nodes.
|
||||
|
||||
## A run, end to end
|
||||
|
||||
|
||||
@ -104,7 +104,6 @@ Owned backends:
|
||||
- `hetzner-static`: pre-created warm machines.
|
||||
- `hetzner-ephemeral`: created per lease or overflow.
|
||||
- `aws`: one-time EC2 instances for burst capacity, managed Windows/WSL2, and EC2 Mac.
|
||||
- `azure`: one-time Azure VMs for Linux and native Windows SSH/sync/run.
|
||||
- `ssh-static`: manually managed machines reachable by SSH.
|
||||
|
||||
Brokered backends, later:
|
||||
@ -112,7 +111,7 @@ Brokered backends, later:
|
||||
- `github-actions`: register or dispatch real Actions-backed runner work when workflow parity is required.
|
||||
- `external-runner`: adapter boundary for other hosted runner systems if needed.
|
||||
|
||||
The current broker implements `hetzner-ephemeral`, `aws`, and `azure`, and leaves interfaces ready for `hetzner-static`.
|
||||
The current broker implements `hetzner-ephemeral` and `aws`, and leaves interfaces ready for `hetzner-static`.
|
||||
|
||||
## Machine Bootstrap
|
||||
|
||||
|
||||
27
docs/cli.md
27
docs/cli.md
@ -25,16 +25,16 @@ Primary output goes to stdout. Progress, diagnostics, and errors go to stderr. J
|
||||
|
||||
```text
|
||||
crabbox doctor
|
||||
crabbox login [--url <url>] [--provider hetzner|aws|azure] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider hetzner|aws|azure]
|
||||
crabbox login [--url <url>] [--provider hetzner|aws] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider hetzner|aws]
|
||||
crabbox logout
|
||||
crabbox whoami [--json]
|
||||
crabbox init [--force]
|
||||
crabbox config show [--json]
|
||||
crabbox config path
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws|azure]
|
||||
crabbox warmup [--provider hetzner|aws|azure|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|azure|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 config set-broker --url <url> --token-stdin [--provider hetzner|aws]
|
||||
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>] [--egress <profile>] [--webvnc] [--open] [-- <command...>]
|
||||
crabbox desktop doctor --id <lease-id-or-slug> [--network auto|tailscale|public]
|
||||
crabbox desktop click --id <lease-id-or-slug> --x <n> --y <n> [--network auto|tailscale|public]
|
||||
@ -49,11 +49,6 @@ crabbox egress client --id <lease-id-or-slug> [--listen <addr>] [--ticket <ticke
|
||||
crabbox egress status --id <lease-id-or-slug>
|
||||
crabbox egress stop --id <lease-id-or-slug>
|
||||
crabbox media preview --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
|
||||
crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>] [--run <run-id>] [--all] [--screenshot] [--video] [--gif] [--doctor] [--webvnc-status] [--metadata] [--duration <duration>] [--fps <n>] [--gif-width <px>] [--network auto|tailscale|public] [--json]
|
||||
crabbox artifacts video --id <lease-id-or-slug> [--output <path>] [--duration <duration>] [--fps <n>]
|
||||
crabbox artifacts gif --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
|
||||
crabbox artifacts template openclaw|mantis [--summary <text>|--summary-file <path>] [--before <path>] [--after <path>] [--output <path>]
|
||||
crabbox artifacts publish --dir <dir> [--pr <n>] [--repo owner/name] [--storage auto|broker|s3|cloudflare|r2|local] [--bucket <name>] [--prefix <path>] [--base-url <url>] [--region <region>] [--profile <profile>] [--endpoint-url <url>] [--acl <acl>] [--presign] [--expires <duration>] [--dry-run] [--no-comment]
|
||||
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
|
||||
crabbox sync-plan [--limit <n>]
|
||||
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
|
||||
@ -126,8 +121,6 @@ crabbox share --id blue-lobster --user friend@example.com
|
||||
crabbox share --id blue-lobster --org
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
|
||||
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
|
||||
crabbox artifacts publish --dir artifacts/blue-lobster --pr 123
|
||||
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
|
||||
crabbox stop blue-lobster
|
||||
```
|
||||
@ -605,16 +598,6 @@ CRABBOX_TAILSCALE_AUTH_KEY_ENV
|
||||
CRABBOX_TAILSCALE_AUTH_KEY direct-provider only, via auth-key env
|
||||
CRABBOX_TAILSCALE_EXIT_NODE
|
||||
CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS
|
||||
CRABBOX_ARTIFACTS_STORAGE default --storage for artifacts publish
|
||||
CRABBOX_ARTIFACTS_BUCKET
|
||||
CRABBOX_ARTIFACTS_PREFIX
|
||||
CRABBOX_ARTIFACTS_BASE_URL
|
||||
CRABBOX_ARTIFACTS_AWS_REGION
|
||||
CRABBOX_ARTIFACTS_AWS_PROFILE
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL
|
||||
CRABBOX_ARTIFACTS_S3_ACL
|
||||
CRABBOX_ARTIFACTS_PRESIGN
|
||||
CRABBOX_ARTIFACTS_EXPIRES
|
||||
```
|
||||
|
||||
Provider/deploy variables live outside normal CLI operation:
|
||||
|
||||
@ -13,7 +13,6 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [run](run.md)
|
||||
- [desktop](desktop.md)
|
||||
- [media](media.md)
|
||||
- [artifacts](artifacts.md)
|
||||
- [sync-plan](sync-plan.md)
|
||||
- [history](history.md)
|
||||
- [logs](logs.md)
|
||||
|
||||
@ -1,235 +0,0 @@
|
||||
# artifacts
|
||||
|
||||
`crabbox artifacts` collects desktop QA evidence into a durable bundle, creates
|
||||
trimmed review media, and publishes inline-ready assets for pull requests.
|
||||
|
||||
Use it when a desktop/WebVNC issue or UI fix needs more than a one-off
|
||||
screenshot: MP4 recording, trimmed GIF, logs, doctor output, WebVNC status, and
|
||||
metadata in one directory.
|
||||
|
||||
## Collect
|
||||
|
||||
```sh
|
||||
crabbox artifacts collect --id blue-lobster --output artifacts/blue-lobster
|
||||
crabbox artifacts collect --id blue-lobster --all --duration 20s --output artifacts/blue-lobster
|
||||
crabbox artifacts collect --id blue-lobster --run run_123 --output artifacts/blue-lobster
|
||||
```
|
||||
|
||||
By default `collect` writes:
|
||||
|
||||
- `metadata.json`
|
||||
- `screenshot.png`
|
||||
- `doctor.txt`
|
||||
- `webvnc-status.json` when a coordinator login is configured
|
||||
- `logs.txt` and `run.json` when `--run <run-id>` is provided
|
||||
|
||||
`--all` also records `screen.mp4`, creates `screen.trimmed.gif`, and writes
|
||||
`screen.trimmed.mp4` using the same motion window. Video/GIF capture currently
|
||||
requires a Linux desktop lease with `ffmpeg` and X11 capture support.
|
||||
|
||||
Useful flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--output <dir>
|
||||
--run <run-id>
|
||||
--all
|
||||
--screenshot
|
||||
--video
|
||||
--gif
|
||||
--doctor
|
||||
--webvnc-status
|
||||
--metadata
|
||||
--duration <duration> default 10s
|
||||
--fps <n> default 15
|
||||
--gif-width <px> default 640
|
||||
--provider <name>
|
||||
--network auto|public|tailscale
|
||||
--json
|
||||
```
|
||||
|
||||
When collection hits an unhealthy desktop, WebVNC, VNC, or input layer, it
|
||||
prints the same inline `problem:`, `detail:`, and `rescue:` commands used by the
|
||||
desktop and WebVNC commands. With `--json`, stdout remains valid JSON and those
|
||||
same repair hints are returned in the `warnings` array instead of being printed
|
||||
as text before the JSON document. If a capture step fails after the bundle has
|
||||
started, the command still exits nonzero and includes an `error` object with a
|
||||
stable code and message.
|
||||
|
||||
## Video
|
||||
|
||||
```sh
|
||||
crabbox artifacts video --id blue-lobster --duration 15s --output screen.mp4
|
||||
```
|
||||
|
||||
`video` records only an MP4 from a Linux desktop lease. It is useful when you
|
||||
want to keep capture separate from bundle collection.
|
||||
|
||||
## GIF
|
||||
|
||||
```sh
|
||||
crabbox artifacts gif \
|
||||
--input screen.mp4 \
|
||||
--output screen.trimmed.gif \
|
||||
--trimmed-video-output screen.trimmed.mp4
|
||||
```
|
||||
|
||||
`gif` is an alias for the same local motion-trimmed preview logic as
|
||||
[`crabbox media preview`](media.md).
|
||||
|
||||
## Templates
|
||||
|
||||
```sh
|
||||
crabbox artifacts template openclaw \
|
||||
--before before.png \
|
||||
--after after.gif \
|
||||
--summary "Login modal no longer overlaps the toolbar." \
|
||||
--output summary.md
|
||||
|
||||
crabbox artifacts template mantis --summary-file qa-notes.md
|
||||
```
|
||||
|
||||
Templates write Markdown with `Summary`, `Before / After`, and `Evidence`
|
||||
sections sized for Mantis/OpenClaw QA comments.
|
||||
|
||||
## Publish
|
||||
|
||||
```sh
|
||||
crabbox artifacts publish \
|
||||
--dir artifacts/blue-lobster \
|
||||
--pr 123
|
||||
|
||||
crabbox artifacts publish \
|
||||
--dir artifacts/blue-lobster \
|
||||
--pr 123 \
|
||||
--storage s3 \
|
||||
--bucket qa-artifacts \
|
||||
--prefix pr-123/blue-lobster \
|
||||
--base-url https://qa-artifacts.example.com
|
||||
|
||||
crabbox artifacts publish \
|
||||
--dir artifacts/blue-lobster \
|
||||
--pr 123 \
|
||||
--storage cloudflare \
|
||||
--bucket qa-artifacts \
|
||||
--prefix pr-123/blue-lobster \
|
||||
--base-url https://artifacts.example.com
|
||||
```
|
||||
|
||||
`publish` uploads bundle files, writes `published-artifacts.md`, and comments
|
||||
on the PR with inline images/GIFs plus links to videos, logs, and metadata.
|
||||
Use `--dry-run` to generate markdown and print intended actions without upload
|
||||
or comment side effects.
|
||||
|
||||
Storage backends:
|
||||
|
||||
- `--storage auto` is the default. When a coordinator is configured, Crabbox
|
||||
asks the broker for upload URLs and the broker-owned artifact backend handles
|
||||
storage credentials. Without a coordinator, auto falls back to local markdown.
|
||||
- `--storage broker` requires a configured coordinator and uploads through
|
||||
broker-minted URLs.
|
||||
- `--storage s3` uses the AWS CLI and uploads to `s3://<bucket>/<prefix>/...`.
|
||||
- `--storage cloudflare` uses `wrangler r2 object put --remote`.
|
||||
- `--storage r2` uses the AWS CLI against an S3-compatible R2 endpoint.
|
||||
- `--storage local` writes markdown only. For `--pr`, local publishing needs a
|
||||
`--base-url` that already serves the files, otherwise the PR would contain
|
||||
unusable local paths.
|
||||
|
||||
S3 flags:
|
||||
|
||||
```text
|
||||
--bucket <name>
|
||||
--prefix <path>
|
||||
--base-url <url>
|
||||
--region <region>
|
||||
--profile <profile>
|
||||
--endpoint-url <url>
|
||||
--acl <acl>
|
||||
--presign
|
||||
--expires <duration> default 168h
|
||||
```
|
||||
|
||||
When `--base-url` is supplied, published links use that public URL. Otherwise
|
||||
`--presign` generates temporary AWS/R2 S3 URLs after upload.
|
||||
|
||||
Cloudflare R2 flags:
|
||||
|
||||
```text
|
||||
--bucket <name>
|
||||
--prefix <path>
|
||||
--base-url <url> required for --pr inline-ready links
|
||||
```
|
||||
|
||||
For native Cloudflare publishing, `publish` runs `wrangler` with
|
||||
`CRABBOX_ARTIFACTS_CLOUDFLARE_*` when present, then the generic
|
||||
`CLOUDFLARE_*` environment. Prefer brokered publishing for shared teams so
|
||||
Cloudflare and object-store secrets stay on the coordinator.
|
||||
|
||||
For S3-compatible R2 publishing, pass `--storage r2 --endpoint-url <r2-endpoint>
|
||||
--profile <r2-profile>`. When present, Crabbox uses
|
||||
`CRABBOX_ARTIFACTS_R2_ENDPOINT_URL` and `CRABBOX_ARTIFACTS_R2_AWS_PROFILE`
|
||||
before falling back to generic AWS defaults.
|
||||
|
||||
Coordinator artifact backend configuration:
|
||||
|
||||
```text
|
||||
CRABBOX_ARTIFACTS_BACKEND=s3|r2
|
||||
CRABBOX_ARTIFACTS_BUCKET
|
||||
CRABBOX_ARTIFACTS_PREFIX
|
||||
CRABBOX_ARTIFACTS_BASE_URL
|
||||
CRABBOX_ARTIFACTS_REGION
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS
|
||||
```
|
||||
|
||||
For brokered publishing, the CLI never receives object-store credentials. It
|
||||
sends artifact names, sizes, content types, and hashes to
|
||||
`POST /v1/artifacts/uploads`; the coordinator returns one short-lived upload URL
|
||||
per file plus the final URL to place in Markdown. Upload grants are signed with
|
||||
the declared `content-length`, so the object store rejects oversized PUTs during
|
||||
the grant window; the broker also caps each upload request at 5 GiB total before
|
||||
signing grants. When `--prefix` is omitted for hosted publishing, the CLI derives
|
||||
a unique prefix from the PR number, bundle directory, and current time so later
|
||||
QA comments do not overwrite earlier evidence.
|
||||
|
||||
Coordinator artifact values split into two groups:
|
||||
|
||||
- Worker vars: `CRABBOX_ARTIFACTS_BACKEND`, `CRABBOX_ARTIFACTS_BUCKET`,
|
||||
`CRABBOX_ARTIFACTS_PREFIX`, `CRABBOX_ARTIFACTS_BASE_URL`,
|
||||
`CRABBOX_ARTIFACTS_REGION`, `CRABBOX_ARTIFACTS_ENDPOINT_URL`,
|
||||
`CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`, and
|
||||
`CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS`. These describe where artifacts go and
|
||||
how long URLs should live.
|
||||
- Worker secrets: `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`,
|
||||
`CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`, and optional
|
||||
`CRABBOX_ARTIFACTS_SESSION_TOKEN`. These are S3-compatible object-store keys
|
||||
used only by the coordinator to sign artifact upload/read URLs.
|
||||
|
||||
Our deployed coordinator currently uses R2-compatible storage with public final
|
||||
URLs on `https://artifacts.openclaw.ai`, bucket
|
||||
`openclaw-crabbox-artifacts`, and object prefix `crabbox-artifacts`. The actual
|
||||
R2 access key id and secret access key are Worker secrets; they are not required
|
||||
on developer machines for normal `crabbox artifacts publish`.
|
||||
|
||||
Environment defaults:
|
||||
|
||||
```text
|
||||
CRABBOX_ARTIFACTS_STORAGE
|
||||
CRABBOX_ARTIFACTS_BUCKET
|
||||
CRABBOX_ARTIFACTS_PREFIX
|
||||
CRABBOX_ARTIFACTS_BASE_URL
|
||||
CRABBOX_ARTIFACTS_AWS_REGION
|
||||
CRABBOX_ARTIFACTS_AWS_PROFILE
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL
|
||||
CRABBOX_ARTIFACTS_S3_ACL
|
||||
CRABBOX_ARTIFACTS_PRESIGN
|
||||
CRABBOX_ARTIFACTS_EXPIRES
|
||||
```
|
||||
|
||||
`publish --pr` uses `gh issue comment <pr> --body-file ...`, so the current
|
||||
checkout must be authenticated with GitHub. Pass `--repo owner/name` when the
|
||||
working directory is not inside the target repository.
|
||||
@ -10,8 +10,10 @@ crabbox attach run_abcdef123456 --poll 500ms
|
||||
|
||||
## Behavior
|
||||
|
||||
`attach` polls the coordinator for new run events on a fixed interval,
|
||||
prints them as they arrive, and exits when the run finishes.
|
||||
`attach` follows coordinator run events, prints them as they arrive, and exits
|
||||
when the run finishes. Newer brokers stream events over the authenticated
|
||||
coordinator control WebSocket; older brokers or dropped sockets fall back to
|
||||
the HTTPS events API from the last printed sequence.
|
||||
|
||||
- stdout and stderr preview events are written back to stdout and stderr,
|
||||
preserving the stream split;
|
||||
@ -20,8 +22,8 @@ prints them as they arrive, and exits when the run finishes.
|
||||
message;
|
||||
- when the run has already finished, attach prints any remaining events
|
||||
and exits;
|
||||
- when the run is still active, attach polls until it sees a `finish`
|
||||
event.
|
||||
- when the run is still active, attach waits for streamed events or polls until
|
||||
it sees the run finish.
|
||||
|
||||
`attach` is not detached command execution. It follows the events the
|
||||
original CLI is emitting; if that CLI process dies, the run state remains
|
||||
@ -40,7 +42,7 @@ after completion.
|
||||
```text
|
||||
--id <run-id> run id (also accepted as a positional argument)
|
||||
--after <seq> resume after this event sequence number
|
||||
--poll <duration> polling interval, default 1s
|
||||
--poll <duration> fallback poll interval and WebSocket idle check, default 1s
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
@ -47,7 +47,7 @@ error and continue with the next candidate.
|
||||
## Flags
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure provider to sweep (delegated providers do not need cleanup)
|
||||
--provider hetzner|aws provider to sweep (delegated providers do not need cleanup)
|
||||
--target linux|macos|windows for AWS, restrict by target
|
||||
--windows-mode normal|wsl2 when target=windows
|
||||
--static-host <host> ignored (provider=ssh has nothing to sweep)
|
||||
|
||||
@ -64,7 +64,7 @@ and extension-host traffic stay below coordinator websocket frame limits.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure
|
||||
--provider hetzner|aws
|
||||
--target linux
|
||||
--network auto|tailscale|public
|
||||
--local-port <port>
|
||||
|
||||
@ -15,7 +15,7 @@ Subcommands:
|
||||
```text
|
||||
path
|
||||
show [--json]
|
||||
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws|azure]
|
||||
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws]
|
||||
```
|
||||
|
||||
`config show` reports broker auth as `auth` and `admin_auth`, plus
|
||||
|
||||
@ -73,7 +73,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -71,7 +71,7 @@ the exit code.
|
||||
## Flags
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh provider to validate
|
||||
--provider hetzner|aws|ssh provider to validate
|
||||
--target linux|macos|windows target OS for ssh provider checks
|
||||
--windows-mode normal|wsl2 when target=windows
|
||||
--static-host <host> static SSH host
|
||||
|
||||
@ -37,7 +37,7 @@ included.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug> lease to inspect; required for managed providers
|
||||
--provider hetzner|aws|azure|ssh|daytona override the configured provider
|
||||
--provider hetzner|aws|ssh override the configured provider
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host> static SSH host for provider=ssh
|
||||
|
||||
@ -35,7 +35,7 @@ use the normalized Crabbox lease view.
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -27,7 +27,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--url <url> broker URL
|
||||
--provider hetzner|aws|azure default provider to store with the broker
|
||||
--provider hetzner|aws default provider to store with the broker
|
||||
--no-browser print the GitHub login URL instead of opening it
|
||||
--token-stdin read broker token from stdin for operator automation
|
||||
--json print JSON
|
||||
|
||||
@ -89,7 +89,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -38,7 +38,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -15,7 +15,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -26,7 +26,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -15,7 +15,7 @@ The argument accepts the stable `cbx_...` ID or an active friendly slug. In `bla
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -233,7 +233,7 @@ make sure the Dedicated Host is allocated in the selected AWS region.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -9,7 +9,6 @@ crabbox warmup --browser
|
||||
crabbox warmup --tailscale
|
||||
crabbox warmup --desktop --browser
|
||||
crabbox warmup --provider aws --target windows --desktop
|
||||
crabbox warmup --provider azure --target windows
|
||||
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
|
||||
@ -42,10 +41,8 @@ the updated PATH.
|
||||
|
||||
With `--provider hetzner`, managed provisioning supports Linux only. Hetzner can
|
||||
run Windows through ISO/snapshot installation flows, but Crabbox does not manage
|
||||
that path today. Use `--provider aws --target windows` for managed Windows
|
||||
desktop or WSL2, `--provider azure --target windows` for native Windows
|
||||
SSH/sync/run, or `--provider ssh --target windows` for an existing Hetzner
|
||||
Windows host.
|
||||
that path today. Use `--provider aws --target windows` for managed Windows, or
|
||||
`--provider ssh --target windows` for an existing Hetzner Windows host.
|
||||
|
||||
With `--provider aws --target windows --windows-mode normal --desktop`, Crabbox
|
||||
creates a real AWS Windows Server lease. EC2Launch user data installs OpenSSH
|
||||
@ -60,11 +57,6 @@ imports an Ubuntu rootfs, and prepares the Linux-side `crabbox-ready` toolchain.
|
||||
The AWS launch enables nested virtualization and uses C8i, M8i, or R8i instance
|
||||
families for this mode. Commands and sync then use the POSIX WSL contract.
|
||||
|
||||
With `--provider azure --target windows`, Crabbox creates a native Windows
|
||||
Server lease, uses the Azure VM Agent Custom Script Extension to install
|
||||
OpenSSH Server and Git for Windows, and configures the `crabbox` user for
|
||||
SSH/sync/run. Azure Windows does not provision VNC/browser/WSL2.
|
||||
|
||||
With `--provider aws --target macos --desktop`, Crabbox launches an EC2 Mac
|
||||
instance on an already allocated Dedicated Host. Set `CRABBOX_AWS_MAC_HOST_ID`
|
||||
or `aws.macHostId`, use `--market on-demand`, and expect EC2 Mac host lifecycle
|
||||
@ -77,7 +69,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -66,15 +66,6 @@ pid file under its local state directory and prints both paths. Use
|
||||
that lease. Shutdown terminates both the daemon supervisor and the active child
|
||||
bridge process.
|
||||
|
||||
The bridge keeps a warm pool of backend VNC sessions open (default 4 slots,
|
||||
which is what the `slots=` field in `webvnc status` reports). That lets
|
||||
multiple portal viewers join the same lease: one viewer is the controller,
|
||||
later viewers start in observer mode, and any viewer can press **take over**
|
||||
to become the controller — including the prior controller, who stays connected
|
||||
as an observer and can reclaim control the same way. Observer mode is a
|
||||
collaboration UX for trusted shared leases; it relies on the portal noVNC
|
||||
client staying read-only and is not a hostile-client isolation boundary.
|
||||
|
||||
The older `crabbox webvnc --id <lease> --daemon`, `--background`, `--status`,
|
||||
and `--stop` forms remain accepted as compatibility aliases, but new docs and
|
||||
automation should use the explicit `daemon` subcommands.
|
||||
@ -92,8 +83,7 @@ Typical status output is meant to be directly actionable:
|
||||
webvnc daemon: pid=12345 log=...
|
||||
vnc target: reachable 127.0.0.1:5900 managed=true
|
||||
ssh tunnel: ssh ... -L 5901:127.0.0.1:5900 ...
|
||||
portal bridge: connected=true viewers=2 observers=1 slots=2
|
||||
portal controller: peter
|
||||
portal bridge: connected=true viewer=false
|
||||
event: 2026-05-07T12:00:00Z bridge_connected
|
||||
webvnc: https://crabbox.openclaw.ai/portal/leases/cbx_.../vnc#password=...
|
||||
fallback: crabbox vnc --provider aws --target linux --network tailscale --id cbx_... --open
|
||||
@ -102,10 +92,10 @@ fallback: crabbox vnc --provider aws --target linux --network tailscale --id cbx
|
||||
When a layer is unhealthy, the CLI prints `problem:`, optional `detail:`, and
|
||||
one or more exact `rescue:` commands in the command output, not only in docs.
|
||||
Common problems include `VNC bridge disconnected`, `WebVNC daemon not running`,
|
||||
`waiting for an available WebVNC observer slot`, and `VNC target unreachable`.
|
||||
If the browser portal path looks unhealthy but the target VNC service is
|
||||
reachable, the output also prints the native `crabbox vnc ... --open` fallback
|
||||
command with the same provider/target/network flags.
|
||||
`WebVNC viewer already active`, and `VNC target unreachable`. If the browser
|
||||
portal path looks unhealthy but the target VNC service is reachable, the output
|
||||
also prints the native `crabbox vnc ... --open` fallback command with the same
|
||||
provider/target/network flags.
|
||||
|
||||
Use `crabbox webvnc reset --id <lease> --open` when the portal is stuck on a
|
||||
stale bridge/viewer/session. Reset closes only that lease's coordinator
|
||||
@ -155,7 +145,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure
|
||||
--provider hetzner|aws
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
@ -177,7 +167,7 @@ daemon stop
|
||||
|
||||
Limitations:
|
||||
|
||||
- Coordinator-backed Hetzner, AWS, and Azure Linux desktop leases are supported.
|
||||
- Coordinator-backed Hetzner and AWS desktop leases are supported.
|
||||
- Static SSH hosts are intentionally not supported yet because the portal cannot
|
||||
prove that host-managed VNC credentials and prompts are safe to expose.
|
||||
- Blacksmith Testbox still owns its own machine connectivity.
|
||||
@ -189,7 +179,7 @@ Limitations:
|
||||
Run `crabbox login` for the coordinator you are using. WebVNC needs both the CLI
|
||||
bridge and the browser portal to authenticate with the coordinator.
|
||||
|
||||
`webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases`
|
||||
`webvnc currently supports coordinator-backed hetzner/aws desktop leases`
|
||||
|
||||
WebVNC is not available for static SSH hosts or Blacksmith Testbox. Use
|
||||
`crabbox vnc` for static hosts when you explicitly trust the host-managed VNC
|
||||
@ -209,11 +199,9 @@ with that lease. Start or restart `crabbox webvnc daemon start --id <lease>
|
||||
session state are likely. If the command is still running, wait for the portal
|
||||
retry or reload the browser tab.
|
||||
|
||||
`waiting for an available WebVNC observer slot`
|
||||
`WebVNC viewer already active`
|
||||
|
||||
The portal is reachable, but all bridge slots are already paired with viewers.
|
||||
Restart the bridge with a current Crabbox CLI so it opens the default backend
|
||||
pool. If the portal still cannot get a slot, run:
|
||||
Close old WebVNC tabs first. If the portal still reports a stale viewer, run:
|
||||
|
||||
```sh
|
||||
crabbox webvnc reset --id <lease-id-or-slug> --open
|
||||
|
||||
@ -35,7 +35,6 @@ Read when:
|
||||
- [Provider backends](../provider-backends.md): contract reference for backend interfaces and registration.
|
||||
- [Authoring a provider](provider-authoring.md): step-by-step guide to writing a new provider.
|
||||
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
|
||||
- [Azure](azure.md): Azure Linux/native Windows, shared infra, capacity, and cleanup.
|
||||
- [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.
|
||||
@ -55,7 +54,6 @@ Read when:
|
||||
- [Sync](sync.md): Git file-list manifests, rsync, fingerprints, excludes, guardrails, and sanity checks.
|
||||
- [Actions hydration](actions-hydration.md): let GitHub Actions prepare a runner, then sync local work into that workspace.
|
||||
- [Interactive desktop and VNC](interactive-desktop-vnc.md): VNC hub, support matrix, tunnel model, and QA boundaries.
|
||||
- [Artifacts](artifacts.md): screenshots, video, trimmed GIFs, logs, metadata, templates, and PR publishing.
|
||||
- [Linux VNC](vnc-linux.md), [Windows VNC](vnc-windows.md), [macOS VNC](vnc-macos.md): OS-specific desktop setup and troubleshooting.
|
||||
- [Test results](test-results.md): JUnit summaries attached to recorded runs.
|
||||
- [Cache controls](cache.md): inspect, purge, and warm remote package/build caches.
|
||||
@ -75,7 +73,6 @@ Read when:
|
||||
- [history](../commands/history.md)
|
||||
- [logs](../commands/logs.md)
|
||||
- [results](../commands/results.md)
|
||||
- [artifacts](../commands/artifacts.md)
|
||||
- [cache](../commands/cache.md)
|
||||
- [status](../commands/status.md)
|
||||
- [list](../commands/list.md)
|
||||
|
||||
@ -8,9 +8,9 @@ Read when:
|
||||
|
||||
Actions hydration lets a repository reuse its existing GitHub Actions setup without putting repository-specific setup code in the Crabbox binary.
|
||||
|
||||
Runner registration is currently Linux-only. Brokered Hetzner/AWS/Azure Linux
|
||||
targets work; static macOS/Windows and managed Windows/macOS targets are for
|
||||
direct `crabbox run` loops until platform-specific runner installation is added.
|
||||
Runner registration is currently Linux-only. Brokered Hetzner/AWS Linux targets
|
||||
work; static macOS/Windows and managed AWS Windows/macOS targets are for direct
|
||||
`crabbox run` loops until platform-specific runner installation is added.
|
||||
|
||||
The flow:
|
||||
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
# Artifacts
|
||||
|
||||
Read when:
|
||||
|
||||
- collecting screenshots, videos, logs, or metadata from a desktop lease;
|
||||
- turning a desktop recording into a trimmed GIF;
|
||||
- publishing QA proof into a GitHub PR;
|
||||
- deciding whether AWS S3 or Cloudflare R2 should host inline assets.
|
||||
|
||||
Crabbox artifacts are a local bundle plus optional hosted URLs. The command is
|
||||
designed for QA handoff: capture the state of a lease, preserve enough metadata
|
||||
to reproduce what happened, and publish a concise before/after/summary comment.
|
||||
|
||||
## Bundle Contract
|
||||
|
||||
`crabbox artifacts collect --id <lease>` writes a directory such as
|
||||
`artifacts/blue-lobster` with:
|
||||
|
||||
- `metadata.json`: Crabbox version, lease id, slug, provider, network, target,
|
||||
run id when provided, and capture time.
|
||||
- `screenshot.png`: a desktop screenshot captured through the managed VNC
|
||||
boundary.
|
||||
- `doctor.txt`: the same desktop/session checks as `crabbox desktop doctor`.
|
||||
- `webvnc-status.json`: bridge/viewer status when the lease is coordinator
|
||||
backed.
|
||||
- `logs.txt` and `run.json`: retained run output and run metadata when
|
||||
`--run <run-id>` is set.
|
||||
- `screen.mp4`, `screen.trimmed.gif`, and `screen.trimmed.mp4` when video/GIF
|
||||
capture is requested.
|
||||
|
||||
Failures keep the rescue-first UX. If the input stack is dead, the VNC bridge
|
||||
is disconnected, the browser did not launch, or screenshot/video capture fails,
|
||||
the command prints a concrete `problem:` plus exact `rescue:` commands before
|
||||
returning. In `--json` mode those hints are kept in `warnings`, stdout remains
|
||||
parseable JSON, and post-start capture failures add an `error` object while
|
||||
still returning a nonzero exit code.
|
||||
|
||||
## Media
|
||||
|
||||
Video capture is intentionally lease-local and Linux-first. The CLI records
|
||||
the X11 desktop with remote `ffmpeg` and streams the MP4 back over SSH. GIF
|
||||
generation then reuses the local motion-trimming logic from `crabbox media
|
||||
preview`: leading/trailing static regions are removed and an optional trimmed
|
||||
MP4 can be emitted beside the GIF.
|
||||
|
||||
Use `desktop launch --fullscreen` only when the artifact should show a
|
||||
browser-only capture. The standard human QA profile remains windowed so panel
|
||||
and window chrome stay visible.
|
||||
|
||||
## Publishing
|
||||
|
||||
GitHub comments cannot directly upload arbitrary local files through the issue
|
||||
comment API. `crabbox artifacts publish --pr <n>` therefore uploads files to a
|
||||
storage backend first, renders Markdown with inline image/GIF links, writes the
|
||||
same body to `published-artifacts.md`, and posts that body with `gh`.
|
||||
|
||||
Supported storage:
|
||||
|
||||
- Brokered coordinator publishing through `crabbox artifacts publish` with no
|
||||
storage flags. The coordinator owns object-store credentials and returns
|
||||
short-lived upload URLs plus final public URLs.
|
||||
- AWS S3 through the `aws` CLI.
|
||||
- Cloudflare R2 through `wrangler r2 object put`.
|
||||
- Local/hosted mode through `--storage local --base-url <url>` when another
|
||||
process already serves the bundle.
|
||||
|
||||
For AWS S3, use either public/custom-domain URLs through `--base-url` or
|
||||
temporary links through `--presign --expires <duration>`. For Cloudflare R2,
|
||||
provide a public bucket/custom-domain `--base-url` when publishing to a PR;
|
||||
without it, the upload can succeed but the PR would only have `r2://` object
|
||||
identifiers, not inline-ready links.
|
||||
|
||||
## Broker Secret Model
|
||||
|
||||
Brokered publishing is intentionally asymmetric. Local users and agents only
|
||||
need normal Crabbox coordinator auth. The coordinator holds the storage keys and
|
||||
uses them to sign one upload request per artifact. Each upload grant includes a
|
||||
signed `content-length`, so the configured size cap is enforced by the storage
|
||||
backend, not only by the request metadata. The broker enforces both a 1 GiB
|
||||
per-file cap and a 5 GiB per-request aggregate cap before minting upload URLs.
|
||||
When users do not pass `--prefix`, hosted publishing adds a unique
|
||||
PR/bundle/timestamp prefix so later artifact bundles cannot overwrite links from
|
||||
earlier QA comments.
|
||||
|
||||
Coordinator artifact vars describe the backend:
|
||||
|
||||
- `CRABBOX_ARTIFACTS_BACKEND`: `s3` or `r2`.
|
||||
- `CRABBOX_ARTIFACTS_BUCKET`: destination bucket.
|
||||
- `CRABBOX_ARTIFACTS_PREFIX`: root object prefix for all brokered uploads.
|
||||
- `CRABBOX_ARTIFACTS_BASE_URL`: public URL prefix for final Markdown links.
|
||||
- `CRABBOX_ARTIFACTS_REGION` and `CRABBOX_ARTIFACTS_ENDPOINT_URL`: S3/R2 signing
|
||||
endpoint details.
|
||||
- `CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`: lifetime for write grants.
|
||||
- `CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS`: lifetime for signed read URLs when
|
||||
no public base URL is configured.
|
||||
|
||||
Coordinator artifact secrets authorize signing:
|
||||
|
||||
- `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`
|
||||
- `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`
|
||||
- `CRABBOX_ARTIFACTS_SESSION_TOKEN` when the backend uses temporary
|
||||
credentials.
|
||||
|
||||
These keys are object-store credentials, not Crabbox provider credentials. They
|
||||
should be scoped to the artifact bucket/prefix and should not grant Worker
|
||||
deployment, Cloudflare account administration, lease creation, or cloud VM
|
||||
provider access. The CLI receives only pre-signed URLs and final asset URLs.
|
||||
|
||||
## Templates
|
||||
|
||||
`crabbox artifacts template openclaw` and `crabbox artifacts template mantis`
|
||||
produce Markdown with:
|
||||
|
||||
- `Summary`
|
||||
- `Before / After`
|
||||
- `Evidence`
|
||||
|
||||
The publish command uses the same layout, so local preview and PR comments stay
|
||||
consistent.
|
||||
@ -1,133 +0,0 @@
|
||||
# Azure
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing Azure as the Crabbox provider;
|
||||
- debugging Azure VM capacity, quotas, images, or SSH readiness;
|
||||
- changing Azure provisioning code in the CLI.
|
||||
|
||||
Azure is a managed provider for Linux and native Windows SSH leases. It
|
||||
creates VMs in a shared resource group, tags them with Crabbox lease
|
||||
metadata, and bootstraps the normal SSH/sync contract through cloud-init
|
||||
on Linux or Custom Script Extension on Windows. It works in direct mode with
|
||||
local Azure auth and in brokered mode through Worker-owned service principal
|
||||
secrets.
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Managed | Notes |
|
||||
| --- | --- | --- |
|
||||
| Linux | Yes | Cloud-init bootstrap, SSH, rsync, optional desktop/browser/code. |
|
||||
| Windows | Yes | Native Windows SSH/sync/run only. No Azure desktop/browser/WSL2. |
|
||||
| macOS | No | Azure does not offer managed macOS; use AWS EC2 Mac or static SSH. |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider azure --class beast
|
||||
crabbox run --provider azure --class standard -- pnpm test
|
||||
crabbox warmup --provider azure --target windows --class standard
|
||||
crabbox warmup --provider azure --desktop --browser
|
||||
crabbox vnc --id blue-lobster --open
|
||||
```
|
||||
|
||||
## Classes
|
||||
|
||||
```text
|
||||
standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, then D/F 16-vCPU fallbacks
|
||||
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, then D/F 48-vCPU and 32-vCPU fallbacks
|
||||
large Standard_D96ads_v6, Standard_D96ds_v6, then D/F 64-vCPU and 48-vCPU fallbacks
|
||||
beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96-vCPU and 64-vCPU fallbacks
|
||||
```
|
||||
|
||||
Native Windows uses the smaller AWS Windows class scale:
|
||||
|
||||
```text
|
||||
standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, then Standard_D2as_v6
|
||||
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, then Standard_D4as_v6
|
||||
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, then Standard_D8as_v6
|
||||
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, then Standard_D8ads_v6
|
||||
```
|
||||
|
||||
Crabbox falls back through the candidate list when Azure rejects a SKU for
|
||||
capacity or quota. Explicit `--type` is exact and fails clearly when the
|
||||
SKU cannot be created. Spot leases fall back to on-demand when
|
||||
`capacity.fallback` starts with `on-demand`.
|
||||
|
||||
Default Azure Linux class candidates mirror the vCPU scale of the AWS Linux
|
||||
class table. Default Azure native Windows candidates mirror the AWS native
|
||||
Windows class table. Crabbox asks Azure Resource SKUs whether the selected VM
|
||||
supports ephemeral OS disks; ephemeral-capable sizes use local OS disks,
|
||||
while exact `--type` requests for non-ephemeral sizes use managed
|
||||
`StandardSSD_LRS` OS disks.
|
||||
|
||||
## Direct Auth And Env
|
||||
|
||||
Service principal env vars consumed by `DefaultAzureCredential`:
|
||||
|
||||
```text
|
||||
AZURE_TENANT_ID
|
||||
AZURE_CLIENT_ID
|
||||
AZURE_CLIENT_SECRET
|
||||
AZURE_SUBSCRIPTION_ID
|
||||
```
|
||||
|
||||
Crabbox-specific overrides:
|
||||
|
||||
```text
|
||||
CRABBOX_AZURE_SUBSCRIPTION_ID
|
||||
CRABBOX_AZURE_TENANT_ID
|
||||
CRABBOX_AZURE_CLIENT_ID
|
||||
CRABBOX_AZURE_LOCATION
|
||||
CRABBOX_AZURE_RESOURCE_GROUP
|
||||
CRABBOX_AZURE_IMAGE
|
||||
CRABBOX_AZURE_VNET
|
||||
CRABBOX_AZURE_SUBNET
|
||||
CRABBOX_AZURE_NSG
|
||||
CRABBOX_AZURE_SSH_CIDRS
|
||||
```
|
||||
|
||||
The service principal needs the
|
||||
[Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor)
|
||||
role on the target resource group (or subscription, if you want Crabbox to
|
||||
create the resource group on first use).
|
||||
|
||||
Brokered Azure uses `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`,
|
||||
`AZURE_CLIENT_SECRET`, and `AZURE_SUBSCRIPTION_ID` on the Worker. Operators
|
||||
own the shared infra settings through `CRABBOX_AZURE_*`. Lease requests may
|
||||
override only `azureLocation` and `azureImage`.
|
||||
|
||||
## Shared Infra
|
||||
|
||||
The first acquire in an empty subscription creates:
|
||||
|
||||
- a resource group (default `crabbox-leases`);
|
||||
- a virtual network and subnet (`10.42.0.0/16` / `10.42.0.0/24`);
|
||||
- a network security group with SSH rules derived from `azure.sshCIDRs`,
|
||||
the configured SSH port, and fallback ports.
|
||||
|
||||
These resources are created with `createOrUpdate` and reused across leases.
|
||||
Per-lease provisioning creates only the public IP, NIC, VM, and OS disk.
|
||||
|
||||
Azure pricing is not hardcoded. Use `CRABBOX_COST_RATES_JSON` for exact
|
||||
Azure cost guardrails.
|
||||
|
||||
## Desktop
|
||||
|
||||
Azure desktop leases use the standard Linux VNC path: Xvfb, a lightweight
|
||||
desktop session, x11vnc bound to `127.0.0.1:5900`, and an SSH local tunnel
|
||||
created by `crabbox vnc`. Azure native Windows currently supports SSH, sync,
|
||||
and run only. Use AWS for managed Windows desktop or Windows WSL2.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Direct cleanup is best-effort through Crabbox lease tags. `crabbox cleanup
|
||||
--provider azure` enumerates VMs in the configured resource group, skips
|
||||
kept or unexpired leases, and cascade-deletes expired ones. The shared
|
||||
resource group, vnet, subnet, and NSG are preserved.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Providers](providers.md)
|
||||
- [Linux VNC](vnc-linux.md)
|
||||
- [cleanup command](../commands/cleanup.md)
|
||||
@ -14,7 +14,6 @@ Responsibilities:
|
||||
- serialize fleet state in one Durable Object;
|
||||
- create, heartbeat, release, expire, and look up leases;
|
||||
- own provider credentials;
|
||||
- own artifact storage credentials and mint scoped artifact upload URLs;
|
||||
- create and delete provider resources;
|
||||
- list the pool;
|
||||
- enforce cost and active-lease guardrails;
|
||||
@ -36,7 +35,6 @@ POST /v1/runs
|
||||
GET /v1/runs/{run-id}
|
||||
GET /v1/runs/{run-id}/logs
|
||||
POST /v1/runs/{run-id}/finish
|
||||
POST /v1/artifacts/uploads
|
||||
GET /v1/runners
|
||||
POST /v1/runners/sync
|
||||
GET /v1/usage
|
||||
|
||||
@ -69,10 +69,8 @@ Scenario systems such as Mantis own:
|
||||
| --- | --- | --- | --- |
|
||||
| Linux on Hetzner | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
|
||||
| Linux on AWS | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
|
||||
| Linux on Azure | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
|
||||
| AWS Windows | Yes | TightVNC over SSH tunnel | [Windows VNC](vnc-windows.md) |
|
||||
| AWS EC2 Mac | Yes | Screen Sharing/VNC over SSH tunnel | [macOS VNC](vnc-macos.md) |
|
||||
| Azure Windows | No | SSH/sync/run only | [Azure](azure.md) |
|
||||
| Static Linux | Host-managed | Existing loopback VNC service | [Linux VNC](vnc-linux.md) |
|
||||
| Static macOS | Host-managed | Existing Screen Sharing/VNC | [macOS VNC](vnc-macos.md) |
|
||||
| Static Windows | Host-managed | Existing VNC service | [Windows VNC](vnc-windows.md) |
|
||||
@ -105,14 +103,6 @@ connects to the coordinator with a one-use bridge ticket, and the browser uses
|
||||
bundled noVNC from the authenticated portal. The portal does not connect to the
|
||||
runner by itself; the local bridge must keep running.
|
||||
|
||||
WebVNC supports collaborative viewing. The local bridge keeps a warm pool of
|
||||
backend VNC sessions (default 4 slots), the first browser viewer controls the
|
||||
lease, and additional viewers join as read-only observers. Any viewer — a new
|
||||
observer or the prior controller — can press **take over** to become the
|
||||
controller; whoever loses control stays connected as an observer and sees who
|
||||
took over. Observer mode is intended for trusted shared leases; it is not a
|
||||
hostile-client security boundary.
|
||||
|
||||
The portal toolbar supports explicit clipboard exchange. Paste reads the local
|
||||
browser clipboard, forwards it to the remote VNC server, and sends the target
|
||||
paste shortcut. Copy-remote is enabled after the remote server publishes
|
||||
@ -126,14 +116,6 @@ Use `crabbox screenshot` when you need a PNG without taking over the session:
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
```
|
||||
|
||||
Use `crabbox artifacts` when QA needs a durable proof bundle instead of a
|
||||
single screenshot:
|
||||
|
||||
```sh
|
||||
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
|
||||
crabbox artifacts publish --dir artifacts/blue-lobster --pr 123 --storage s3 --bucket qa-artifacts
|
||||
```
|
||||
|
||||
Use `crabbox desktop launch` to start a browser or app inside the visible
|
||||
session without keeping the SSH command attached:
|
||||
|
||||
@ -187,11 +169,10 @@ Managed VNC is tunnel-first:
|
||||
- `--network tailscale` changes only the SSH endpoint used by that tunnel.
|
||||
- WebVNC keeps the same local SSH tunnel and adds an authenticated browser
|
||||
websocket through the coordinator.
|
||||
- WebVNC browser websockets are paired with local bridge backend sessions
|
||||
inside the coordinator Durable Object. One viewer is the controller; other
|
||||
viewers are observers until they press **take over**. If a browser view
|
||||
disconnects, only its paired backend session is reset and the local command
|
||||
reconnects a fresh bridge slot for the next portal retry.
|
||||
- The WebVNC browser websocket is paired with the local bridge process inside
|
||||
the coordinator Durable Object; if the browser view disconnects, the local
|
||||
command reconnects a fresh bridge for the portal retry. If the local process
|
||||
exits, the browser view disconnects until you start it again.
|
||||
- `crabbox webvnc status` reports the local daemon pid/log, SSH tunnel command,
|
||||
target VNC reachability, coordinator bridge/viewer state, recent bridge
|
||||
events, portal URL/password, and the exact native `crabbox vnc ... --open`
|
||||
@ -242,6 +223,6 @@ often machine- and user-encrypted.
|
||||
- [AWS](aws.md): AWS target matrix, capacity, AMIs, and EC2 Mac host requirements.
|
||||
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior.
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox behavior and why VNC is not a Crabbox feature there yet.
|
||||
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md), [artifacts command](../commands/artifacts.md), [egress command](../commands/egress.md).
|
||||
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md), [egress command](../commands/egress.md).
|
||||
- [Mediated egress](egress.md): per-app browser/app egress through the operator
|
||||
machine for Discord, Slack, and similar source-IP-sensitive QA.
|
||||
|
||||
@ -2,22 +2,20 @@
|
||||
|
||||
Read when:
|
||||
|
||||
- changing Hetzner, AWS, Azure, or Blacksmith Testbox provisioning;
|
||||
- changing Hetzner, AWS, or Blacksmith Testbox provisioning;
|
||||
- adding a backend;
|
||||
- adjusting machine classes, fallback order, regions, or images.
|
||||
|
||||
Crabbox currently supports three brokered providers:
|
||||
Crabbox currently supports two brokered providers:
|
||||
|
||||
```text
|
||||
hetzner Hetzner Cloud servers
|
||||
aws AWS EC2 instances
|
||||
azure Azure Virtual Machines
|
||||
```
|
||||
|
||||
Brokered Hetzner leases are Linux targets. Brokered AWS supports Linux, native
|
||||
Windows Server, Windows WSL2, and EC2 Mac when a Dedicated Host is configured.
|
||||
Brokered Azure supports Linux and native Windows SSH/sync/run. Static SSH still
|
||||
exists for reusing existing macOS and Windows machines:
|
||||
Windows Server, and EC2 Mac when a Dedicated Host is configured. Static SSH
|
||||
still exists for reusing existing macOS and Windows machines:
|
||||
|
||||
```text
|
||||
ssh Existing SSH host selected by static.host
|
||||
@ -34,7 +32,6 @@ islo Islo sandboxes with delegated command execution
|
||||
|
||||
- [Provider reference](../providers/README.md): one page per built-in backend.
|
||||
- [AWS](../providers/aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
|
||||
- [Azure](../providers/azure.md): Azure Linux/native Windows, shared infra, capacity, and cleanup.
|
||||
- [Hetzner](../providers/hetzner.md): Linux-only managed provider behavior, classes, and cleanup.
|
||||
- [Static SSH](../providers/ssh.md): existing Linux, macOS, and Windows SSH hosts.
|
||||
- [Blacksmith Testbox](../providers/blacksmith-testbox.md): delegated Testbox backend behavior.
|
||||
|
||||
@ -7,7 +7,7 @@ Read when:
|
||||
- changing SSH, VNC, or coordinator bootstrap behavior.
|
||||
|
||||
Tailscale is an optional Crabbox reachability layer. It is not a provider.
|
||||
Providers still own machines: Hetzner, AWS, Azure, static SSH hosts, and Blacksmith
|
||||
Providers still own machines: Hetzner, AWS, static SSH hosts, and Blacksmith
|
||||
Testbox. Tailscale only changes which host Crabbox dials for SSH-backed work.
|
||||
|
||||
V1 support:
|
||||
|
||||
@ -36,11 +36,17 @@ Cloud machines are vanilla Ubuntu runners that hold no broker secrets. They are
|
||||
|
||||
The CLI talks to the broker over HTTPS, then talks **directly** to the leased runner over SSH and rsync. The runner never calls the broker; that path stays one-way.
|
||||
|
||||
For long-lived coordinator interactions, newer CLIs also open one authenticated
|
||||
WebSocket to the Fleet Durable Object at `/v1/control`. That socket carries
|
||||
run-event attach streams and lease heartbeats so high-latency links do less
|
||||
request polling. HTTPS endpoints remain canonical storage and compatibility
|
||||
fallbacks, so older CLIs and older brokers still work.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Layer | Owns |
|
||||
|:------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **CLI** | config + flags; per-lease SSH key; SSH readiness; Git seeding + rsync; sync fingerprints + sanity checks; remote command + streaming; heartbeats; release |
|
||||
| **CLI** | config + flags; per-lease SSH key; SSH readiness; Git seeding + rsync; sync fingerprints + sanity checks; remote command + streaming; control WebSocket when available; HTTP fallback; release |
|
||||
| **Broker** | request auth + identity; serialized lease state; provider credentials; machine create/delete; lease expiry; pool/status/inspect; usage; spend caps |
|
||||
| **Provider** | raw compute: Hetzner Cloud servers or AWS EC2 instances |
|
||||
| **Runner** | nothing durable for brokered boxes: Linux prepared by cloud-init with SSH, Git, rsync, curl, jq, `/work/crabbox`; AWS Windows/WSL2/macOS targets have provider-specific bootstrap; static targets are existing SSH hosts; project runtimes come from repo-owned setup |
|
||||
|
||||
@ -352,26 +352,8 @@ CRABBOX_TAILSCALE_CLIENT_ID optional; required for brokered --tailscale
|
||||
CRABBOX_TAILSCALE_CLIENT_SECRET optional; required for brokered --tailscale
|
||||
CRABBOX_TAILSCALE_TAILNET optional
|
||||
CRABBOX_TAILSCALE_TAGS optional
|
||||
CRABBOX_ARTIFACTS_BACKEND optional; currently r2
|
||||
CRABBOX_ARTIFACTS_BUCKET optional; currently openclaw-crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_PREFIX optional; currently crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_BASE_URL optional; currently https://artifacts.openclaw.ai
|
||||
CRABBOX_ARTIFACTS_REGION optional; currently auto
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL optional; currently the R2 S3-compatible endpoint
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID optional; Worker secret when artifacts backend is enabled
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY optional; Worker secret when artifacts backend is enabled
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN optional; Worker secret for temporary credentials
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS optional
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS optional
|
||||
```
|
||||
|
||||
Artifact credentials on the coordinator are storage-only S3-compatible keys.
|
||||
They exist so the Worker can sign one upload URL per artifact and return the
|
||||
final asset URL. They are not Cloudflare deploy tokens, not Crabbox bearer/admin
|
||||
tokens, and not VM provider credentials. Keep direct local S3/R2 credentials as
|
||||
operator fallback only; normal artifact publishing should go through the
|
||||
coordinator.
|
||||
|
||||
## Verified OpenClaw Run
|
||||
|
||||
Historical warm-run command from an OpenClaw checkout through the Cloudflare coordinator:
|
||||
|
||||
@ -115,41 +115,8 @@ CRABBOX_TAILSCALE_CLIENT_SECRET required for brokered --tailscale
|
||||
CRABBOX_TAILSCALE_TAILNET optional
|
||||
CRABBOX_TAILSCALE_TAGS optional
|
||||
CRABBOX_TAILSCALE_ENABLED optional; set 0 to disable brokered Tailscale
|
||||
CRABBOX_ARTIFACTS_BACKEND optional; enables brokered artifact publishing
|
||||
CRABBOX_ARTIFACTS_BUCKET required when artifact backend is enabled
|
||||
CRABBOX_ARTIFACTS_PREFIX optional
|
||||
CRABBOX_ARTIFACTS_BASE_URL optional; public final artifact URL prefix
|
||||
CRABBOX_ARTIFACTS_REGION optional
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL optional; required for R2/custom S3 endpoints
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID required when artifact backend is enabled
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY required when artifact backend is enabled
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN optional
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS optional
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS optional
|
||||
```
|
||||
|
||||
Artifact backend vars are ordinary Worker vars except
|
||||
`CRABBOX_ARTIFACTS_ACCESS_KEY_ID`, `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`, and
|
||||
optional `CRABBOX_ARTIFACTS_SESSION_TOKEN`, which must be Worker secrets. These
|
||||
object-store keys let the coordinator sign short-lived artifact upload/read
|
||||
URLs; they should be scoped to the artifact bucket or prefix and should not have
|
||||
Cloudflare account, Worker deployment, lease-provider, or VM permissions.
|
||||
|
||||
Our current coordinator artifact config is R2-compatible:
|
||||
|
||||
```text
|
||||
CRABBOX_ARTIFACTS_BACKEND=r2
|
||||
CRABBOX_ARTIFACTS_BUCKET=openclaw-crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_PREFIX=crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_BASE_URL=https://artifacts.openclaw.ai
|
||||
CRABBOX_ARTIFACTS_REGION=auto
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL=<account>.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
The corresponding R2 access key id and secret access key are deployed as Worker
|
||||
secrets, not local CLI defaults. Normal users should run
|
||||
`crabbox artifacts publish` without direct S3/R2 credentials.
|
||||
|
||||
Cost-control secrets and settings:
|
||||
|
||||
```text
|
||||
|
||||
@ -9,6 +9,19 @@ Read when:
|
||||
|
||||
Crabbox performance comes from avoiding repeated setup, keeping the sync small, choosing available capacity, and reusing project-defined hydration when it matters.
|
||||
|
||||
## High-Latency Links
|
||||
|
||||
Crabbox should not require a special slow-network mode. The CLI keeps SSH as
|
||||
the universal command transport, but uses SSH ControlMaster with a longer
|
||||
persist window so repeated probes, sync helpers, and commands avoid paying a
|
||||
new handshake every time. Streaming commands retry coordinator-provided
|
||||
fallback ports just like readiness and helper probes.
|
||||
|
||||
When the broker supports it, `crabbox attach` and lease heartbeats use one
|
||||
authenticated coordinator WebSocket instead of repeated HTTP polls. If the
|
||||
socket cannot connect or drops, the CLI resumes through the existing HTTPS API
|
||||
from the last acknowledged run-event sequence.
|
||||
|
||||
## Warm Leases
|
||||
|
||||
Use `warmup` for repeated agent loops:
|
||||
|
||||
@ -12,7 +12,6 @@ static SSH provider for existing machines.
|
||||
| Provider | Backend kind | Targets | Best for |
|
||||
| --- | --- | --- | --- |
|
||||
| [AWS](aws.md) | SSH lease | Linux, Windows, macOS | broad managed capacity, Windows, EC2 Mac |
|
||||
| [Azure](azure.md) | SSH lease | Linux, Windows | Azure-backed Linux and native Windows capacity |
|
||||
| [Hetzner](hetzner.md) | SSH lease | Linux | fast Linux capacity at low cost |
|
||||
| [Static SSH](ssh.md) | SSH lease | Linux, macOS, Windows | reusing an existing host |
|
||||
| [Blacksmith Testbox](blacksmith-testbox.md) | delegated run | Linux | existing Blacksmith Testbox workflows |
|
||||
@ -39,8 +38,7 @@ crabbox run --provider blacksmith-testbox --id tbx_123 -- pnpm test
|
||||
|
||||
## Brokered Versus Direct
|
||||
|
||||
AWS, Azure, and Hetzner can run through the Crabbox coordinator or directly
|
||||
from the CLI.
|
||||
AWS and Hetzner can run through the Crabbox coordinator or directly from the CLI.
|
||||
Coordinator mode is the normal shared-team path: the Worker owns cloud
|
||||
credentials, cost state, cleanup alarms, and lease accounting.
|
||||
|
||||
@ -58,7 +56,6 @@ Delegated providers do not use the Crabbox coordinator:
|
||||
| Provider | `run` | `warmup` | `ssh` | VNC/code | Crabbox sync | Provider sync |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| AWS | yes | yes | yes | yes | yes | no |
|
||||
| Azure | yes | yes | yes | Linux VNC/code | yes | no |
|
||||
| Hetzner | yes | yes | yes | Linux VNC/code | yes | no |
|
||||
| Static SSH | yes | resolves host | yes | host-dependent | yes | no |
|
||||
| Blacksmith Testbox | yes | yes | no | no | no | yes |
|
||||
|
||||
@ -1,215 +0,0 @@
|
||||
# Azure Provider
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing `provider: azure`;
|
||||
- debugging Azure VM capacity, quotas, images, or SSH readiness;
|
||||
- changing `internal/providers/azure` or the direct Azure provisioning code.
|
||||
|
||||
Azure is a managed provider for Linux and native Windows SSH leases. Azure
|
||||
provisions the VM, public IP, NIC, and OS disk, then Crabbox owns SSH
|
||||
readiness, sync, command execution, results, and cleanup.
|
||||
|
||||
## When To Use
|
||||
|
||||
Use Azure when the team's cloud capacity lives in an Azure subscription, or
|
||||
when Microsoft tooling, Entra ID, or Azure-specific networking constraints
|
||||
make AWS or Hetzner inappropriate. Use Hetzner for cheaper Linux-only
|
||||
capacity and AWS for Windows desktop, Windows WSL2, or macOS targets.
|
||||
|
||||
Azure supports direct mode and brokered Linux/native Windows leases. Direct
|
||||
mode uses local Azure credentials. Brokered mode uses the operator-owned
|
||||
Azure service principal configured on the Worker.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider azure --class beast
|
||||
crabbox run --provider azure --class standard -- pnpm test
|
||||
crabbox warmup --provider azure --target windows --class standard
|
||||
crabbox warmup --provider azure --desktop --browser
|
||||
crabbox ssh --provider azure --id blue-lobster
|
||||
crabbox stop --provider azure blue-lobster
|
||||
crabbox cleanup --provider azure
|
||||
```
|
||||
|
||||
`--type` is exact (e.g. `--type Standard_D32ads_v6`). Use `--class` when SKU
|
||||
fallback is desired.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
provider: azure
|
||||
target: linux
|
||||
class: beast
|
||||
azure:
|
||||
subscriptionId: 00000000-0000-0000-0000-000000000000
|
||||
tenantId: 00000000-0000-0000-0000-000000000000
|
||||
clientId: 00000000-0000-0000-0000-000000000000
|
||||
location: eastus
|
||||
resourceGroup: crabbox-leases
|
||||
image: Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest
|
||||
vnet: crabbox-vnet
|
||||
subnet: crabbox-subnet
|
||||
nsg: crabbox-nsg
|
||||
sshCIDRs: []
|
||||
```
|
||||
|
||||
`subscriptionId`, `tenantId`, and `clientId` may be set in config or sourced
|
||||
from environment variables. The client secret is never read from config; it
|
||||
must come from the environment.
|
||||
|
||||
Important direct-mode environment:
|
||||
|
||||
```text
|
||||
AZURE_SUBSCRIPTION_ID
|
||||
AZURE_TENANT_ID
|
||||
AZURE_CLIENT_ID
|
||||
AZURE_CLIENT_SECRET
|
||||
CRABBOX_AZURE_SUBSCRIPTION_ID
|
||||
CRABBOX_AZURE_TENANT_ID
|
||||
CRABBOX_AZURE_CLIENT_ID
|
||||
CRABBOX_AZURE_LOCATION
|
||||
CRABBOX_AZURE_RESOURCE_GROUP
|
||||
CRABBOX_AZURE_IMAGE
|
||||
CRABBOX_AZURE_VNET
|
||||
CRABBOX_AZURE_SUBNET
|
||||
CRABBOX_AZURE_NSG
|
||||
CRABBOX_AZURE_SSH_CIDRS
|
||||
```
|
||||
|
||||
`AZURE_*` are the standard service principal env vars consumed by
|
||||
`DefaultAzureCredential`. Crabbox does not read or print the client secret.
|
||||
|
||||
Brokered mode uses the same Azure service-principal secrets on the Worker:
|
||||
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and
|
||||
`AZURE_SUBSCRIPTION_ID`. Operators own the resource group, vnet, subnet,
|
||||
NSG, and SSH CIDR defaults through `CRABBOX_AZURE_*` env vars. A lease
|
||||
request may override only `azureLocation` and `azureImage`.
|
||||
|
||||
## Auth
|
||||
|
||||
If `azure.tenantId` and `azure.clientId` (or `CRABBOX_AZURE_TENANT_ID` /
|
||||
`CRABBOX_AZURE_CLIENT_ID`) are configured and `AZURE_CLIENT_SECRET` is set
|
||||
in the environment, Crabbox builds a `ClientSecretCredential` from those
|
||||
explicit values. Otherwise it falls back to
|
||||
[`azidentity.NewDefaultAzureCredential`](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#DefaultAzureCredential),
|
||||
which scans environment, workload identity, managed identity, and CLI
|
||||
credentials in order. The simplest setup is a service principal with the
|
||||
[Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor)
|
||||
role scoped to the resource group, configured via:
|
||||
|
||||
```sh
|
||||
export AZURE_TENANT_ID=...
|
||||
export AZURE_CLIENT_ID=...
|
||||
export AZURE_CLIENT_SECRET=...
|
||||
export AZURE_SUBSCRIPTION_ID=...
|
||||
```
|
||||
|
||||
See [Authenticate Go apps to Azure services with service principals](https://learn.microsoft.com/azure/developer/go/sdk/authentication/local-development-service-principal).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Resolve credentials per the rules above.
|
||||
2. Ensure the shared resource group, virtual network, subnet, and network
|
||||
security group exist. Crabbox first issues `Get` calls against each
|
||||
resource. If a resource exists without the `managed_by=crabbox` tag,
|
||||
Crabbox refuses to mutate it and returns an adopt-or-rename error. If a
|
||||
resource exists with the tag, it is left alone (Crabbox does not
|
||||
overwrite tags, address spaces, subnets, or rules on subsequent
|
||||
acquires). If a resource is missing, it is created with Crabbox tags
|
||||
and the configured layout. Inbound SSH rules are derived from
|
||||
`azure.sshCIDRs`, the configured SSH port, and any fallback ports.
|
||||
3. Mint a per-lease SSH key.
|
||||
4. Pick the configured class SKU candidates and try each in order.
|
||||
5. For each lease: create a public IP, NIC, and VM with cloud-init in
|
||||
`osProfile.customData` and the SSH key in
|
||||
`osProfile.linuxConfiguration.ssh.publicKeys` for Linux. Native Windows
|
||||
uses a Windows Server small-disk Gen2 image, Windows `osProfile` fields
|
||||
(`adminPassword`, `computerName`, and `windowsConfiguration`), and a
|
||||
Custom Script Extension that runs the Crabbox bootstrap saved in
|
||||
`C:\AzureData\CustomData.bin`.
|
||||
6. Query Azure Resource SKUs for the VM size. If Azure reports ephemeral OS
|
||||
disk support, use a local ephemeral OS disk. Otherwise use a managed
|
||||
`StandardSSD_LRS` OS disk.
|
||||
7. Tag the VM, NIC, and public IP with Crabbox lease metadata.
|
||||
8. Wait for the public IP to allocate, then for SSH and `crabbox-ready`.
|
||||
9. Let core sync and run over SSH.
|
||||
10. On release/cleanup, cascade-delete VM → NIC → public IP → OS disk. The
|
||||
shared infra remains.
|
||||
|
||||
## Classes
|
||||
|
||||
Default Linux SKUs:
|
||||
|
||||
```text
|
||||
standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, Standard_D32ads_v5, Standard_D32ds_v5, then D/F 16-vCPU fallbacks
|
||||
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, Standard_D64ads_v5, Standard_D64ds_v5, then D/F 48-vCPU and 32-vCPU fallbacks
|
||||
large Standard_D96ads_v6, Standard_D96ds_v6, Standard_D96ads_v5, Standard_D96ds_v5, then D/F 64-vCPU and 48-vCPU fallbacks
|
||||
beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96-vCPU and 64-vCPU fallbacks
|
||||
```
|
||||
|
||||
Default native Windows SKUs:
|
||||
|
||||
```text
|
||||
standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, then Standard_D2as_v6
|
||||
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, then Standard_D4as_v6
|
||||
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, then Standard_D8as_v6
|
||||
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, then Standard_D8ads_v6
|
||||
```
|
||||
|
||||
Class-based provisioning falls back across the candidate list when Azure
|
||||
rejects a SKU for capacity or quota
|
||||
(`SkuNotAvailable`, `QuotaExceeded`, `AllocationFailed`,
|
||||
`OverconstrainedAllocationRequest`). Spot leases fall back to on-demand when
|
||||
`capacity.fallback` starts with `on-demand`. Explicit `--type` is exact.
|
||||
The default Linux candidates mirror the AWS Linux class table's vCPU scale.
|
||||
The default Windows candidates mirror the AWS native Windows class table's
|
||||
vCPU scale. Azure native Windows support covers SSH, sync, and run; Windows
|
||||
WSL2 and macOS remain AWS or static-SSH targets.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- SSH: yes.
|
||||
- Crabbox sync: yes.
|
||||
- Native Windows: yes for SSH, sync, and run.
|
||||
- Desktop / browser / code: Linux only on Azure.
|
||||
- Tailscale: Linux managed leases.
|
||||
- Actions hydration: yes, Linux SSH leases.
|
||||
- Coordinator: yes, brokered Linux/native Windows leases.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Azure VM names are constrained to 1-64 characters and cannot contain
|
||||
underscores. The `leaseProviderName` helper substitutes underscores
|
||||
for dashes; if you customize naming, keep that constraint in mind.
|
||||
- Windows computer names are limited to 15 characters. Crabbox keeps the VM
|
||||
resource name stable and derives a shorter Windows `computerName`.
|
||||
- The first acquire in an empty subscription pays the cost of creating the
|
||||
shared resource group, vnet, and NSG. Subsequent acquires only create
|
||||
per-lease resources.
|
||||
- If you already have a resource group / vnet / NSG with the configured
|
||||
names, Crabbox will refuse to mutate them unless they carry
|
||||
`managed_by=crabbox` as a tag. Either tag them to adopt, choose
|
||||
different names in `azure.*` config, or let Crabbox create dedicated
|
||||
resources.
|
||||
- `crabbox stop --provider azure <name>` will only act on VMs that carry
|
||||
`crabbox=true` (and either no `provider` tag or `provider=azure`). A
|
||||
manually-named VM in the resource group will not be deleted by Crabbox.
|
||||
- The default SSH NSG rule allows `0.0.0.0/0` when `azure.sshCIDRs` is
|
||||
empty. Set explicit CIDRs for any production-adjacent setup.
|
||||
- Azure costs are not hardcoded in Crabbox. Set `CRABBOX_COST_RATES_JSON`
|
||||
when you need exact Azure cost guardrails.
|
||||
- Azure native Windows uses Custom Script Extension because Windows custom
|
||||
data is saved to disk but not executed by Azure provisioning. Do not add
|
||||
rebooting bootstrap work to that extension path.
|
||||
- Azure does not provide managed Windows WSL2 or macOS through this provider.
|
||||
Use AWS or `provider: ssh` for those targets.
|
||||
- Direct-mode cleanup is best effort. Use `crabbox cleanup --provider azure`
|
||||
to sweep expired direct leases.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Feature: Azure](../features/azure.md)
|
||||
- [Linux VNC](../features/vnc-linux.md)
|
||||
- [Provider backends](../provider-backends.md)
|
||||
@ -71,16 +71,6 @@ Rules:
|
||||
- `CRABBOX_TAILSCALE_CLIENT_ID` and `CRABBOX_TAILSCALE_CLIENT_SECRET` are
|
||||
Worker secrets for minting one-off Tailscale auth keys when brokered
|
||||
`--tailscale` leases are requested.
|
||||
- `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`, `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`,
|
||||
and optional `CRABBOX_ARTIFACTS_SESSION_TOKEN` are Worker secrets for
|
||||
brokered artifact publishing. They should be scoped to the artifact
|
||||
bucket/prefix and used only to sign short-lived upload/read URLs.
|
||||
- `CRABBOX_ARTIFACTS_BACKEND`, `CRABBOX_ARTIFACTS_BUCKET`,
|
||||
`CRABBOX_ARTIFACTS_PREFIX`, `CRABBOX_ARTIFACTS_BASE_URL`,
|
||||
`CRABBOX_ARTIFACTS_REGION`, `CRABBOX_ARTIFACTS_ENDPOINT_URL`,
|
||||
`CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`, and
|
||||
`CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS` are Worker config values, not secret
|
||||
material.
|
||||
- `CRABBOX_GITHUB_ALLOWED_ORG(S)` and `CRABBOX_GITHUB_ALLOWED_TEAMS` are Worker config values for browser-login authorization.
|
||||
- `CRABBOX_TAILSCALE_TAGS` is the coordinator allowlist/default for requested
|
||||
Tailscale ACL tags. Do not allow arbitrary user-supplied tags.
|
||||
|
||||
@ -35,7 +35,6 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
|
||||
- Direct Hetzner provider: `internal/providers/hetzner`, with API client helpers in `internal/cli/hcloud.go`
|
||||
- Direct AWS provider: `internal/providers/aws`, with API client helpers in `internal/cli/aws.go`
|
||||
- Direct Azure provider: `internal/providers/azure`, with API client helpers in `internal/cli/azure.go`
|
||||
- Static SSH macOS/Windows provider: `internal/providers/ssh`, with target mapping helpers in `internal/cli/static.go`
|
||||
- Blacksmith Testbox backend and argument/parsing helpers: `internal/providers/blacksmith`
|
||||
- Daytona provider backend and SDK/toolbox wrapper: `internal/providers/daytona`
|
||||
@ -44,21 +43,19 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
`internal/cli/provider_backend.go`
|
||||
- Built-in provider registration packages:
|
||||
`internal/providers/hetzner`, `internal/providers/aws`,
|
||||
`internal/providers/azure`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
`internal/providers/all`
|
||||
- Built-in provider backend implementations:
|
||||
`internal/providers/aws`, `internal/providers/azure`,
|
||||
`internal/providers/hetzner`,
|
||||
`internal/providers/aws`, `internal/providers/hetzner`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
plus shared helpers in `internal/providers/shared`
|
||||
- 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/azure.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
|
||||
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/azure.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.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 reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/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`
|
||||
|
||||
17
go.mod
17
go.mod
@ -17,13 +17,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
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
|
||||
@ -45,12 +38,9 @@ require (
|
||||
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/golang-jwt/jwt/v5 v5.3.0 // 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
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // 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
|
||||
@ -61,10 +51,9 @@ require (
|
||||
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/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.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
|
||||
|
||||
29
go.sum
29
go.sum
@ -1,17 +1,3 @@
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 h1:z7Mqz6l0EFH549GvHEqfjKvi+cRScxLWbaoeLm9wxVQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0/go.mod h1:v6gbfH+7DG7xH2kUNs+ZJ9tF6O3iNnR85wMtmr+F54o=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 h1:HYGD75g0bQ3VO/Omedm54v4LrD3B1cGImuRF3AJ5wLo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0/go.mod h1:ulHyBFJOI0ONiRL4vcJTmS7rx18jQQlEPmAgo80cRdM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
|
||||
@ -74,8 +60,6 @@ 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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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=
|
||||
@ -95,10 +79,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
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=
|
||||
@ -130,21 +110,12 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
|
||||
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/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.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=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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=
|
||||
|
||||
@ -93,8 +93,6 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
|
||||
return a.egress(ctx, helpArgs), true
|
||||
case "screenshot":
|
||||
return a.screenshot(ctx, helpArgs), true
|
||||
case "artifacts":
|
||||
return nil, false
|
||||
case "inspect":
|
||||
return a.inspect(ctx, helpArgs), true
|
||||
case "stop", "release":
|
||||
@ -141,7 +139,6 @@ Commands:
|
||||
run Sync the repo, run a remote command, stream output
|
||||
desktop Launch apps into a visible desktop session
|
||||
media Create preview artifacts from recorded desktop videos
|
||||
artifacts Collect, transform, and publish QA artifacts
|
||||
sync-plan Show local sync manifest size hotspots
|
||||
history List recorded remote runs
|
||||
logs Print recorded run logs
|
||||
@ -177,8 +174,6 @@ Common Flows:
|
||||
crabbox vnc --id blue-lobster --open
|
||||
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
|
||||
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
|
||||
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
|
||||
crabbox artifacts publish --pr 123 --dir artifacts/blue-lobster --storage s3 --bucket qa-artifacts
|
||||
crabbox webvnc --id blue-lobster --open
|
||||
crabbox code --id blue-lobster --open
|
||||
crabbox egress start --id blue-lobster --profile discord --daemon
|
||||
@ -206,11 +201,11 @@ Global:
|
||||
--version Print version
|
||||
|
||||
Config:
|
||||
crabbox login [--url <url>] [--provider aws|azure|hetzner] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider aws|azure|hetzner]
|
||||
crabbox login [--url <url>] [--provider aws|hetzner] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider aws|hetzner]
|
||||
crabbox config path
|
||||
crabbox config show [--json]
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider aws|azure|hetzner]
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider aws|hetzner]
|
||||
|
||||
Environment:
|
||||
CRABBOX_COORDINATOR Broker URL
|
||||
@ -220,7 +215,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, azure, ssh, blacksmith-testbox, daytona, or islo
|
||||
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
|
||||
|
||||
@ -1,607 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type artifactFile struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type artifactBundleMetadata struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Version string `json:"crabboxVersion"`
|
||||
LeaseID string `json:"leaseId,omitempty"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
TargetOS string `json:"targetOS,omitempty"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
}
|
||||
|
||||
type artifactCollectResult struct {
|
||||
Directory string `json:"directory"`
|
||||
Files []artifactFile `json:"files"`
|
||||
Metadata artifactBundleMetadata `json:"metadata"`
|
||||
Warnings []artifactWarning `json:"warnings,omitempty"`
|
||||
Error *artifactCollectError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type artifactCollectError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type artifactWarning struct {
|
||||
Problem string `json:"problem"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Rescue []string `json:"rescue,omitempty"`
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
}
|
||||
|
||||
type artifactPublishOptions struct {
|
||||
Directory string
|
||||
Storage string
|
||||
Bucket string
|
||||
Prefix string
|
||||
BaseURL string
|
||||
PR int
|
||||
Repo string
|
||||
Template string
|
||||
Summary string
|
||||
SummaryFile string
|
||||
Region string
|
||||
Profile string
|
||||
EndpointURL string
|
||||
ACL string
|
||||
Presign bool
|
||||
Expires time.Duration
|
||||
DryRun bool
|
||||
NoComment bool
|
||||
}
|
||||
|
||||
func (a App) artifactsCollect(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("artifacts collect", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
output := fs.String("output", "", "artifact bundle directory")
|
||||
runID := fs.String("run", "", "optional run id whose retained logs should be copied")
|
||||
all := fs.Bool("all", false, "collect screenshot, video, GIF, doctor/status, logs, and metadata")
|
||||
screenshot := fs.Bool("screenshot", true, "capture desktop screenshot")
|
||||
video := fs.Bool("video", false, "record desktop video")
|
||||
gif := fs.Bool("gif", false, "create trimmed GIF from recorded video")
|
||||
doctor := fs.Bool("doctor", true, "write desktop doctor output")
|
||||
webvncStatus := fs.Bool("webvnc-status", true, "write WebVNC portal status when coordinator is configured")
|
||||
metadata := fs.Bool("metadata", true, "write metadata.json")
|
||||
duration := fs.Duration("duration", 10*time.Second, "video capture duration")
|
||||
fps := fs.Float64("fps", 15, "video frames per second")
|
||||
gifWidth := fs.Int("gif-width", 640, "trimmed GIF width")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
jsonOut := fs.Bool("json", false, "print machine-readable result")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
if *all {
|
||||
*video = true
|
||||
*gif = true
|
||||
}
|
||||
if *gif && !*video {
|
||||
return exit(2, "artifacts collect --gif requires --video or --all")
|
||||
}
|
||||
if *duration <= 0 {
|
||||
return exit(2, "artifacts collect --duration must be positive")
|
||||
}
|
||||
if *fps <= 0 {
|
||||
return exit(2, "artifacts collect --fps must be positive")
|
||||
}
|
||||
if *gifWidth <= 0 {
|
||||
return exit(2, "artifacts collect --gif-width must be positive")
|
||||
}
|
||||
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) {
|
||||
return exit(2, "artifacts collect is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
|
||||
}
|
||||
if err := requireLeaseID(*id, "crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>]", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
|
||||
return exit(2, "desktop artifacts are not collected from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
|
||||
}
|
||||
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.claimAndTouchLeaseTarget(ctx, cfg, server, leaseID, *reclaim); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := strings.TrimSpace(*output)
|
||||
if dir == "" {
|
||||
dir = defaultArtifactBundleDir(leaseID, serverSlug(server))
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return exit(2, "create artifact directory: %v", err)
|
||||
}
|
||||
|
||||
result := artifactCollectResult{
|
||||
Directory: dir,
|
||||
Metadata: artifactBundleMetadata{
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Version: version,
|
||||
LeaseID: leaseID,
|
||||
Slug: serverSlug(server),
|
||||
Provider: cfg.Provider,
|
||||
Network: string(cfg.Network),
|
||||
TargetOS: target.TargetOS,
|
||||
RunID: strings.TrimSpace(*runID),
|
||||
},
|
||||
}
|
||||
addFile := func(kind, path string) {
|
||||
result.Files = append(result.Files, artifactFile{Kind: kind, Name: filepath.Base(path), Path: path})
|
||||
}
|
||||
fail := func(err error, warning artifactWarning) error {
|
||||
return a.finishArtifactCollectFailure(&result, *jsonOut, err, warning)
|
||||
}
|
||||
|
||||
if *metadata {
|
||||
path := filepath.Join(dir, "metadata.json")
|
||||
if err := writeJSONFile(path, result.Metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
addFile("metadata", path)
|
||||
}
|
||||
if *screenshot {
|
||||
if err := waitForLoopbackVNC(ctx, &target); err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueVNCTargetUnreachable,
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
path := filepath.Join(dir, "screenshot.png")
|
||||
if err := captureDesktopScreenshot(ctx, target, path); err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: classifyDesktopFailure(err.Error()),
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
addFile("screenshot", path)
|
||||
}
|
||||
if *doctor {
|
||||
path := filepath.Join(dir, "doctor.txt")
|
||||
out, err := runSSHOutput(ctx, target, desktopDoctorRemoteCommand(target))
|
||||
if err != nil {
|
||||
doctorErr := exit(5, "desktop doctor failed: %v", err)
|
||||
return fail(doctorErr, artifactWarning{
|
||||
Problem: classifyDesktopFailure(out),
|
||||
Detail: trimFailureDetail(out),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(out+"\n"), 0o644); err != nil {
|
||||
return exit(2, "write doctor artifact: %v", err)
|
||||
}
|
||||
addFile("doctor", path)
|
||||
}
|
||||
if *webvncStatus {
|
||||
if path, ok, err := a.writeArtifactWebVNCStatus(ctx, cfg, target, leaseID, dir, &result.Warnings); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
addFile("webvnc-status", path)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(*runID) != "" {
|
||||
logPath, runPath, err := writeArtifactRunLogs(ctx, strings.TrimSpace(*runID), dir)
|
||||
if err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueArtifactCaptureFailed,
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{"crabbox logs " + strings.TrimSpace(*runID)},
|
||||
})
|
||||
}
|
||||
addFile("logs", logPath)
|
||||
addFile("run", runPath)
|
||||
}
|
||||
if *video {
|
||||
if target.TargetOS != targetLinux {
|
||||
err := exit(2, "artifacts collect --video currently requires target=linux with ffmpeg/x11grab")
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueArtifactCaptureFailed,
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
path := filepath.Join(dir, "screen.mp4")
|
||||
if err := captureDesktopVideo(ctx, target, path, *duration, *fps); err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: classifyDesktopFailure(err.Error()),
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
addFile("video", path)
|
||||
if *gif {
|
||||
gifPath := filepath.Join(dir, "screen.trimmed.gif")
|
||||
trimmedPath := filepath.Join(dir, "screen.trimmed.mp4")
|
||||
preview, err := createMediaPreview(ctx, mediaPreviewOptions{
|
||||
Input: path,
|
||||
Output: gifPath,
|
||||
TrimmedVideoOutput: trimmedPath,
|
||||
Width: *gifWidth,
|
||||
FPS: 4,
|
||||
TrimStatic: true,
|
||||
TrimPadding: 750 * time.Millisecond,
|
||||
FreezeDuration: 500 * time.Millisecond,
|
||||
FreezeNoise: "-50dB",
|
||||
MinDuration: 1500 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueArtifactCaptureFailed,
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
addFile("gif", preview.Output)
|
||||
if preview.TrimmedVideoOutput != "" {
|
||||
addFile("trimmed-video", preview.TrimmedVideoOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortArtifactFiles(result.Files)
|
||||
if result.Files == nil {
|
||||
result.Files = []artifactFile{}
|
||||
}
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(a.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(result)
|
||||
}
|
||||
for _, warning := range result.Warnings {
|
||||
printArtifactWarning(a.Stdout, warning)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "artifacts: %s\n", dir)
|
||||
for _, file := range result.Files {
|
||||
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "publish: crabbox artifacts publish --dir %s --pr <n>\n", strings.Join(readableShellWords([]string{dir}), " "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) finishArtifactCollectFailure(result *artifactCollectResult, jsonOut bool, err error, warning artifactWarning) error {
|
||||
if result == nil {
|
||||
return err
|
||||
}
|
||||
sortArtifactFiles(result.Files)
|
||||
if result.Files == nil {
|
||||
result.Files = []artifactFile{}
|
||||
}
|
||||
if strings.TrimSpace(warning.Problem) != "" {
|
||||
result.Warnings = append(result.Warnings, normalizeArtifactWarning(warning))
|
||||
}
|
||||
result.Error = &artifactCollectError{
|
||||
Code: artifactErrorCode(result.Warnings),
|
||||
Message: strings.TrimSpace(err.Error()),
|
||||
}
|
||||
if jsonOut {
|
||||
enc := json.NewEncoder(a.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if encodeErr := enc.Encode(result); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, warning := range result.Warnings {
|
||||
printArtifactWarning(a.Stdout, warning)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a App) artifactsVideo(ctx context.Context, args []string) error {
|
||||
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "artifacts video", args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output, _ := stringFlagValue(args, "output")
|
||||
if strings.TrimSpace(output) == "" {
|
||||
output = "crabbox-" + normalizeLeaseSlug(leaseID) + "-screen.mp4"
|
||||
}
|
||||
duration := durationFlagValue(args, "duration", 10*time.Second)
|
||||
fps := floatFlagValue(args, "fps", 15)
|
||||
if duration <= 0 {
|
||||
return exit(2, "artifacts video --duration must be positive")
|
||||
}
|
||||
if fps <= 0 {
|
||||
return exit(2, "artifacts video --fps must be positive")
|
||||
}
|
||||
if target.TargetOS != targetLinux {
|
||||
return exit(2, "artifacts video currently requires target=linux with ffmpeg/x11grab")
|
||||
}
|
||||
if err := captureDesktopVideo(ctx, target, output, duration, fps); err != nil {
|
||||
printRescue(a.Stdout, classifyDesktopFailure(err.Error()), err.Error(), desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}))
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "video: %s\n", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) artifactsGif(ctx context.Context, args []string) error {
|
||||
return a.mediaPreview(ctx, args)
|
||||
}
|
||||
|
||||
func (a App) artifactsTemplate(ctx context.Context, args []string) error {
|
||||
_ = ctx
|
||||
initialKind := ""
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
initialKind = args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
fs := newFlagSet("artifacts template", a.Stderr)
|
||||
kind := fs.String("kind", initialKind, "template kind: openclaw or mantis")
|
||||
before := fs.String("before", "", "before screenshot/GIF URL or path")
|
||||
after := fs.String("after", "", "after screenshot/GIF URL or path")
|
||||
summary := fs.String("summary", "", "summary text")
|
||||
summaryFile := fs.String("summary-file", "", "summary markdown file")
|
||||
output := fs.String("output", "", "output markdown path; stdout when omitted")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := summaryText(*summary, *summaryFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := artifactTemplateMarkdown(*kind, text, *before, *after, nil)
|
||||
if strings.TrimSpace(*output) == "" {
|
||||
fmt.Fprint(a.Stdout, body)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(*output, []byte(body), 0o644); err != nil {
|
||||
return exit(2, "write template: %v", err)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "template: %s\n", *output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) artifactsPublish(ctx context.Context, args []string) error {
|
||||
opts, err := parseArtifactPublishOptions(args, a.Stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var coord *CoordinatorClient
|
||||
if opts.Storage == "auto" || opts.Storage == "broker" {
|
||||
cfg, cfgErr := loadConfig()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
var useCoordinator bool
|
||||
coord, useCoordinator, err = newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Storage == "auto" {
|
||||
if useCoordinator && coord != nil && coord.Token != "" {
|
||||
opts.Storage = "broker"
|
||||
} else {
|
||||
opts.Storage = "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureArtifactPublishPrefix(&opts)
|
||||
files, err := listArtifactBundleFiles(opts.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return exit(2, "artifact directory has no files: %s", opts.Directory)
|
||||
}
|
||||
summary, err := summaryText(opts.Summary, opts.SummaryFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var published []artifactFile
|
||||
if opts.Storage == "broker" {
|
||||
published, err = publishArtifactFilesBroker(ctx, coord, opts, files)
|
||||
} else {
|
||||
published, err = publishArtifactFiles(ctx, opts, files)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := artifactTemplateMarkdown(opts.Template, summary, "", "", published)
|
||||
bodyPath := filepath.Join(opts.Directory, "published-artifacts.md")
|
||||
if err := os.WriteFile(bodyPath, []byte(body), 0o644); err != nil {
|
||||
return exit(2, "write publish markdown: %v", err)
|
||||
}
|
||||
if opts.PR > 0 && !opts.NoComment {
|
||||
if opts.Storage == "local" && opts.BaseURL == "" {
|
||||
return exit(2, "artifacts publish --pr needs brokered publishing, --storage s3|r2|cloudflare, or --base-url for already-hosted local assets")
|
||||
}
|
||||
if opts.DryRun {
|
||||
fmt.Fprintf(a.Stdout, "dry-run comment: gh issue comment %d --body-file %s\n", opts.PR, bodyPath)
|
||||
} else if err := postGitHubPRComment(ctx, opts.PR, opts.Repo, bodyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, file := range published {
|
||||
if file.URL != "" {
|
||||
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.URL)
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "markdown: %s\n", bodyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultArtifactBundleDir(leaseID, slug string) string {
|
||||
name := strings.TrimSpace(slug)
|
||||
if name == "" {
|
||||
name = leaseID
|
||||
}
|
||||
if name == "" {
|
||||
name = time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
return filepath.Join("artifacts", normalizeLeaseSlug(name))
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return exit(2, "encode %s: %v", path, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return exit(2, "write %s: %v", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) writeArtifactWebVNCStatus(ctx context.Context, cfg Config, target SSHTarget, leaseID, dir string, warnings *[]artifactWarning) (string, bool, error) {
|
||||
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
|
||||
return "", false, nil
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil || !useCoordinator || coord == nil || coord.Token == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
status, err := coord.WebVNCStatus(ctx, leaseID)
|
||||
path := filepath.Join(dir, "webvnc-status.json")
|
||||
payload := map[string]any{"leaseId": leaseID, "target": target.TargetOS}
|
||||
if err != nil {
|
||||
payload["error"] = err.Error()
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
appendArtifactWarning(warnings, rescueVNCBridgeDisconnected, err.Error(), "", webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else {
|
||||
payload["status"] = status
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
if !status.BridgeConnected {
|
||||
appendArtifactWarning(warnings, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else if webVNCObserverSlotsExhausted(status) {
|
||||
appendArtifactWarning(warnings, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
}
|
||||
if err := writeJSONFile(path, payload); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func appendArtifactWarning(warnings *[]artifactWarning, problem, detail, fallback string, rescue ...string) {
|
||||
if warnings == nil {
|
||||
return
|
||||
}
|
||||
clean := normalizeArtifactWarning(artifactWarning{Problem: problem, Detail: detail, Fallback: fallback, Rescue: rescue})
|
||||
if clean.Problem != "" {
|
||||
*warnings = append(*warnings, clean)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeArtifactWarning(warning artifactWarning) artifactWarning {
|
||||
clean := artifactWarning{
|
||||
Problem: strings.TrimSpace(warning.Problem),
|
||||
Detail: strings.TrimSpace(warning.Detail),
|
||||
Fallback: strings.TrimSpace(warning.Fallback),
|
||||
}
|
||||
for _, command := range warning.Rescue {
|
||||
if strings.TrimSpace(command) != "" {
|
||||
clean.Rescue = append(clean.Rescue, strings.TrimSpace(command))
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func artifactErrorCode(warnings []artifactWarning) string {
|
||||
if len(warnings) == 0 || strings.TrimSpace(warnings[len(warnings)-1].Problem) == "" {
|
||||
return "artifact_collect_failed"
|
||||
}
|
||||
return normalizeLeaseSlug(warnings[len(warnings)-1].Problem)
|
||||
}
|
||||
|
||||
func printArtifactWarning(w io.Writer, warning artifactWarning) {
|
||||
printRescueWithFallback(w, warning.Problem, warning.Detail, warning.Fallback, warning.Rescue...)
|
||||
}
|
||||
|
||||
func writeArtifactRunLogs(ctx context.Context, runID, dir string) (string, string, error) {
|
||||
coord, err := configuredCoordinator()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
logText, err := coord.RunLogs(ctx, runID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
run, err := coord.Run(ctx, runID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
logPath := filepath.Join(dir, "logs.txt")
|
||||
runPath := filepath.Join(dir, "run.json")
|
||||
if err := os.WriteFile(logPath, []byte(logText), 0o644); err != nil {
|
||||
return "", "", exit(2, "write logs artifact: %v", err)
|
||||
}
|
||||
if err := writeJSONFile(runPath, run); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return logPath, runPath, nil
|
||||
}
|
||||
|
||||
func captureDesktopVideo(ctx context.Context, target SSHTarget, outputPath string, duration time.Duration, fps float64) error {
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil && filepath.Dir(outputPath) != "." {
|
||||
return exit(2, "create video directory: %v", err)
|
||||
}
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return exit(2, "create video %s: %v", outputPath, err)
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !ok {
|
||||
_ = os.Remove(outputPath)
|
||||
}
|
||||
}()
|
||||
if err := runSSHToWriter(ctx, target, desktopVideoRemoteCommand(duration, fps), file); err != nil {
|
||||
return exit(5, "capture video: %v", err)
|
||||
}
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func desktopVideoRemoteCommand(duration time.Duration, fps float64) string {
|
||||
seconds := strconv.FormatFloat(duration.Seconds(), 'f', 3, 64)
|
||||
frameRate := strconv.FormatFloat(fps, 'f', 3, 64)
|
||||
return fmt.Sprintf(`set -eu
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "missing ffmpeg; warm a new --desktop lease or install ffmpeg" >&2
|
||||
exit 127
|
||||
fi
|
||||
if command -v xdpyinfo >/dev/null 2>&1; then
|
||||
size="$(xdpyinfo | awk '/dimensions:/{print $2; exit}')"
|
||||
else
|
||||
size=""
|
||||
fi
|
||||
if [ -z "$size" ]; then size="1920x1080"; fi
|
||||
ffmpeg -hide_banner -loglevel error -y -f x11grab -video_size "$size" -framerate %s -i "$DISPLAY" -t %s -pix_fmt yuv420p -an -movflags frag_keyframe+empty_moov -f mp4 -
|
||||
`, frameRate, seconds)
|
||||
}
|
||||
@ -1,636 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseArtifactPublishOptions(args []string, stderr io.Writer) (artifactPublishOptions, error) {
|
||||
fs := newFlagSet("artifacts publish", stderr)
|
||||
dir := fs.String("dir", os.Getenv("CRABBOX_ARTIFACTS_DIR"), "artifact bundle directory")
|
||||
storage := fs.String("storage", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_STORAGE"), "auto"), "storage backend: auto, broker, local, s3, cloudflare, or r2")
|
||||
bucket := fs.String("bucket", os.Getenv("CRABBOX_ARTIFACTS_BUCKET"), "storage bucket")
|
||||
prefix := fs.String("prefix", os.Getenv("CRABBOX_ARTIFACTS_PREFIX"), "object key prefix")
|
||||
baseURL := fs.String("base-url", os.Getenv("CRABBOX_ARTIFACTS_BASE_URL"), "public base URL for inline-ready asset links")
|
||||
pr := fs.Int("pr", 0, "GitHub pull request number to comment on")
|
||||
repo := fs.String("repo", "", "GitHub repository slug for gh, e.g. openclaw/crabbox")
|
||||
template := fs.String("template", "openclaw", "comment template: openclaw or mantis")
|
||||
summary := fs.String("summary", "", "summary text")
|
||||
summaryFile := fs.String("summary-file", "", "summary markdown file")
|
||||
region := fs.String("region", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_AWS_REGION"), os.Getenv("AWS_REGION"), os.Getenv("AWS_DEFAULT_REGION")), "AWS region for S3 URLs/CLI")
|
||||
profile := fs.String("profile", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_AWS_PROFILE"), os.Getenv("AWS_PROFILE")), "AWS profile for S3 CLI")
|
||||
endpointURL := fs.String("endpoint-url", os.Getenv("CRABBOX_ARTIFACTS_ENDPOINT_URL"), "S3-compatible endpoint URL")
|
||||
acl := fs.String("acl", os.Getenv("CRABBOX_ARTIFACTS_S3_ACL"), "optional S3 ACL, e.g. public-read")
|
||||
presign := fs.Bool("presign", envBool("CRABBOX_ARTIFACTS_PRESIGN"), "use aws s3 presign URLs after upload")
|
||||
expires := fs.Duration("expires", envDuration("CRABBOX_ARTIFACTS_EXPIRES", 7*24*time.Hour), "presigned URL lifetime")
|
||||
dryRun := fs.Bool("dry-run", false, "print upload/comment commands without running them")
|
||||
noComment := fs.Bool("no-comment", false, "skip GitHub PR comment")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return artifactPublishOptions{}, err
|
||||
}
|
||||
explicit := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
explicit[f.Name] = true
|
||||
})
|
||||
opts := artifactPublishOptions{
|
||||
Directory: strings.TrimSpace(*dir),
|
||||
Storage: normalizeArtifactStorage(*storage),
|
||||
Bucket: strings.TrimSpace(*bucket),
|
||||
Prefix: strings.Trim(strings.TrimSpace(*prefix), "/"),
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(*baseURL), "/"),
|
||||
PR: *pr,
|
||||
Repo: strings.TrimSpace(*repo),
|
||||
Template: strings.TrimSpace(*template),
|
||||
Summary: *summary,
|
||||
SummaryFile: strings.TrimSpace(*summaryFile),
|
||||
Region: strings.TrimSpace(*region),
|
||||
Profile: strings.TrimSpace(*profile),
|
||||
EndpointURL: strings.TrimSpace(*endpointURL),
|
||||
ACL: strings.TrimSpace(*acl),
|
||||
Presign: *presign,
|
||||
Expires: *expires,
|
||||
DryRun: *dryRun,
|
||||
NoComment: *noComment,
|
||||
}
|
||||
if opts.Storage == "r2" {
|
||||
if !explicit["profile"] {
|
||||
opts.Profile = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_AWS_PROFILE"), opts.Profile)
|
||||
}
|
||||
if !explicit["endpoint-url"] {
|
||||
opts.EndpointURL = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_ENDPOINT_URL"), opts.EndpointURL)
|
||||
}
|
||||
if !explicit["region"] {
|
||||
opts.Region = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_AWS_REGION"), "auto")
|
||||
}
|
||||
}
|
||||
if opts.Directory == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish requires --dir")
|
||||
}
|
||||
if opts.PR < 0 {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --pr must be positive")
|
||||
}
|
||||
if opts.Expires <= 0 {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --expires must be positive")
|
||||
}
|
||||
switch opts.Storage {
|
||||
case "auto", "broker", "local":
|
||||
case "s3", "cloudflare", "r2":
|
||||
if opts.Bucket == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage %s requires --bucket", opts.Storage)
|
||||
}
|
||||
default:
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage must be auto, broker, local, s3, cloudflare, or r2")
|
||||
}
|
||||
if opts.Storage == "r2" && opts.EndpointURL == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage r2 requires --endpoint-url or CRABBOX_ARTIFACTS_R2_ENDPOINT_URL")
|
||||
}
|
||||
if (opts.Storage == "cloudflare" || opts.Storage == "r2") && opts.PR > 0 && !opts.NoComment && opts.BaseURL == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage %s --pr requires --base-url for inline-ready R2 asset links", opts.Storage)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func normalizeArtifactStorage(storage string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(storage)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "broker", "coordinator":
|
||||
return "broker"
|
||||
case "local":
|
||||
return "local"
|
||||
case "s3", "aws", "aws-s3":
|
||||
return "s3"
|
||||
case "r2", "cloudflare-r2":
|
||||
return "r2"
|
||||
case "cloudflare", "cf":
|
||||
return "cloudflare"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(storage))
|
||||
}
|
||||
}
|
||||
|
||||
func listArtifactBundleFiles(dir string) ([]artifactFile, error) {
|
||||
var files []artifactFile
|
||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := entry.Name()
|
||||
if name == "published-artifacts.md" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, artifactFile{Kind: artifactKindForPath(path), Name: filepath.ToSlash(rel), Path: path})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, exit(2, "read artifact directory: %v", err)
|
||||
}
|
||||
sortArtifactFiles(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func publishArtifactFiles(ctx context.Context, opts artifactPublishOptions, files []artifactFile) ([]artifactFile, error) {
|
||||
published := make([]artifactFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
out := file
|
||||
key := artifactObjectKey(opts.Prefix, file.Name)
|
||||
switch opts.Storage {
|
||||
case "local":
|
||||
if opts.BaseURL != "" {
|
||||
out.URL = joinURLPath(opts.BaseURL, file.Name)
|
||||
}
|
||||
case "s3", "r2":
|
||||
url, err := uploadArtifactS3(ctx, opts, file.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.URL = url
|
||||
case "cloudflare":
|
||||
url, err := uploadArtifactCloudflare(ctx, opts, file.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.URL = url
|
||||
}
|
||||
published = append(published, out)
|
||||
}
|
||||
return published, nil
|
||||
}
|
||||
|
||||
func publishArtifactFilesBroker(ctx context.Context, coord *CoordinatorClient, opts artifactPublishOptions, files []artifactFile) ([]artifactFile, error) {
|
||||
if coord == nil || coord.Token == "" {
|
||||
return nil, exit(2, "artifacts publish --storage broker requires a configured coordinator; run `crabbox login` or pass --storage local|s3|r2")
|
||||
}
|
||||
ensureArtifactPublishPrefix(&opts)
|
||||
input := CoordinatorArtifactUploadRequest{
|
||||
Prefix: opts.Prefix,
|
||||
Files: make([]CoordinatorArtifactUploadInput, 0, len(files)),
|
||||
}
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file.Path)
|
||||
if err != nil {
|
||||
return nil, exit(2, "stat artifact %s: %v", file.Name, err)
|
||||
}
|
||||
hash, err := fileSHA256(file.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input.Files = append(input.Files, CoordinatorArtifactUploadInput{
|
||||
Name: file.Name,
|
||||
Size: info.Size(),
|
||||
ContentType: artifactContentType(file.Path),
|
||||
SHA256: hash,
|
||||
})
|
||||
}
|
||||
grants, err := coord.CreateArtifactUploads(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string]CoordinatorArtifactUploadGrant{}
|
||||
for _, grant := range grants.Files {
|
||||
byName[grant.Name] = grant
|
||||
}
|
||||
published := make([]artifactFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
grant, ok := byName[file.Name]
|
||||
if !ok {
|
||||
return nil, exit(2, "artifact broker did not return an upload grant for %s", file.Name)
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if err := uploadArtifactGrant(ctx, file.Path, grant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out := file
|
||||
out.URL = grant.URL
|
||||
published = append(published, out)
|
||||
}
|
||||
return published, nil
|
||||
}
|
||||
|
||||
func ensureArtifactPublishPrefix(opts *artifactPublishOptions) {
|
||||
if opts == nil || opts.Prefix != "" || opts.Storage == "local" {
|
||||
return
|
||||
}
|
||||
opts.Prefix = defaultArtifactPublishPrefix(*opts, time.Now())
|
||||
}
|
||||
|
||||
func defaultArtifactPublishPrefix(opts artifactPublishOptions, now time.Time) string {
|
||||
scope := "publish"
|
||||
if opts.PR > 0 {
|
||||
scope = "pr-" + strconv.Itoa(opts.PR)
|
||||
}
|
||||
bundle := normalizeLeaseSlug(filepath.Base(filepath.Clean(opts.Directory)))
|
||||
if bundle == "" || bundle == "." {
|
||||
bundle = "bundle"
|
||||
}
|
||||
stamp := now.UTC().Format("20060102-150405") + "-" + fmt.Sprintf("%09d", now.UTC().Nanosecond())
|
||||
return strings.Join([]string{scope, bundle, stamp}, "/")
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", exit(2, "open artifact %s: %v", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", exit(2, "hash artifact %s: %v", path, err)
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func uploadArtifactGrant(ctx context.Context, path string, grant CoordinatorArtifactUploadGrant) error {
|
||||
if grant.Upload.URL == "" {
|
||||
return exit(2, "artifact broker returned an empty upload URL for %s", grant.Name)
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(grant.Upload.Method))
|
||||
if method == "" {
|
||||
method = http.MethodPut
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return exit(2, "open artifact %s: %v", grant.Name, err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return exit(2, "stat artifact %s: %v", grant.Name, err)
|
||||
}
|
||||
contentLength := info.Size()
|
||||
if expected, ok, err := grantContentLength(grant.Upload.Headers); err != nil {
|
||||
return exit(2, "artifact broker returned an invalid content-length for %s: %v", grant.Name, err)
|
||||
} else if ok {
|
||||
if expected != info.Size() {
|
||||
return exit(2, "artifact %s size changed after broker grant: got %d bytes, expected %d", grant.Name, info.Size(), expected)
|
||||
}
|
||||
contentLength = expected
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, grant.Upload.URL, file)
|
||||
if err != nil {
|
||||
return exit(2, "create artifact upload request for %s: %v", grant.Name, err)
|
||||
}
|
||||
req.ContentLength = contentLength
|
||||
for key, value := range grant.Upload.Headers {
|
||||
if strings.EqualFold(strings.TrimSpace(key), "content-length") {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return exit(2, "upload artifact %s: %v", grant.Name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return exit(2, "upload artifact %s: http %d: %s", grant.Name, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func grantContentLength(headers map[string]string) (int64, bool, error) {
|
||||
for key, value := range headers {
|
||||
if !strings.EqualFold(strings.TrimSpace(key), "content-length") {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("negative value")
|
||||
}
|
||||
return 0, true, err
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func uploadArtifactS3(ctx context.Context, opts artifactPublishOptions, path, key string) (string, error) {
|
||||
dest := "s3://" + opts.Bucket + "/" + key
|
||||
args := awsBaseArgs(opts)
|
||||
args = append(args, "s3", "cp", path, dest, "--content-type", artifactContentType(path))
|
||||
if opts.ACL != "" {
|
||||
args = append(args, "--acl", opts.ACL)
|
||||
}
|
||||
if opts.DryRun {
|
||||
return artifactS3URL(opts, key), nil
|
||||
}
|
||||
if _, err := exec.LookPath("aws"); err != nil {
|
||||
return "", exit(2, "aws CLI is required for artifacts publish --storage s3: %v", err)
|
||||
}
|
||||
if out, err := commandOutput(ctx, "aws", args...); err != nil {
|
||||
return "", exit(2, "aws s3 upload failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
if opts.Presign && opts.BaseURL == "" {
|
||||
presignArgs := awsBaseArgs(opts)
|
||||
presignArgs = append(presignArgs, "s3", "presign", dest, "--expires-in", fmt.Sprintf("%.0f", opts.Expires.Seconds()))
|
||||
out, err := commandOutput(ctx, "aws", presignArgs...)
|
||||
if err != nil {
|
||||
return "", exit(2, "aws s3 presign failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
return artifactS3URL(opts, key), nil
|
||||
}
|
||||
|
||||
func awsBaseArgs(opts artifactPublishOptions) []string {
|
||||
var args []string
|
||||
if opts.Profile != "" {
|
||||
args = append(args, "--profile", opts.Profile)
|
||||
}
|
||||
if opts.Region != "" {
|
||||
args = append(args, "--region", opts.Region)
|
||||
}
|
||||
if opts.EndpointURL != "" {
|
||||
args = append(args, "--endpoint-url", opts.EndpointURL)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func uploadArtifactCloudflare(ctx context.Context, opts artifactPublishOptions, path, key string) (string, error) {
|
||||
if opts.DryRun {
|
||||
return artifactCloudflareURL(opts, key), nil
|
||||
}
|
||||
if _, err := exec.LookPath("wrangler"); err != nil {
|
||||
return "", exit(2, "wrangler CLI is required for artifacts publish --storage cloudflare: %v", err)
|
||||
}
|
||||
out, err := commandOutputWithEnv(ctx, artifactCloudflareEnv(), "wrangler", "r2", "object", "put", opts.Bucket+"/"+key, "--file", path, "--content-type", artifactContentType(path), "--remote")
|
||||
if err != nil {
|
||||
return "", exit(2, "wrangler r2 upload failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
return artifactCloudflareURL(opts, key), nil
|
||||
}
|
||||
|
||||
func artifactCloudflareEnv() []string {
|
||||
env := os.Environ()
|
||||
if token := firstNonBlank(
|
||||
os.Getenv("CRABBOX_ARTIFACTS_CLOUDFLARE_API_TOKEN"),
|
||||
os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||
); token != "" {
|
||||
env = append(env, "CLOUDFLARE_API_TOKEN="+token)
|
||||
}
|
||||
if accountID := firstNonBlank(
|
||||
os.Getenv("CRABBOX_ARTIFACTS_CLOUDFLARE_ACCOUNT_ID"),
|
||||
os.Getenv("CLOUDFLARE_ACCOUNT_ID"),
|
||||
); accountID != "" {
|
||||
env = append(env, "CLOUDFLARE_ACCOUNT_ID="+accountID)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func artifactS3URL(opts artifactPublishOptions, key string) string {
|
||||
if opts.BaseURL != "" {
|
||||
return joinURLPath(opts.BaseURL, key)
|
||||
}
|
||||
if opts.EndpointURL != "" {
|
||||
return joinURLPath(opts.EndpointURL, opts.Bucket+"/"+key)
|
||||
}
|
||||
escapedKey := pathEscapeSegments(key)
|
||||
if opts.Region != "" {
|
||||
return "https://" + opts.Bucket + ".s3." + opts.Region + ".amazonaws.com/" + escapedKey
|
||||
}
|
||||
return "https://" + opts.Bucket + ".s3.amazonaws.com/" + escapedKey
|
||||
}
|
||||
|
||||
func artifactCloudflareURL(opts artifactPublishOptions, key string) string {
|
||||
if opts.BaseURL != "" {
|
||||
return joinURLPath(opts.BaseURL, key)
|
||||
}
|
||||
return "r2://" + opts.Bucket + "/" + key
|
||||
}
|
||||
|
||||
func artifactObjectKey(prefix, name string) string {
|
||||
name = strings.TrimLeft(filepath.ToSlash(name), "/")
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return name
|
||||
}
|
||||
return strings.Trim(strings.TrimSpace(prefix), "/") + "/" + name
|
||||
}
|
||||
|
||||
func artifactContentType(path string) string {
|
||||
if contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); contentType != "" {
|
||||
return contentType
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".md":
|
||||
return "text/markdown; charset=utf-8"
|
||||
case ".log":
|
||||
return "text/plain; charset=utf-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func artifactKindForPath(path string) string {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch {
|
||||
case ext == ".gif":
|
||||
return "gif"
|
||||
case artifactExtIsVideo(ext):
|
||||
return "video"
|
||||
case (artifactExtIsImage(ext) || ext == "") && (strings.Contains(name, "screenshot") || strings.Contains(name, "before") || strings.Contains(name, "after")):
|
||||
return "screenshot"
|
||||
case strings.Contains(name, "doctor"):
|
||||
return "doctor"
|
||||
case strings.Contains(name, "webvnc"):
|
||||
return "webvnc-status"
|
||||
case strings.Contains(name, "metadata"):
|
||||
return "metadata"
|
||||
case strings.Contains(name, "log") || ext == ".txt":
|
||||
return "logs"
|
||||
default:
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
|
||||
func artifactExtIsVideo(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mp4", ".mov", ".webm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func artifactExtIsImage(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func artifactTemplateMarkdown(kind, summary, before, after string, files []artifactFile) string {
|
||||
title := "OpenClaw QA Artifacts"
|
||||
if strings.EqualFold(strings.TrimSpace(kind), "mantis") {
|
||||
title = "Mantis QA Artifacts"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "## %s\n\n", title)
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
fmt.Fprintf(&b, "### Summary\n%s\n\n", strings.TrimSpace(summary))
|
||||
}
|
||||
if before != "" || after != "" {
|
||||
b.WriteString("### Before / After\n\n")
|
||||
if before != "" {
|
||||
fmt.Fprintf(&b, "**Before**\n\n%s\n\n", artifactMarkdownForAsset("before", before))
|
||||
}
|
||||
if after != "" {
|
||||
fmt.Fprintf(&b, "**After**\n\n%s\n\n", artifactMarkdownForAsset("after", after))
|
||||
}
|
||||
}
|
||||
if len(files) > 0 {
|
||||
b.WriteString("### Evidence\n\n")
|
||||
for _, file := range files {
|
||||
location := firstNonBlank(file.URL, file.Path)
|
||||
if location == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s: %s\n", file.Kind, artifactMarkdownForFile(file, location))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func artifactMarkdownForFile(file artifactFile, location string) string {
|
||||
if artifactFileIsInlineImage(file, location) {
|
||||
return fmt.Sprintf("", file.Name, location)
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", file.Name, location)
|
||||
}
|
||||
|
||||
func artifactMarkdownForAsset(label, location string) string {
|
||||
if artifactLocationHasImageExtension(location) {
|
||||
return fmt.Sprintf("", label, location)
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", label, location)
|
||||
}
|
||||
|
||||
func artifactFileIsInlineImage(file artifactFile, location string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(file.Kind)) {
|
||||
case "gif", "screenshot", "image":
|
||||
return true
|
||||
}
|
||||
return artifactLocationHasImageExtension(firstNonBlank(file.Name, location))
|
||||
}
|
||||
|
||||
func artifactLocationHasImageExtension(location string) bool {
|
||||
location = strings.TrimSpace(location)
|
||||
if parsed, err := url.Parse(location); err == nil && parsed.Path != "" {
|
||||
location = parsed.Path
|
||||
} else {
|
||||
location = strings.SplitN(location, "?", 2)[0]
|
||||
location = strings.SplitN(location, "#", 2)[0]
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(location)) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func summaryText(summary, summaryFile string) (string, error) {
|
||||
if strings.TrimSpace(summaryFile) == "" {
|
||||
return strings.TrimSpace(summary), nil
|
||||
}
|
||||
data, err := os.ReadFile(summaryFile)
|
||||
if err != nil {
|
||||
return "", exit(2, "read summary file: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
return strings.TrimSpace(summary) + "\n\n" + strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func postGitHubPRComment(ctx context.Context, pr int, repo, bodyPath string) error {
|
||||
args := []string{"issue", "comment", strconv.Itoa(pr), "--body-file", bodyPath}
|
||||
if strings.TrimSpace(repo) != "" {
|
||||
args = append(args, "--repo", strings.TrimSpace(repo))
|
||||
}
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
return exit(2, "gh CLI is required for artifacts publish --pr: %v", err)
|
||||
}
|
||||
if out, err := commandOutput(ctx, "gh", args...); err != nil {
|
||||
return exit(2, "gh issue comment failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortArtifactFiles(files []artifactFile) {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].Kind == files[j].Kind {
|
||||
return files[i].Name < files[j].Name
|
||||
}
|
||||
return files[i].Kind < files[j].Kind
|
||||
})
|
||||
}
|
||||
|
||||
func joinURLPath(base, rel string) string {
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
rel = strings.TrimLeft(filepath.ToSlash(rel), "/")
|
||||
if base == "" {
|
||||
return rel
|
||||
}
|
||||
return base + "/" + pathEscapeSegments(rel)
|
||||
}
|
||||
|
||||
func pathEscapeSegments(path string) string {
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func envBool(name string) bool {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
switch strings.ToLower(value) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func envDuration(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return duration
|
||||
}
|
||||
@ -1,518 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseArtifactPublishOptionsNormalizesStorage(t *testing.T) {
|
||||
opts, err := parseArtifactPublishOptions([]string{
|
||||
"--dir", "bundle",
|
||||
"--storage", "r2",
|
||||
"--bucket", "qa",
|
||||
"--base-url", "https://artifacts.example.com/root/",
|
||||
"--endpoint-url", "https://account.r2.cloudflarestorage.com",
|
||||
"--prefix", "/runs/123/",
|
||||
"--pr", "42",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Storage != "r2" {
|
||||
t.Fatalf("storage=%q", opts.Storage)
|
||||
}
|
||||
if opts.BaseURL != "https://artifacts.example.com/root" {
|
||||
t.Fatalf("baseURL=%q", opts.BaseURL)
|
||||
}
|
||||
if opts.Prefix != "runs/123" {
|
||||
t.Fatalf("prefix=%q", opts.Prefix)
|
||||
}
|
||||
if opts.PR != 42 {
|
||||
t.Fatalf("pr=%d", opts.PR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsRequiresExplicitDir(t *testing.T) {
|
||||
t.Setenv("CRABBOX_ARTIFACTS_DIR", "")
|
||||
_, err := parseArtifactPublishOptions([]string{
|
||||
"--storage", "local",
|
||||
}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing dir error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --dir") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsAllowsDirFromEnv(t *testing.T) {
|
||||
t.Setenv("CRABBOX_ARTIFACTS_DIR", "bundle")
|
||||
opts, err := parseArtifactPublishOptions([]string{
|
||||
"--storage", "local",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Directory != "bundle" {
|
||||
t.Fatalf("dir=%q", opts.Directory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultArtifactPublishPrefixIsUniqueAndScoped(t *testing.T) {
|
||||
when := time.Date(2026, 5, 8, 3, 40, 41, 123456789, time.UTC)
|
||||
got := defaultArtifactPublishPrefix(artifactPublishOptions{
|
||||
Directory: "/tmp/artifacts/Blue Lobster",
|
||||
PR: 42,
|
||||
}, when)
|
||||
if got != "pr-42/blue-lobster/20260508-034041-123456789" {
|
||||
t.Fatalf("prefix=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureArtifactPublishPrefixOnlyForHostedStorage(t *testing.T) {
|
||||
hosted := artifactPublishOptions{Storage: "s3", Directory: "/tmp/artifacts/Blue Lobster", PR: 42}
|
||||
ensureArtifactPublishPrefix(&hosted)
|
||||
if !strings.HasPrefix(hosted.Prefix, "pr-42/blue-lobster/") {
|
||||
t.Fatalf("hosted prefix=%q", hosted.Prefix)
|
||||
}
|
||||
|
||||
local := artifactPublishOptions{Storage: "local", Directory: "/tmp/artifacts/Blue Lobster", PR: 42}
|
||||
ensureArtifactPublishPrefix(&local)
|
||||
if local.Prefix != "" {
|
||||
t.Fatalf("local prefix=%q", local.Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsR2UsesR2Defaults(t *testing.T) {
|
||||
t.Setenv("AWS_PROFILE", "default")
|
||||
t.Setenv("AWS_REGION", "us-east-1")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_R2_AWS_PROFILE", "qa-r2")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_R2_ENDPOINT_URL", "https://account.r2.cloudflarestorage.com")
|
||||
|
||||
opts, err := parseArtifactPublishOptions([]string{
|
||||
"--dir", "bundle",
|
||||
"--storage", "r2",
|
||||
"--bucket", "qa",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Profile != "qa-r2" {
|
||||
t.Fatalf("profile=%q", opts.Profile)
|
||||
}
|
||||
if opts.Region != "auto" {
|
||||
t.Fatalf("region=%q", opts.Region)
|
||||
}
|
||||
if opts.EndpointURL != "https://account.r2.cloudflarestorage.com" {
|
||||
t.Fatalf("endpointURL=%q", opts.EndpointURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsRequiresCloudflareBaseURLForPR(t *testing.T) {
|
||||
_, err := parseArtifactPublishOptions([]string{
|
||||
"--dir", "bundle",
|
||||
"--storage", "cloudflare",
|
||||
"--bucket", "qa",
|
||||
"--pr", "42",
|
||||
}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected base-url validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --base-url") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStorageURLs(t *testing.T) {
|
||||
s3 := artifactS3URL(artifactPublishOptions{Bucket: "qa", Region: "eu-west-1"}, "runs/1/screen shot.png")
|
||||
if s3 != "https://qa.s3.eu-west-1.amazonaws.com/runs/1/screen%20shot.png" {
|
||||
t.Fatalf("s3 url=%s", s3)
|
||||
}
|
||||
custom := artifactS3URL(artifactPublishOptions{Bucket: "qa", EndpointURL: "https://s3.example.com/root"}, "runs/1/screen shot.png")
|
||||
if custom != "https://s3.example.com/root/qa/runs/1/screen%20shot.png" {
|
||||
t.Fatalf("custom s3 url=%s", custom)
|
||||
}
|
||||
r2 := artifactCloudflareURL(artifactPublishOptions{Bucket: "qa", BaseURL: "https://assets.example.com/base"}, "runs/1/after.gif")
|
||||
if r2 != "https://assets.example.com/base/runs/1/after.gif" {
|
||||
t.Fatalf("r2 url=%s", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCloudflareEnvUsesGenericCredentials(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_API_TOKEN", "generic-token")
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "generic-account")
|
||||
|
||||
env := strings.Join(artifactCloudflareEnv(), "\n")
|
||||
for _, want := range []string{
|
||||
"CLOUDFLARE_API_TOKEN=generic-token",
|
||||
"CLOUDFLARE_ACCOUNT_ID=generic-account",
|
||||
} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Fatalf("env missing %q in %s", want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCloudflareEnvArtifactCredentialsWin(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_API_TOKEN", "generic-token")
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "generic-account")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_CLOUDFLARE_API_TOKEN", "artifact-token")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_CLOUDFLARE_ACCOUNT_ID", "artifact-account")
|
||||
|
||||
env := strings.Join(artifactCloudflareEnv(), "\n")
|
||||
for _, want := range []string{
|
||||
"CLOUDFLARE_API_TOKEN=artifact-token",
|
||||
"CLOUDFLARE_ACCOUNT_ID=artifact-account",
|
||||
} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Fatalf("env missing %q in %s", want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactTemplateMarkdownUsesInlineImages(t *testing.T) {
|
||||
body := artifactTemplateMarkdown("mantis", "fixed login", "before.png", "https://cdn.example.com/after.gif", []artifactFile{
|
||||
{Kind: "logs", Name: "logs.txt", URL: "https://cdn.example.com/logs.txt"},
|
||||
{Kind: "screenshot", Name: "screenshot.png", URL: "https://s3.example.com/screenshot.png?X-Amz-Signature=abc"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"## Mantis QA Artifacts",
|
||||
"fixed login",
|
||||
"",
|
||||
"",
|
||||
"[logs.txt](https://cdn.example.com/logs.txt)",
|
||||
"",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactMarkdownForAssetIgnoresQueryString(t *testing.T) {
|
||||
got := artifactMarkdownForAsset("after", "https://cdn.example.com/after.gif?token=secret")
|
||||
if got != "" {
|
||||
t.Fatalf("markdown=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactKindClassifiesBeforeAfterVideosAsVideo(t *testing.T) {
|
||||
for _, name := range []string{"before.mp4", "after.mov", "nested/after.webm"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := artifactKindForPath(name); got != "video" {
|
||||
t.Fatalf("kind=%q", got)
|
||||
}
|
||||
markdown := artifactMarkdownForFile(artifactFile{Kind: artifactKindForPath(name), Name: filepath.Base(name)}, "https://cdn.example.com/"+filepath.Base(name))
|
||||
if strings.HasPrefix(markdown, "![") {
|
||||
t.Fatalf("video markdown should be a link, got %s", markdown)
|
||||
}
|
||||
if !strings.HasPrefix(markdown, "[") {
|
||||
t.Fatalf("video markdown should be a link, got %s", markdown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListArtifactBundleFilesSkipsPublishedMarkdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(dir, "screenshot.png"), "png")
|
||||
mustWriteFile(t, filepath.Join(dir, "published-artifacts.md"), "old")
|
||||
mustWriteFile(t, filepath.Join(dir, "nested", "logs.txt"), "logs")
|
||||
files, err := listArtifactBundleFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, file := range files {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
got := strings.Join(names, ",")
|
||||
if got != "nested/logs.txt,screenshot.png" {
|
||||
t.Fatalf("files=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishArtifactFilesDryRunS3(t *testing.T) {
|
||||
files := []artifactFile{{Kind: "gif", Name: "screen.gif", Path: "screen.gif"}}
|
||||
opts := artifactPublishOptions{
|
||||
Storage: "s3",
|
||||
Bucket: "qa",
|
||||
Region: "us-east-1",
|
||||
Prefix: "runs/abc",
|
||||
DryRun: true,
|
||||
Expires: time.Hour,
|
||||
}
|
||||
published, err := publishArtifactFiles(context.Background(), opts, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(published) != 1 || published[0].URL != "https://qa.s3.us-east-1.amazonaws.com/runs/abc/screen.gif" {
|
||||
t.Fatalf("published=%#v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishArtifactFilesDryRunS3BaseURLWinsOverPresign(t *testing.T) {
|
||||
files := []artifactFile{{Kind: "screenshot", Name: "screenshot.png", Path: "screenshot.png"}}
|
||||
opts := artifactPublishOptions{
|
||||
Storage: "r2",
|
||||
Bucket: "qa",
|
||||
Region: "auto",
|
||||
Prefix: "runs/abc",
|
||||
BaseURL: "https://artifacts.example.com",
|
||||
Presign: true,
|
||||
DryRun: true,
|
||||
Expires: time.Hour,
|
||||
}
|
||||
published, err := publishArtifactFiles(context.Background(), opts, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(published) != 1 || published[0].URL != "https://artifacts.example.com/runs/abc/screenshot.png" {
|
||||
t.Fatalf("published=%#v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishArtifactFilesBrokerUploadsViaGrantedURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "screenshot.png")
|
||||
mustWriteFile(t, path, "png-data")
|
||||
wantHash := fmt.Sprintf("%x", sha256.Sum256([]byte("png-data")))
|
||||
var uploaded string
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/artifacts/uploads":
|
||||
var req CoordinatorArtifactUploadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode upload request: %v", err)
|
||||
}
|
||||
if len(req.Files) != 1 || req.Files[0].Name != "screenshot.png" || req.Files[0].SHA256 != wantHash {
|
||||
t.Fatalf("request=%#v", req)
|
||||
}
|
||||
w.Header().Set("content-type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{
|
||||
"backend":"r2",
|
||||
"bucket":"qa",
|
||||
"prefix":"runs/abc",
|
||||
"expiresAt":"2026-05-08T00:00:00Z",
|
||||
"files":[{
|
||||
"name":"screenshot.png",
|
||||
"key":"runs/abc/screenshot.png",
|
||||
"upload":{"method":"PUT","url":%q,"headers":{"content-type":"image/png","content-length":"8"},"expiresAt":"2026-05-08T00:00:00Z"},
|
||||
"url":"https://artifacts.example.com/runs/abc/screenshot.png"
|
||||
}]
|
||||
}`, server.URL+"/upload/screenshot.png")
|
||||
case "/upload/screenshot.png":
|
||||
if r.Method != http.MethodPut {
|
||||
t.Fatalf("method=%s", r.Method)
|
||||
}
|
||||
if r.ContentLength != int64(len("png-data")) {
|
||||
t.Fatalf("content length=%d", r.ContentLength)
|
||||
}
|
||||
if len(r.TransferEncoding) > 0 {
|
||||
t.Fatalf("transfer encoding=%v", r.TransferEncoding)
|
||||
}
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
uploaded = string(data)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
published, err := publishArtifactFilesBroker(context.Background(), &CoordinatorClient{
|
||||
BaseURL: server.URL,
|
||||
Token: "token",
|
||||
Client: server.Client(),
|
||||
}, artifactPublishOptions{Storage: "broker", Prefix: "runs/abc"}, []artifactFile{
|
||||
{Kind: "screenshot", Name: "screenshot.png", Path: path},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if uploaded != "png-data" {
|
||||
t.Fatalf("uploaded=%q", uploaded)
|
||||
}
|
||||
if len(published) != 1 || published[0].URL != "https://artifacts.example.com/runs/abc/screenshot.png" {
|
||||
t.Fatalf("published=%#v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArtifactGrantRejectsSizeMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "screenshot.png")
|
||||
mustWriteFile(t, path, "png-data")
|
||||
|
||||
err := uploadArtifactGrant(context.Background(), path, CoordinatorArtifactUploadGrant{
|
||||
Name: "screenshot.png",
|
||||
Upload: struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}{
|
||||
Method: "PUT",
|
||||
URL: "https://artifacts.example.com/upload",
|
||||
Headers: map[string]string{"content-length": "1"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected size mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "size changed after broker grant") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactsPublishValidatesSummaryBeforeMarkdownSideEffects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(dir, "screenshot.png"), "png")
|
||||
var stdout, stderr bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: &stderr}
|
||||
err := app.artifactsPublish(context.Background(), []string{
|
||||
"--dir", dir,
|
||||
"--storage", "s3",
|
||||
"--bucket", "qa",
|
||||
"--dry-run",
|
||||
"--summary-file", filepath.Join(dir, "missing.md"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing summary file error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read summary file") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "published-artifacts.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("published markdown should not be written before summary validation: %v", statErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty before publish side effects, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintArtifactWarningUsesRescueShape(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printArtifactWarning(&out, artifactWarning{
|
||||
Problem: "WebVNC daemon not running",
|
||||
Detail: "portal has no active bridge",
|
||||
Rescue: []string{"crabbox webvnc reset --id blue --open"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"problem: WebVNC daemon not running\n",
|
||||
"detail: portal has no active bridge\n",
|
||||
"rescue: crabbox webvnc reset --id blue --open\n",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("warning missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollectFailureJSONIsParseable(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: io.Discard}
|
||||
result := artifactCollectResult{
|
||||
Directory: "/tmp/bundle",
|
||||
Metadata: artifactBundleMetadata{LeaseID: "cbx_123"},
|
||||
Files: []artifactFile{{Kind: "metadata", Name: "metadata.json", Path: "/tmp/bundle/metadata.json"}},
|
||||
}
|
||||
err := app.finishArtifactCollectFailure(&result, true, exit(5, "capture screenshot: boom"), artifactWarning{
|
||||
Problem: rescueScreenshotCaptureBroken,
|
||||
Detail: "capture screenshot: boom",
|
||||
Rescue: []string{"crabbox desktop doctor --id cbx_123"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected original collection error")
|
||||
}
|
||||
if strings.Contains(stdout.String(), "problem:") {
|
||||
t.Fatalf("json stdout contains human rescue text: %q", stdout.String())
|
||||
}
|
||||
var decoded artifactCollectResult
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &decoded); decodeErr != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", decodeErr, stdout.String())
|
||||
}
|
||||
if decoded.Error == nil || decoded.Error.Code != "screenshot-capture-broken" {
|
||||
t.Fatalf("decoded error=%#v", decoded.Error)
|
||||
}
|
||||
if decoded.Files == nil || len(decoded.Files) != 1 {
|
||||
t.Fatalf("files should remain a JSON array: %#v", decoded.Files)
|
||||
}
|
||||
if len(decoded.Warnings) != 1 || decoded.Warnings[0].Problem != rescueScreenshotCaptureBroken {
|
||||
t.Fatalf("warnings=%#v", decoded.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactsCollectValidationThroughKongStripsCommandPath(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: &stderr}
|
||||
err := app.Run(context.Background(), []string{"artifacts", "collect", "--gif", "--id", "dummy"})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--gif requires --video or --all") {
|
||||
t.Fatalf("err=%v stderr=%s stdout=%s", err, stderr.String(), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArtifactWebVNCStatusRecordsWarningsWithoutStdout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/leases/cbx_123/webvnc/status" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(CoordinatorWebVNCStatus{
|
||||
LeaseID: "cbx_123",
|
||||
Slug: "blue-lobster",
|
||||
BridgeConnected: false,
|
||||
ViewerConnected: true,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: io.Discard}
|
||||
var warnings []artifactWarning
|
||||
path, ok, err := app.writeArtifactWebVNCStatus(context.Background(), Config{
|
||||
Provider: "aws",
|
||||
TargetOS: targetLinux,
|
||||
Coordinator: server.URL,
|
||||
CoordToken: "token",
|
||||
}, SSHTarget{TargetOS: targetLinux}, "cbx_123", dir, &warnings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || path == "" {
|
||||
t.Fatalf("path=%q ok=%t", path, ok)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("status helper should not write rescue text directly to stdout, got %q", stdout.String())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings=%#v", warnings)
|
||||
}
|
||||
if warnings[0].Problem != rescueVNCBridgeNotRunning {
|
||||
t.Fatalf("warnings=%#v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@ const defaultCoordinatorURL = "https://crabbox.openclaw.ai"
|
||||
func (a App) login(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("login", a.Stderr)
|
||||
brokerURL := fs.String("url", "", "broker URL")
|
||||
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", "", "default provider: hetzner or aws")
|
||||
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
|
||||
noBrowser := fs.Bool("no-browser", false, "print GitHub login URL instead of opening a browser")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
|
||||
@ -1,976 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
|
||||
)
|
||||
|
||||
const (
|
||||
azureAddressSpace = "10.42.0.0/16"
|
||||
azureSubnetCIDR = "10.42.0.0/24"
|
||||
azureProviderTag = "crabbox"
|
||||
defaultAzureLinuxImage = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"
|
||||
defaultAzureWindowsImage = "MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest"
|
||||
azureDeleteRetryDelay = 15 * time.Second
|
||||
azureDeleteRetryAttempts = 13
|
||||
)
|
||||
|
||||
type AzureClient struct {
|
||||
SubscriptionID string
|
||||
Location string
|
||||
ResourceGroup string
|
||||
VNet string
|
||||
Subnet string
|
||||
NSG string
|
||||
SSHCIDRs []string
|
||||
Image azureImageRef
|
||||
SSHPort string
|
||||
FallbackPorts []string
|
||||
|
||||
cred azcore.TokenCredential
|
||||
rg *armresources.ResourceGroupsClient
|
||||
vnetc *armnetwork.VirtualNetworksClient
|
||||
sgc *armnetwork.SecurityGroupsClient
|
||||
pipc *armnetwork.PublicIPAddressesClient
|
||||
nicc *armnetwork.InterfacesClient
|
||||
vmc *armcompute.VirtualMachinesClient
|
||||
vmextc *armcompute.VirtualMachineExtensionsClient
|
||||
diskc *armcompute.DisksClient
|
||||
skuc *armcompute.ResourceSKUsClient
|
||||
|
||||
ephemeralOSSupport map[string]bool
|
||||
}
|
||||
|
||||
type azureImageRef struct{ Publisher, Offer, SKU, Version string }
|
||||
|
||||
func NewAzureClient(ctx context.Context, cfg Config) (*AzureClient, error) {
|
||||
_ = ctx
|
||||
if cfg.AzureSubscription == "" {
|
||||
return nil, exit(3, "AZURE_SUBSCRIPTION_ID is required for direct azure provider")
|
||||
}
|
||||
if cfg.AzureLocation == "" {
|
||||
return nil, exit(3, "azure location is required (set azure.location or CRABBOX_AZURE_LOCATION)")
|
||||
}
|
||||
cred, err := azureCredentialForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, exit(3, "azure credential: %v", err)
|
||||
}
|
||||
img, err := parseAzureImageRef(azureImageForConfig(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rgFactory, err := armresources.NewClientFactory(cfg.AzureSubscription, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("armresources factory: %w", err)
|
||||
}
|
||||
netFactory, err := armnetwork.NewClientFactory(cfg.AzureSubscription, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("armnetwork factory: %w", err)
|
||||
}
|
||||
cmpFactory, err := armcompute.NewClientFactory(cfg.AzureSubscription, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("armcompute factory: %w", err)
|
||||
}
|
||||
cidrs := cfg.AzureSSHCIDRs
|
||||
if len(cidrs) == 0 {
|
||||
cidrs = []string{"0.0.0.0/0"}
|
||||
}
|
||||
return &AzureClient{
|
||||
SubscriptionID: cfg.AzureSubscription,
|
||||
Location: cfg.AzureLocation,
|
||||
ResourceGroup: cfg.AzureResourceGroup,
|
||||
VNet: cfg.AzureVNet,
|
||||
Subnet: cfg.AzureSubnet,
|
||||
NSG: cfg.AzureNSG,
|
||||
SSHCIDRs: cidrs,
|
||||
Image: img,
|
||||
SSHPort: cfg.SSHPort,
|
||||
FallbackPorts: cfg.SSHFallbackPorts,
|
||||
cred: cred,
|
||||
rg: rgFactory.NewResourceGroupsClient(),
|
||||
vnetc: netFactory.NewVirtualNetworksClient(),
|
||||
sgc: netFactory.NewSecurityGroupsClient(),
|
||||
pipc: netFactory.NewPublicIPAddressesClient(),
|
||||
nicc: netFactory.NewInterfacesClient(),
|
||||
vmc: cmpFactory.NewVirtualMachinesClient(),
|
||||
vmextc: cmpFactory.NewVirtualMachineExtensionsClient(),
|
||||
diskc: cmpFactory.NewDisksClient(),
|
||||
skuc: cmpFactory.NewResourceSKUsClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func azureCredentialForConfig(cfg Config) (azcore.TokenCredential, error) {
|
||||
if cfg.AzureTenant != "" && cfg.AzureClientID != "" {
|
||||
if secret := os.Getenv("AZURE_CLIENT_SECRET"); secret != "" {
|
||||
return azidentity.NewClientSecretCredential(cfg.AzureTenant, cfg.AzureClientID, secret, nil)
|
||||
}
|
||||
}
|
||||
return azidentity.NewDefaultAzureCredential(nil)
|
||||
}
|
||||
|
||||
func parseAzureImageRef(s string) (azureImageRef, error) {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 4 {
|
||||
return azureImageRef{}, exit(2, "azure image must be Publisher:Offer:SKU:Version, got %q", s)
|
||||
}
|
||||
return azureImageRef{Publisher: parts[0], Offer: parts[1], SKU: parts[2], Version: parts[3]}, nil
|
||||
}
|
||||
|
||||
func azureImageForConfig(cfg Config) string {
|
||||
if cfg.TargetOS == targetWindows && (cfg.AzureImage == "" || cfg.AzureImage == defaultAzureLinuxImage) {
|
||||
return defaultAzureWindowsImage
|
||||
}
|
||||
if cfg.AzureImage == "" {
|
||||
return defaultAzureLinuxImage
|
||||
}
|
||||
return cfg.AzureImage
|
||||
}
|
||||
|
||||
func azureVMSizeCandidatesForConfig(cfg Config) []string {
|
||||
return azureVMSizeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, cfg.Class)
|
||||
}
|
||||
|
||||
func azureVMSizeCandidatesForTargetModeClass(target, windowsMode, class string) []string {
|
||||
switch target {
|
||||
case targetLinux:
|
||||
return azureVMSizeCandidatesForClass(class)
|
||||
case targetWindows:
|
||||
if windowsMode == windowsModeNormal {
|
||||
return azureWindowsVMSizeCandidatesForClass(class)
|
||||
}
|
||||
return []string{class}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
}
|
||||
|
||||
func azureVMSizeCandidatesForClass(class string) []string {
|
||||
switch class {
|
||||
case "standard":
|
||||
return []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}
|
||||
case "fast":
|
||||
return []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}
|
||||
case "large":
|
||||
return []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}
|
||||
case "beast":
|
||||
return []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
}
|
||||
|
||||
func azureWindowsVMSizeCandidatesForClass(class string) []string {
|
||||
switch class {
|
||||
case "standard":
|
||||
return []string{"Standard_D2ads_v6", "Standard_D2ds_v6", "Standard_D2ads_v5", "Standard_D2ds_v5", "Standard_D2as_v6"}
|
||||
case "fast":
|
||||
return []string{"Standard_D4ads_v6", "Standard_D4ds_v6", "Standard_D4ads_v5", "Standard_D4ds_v5", "Standard_D4as_v6"}
|
||||
case "large":
|
||||
return []string{"Standard_D8ads_v6", "Standard_D8ds_v6", "Standard_D8ads_v5", "Standard_D8ds_v5", "Standard_D8as_v6"}
|
||||
case "beast":
|
||||
return []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
}
|
||||
|
||||
func azureSupportsEphemeralOS(vmSize string) bool {
|
||||
normalized := strings.ToLower(vmSize)
|
||||
if strings.HasPrefix(normalized, "standard_f") && strings.HasSuffix(normalized, "s_v2") {
|
||||
return true
|
||||
}
|
||||
if (strings.HasPrefix(normalized, "standard_d") || strings.HasPrefix(normalized, "standard_e")) &&
|
||||
(strings.Contains(normalized, "ds_v5") || strings.Contains(normalized, "ds_v6")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *AzureClient) supportsEphemeralOS(ctx context.Context, vmSize string) bool {
|
||||
if c.skuc == nil {
|
||||
return azureSupportsEphemeralOS(vmSize)
|
||||
}
|
||||
if c.ephemeralOSSupport == nil {
|
||||
if err := c.loadEphemeralOSSupport(ctx); err != nil {
|
||||
return azureSupportsEphemeralOS(vmSize)
|
||||
}
|
||||
}
|
||||
supported, ok := c.ephemeralOSSupport[vmSize]
|
||||
if !ok {
|
||||
return azureSupportsEphemeralOS(vmSize)
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
func (c *AzureClient) loadEphemeralOSSupport(ctx context.Context) error {
|
||||
support := map[string]bool{}
|
||||
filter := fmt.Sprintf("location eq '%s'", c.Location)
|
||||
pager := c.skuc.NewListPager(&armcompute.ResourceSKUsClientListOptions{Filter: to.Ptr(filter)})
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sku := range page.Value {
|
||||
if sku == nil || sku.Name == nil || sku.ResourceType == nil || *sku.ResourceType != "virtualMachines" {
|
||||
continue
|
||||
}
|
||||
support[*sku.Name] = azureSKUCapabilityTrue(sku.Capabilities, "EphemeralOSDiskSupported")
|
||||
}
|
||||
}
|
||||
c.ephemeralOSSupport = support
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureSKUCapabilityTrue(capabilities []*armcompute.ResourceSKUCapabilities, name string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil || capability.Name == nil || capability.Value == nil {
|
||||
continue
|
||||
}
|
||||
if *capability.Name == name && strings.EqualFold(*capability.Value, "true") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *AzureClient) EnsureSharedInfra(ctx context.Context) error {
|
||||
if err := c.ensureResourceGroup(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureVNet(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.ensureNSG(ctx)
|
||||
}
|
||||
|
||||
func azureSharedTags() map[string]*string {
|
||||
return map[string]*string{
|
||||
azureProviderTag: to.Ptr("true"),
|
||||
"managed_by": to.Ptr("crabbox"),
|
||||
}
|
||||
}
|
||||
|
||||
func azureManagedByCrabbox(tags map[string]*string) bool {
|
||||
if tags == nil {
|
||||
return false
|
||||
}
|
||||
v := tags["managed_by"]
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return *v == "crabbox"
|
||||
}
|
||||
|
||||
func azureAdoptError(kind, name string) error {
|
||||
return fmt.Errorf("azure %s %q exists but is not Crabbox-managed; either delete it, set tag managed_by=crabbox to adopt it, or use a different name", kind, name)
|
||||
}
|
||||
|
||||
func preserveNonCrabboxRules(rules []*armnetwork.SecurityRule) []*armnetwork.SecurityRule {
|
||||
out := make([]*armnetwork.SecurityRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil || rule.Name == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(*rule.Name, "crabbox-ssh-") {
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *AzureClient) ensureResourceGroup(ctx context.Context) error {
|
||||
existing, err := c.rg.Get(ctx, c.ResourceGroup, nil)
|
||||
if err == nil {
|
||||
if !azureManagedByCrabbox(existing.Tags) {
|
||||
return azureAdoptError("resource group", c.ResourceGroup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("get resource group: %w", err)
|
||||
}
|
||||
if _, err := c.rg.CreateOrUpdate(ctx, c.ResourceGroup, armresources.ResourceGroup{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: azureSharedTags(),
|
||||
}, nil); err != nil {
|
||||
return fmt.Errorf("create resource group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) ensureVNet(ctx context.Context) error {
|
||||
existing, err := c.vnetc.Get(ctx, c.ResourceGroup, c.VNet, nil)
|
||||
if err == nil {
|
||||
if !azureManagedByCrabbox(existing.Tags) {
|
||||
return azureAdoptError("virtual network", c.VNet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("get vnet: %w", err)
|
||||
}
|
||||
poller, err := c.vnetc.BeginCreateOrUpdate(ctx, c.ResourceGroup, c.VNet, armnetwork.VirtualNetwork{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: azureSharedTags(),
|
||||
Properties: &armnetwork.VirtualNetworkPropertiesFormat{
|
||||
AddressSpace: &armnetwork.AddressSpace{
|
||||
AddressPrefixes: []*string{to.Ptr(azureAddressSpace)},
|
||||
},
|
||||
Subnets: []*armnetwork.Subnet{{
|
||||
Name: to.Ptr(c.Subnet),
|
||||
Properties: &armnetwork.SubnetPropertiesFormat{
|
||||
AddressPrefix: to.Ptr(azureSubnetCIDR),
|
||||
},
|
||||
}},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin vnet create: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return fmt.Errorf("vnet create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) ensureNSG(ctx context.Context) error {
|
||||
existing, err := c.sgc.Get(ctx, c.ResourceGroup, c.NSG, nil)
|
||||
existingRules := []*armnetwork.SecurityRule{}
|
||||
if err == nil {
|
||||
if !azureManagedByCrabbox(existing.Tags) {
|
||||
return azureAdoptError("network security group", c.NSG)
|
||||
}
|
||||
if existing.Properties != nil {
|
||||
existingRules = existing.Properties.SecurityRules
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("get nsg: %w", err)
|
||||
}
|
||||
rules := preserveNonCrabboxRules(existingRules)
|
||||
usedPriorities := azureNSGUsedPriorities(rules)
|
||||
for _, port := range sshPortCandidates(c.SSHPort, c.FallbackPorts) {
|
||||
for j, cidr := range c.SSHCIDRs {
|
||||
priority, err := nextAzureNSGPriority(usedPriorities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules = append(rules, &armnetwork.SecurityRule{
|
||||
Name: to.Ptr(fmt.Sprintf("crabbox-ssh-%s-%d", port, j)),
|
||||
Properties: &armnetwork.SecurityRulePropertiesFormat{
|
||||
Protocol: to.Ptr(armnetwork.SecurityRuleProtocolTCP),
|
||||
Access: to.Ptr(armnetwork.SecurityRuleAccessAllow),
|
||||
Direction: to.Ptr(armnetwork.SecurityRuleDirectionInbound),
|
||||
Priority: to.Ptr(priority),
|
||||
SourceAddressPrefix: to.Ptr(cidr),
|
||||
SourcePortRange: to.Ptr("*"),
|
||||
DestinationAddressPrefix: to.Ptr("*"),
|
||||
DestinationPortRange: to.Ptr(port),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
poller, err := c.sgc.BeginCreateOrUpdate(ctx, c.ResourceGroup, c.NSG, armnetwork.SecurityGroup{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: azureSharedTags(),
|
||||
Properties: &armnetwork.SecurityGroupPropertiesFormat{
|
||||
SecurityRules: rules,
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin nsg create: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return fmt.Errorf("nsg create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureNSGUsedPriorities(rules []*armnetwork.SecurityRule) map[int32]bool {
|
||||
used := map[int32]bool{}
|
||||
for _, rule := range rules {
|
||||
if rule == nil || rule.Properties == nil || rule.Properties.Priority == nil {
|
||||
continue
|
||||
}
|
||||
used[*rule.Properties.Priority] = true
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func nextAzureNSGPriority(used map[int32]bool) (int32, error) {
|
||||
for priority := int32(100); priority <= 4096; priority++ {
|
||||
if !used[priority] {
|
||||
used[priority] = true
|
||||
return priority, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("azure nsg: no available security rule priorities")
|
||||
}
|
||||
|
||||
func (c *AzureClient) CreateServerWithFallback(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, logf func(string, ...any)) (Server, Config, error) {
|
||||
if err := c.EnsureSharedInfra(ctx); err != nil {
|
||||
return Server{}, cfg, err
|
||||
}
|
||||
var candidates []string
|
||||
if cfg.ServerTypeExplicit && cfg.ServerType != "" {
|
||||
candidates = []string{cfg.ServerType}
|
||||
} else {
|
||||
candidates = azureVMSizeCandidatesForConfig(cfg)
|
||||
if cfg.ServerType != "" && cfg.ServerType != candidates[0] {
|
||||
candidates = append([]string{cfg.ServerType}, candidates...)
|
||||
}
|
||||
}
|
||||
var errs []error
|
||||
for i, vmSize := range candidates {
|
||||
next := cfg
|
||||
next.ServerType = vmSize
|
||||
if i > 0 && logf != nil {
|
||||
logf("fallback provisioning type=%s after quota/capacity rejection\n", vmSize)
|
||||
}
|
||||
server, err := c.createServer(ctx, next, publicKey, leaseID, slug, keep)
|
||||
if err == nil {
|
||||
return server, next, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", vmSize, err))
|
||||
if !isAzureRetryableProvisioningError(err) {
|
||||
return Server{}, next, joinErrors(errs)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(cfg.Capacity.Market, "spot") && strings.HasPrefix(cfg.Capacity.Fallback, "on-demand") {
|
||||
for _, vmSize := range candidates {
|
||||
next := cfg
|
||||
next.ServerType = vmSize
|
||||
next.Capacity.Market = "on-demand"
|
||||
if logf != nil {
|
||||
logf("fallback provisioning type=%s market=on-demand after spot rejection\n", vmSize)
|
||||
}
|
||||
server, err := c.createServer(ctx, next, publicKey, leaseID, slug, keep)
|
||||
if err == nil {
|
||||
return server, next, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("on-demand %s: %w", vmSize, err))
|
||||
if !isAzureRetryableProvisioningError(err) {
|
||||
return Server{}, next, joinErrors(errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Server{}, cfg, joinErrors(errs)
|
||||
}
|
||||
|
||||
func (c *AzureClient) createServer(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool) (server Server, err error) {
|
||||
name := leaseProviderName(leaseID, slug)
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
_ = c.deleteVMResources(context.Background(), name)
|
||||
}()
|
||||
return c.createServerSteps(ctx, cfg, publicKey, leaseID, slug, keep, name)
|
||||
}
|
||||
|
||||
func (c *AzureClient) createServerSteps(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, name string) (Server, error) {
|
||||
pipName := name + "-pip"
|
||||
nicName := name + "-nic"
|
||||
diskName := name + "-osdisk"
|
||||
|
||||
if cfg.Tailscale.Enabled && cfg.Tailscale.Hostname == "" {
|
||||
cfg.Tailscale.Hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, leaseID, slug, cfg.Provider)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
labels := directLeaseLabels(cfg, leaseID, slug, "azure", mapMarket(strings.EqualFold(cfg.Capacity.Market, "spot")), keep, now)
|
||||
tags := azureLabelsToTags(labels)
|
||||
|
||||
pipPoller, err := c.pipc.BeginCreateOrUpdate(ctx, c.ResourceGroup, pipName, armnetwork.PublicIPAddress{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
SKU: &armnetwork.PublicIPAddressSKU{
|
||||
Name: to.Ptr(armnetwork.PublicIPAddressSKUNameStandard),
|
||||
},
|
||||
Properties: &armnetwork.PublicIPAddressPropertiesFormat{
|
||||
PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodStatic),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("begin public ip: %w", err)
|
||||
}
|
||||
pipResp, err := pipPoller.PollUntilDone(ctx, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("public ip: %w", err)
|
||||
}
|
||||
pipID := *pipResp.ID
|
||||
|
||||
subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s/subnets/%s",
|
||||
c.SubscriptionID, c.ResourceGroup, c.VNet, c.Subnet)
|
||||
nsgID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkSecurityGroups/%s",
|
||||
c.SubscriptionID, c.ResourceGroup, c.NSG)
|
||||
nicPoller, err := c.nicc.BeginCreateOrUpdate(ctx, c.ResourceGroup, nicName, armnetwork.Interface{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
Properties: &armnetwork.InterfacePropertiesFormat{
|
||||
IPConfigurations: []*armnetwork.InterfaceIPConfiguration{{
|
||||
Name: to.Ptr("ipconfig"),
|
||||
Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{
|
||||
PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodDynamic),
|
||||
Subnet: &armnetwork.Subnet{ID: to.Ptr(subnetID)},
|
||||
PublicIPAddress: &armnetwork.PublicIPAddress{ID: to.Ptr(pipID)},
|
||||
},
|
||||
}},
|
||||
NetworkSecurityGroup: &armnetwork.SecurityGroup{ID: to.Ptr(nsgID)},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("begin nic: %w", err)
|
||||
}
|
||||
nicResp, err := nicPoller.PollUntilDone(ctx, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("nic: %w", err)
|
||||
}
|
||||
nicID := *nicResp.ID
|
||||
|
||||
osProfile, err := c.azureOSProfile(cfg, publicKey, name, leaseID)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
osDisk := &armcompute.OSDisk{
|
||||
Name: to.Ptr(diskName),
|
||||
CreateOption: to.Ptr(armcompute.DiskCreateOptionTypesFromImage),
|
||||
}
|
||||
if c.supportsEphemeralOS(ctx, cfg.ServerType) {
|
||||
osDisk.Caching = to.Ptr(armcompute.CachingTypesReadOnly)
|
||||
osDisk.DiffDiskSettings = &armcompute.DiffDiskSettings{
|
||||
Option: to.Ptr(armcompute.DiffDiskOptionsLocal),
|
||||
}
|
||||
} else {
|
||||
osDisk.Caching = to.Ptr(armcompute.CachingTypesReadWrite)
|
||||
osDisk.ManagedDisk = &armcompute.ManagedDiskParameters{
|
||||
StorageAccountType: to.Ptr(armcompute.StorageAccountTypesStandardSSDLRS),
|
||||
}
|
||||
}
|
||||
vmProperties := &armcompute.VirtualMachineProperties{
|
||||
HardwareProfile: &armcompute.HardwareProfile{
|
||||
VMSize: to.Ptr(armcompute.VirtualMachineSizeTypes(cfg.ServerType)),
|
||||
},
|
||||
StorageProfile: &armcompute.StorageProfile{
|
||||
ImageReference: &armcompute.ImageReference{
|
||||
Publisher: to.Ptr(c.Image.Publisher),
|
||||
Offer: to.Ptr(c.Image.Offer),
|
||||
SKU: to.Ptr(c.Image.SKU),
|
||||
Version: to.Ptr(c.Image.Version),
|
||||
},
|
||||
OSDisk: osDisk,
|
||||
},
|
||||
OSProfile: osProfile,
|
||||
NetworkProfile: &armcompute.NetworkProfile{
|
||||
NetworkInterfaces: []*armcompute.NetworkInterfaceReference{{
|
||||
ID: to.Ptr(nicID),
|
||||
}},
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(cfg.Capacity.Market, "spot") {
|
||||
vmProperties.Priority = to.Ptr(armcompute.VirtualMachinePriorityTypesSpot)
|
||||
vmProperties.EvictionPolicy = to.Ptr(armcompute.VirtualMachineEvictionPolicyTypesDelete)
|
||||
}
|
||||
vmPoller, err := c.vmc.BeginCreateOrUpdate(ctx, c.ResourceGroup, name, armcompute.VirtualMachine{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
Properties: vmProperties,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("begin vm: %w", err)
|
||||
}
|
||||
vmResp, err := vmPoller.PollUntilDone(ctx, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("vm: %w", err)
|
||||
}
|
||||
if cfg.TargetOS == targetWindows {
|
||||
if err := c.installWindowsBootstrapExtension(ctx, name, tags); err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
}
|
||||
return azureVMToServer(vmResp.VirtualMachine, ""), nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) azureOSProfile(cfg Config, publicKey, name, leaseID string) (*armcompute.OSProfile, error) {
|
||||
if cfg.TargetOS != targetWindows {
|
||||
sshPath := fmt.Sprintf("/home/%s/.ssh/authorized_keys", cfg.SSHUser)
|
||||
return &armcompute.OSProfile{
|
||||
ComputerName: to.Ptr(name),
|
||||
AdminUsername: to.Ptr(cfg.SSHUser),
|
||||
CustomData: to.Ptr(base64.StdEncoding.EncodeToString([]byte(cloudInit(cfg, publicKey)))),
|
||||
LinuxConfiguration: &armcompute.LinuxConfiguration{
|
||||
DisablePasswordAuthentication: to.Ptr(true),
|
||||
SSH: &armcompute.SSHConfiguration{
|
||||
PublicKeys: []*armcompute.SSHPublicKey{{
|
||||
Path: to.Ptr(sshPath),
|
||||
KeyData: to.Ptr(publicKey),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
password, err := azureRandomAdminPassword()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &armcompute.OSProfile{
|
||||
ComputerName: to.Ptr(azureComputerName(name, leaseID, cfg.TargetOS)),
|
||||
AdminUsername: to.Ptr("crabadmin"),
|
||||
AdminPassword: to.Ptr(password),
|
||||
AllowExtensionOperations: to.Ptr(true),
|
||||
CustomData: to.Ptr(base64.StdEncoding.EncodeToString([]byte(azureWindowsBootstrapPowerShell(cfg, publicKey)))),
|
||||
WindowsConfiguration: &armcompute.WindowsConfiguration{
|
||||
EnableAutomaticUpdates: to.Ptr(false),
|
||||
ProvisionVMAgent: to.Ptr(true),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) installWindowsBootstrapExtension(ctx context.Context, vmName string, tags map[string]*string) error {
|
||||
poller, err := c.vmextc.BeginCreateOrUpdate(ctx, c.ResourceGroup, vmName, "crabbox-bootstrap", armcompute.VirtualMachineExtension{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
Properties: &armcompute.VirtualMachineExtensionProperties{
|
||||
Publisher: to.Ptr("Microsoft.Compute"),
|
||||
Type: to.Ptr("CustomScriptExtension"),
|
||||
TypeHandlerVersion: to.Ptr("1.10"),
|
||||
AutoUpgradeMinorVersion: to.Ptr(true),
|
||||
Settings: map[string]any{"timestamp": time.Now().Unix()},
|
||||
ProtectedSettings: map[string]any{
|
||||
"commandToExecute": azureWindowsBootstrapCommand(),
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin windows bootstrap extension: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return fmt.Errorf("windows bootstrap extension: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureWindowsBootstrapCommand() string {
|
||||
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$p=Join-Path $env:SystemDrive 'AzureData\CustomData.bin'; $d=Join-Path $env:SystemDrive 'AzureData\crabbox-bootstrap.ps1'; Copy-Item -Force $p $d; & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $d"`
|
||||
}
|
||||
|
||||
func azureRandomAdminPassword() (string, error) {
|
||||
var b [18]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("generate azure admin password: %w", err)
|
||||
}
|
||||
return "Cb1!" + base64.StdEncoding.EncodeToString(b[:])[:18], nil
|
||||
}
|
||||
|
||||
func azureComputerName(vmName, leaseID, target string) string {
|
||||
if target != targetWindows {
|
||||
return vmName
|
||||
}
|
||||
source := leaseID
|
||||
if source == "" {
|
||||
source = vmName
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(source) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
suffix := b.String()
|
||||
if suffix == "" {
|
||||
suffix = "windows"
|
||||
}
|
||||
if len(suffix) > 12 {
|
||||
suffix = suffix[:12]
|
||||
}
|
||||
return "cbx" + suffix
|
||||
}
|
||||
|
||||
func (c *AzureClient) WaitForServerIP(ctx context.Context, name string) (Server, error) {
|
||||
pipName := name + "-pip"
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
for {
|
||||
pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
if pip.Properties != nil && pip.Properties.IPAddress != nil && *pip.Properties.IPAddress != "" {
|
||||
vm, err := c.vmc.Get(ctx, c.ResourceGroup, name, nil)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
return azureVMToServer(vm.VirtualMachine, *pip.Properties.IPAddress), nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return Server{}, fmt.Errorf("timeout waiting for public ip on %s", name)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Server{}, ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AzureClient) GetServer(ctx context.Context, name string) (Server, error) {
|
||||
vm, err := c.vmc.Get(ctx, c.ResourceGroup, name, nil)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
pipName := name + "-pip"
|
||||
ip := ""
|
||||
if pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil); err == nil && pip.Properties != nil && pip.Properties.IPAddress != nil {
|
||||
ip = *pip.Properties.IPAddress
|
||||
}
|
||||
return azureVMToServer(vm.VirtualMachine, ip), nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) ListCrabboxServers(ctx context.Context) ([]Server, error) {
|
||||
pager := c.vmc.NewListPager(c.ResourceGroup, nil)
|
||||
var servers []Server
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
if isAzureNotFoundError(err) {
|
||||
return servers, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, vm := range page.Value {
|
||||
if vm == nil {
|
||||
continue
|
||||
}
|
||||
if vm.Tags == nil || vm.Tags[azureProviderTag] == nil || *vm.Tags[azureProviderTag] != "true" {
|
||||
continue
|
||||
}
|
||||
ip := ""
|
||||
if vm.Name != nil {
|
||||
pipName := *vm.Name + "-pip"
|
||||
if pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil); err == nil && pip.Properties != nil && pip.Properties.IPAddress != nil {
|
||||
ip = *pip.Properties.IPAddress
|
||||
}
|
||||
}
|
||||
servers = append(servers, azureVMToServer(*vm, ip))
|
||||
}
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) DeleteServer(ctx context.Context, name string) error {
|
||||
return c.deleteVMResources(ctx, name)
|
||||
}
|
||||
|
||||
func (c *AzureClient) deleteVMResources(ctx context.Context, name string) error {
|
||||
for attempt := 0; ; attempt++ {
|
||||
errs, retry := c.deleteVMResourcesOnce(ctx, name)
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !retry || attempt >= azureDeleteRetryAttempts-1 {
|
||||
return joinErrors(errs)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errs = append(errs, ctx.Err())
|
||||
return joinErrors(errs)
|
||||
case <-time.After(azureDeleteRetryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AzureClient) deleteVMResourcesOnce(ctx context.Context, name string) ([]error, bool) {
|
||||
var errs []error
|
||||
retry := false
|
||||
if poller, err := c.vmc.BeginDelete(ctx, c.ResourceGroup, name, nil); err == nil {
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("delete vm %s: %w", name, err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("begin delete vm: %w", err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
if poller, err := c.nicc.BeginDelete(ctx, c.ResourceGroup, name+"-nic", nil); err == nil {
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("delete nic %s-nic: %w", name, err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("begin delete nic: %w", err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
if err := c.deletePublicIP(ctx, name+"-pip"); err != nil {
|
||||
errs = append(errs, err)
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
if poller, err := c.diskc.BeginDelete(ctx, c.ResourceGroup, name+"-osdisk", nil); err == nil {
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("delete disk %s-osdisk: %w", name, err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("begin delete disk: %w", err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
return errs, retry
|
||||
}
|
||||
|
||||
func (c *AzureClient) deletePublicIP(ctx context.Context, pipName string) error {
|
||||
poller, err := c.pipc.BeginDelete(ctx, c.ResourceGroup, pipName, nil)
|
||||
if err != nil {
|
||||
if isAzureNotFoundError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("begin delete pip: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("delete pip %s: %w", pipName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) SetTags(ctx context.Context, name string, labels map[string]string) error {
|
||||
poller, err := c.vmc.BeginUpdate(ctx, c.ResourceGroup, name, armcompute.VirtualMachineUpdate{
|
||||
Tags: azureLabelsToTags(labels),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureVMToServer(vm armcompute.VirtualMachine, ip string) Server {
|
||||
s := Server{
|
||||
Provider: "azure",
|
||||
Labels: map[string]string{},
|
||||
}
|
||||
if vm.Name != nil {
|
||||
s.CloudID = *vm.Name
|
||||
s.Name = *vm.Name
|
||||
}
|
||||
if vm.Properties != nil && vm.Properties.ProvisioningState != nil {
|
||||
s.Status = *vm.Properties.ProvisioningState
|
||||
}
|
||||
if vm.Properties != nil && vm.Properties.HardwareProfile != nil && vm.Properties.HardwareProfile.VMSize != nil {
|
||||
s.ServerType.Name = string(*vm.Properties.HardwareProfile.VMSize)
|
||||
}
|
||||
s.PublicNet.IPv4.IP = ip
|
||||
for k, v := range vm.Tags {
|
||||
if v != nil {
|
||||
s.Labels[azureTagToLabelKey(k)] = *v
|
||||
}
|
||||
}
|
||||
normalizeAzureWindowsModeLabel(s.Labels)
|
||||
return s
|
||||
}
|
||||
|
||||
func azureLabelsToTags(labels map[string]string) map[string]*string {
|
||||
return stringMapToPtrMap(azureTagsFromLabels(labels))
|
||||
}
|
||||
|
||||
func azureTagsFromLabels(labels map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(labels))
|
||||
for k, v := range labels {
|
||||
out[azureLabelToTagKey(k)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func azureLabelToTagKey(key string) string {
|
||||
if strings.HasPrefix(strings.ToLower(key), "windows") {
|
||||
return "crabbox_" + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func azureTagToLabelKey(key string) string {
|
||||
if strings.HasPrefix(key, "crabbox_windows") {
|
||||
return strings.TrimPrefix(key, "crabbox_")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func normalizeAzureWindowsModeLabel(labels map[string]string) {
|
||||
if labels == nil {
|
||||
return
|
||||
}
|
||||
if labels["windows_mode"] == "" && labels["crabbox_windows_mode"] != "" {
|
||||
labels["windows_mode"] = labels["crabbox_windows_mode"]
|
||||
}
|
||||
}
|
||||
|
||||
func stringMapToPtrMap(m map[string]string) map[string]*string {
|
||||
out := make(map[string]*string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = to.Ptr(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isAzureRetryableProvisioningError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "SkuNotAvailable") ||
|
||||
strings.Contains(s, "QuotaExceeded") ||
|
||||
strings.Contains(s, "OperationNotAllowed") ||
|
||||
strings.Contains(s, "AllocationFailed") ||
|
||||
strings.Contains(s, "ZonalAllocationFailed") ||
|
||||
strings.Contains(s, "OverconstrainedAllocationRequest")
|
||||
}
|
||||
|
||||
func isAzureNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var respErr *azcore.ResponseError
|
||||
if errors.As(err, &respErr) && respErr.StatusCode == 404 {
|
||||
return true
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "ResourceNotFound") || strings.Contains(s, "NotFound")
|
||||
}
|
||||
|
||||
func isAzureRetryableDeleteError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "NicReservedForAnotherVm") ||
|
||||
strings.Contains(s, "PublicIPAddressCannotBeDeleted") ||
|
||||
strings.Contains(s, "InUse") ||
|
||||
strings.Contains(s, "AnotherOperationInProgress") ||
|
||||
(strings.Contains(s, "OperationNotAllowed") && strings.Contains(s, "retry after"))
|
||||
}
|
||||
|
||||
func deleteAzureServer(ctx context.Context, cfg Config, server Server) error {
|
||||
client, err := NewAzureClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := server.CloudID
|
||||
if name == "" {
|
||||
name = server.Name
|
||||
}
|
||||
if name == "" {
|
||||
return errors.New("azure delete: server has no name")
|
||||
}
|
||||
return client.DeleteServer(ctx, name)
|
||||
}
|
||||
@ -1,389 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
|
||||
)
|
||||
|
||||
func TestParseAzureImageRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want azureImageRef
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ubuntu jammy gen2",
|
||||
input: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
|
||||
want: azureImageRef{Publisher: "Canonical", Offer: "0001-com-ubuntu-server-jammy", SKU: "22_04-lts-gen2", Version: "latest"},
|
||||
},
|
||||
{
|
||||
name: "missing version",
|
||||
input: "Canonical:offer:sku",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseAzureImageRef(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureImageForConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
linux := baseConfig()
|
||||
linux.TargetOS = targetLinux
|
||||
if got := azureImageForConfig(linux); got != defaultAzureLinuxImage {
|
||||
t.Fatalf("linux image=%q want %q", got, defaultAzureLinuxImage)
|
||||
}
|
||||
windows := baseConfig()
|
||||
windows.TargetOS = targetWindows
|
||||
if got := azureImageForConfig(windows); got != defaultAzureWindowsImage {
|
||||
t.Fatalf("windows image=%q want %q", got, defaultAzureWindowsImage)
|
||||
}
|
||||
windows.AzureImage = "Contoso:offer:sku:latest"
|
||||
if got := azureImageForConfig(windows); got != windows.AzureImage {
|
||||
t.Fatalf("windows explicit image=%q want %q", got, windows.AzureImage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureVMSizeCandidatesForClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
class string
|
||||
want []string
|
||||
}{
|
||||
{class: "standard", want: []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}},
|
||||
{class: "fast", want: []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}},
|
||||
{class: "large", want: []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}},
|
||||
{class: "beast", want: []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}},
|
||||
{class: "Standard_F2s", want: []string{"Standard_F2s"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := azureVMSizeCandidatesForClass(tc.class)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("class=%q: got %v, want %v", tc.class, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureVMSizeCandidatesForTargetModeClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
linux := azureVMSizeCandidatesForTargetModeClass(targetLinux, windowsModeNormal, "standard")
|
||||
if !reflect.DeepEqual(linux, azureVMSizeCandidatesForClass("standard")) {
|
||||
t.Fatalf("linux target got %v want azure linux table", linux)
|
||||
}
|
||||
windows := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeNormal, "standard")
|
||||
if want := azureWindowsVMSizeCandidatesForClass("standard"); !reflect.DeepEqual(windows, want) {
|
||||
t.Fatalf("windows target got %v want %v", windows, want)
|
||||
}
|
||||
wsl2 := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeWSL2, "standard")
|
||||
if !reflect.DeepEqual(wsl2, []string{"standard"}) {
|
||||
t.Fatalf("wsl2 target got %v want explicit fallback", wsl2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureWindowsVMSizeCandidatesForClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := azureWindowsVMSizeCandidatesForClass("beast")
|
||||
want := []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTypeForProviderClassAzure(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := serverTypeForProviderClass("azure", "beast")
|
||||
if got != "Standard_D192ds_v6" {
|
||||
t.Fatalf("got %q, want Standard_D192ds_v6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureSupportsEphemeralOS(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"Standard_D2as_v5": false,
|
||||
"Standard_D8s_v5": false,
|
||||
"Standard_D2ads_v5": true,
|
||||
"Standard_D2ads_v6": true,
|
||||
"Standard_F2s_v2": true,
|
||||
"Standard_E4ds_v5": true,
|
||||
"Standard_D2as_v6": false,
|
||||
"Standard_D2s_v6": false,
|
||||
"Standard_B2s": false,
|
||||
"Standard_A2_v2": false,
|
||||
"": false,
|
||||
}
|
||||
for size, want := range cases {
|
||||
if got := azureSupportsEphemeralOS(size); got != want {
|
||||
t.Fatalf("size=%q got %v want %v", size, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureComputerNameWindowsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetWindows)
|
||||
if len(got) > 15 {
|
||||
t.Fatalf("computer name %q length=%d", got, len(got))
|
||||
}
|
||||
if got != "cbxcbx8556d7bc1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if linux := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetLinux); linux != "crabbox-coral-lobster-c9adbbb9" {
|
||||
t.Fatalf("linux computer name changed to %q", linux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureWindowsBootstrapPowerShell(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "azure"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WorkRoot = defaultWindowsWorkRoot
|
||||
got := azureWindowsBootstrapPowerShell(cfg, "ssh-rsa test")
|
||||
for _, want := range []string{
|
||||
"OpenSSH-Win64.zip",
|
||||
"Git-2.52.0-64-bit.exe",
|
||||
"administrators_authorized_keys",
|
||||
"Match Group administrators",
|
||||
"$sshPorts = @('2222', '22')",
|
||||
"PasswordAuthentication no",
|
||||
"Restart-Service sshd -Force",
|
||||
"Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("bootstrap missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Restart-Computer") {
|
||||
t.Fatalf("azure extension bootstrap must not restart inside Custom Script Extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureTagsMapReservedWindowsPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"crabbox": "true",
|
||||
"windows_mode": "normal",
|
||||
}
|
||||
tags := azureTagsFromLabels(labels)
|
||||
if tags["windows_mode"] != "" {
|
||||
t.Fatalf("reserved windows tag key was not remapped: %#v", tags)
|
||||
}
|
||||
if tags["crabbox_windows_mode"] != "normal" {
|
||||
t.Fatalf("missing remapped windows mode tag: %#v", tags)
|
||||
}
|
||||
server := azureVMToServer(armcompute.VirtualMachine{
|
||||
Tags: stringMapToPtrMap(tags),
|
||||
}, "")
|
||||
if server.Labels["windows_mode"] != "normal" {
|
||||
t.Fatalf("windows_mode label not restored: %#v", server.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureSKUCapabilityTrue(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := []*armcompute.ResourceSKUCapabilities{
|
||||
{Name: to.Ptr("EphemeralOSDiskSupported"), Value: to.Ptr("True")},
|
||||
}
|
||||
if !azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
|
||||
t.Fatal("capability should be true")
|
||||
}
|
||||
caps[0].Value = to.Ptr("False")
|
||||
if azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
|
||||
t.Fatal("capability should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringMapToPtrMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]string{"a": "1", "b": "2"}
|
||||
out := stringMapToPtrMap(in)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len=%d, want 2", len(out))
|
||||
}
|
||||
if *out["a"] != "1" || *out["b"] != "2" {
|
||||
t.Fatalf("values = %v, %v", *out["a"], *out["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAzureRetryableProvisioningError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"some other error": false,
|
||||
"compute.VMs: SkuNotAvailable in this region": true,
|
||||
"QuotaExceeded for cores": true,
|
||||
"AllocationFailed: out of capacity": true,
|
||||
"OverconstrainedAllocationRequest: zone exhausted": true,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
var err error
|
||||
if msg != "" {
|
||||
err = errSentinel(msg)
|
||||
}
|
||||
if got := isAzureRetryableProvisioningError(err); got != want {
|
||||
t.Fatalf("msg=%q got %v want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAzureNotFoundError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"transient": false,
|
||||
"ResponseError: ResourceNotFound: vm missing": true,
|
||||
"NotFound: pip already deleted": true,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
var err error
|
||||
if msg != "" {
|
||||
err = errSentinel(msg)
|
||||
}
|
||||
if got := isAzureNotFoundError(err); got != want {
|
||||
t.Fatalf("msg=%q got %v want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAzureRetryableDeleteError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"validation failed": false,
|
||||
"NicReservedForAnotherVm retry after 180 seconds": true,
|
||||
"PublicIPAddressCannotBeDeleted because in use": true,
|
||||
"AnotherOperationInProgress": true,
|
||||
"OperationNotAllowed retry after 180 seconds": true,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
var err error
|
||||
if msg != "" {
|
||||
err = errSentinel(msg)
|
||||
}
|
||||
if got := isAzureRetryableDeleteError(err); got != want {
|
||||
t.Fatalf("msg=%q got %v want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveNonCrabboxRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := []*armnetwork.SecurityRule{
|
||||
{Name: to.Ptr("crabbox-ssh-2222-0")},
|
||||
{Name: to.Ptr("operator-https")},
|
||||
nil,
|
||||
{},
|
||||
}
|
||||
got := preserveNonCrabboxRules(in)
|
||||
if len(got) != 1 || got[0] == nil || got[0].Name == nil || *got[0].Name != "operator-https" {
|
||||
t.Fatalf("got %+v, want a single operator-https rule", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextAzureNSGPrioritySkipsPreservedRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
used := azureNSGUsedPriorities([]*armnetwork.SecurityRule{{
|
||||
Name: to.Ptr("operator-ssh"),
|
||||
Properties: &armnetwork.SecurityRulePropertiesFormat{
|
||||
Priority: to.Ptr[int32](100),
|
||||
},
|
||||
}})
|
||||
got, err := nextAzureNSGPriority(used)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 101 {
|
||||
t.Fatalf("got %d want 101", got)
|
||||
}
|
||||
}
|
||||
|
||||
type errSentinel string
|
||||
|
||||
func (e errSentinel) Error() string { return string(e) }
|
||||
|
||||
func TestAzureManagedByCrabbox(t *testing.T) {
|
||||
t.Parallel()
|
||||
val := "crabbox"
|
||||
other := "platform-team"
|
||||
cases := []struct {
|
||||
name string
|
||||
tags map[string]*string
|
||||
want bool
|
||||
}{
|
||||
{name: "nil tags", tags: nil, want: false},
|
||||
{name: "missing key", tags: map[string]*string{"crabbox": &val}, want: false},
|
||||
{name: "wrong value", tags: map[string]*string{"managed_by": &other}, want: false},
|
||||
{name: "match", tags: map[string]*string{"managed_by": &val}, want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := azureManagedByCrabbox(tc.tags); got != tc.want {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureCredentialForConfigPrefersClientSecret(t *testing.T) {
|
||||
t.Setenv("AZURE_CLIENT_SECRET", "shh")
|
||||
cfg := Config{
|
||||
AzureTenant: "00000000-0000-0000-0000-000000000001",
|
||||
AzureClientID: "00000000-0000-0000-0000-000000000002",
|
||||
}
|
||||
cred, err := azureCredentialForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := cred.(*azidentity.ClientSecretCredential); !ok {
|
||||
t.Fatalf("got %T, want *azidentity.ClientSecretCredential", cred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureCredentialForConfigFallsBackToDefault(t *testing.T) {
|
||||
// Make sure env vars don't accidentally yield ClientSecretCredential.
|
||||
t.Setenv("AZURE_CLIENT_SECRET", "")
|
||||
cfg := Config{AzureTenant: "tenant", AzureClientID: "client"}
|
||||
cred, err := azureCredentialForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := cred.(*azidentity.ClientSecretCredential); ok {
|
||||
t.Fatalf("got ClientSecretCredential, want DefaultAzureCredential")
|
||||
}
|
||||
if _, ok := cred.(*azidentity.DefaultAzureCredential); !ok {
|
||||
t.Fatalf("got %T, want *azidentity.DefaultAzureCredential", cred)
|
||||
}
|
||||
}
|
||||
@ -84,8 +84,8 @@ runcmd:
|
||||
mkdir -p %[3]s /var/cache/crabbox/pnpm /var/cache/crabbox/npm
|
||||
chown -R %[1]s:%[1]s %[3]s /var/cache/crabbox
|
||||
install -d /var/lib/crabbox
|
||||
systemctl enable ssh || true
|
||||
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
|
||||
systemctl enable --now ssh
|
||||
systemctl restart ssh
|
||||
%[7]s
|
||||
touch /var/lib/crabbox/bootstrapped
|
||||
crabbox-ready
|
||||
@ -102,7 +102,12 @@ tasks:
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapHeaderPowerShell(cfg Config, publicKey, workRoot string) string {
|
||||
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = `C:\crabbox`
|
||||
}
|
||||
wslMode := cfg.WindowsMode == windowsModeWSL2
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
@ -125,108 +130,6 @@ function New-CrabboxPassword {
|
||||
$user = ` + psQuote(cfg.SSHUser) + `
|
||||
$publicKey = ` + psQuote(publicKey) + `
|
||||
$workRoot = ` + psQuote(workRoot) + `
|
||||
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
|
||||
$base = "C:\ProgramData\crabbox"
|
||||
$setupCompletePath = Join-Path $base "setup-complete"
|
||||
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
|
||||
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
|
||||
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapCorePowerShell() string {
|
||||
return `
|
||||
if (-not (Test-Path -LiteralPath $passwordPath)) {
|
||||
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $passwordPath
|
||||
}
|
||||
$userPassword = (Get-Content -Raw -Path $passwordPath).Trim()
|
||||
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
|
||||
$userPassword = New-CrabboxPassword
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
|
||||
}
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
} else {
|
||||
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $usernamePath -Value $user
|
||||
if ($passwordMirrorPath) {
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordMirrorPath -Value $userPassword
|
||||
}
|
||||
$userSID = (Get-LocalUser -Name $user).SID.Value
|
||||
icacls.exe $workRoot /grant "*${userSID}:(OI)(CI)F" | Out-Null
|
||||
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
|
||||
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
|
||||
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
|
||||
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
|
||||
icacls.exe $userSSHDir /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
|
||||
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
|
||||
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
|
||||
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
|
||||
}
|
||||
& "C:\Program Files\OpenSSH\install-sshd.ps1"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
|
||||
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
|
||||
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
|
||||
$sshdConfig = ""
|
||||
if (Test-Path -LiteralPath $sshdConfigPath) {
|
||||
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
|
||||
}
|
||||
$globalLines = @()
|
||||
$matchLines = @()
|
||||
$inMatch = $false
|
||||
foreach ($line in ($sshdConfig -split "\r?\n")) {
|
||||
if ($line -match '^\s*Match\s+') { $inMatch = $true }
|
||||
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
|
||||
if ($enforceKeyAuth -and -not $inMatch -and $line -match '^\s*(PasswordAuthentication|PubkeyAuthentication)\s+') { continue }
|
||||
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
|
||||
}
|
||||
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
|
||||
if ($enforceKeyAuth) {
|
||||
$globalLines += "PubkeyAuthentication yes"
|
||||
$globalLines += "PasswordAuthentication no"
|
||||
}
|
||||
if (($matchLines -join [Environment]::NewLine) -notmatch '(?im)^\s*Match\s+Group\s+administrators\b') {
|
||||
$matchLines += "Match Group administrators"
|
||||
$matchLines += " AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys"
|
||||
}
|
||||
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
|
||||
foreach ($port in $sshPorts) {
|
||||
$ruleName = "crabbox-sshd-$port"
|
||||
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
|
||||
}
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
|
||||
}
|
||||
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
|
||||
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = `C:\crabbox`
|
||||
}
|
||||
wslMode := cfg.WindowsMode == windowsModeWSL2
|
||||
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
|
||||
$wslMode = $` + fmt.Sprint(wslMode) + `
|
||||
$wslDistro = "Crabbox"
|
||||
$wslRoot = "C:\ProgramData\crabbox\wsl\Crabbox"
|
||||
@ -236,16 +139,17 @@ $wslRootfsMinBytes = 100 * 1024 * 1024
|
||||
$wslSetup = "C:\ProgramData\crabbox\wsl\linux-setup.sh"
|
||||
$wslFeaturesMarker = "C:\ProgramData\crabbox\wsl-features-rebooted"
|
||||
$wslKernelMarker = "C:\ProgramData\crabbox\wsl-kernel-rebooted"
|
||||
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
|
||||
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
|
||||
$windowsUsernamePath = "C:\ProgramData\crabbox\windows.username"
|
||||
$windowsPasswordPath = "C:\ProgramData\crabbox\windows.password"
|
||||
$passwordPath = $vncPasswordPath
|
||||
$usernamePath = $windowsUsernamePath
|
||||
$passwordMirrorPath = $windowsPasswordPath
|
||||
$enforceKeyAuth = $false
|
||||
$userVNCStartupPath = "C:\ProgramData\crabbox\start-user-vnc.ps1"
|
||||
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\Users" $user) "AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup") "crabbox-user-vnc.cmd"
|
||||
$setupCompletePath = "C:\ProgramData\crabbox\setup-complete"
|
||||
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
|
||||
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
|
||||
$tightVNCInstaller = "$env:TEMP\tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
New-Item -ItemType Directory -Force -Path "C:\ProgramData\crabbox", $workRoot | Out-Null
|
||||
function Restart-CrabboxBootstrap($MarkerPath) {
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $MarkerPath -Value (Get-Date).ToString("o")
|
||||
Restart-Computer -Force
|
||||
@ -340,7 +244,74 @@ crabbox-ready
|
||||
wsl.exe -d $wslDistro --user root --exec bash /mnt/c/ProgramData/crabbox/wsl/linux-setup.sh
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL setup failed with exit $LASTEXITCODE" }
|
||||
}
|
||||
` + windowsBootstrapCorePowerShell() + `
|
||||
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
|
||||
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
|
||||
}
|
||||
$userPassword = Get-Content -Raw -Path $vncPasswordPath
|
||||
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
|
||||
$userPassword = New-CrabboxPassword
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath -Value $userPassword
|
||||
}
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
} else {
|
||||
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsUsernamePath -Value $user
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsPasswordPath -Value $userPassword
|
||||
$userSID = (Get-LocalUser -Name $user).SID.Value
|
||||
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
|
||||
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
|
||||
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
|
||||
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
|
||||
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
|
||||
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
|
||||
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
|
||||
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
|
||||
}
|
||||
& "C:\Program Files\OpenSSH\install-sshd.ps1"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
|
||||
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
|
||||
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
|
||||
$sshdConfig = ""
|
||||
if (Test-Path -LiteralPath $sshdConfigPath) {
|
||||
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
|
||||
}
|
||||
$globalLines = @()
|
||||
$matchLines = @()
|
||||
$inMatch = $false
|
||||
foreach ($line in ($sshdConfig -split "\r?\n")) {
|
||||
if ($line -match '^\s*Match\s+') { $inMatch = $true }
|
||||
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
|
||||
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
|
||||
}
|
||||
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
|
||||
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
|
||||
foreach ($port in $sshPorts) {
|
||||
$ruleName = "crabbox-sshd-$port"
|
||||
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
|
||||
}
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
|
||||
}
|
||||
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
|
||||
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
Initialize-CrabboxWSL2
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
|
||||
@ -409,24 +380,6 @@ if (-not (Test-Path -LiteralPath $setupCompletePath)) {
|
||||
`
|
||||
}
|
||||
|
||||
func azureWindowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = defaultWindowsWorkRoot
|
||||
}
|
||||
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
|
||||
$passwordPath = Join-Path $base "windows.password"
|
||||
$usernamePath = Join-Path $base "windows.username"
|
||||
$passwordMirrorPath = $null
|
||||
$enforceKeyAuth = $true
|
||||
` + windowsBootstrapCorePowerShell() + `
|
||||
Restart-Service sshd -Force
|
||||
git --version | Out-Null
|
||||
tar --version | Out-Null
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
|
||||
`
|
||||
}
|
||||
|
||||
func windowsSSHPortsPowerShell(cfg Config) string {
|
||||
ports := sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts)
|
||||
quoted := make([]string, 0, len(ports))
|
||||
|
||||
@ -17,8 +17,6 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) {
|
||||
"test -f /var/lib/crabbox/bootstrapped",
|
||||
"test -w /work/crabbox",
|
||||
" Port 2222\n Port 22",
|
||||
"systemctl enable ssh || true",
|
||||
"timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true",
|
||||
"touch /var/lib/crabbox/bootstrapped",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
@ -28,9 +26,6 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) {
|
||||
if strings.Contains(got, "\npackages:\n") {
|
||||
t.Fatal("cloudInit() must not use cloud-init's one-shot packages module")
|
||||
}
|
||||
if strings.Contains(got, "systemctl enable --now ssh") {
|
||||
t.Fatal("cloudInit() must not use blocking systemctl enable --now ssh")
|
||||
}
|
||||
for _, notWant := range []string{"go version", "golang-go", "go.dev/dl/go", "/usr/local/go", "node --version", "pnpm --version", "docker --version", "build-essential", "docker.io", "corepack"} {
|
||||
if strings.Contains(got, notWant) {
|
||||
t.Fatalf("cloudInit() should not install project language runtime %q", notWant)
|
||||
@ -42,7 +37,7 @@ func TestCloudInitStartsSSHBeforeOptionalDesktopBootstrap(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Desktop = true
|
||||
got := cloudInit(cfg, "ssh-ed25519 test")
|
||||
sshIndex := strings.Index(got, "timeout 30s systemctl restart ssh")
|
||||
sshIndex := strings.Index(got, "systemctl restart ssh")
|
||||
desktopIndex := strings.Index(got, "retry apt-get install -y --no-install-recommends xvfb")
|
||||
bootstrappedIndex := strings.Index(got, "touch /var/lib/crabbox/bootstrapped")
|
||||
if sshIndex < 0 || desktopIndex < 0 || bootstrappedIndex < 0 {
|
||||
@ -187,7 +182,6 @@ func TestAWSUserDataWindowsProfile(t *testing.T) {
|
||||
"OpenSSH-Win64.zip",
|
||||
"install-sshd.ps1",
|
||||
"administrators_authorized_keys",
|
||||
"Match Group administrators",
|
||||
"$sshPorts = @('2222', '22')",
|
||||
"sshd_config",
|
||||
"Port $port",
|
||||
|
||||
@ -47,9 +47,6 @@ func validateRequestedCapabilities(cfg Config) error {
|
||||
if cfg.Code && !featureSetHas(spec.Features, FeatureCode) {
|
||||
return exit(2, "web code is not supported for provider=%s", provider.Name())
|
||||
}
|
||||
if cfg.Provider == "azure" && cfg.TargetOS == targetWindows && (cfg.Desktop || cfg.Browser || cfg.Code || cfg.Tailscale.Enabled) {
|
||||
return exit(2, "provider=azure target=windows currently supports SSH, sync, and run; desktop/browser/code/tailscale require Linux or AWS Windows where supported")
|
||||
}
|
||||
if cfg.Code && cfg.TargetOS != targetLinux {
|
||||
return exit(2, "web code currently supports managed Linux leases only")
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ type crabboxKongCLI struct {
|
||||
Run runKongCmd `cmd:"" passthrough:"" help:"Sync the repo, run a remote command, stream output."`
|
||||
Desktop desktopKongCmd `cmd:"" help:"Launch apps into a visible desktop session."`
|
||||
Media mediaKongCmd `cmd:"" help:"Create preview artifacts from recorded desktop videos."`
|
||||
Artifacts artifactsKongCmd `cmd:"" help:"Collect, transform, and publish QA artifacts."`
|
||||
SyncPlan syncPlanKongCmd `cmd:"" name:"sync-plan" passthrough:"" help:"Show local sync manifest size hotspots."`
|
||||
History historyKongCmd `cmd:"" passthrough:"" help:"List recorded remote runs."`
|
||||
Logs logsKongCmd `cmd:"" passthrough:"" help:"Print recorded run logs."`
|
||||
@ -112,7 +111,7 @@ func normalizeKongHelpArgs(args []string) []string {
|
||||
|
||||
func isKongCommandGroup(command string) bool {
|
||||
switch command {
|
||||
case "actions", "admin", "artifacts", "cache", "config", "desktop", "image", "machine", "media", "pool":
|
||||
case "actions", "admin", "cache", "config", "desktop", "image", "machine", "media", "pool":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -238,29 +237,6 @@ type mediaPreviewKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
|
||||
type artifactsKongCmd struct {
|
||||
Collect artifactsCollectKongCmd `cmd:"" passthrough:"" help:"Collect screenshots, video, logs, status, and metadata into a bundle."`
|
||||
Video artifactsVideoKongCmd `cmd:"" passthrough:"" help:"Record an MP4 from a desktop lease."`
|
||||
Gif artifactsGifKongCmd `cmd:"" passthrough:"" help:"Create a trimmed GIF preview from a video."`
|
||||
Template artifactsTemplateKongCmd `cmd:"" passthrough:"" help:"Write Mantis/OpenClaw QA summary markdown."`
|
||||
Publish artifactsPublishKongCmd `cmd:"" passthrough:"" help:"Upload a bundle and optionally comment inline-ready assets on a PR."`
|
||||
}
|
||||
type artifactsCollectKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsVideoKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsGifKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsTemplateKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsPublishKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
|
||||
type cacheKongCmd struct {
|
||||
List cacheListKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
|
||||
Stats cacheStatsKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
|
||||
@ -404,22 +380,6 @@ func (c *mediaPreviewKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.mediaPreview(ctx, c.Args)
|
||||
}
|
||||
|
||||
func (c *artifactsCollectKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsCollect(ctx, stripKongCommandPath(c.Args, "artifacts", "collect"))
|
||||
}
|
||||
func (c *artifactsVideoKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsVideo(ctx, stripKongCommandPath(c.Args, "artifacts", "video"))
|
||||
}
|
||||
func (c *artifactsGifKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsGif(ctx, stripKongCommandPath(c.Args, "artifacts", "gif"))
|
||||
}
|
||||
func (c *artifactsTemplateKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsTemplate(ctx, stripKongCommandPath(c.Args, "artifacts", "template"))
|
||||
}
|
||||
func (c *artifactsPublishKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsPublish(ctx, stripKongCommandPath(c.Args, "artifacts", "publish"))
|
||||
}
|
||||
|
||||
func (c *cacheListKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.cacheStats(ctx, c.Args)
|
||||
}
|
||||
@ -484,14 +444,3 @@ func (c *versionKongCmd) Run(app App) error {
|
||||
fmt.Fprintln(app.Stdout, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripKongCommandPath(args []string, path ...string) []string {
|
||||
out := append([]string{}, args...)
|
||||
for _, part := range path {
|
||||
if len(out) == 0 || out[0] != part {
|
||||
return out
|
||||
}
|
||||
out = out[1:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ const (
|
||||
func (a App) webCode(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("code", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
localPort := fs.String("local-port", "", "local code-server tunnel port")
|
||||
|
||||
@ -38,16 +38,6 @@ type Config struct {
|
||||
AWSRootGB int32
|
||||
AWSSSHCIDRs []string
|
||||
AWSMacHostID string
|
||||
AzureSubscription string
|
||||
AzureTenant string
|
||||
AzureClientID string
|
||||
AzureLocation string
|
||||
AzureResourceGroup string
|
||||
AzureImage string
|
||||
AzureVNet string
|
||||
AzureSubnet string
|
||||
AzureNSG string
|
||||
AzureSSHCIDRs []string
|
||||
SSHUser string
|
||||
SSHKey string
|
||||
SSHPort string
|
||||
@ -205,31 +195,25 @@ func baseConfig() Config {
|
||||
class := "beast"
|
||||
provider := "hetzner"
|
||||
return Config{
|
||||
Profile: "default",
|
||||
Provider: provider,
|
||||
TargetOS: "linux",
|
||||
WindowsMode: "normal",
|
||||
Network: NetworkAuto,
|
||||
Class: class,
|
||||
ServerType: "",
|
||||
Location: "fsn1",
|
||||
Image: "ubuntu-24.04",
|
||||
AWSRegion: "eu-west-1",
|
||||
AWSRootGB: 400,
|
||||
AzureLocation: "eastus",
|
||||
AzureResourceGroup: "crabbox-leases",
|
||||
AzureImage: defaultAzureLinuxImage,
|
||||
AzureVNet: "crabbox-vnet",
|
||||
AzureSubnet: "crabbox-subnet",
|
||||
AzureNSG: "crabbox-nsg",
|
||||
SSHUser: "crabbox",
|
||||
SSHKey: sshKey,
|
||||
SSHPort: "2222",
|
||||
SSHFallbackPorts: []string{"22"},
|
||||
ProviderKey: "crabbox-steipete",
|
||||
WorkRoot: defaultPOSIXWorkRoot,
|
||||
TTL: 90 * time.Minute,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
Profile: "default",
|
||||
Provider: provider,
|
||||
TargetOS: "linux",
|
||||
WindowsMode: "normal",
|
||||
Network: NetworkAuto,
|
||||
Class: class,
|
||||
ServerType: "",
|
||||
Location: "fsn1",
|
||||
Image: "ubuntu-24.04",
|
||||
AWSRegion: "eu-west-1",
|
||||
AWSRootGB: 400,
|
||||
SSHUser: "crabbox",
|
||||
SSHKey: sshKey,
|
||||
SSHPort: "2222",
|
||||
SSHFallbackPorts: []string{"22"},
|
||||
ProviderKey: "crabbox-steipete",
|
||||
WorkRoot: defaultPOSIXWorkRoot,
|
||||
TTL: 90 * time.Minute,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
Sync: SyncConfig{
|
||||
Delete: true,
|
||||
Checksum: false,
|
||||
@ -299,7 +283,6 @@ type fileConfig struct {
|
||||
Broker *fileBrokerConfig `yaml:"broker,omitempty"`
|
||||
Hetzner *fileHetznerConfig `yaml:"hetzner,omitempty"`
|
||||
AWS *fileAWSConfig `yaml:"aws,omitempty"`
|
||||
Azure *fileAzureConfig `yaml:"azure,omitempty"`
|
||||
SSH *fileSSHConfig `yaml:"ssh,omitempty"`
|
||||
Sync *fileSyncConfig `yaml:"sync,omitempty"`
|
||||
Env *fileEnvConfig `yaml:"env,omitempty"`
|
||||
@ -353,19 +336,6 @@ type fileAWSConfig struct {
|
||||
MacHostID string `yaml:"macHostId,omitempty"`
|
||||
}
|
||||
|
||||
type fileAzureConfig struct {
|
||||
SubscriptionID string `yaml:"subscriptionId,omitempty"`
|
||||
TenantID string `yaml:"tenantId,omitempty"`
|
||||
ClientID string `yaml:"clientId,omitempty"`
|
||||
Location string `yaml:"location,omitempty"`
|
||||
ResourceGroup string `yaml:"resourceGroup,omitempty"`
|
||||
Image string `yaml:"image,omitempty"`
|
||||
VNet string `yaml:"vnet,omitempty"`
|
||||
Subnet string `yaml:"subnet,omitempty"`
|
||||
NSG string `yaml:"nsg,omitempty"`
|
||||
SSHCIDRs []string `yaml:"sshCIDRs,omitempty"`
|
||||
}
|
||||
|
||||
type fileSSHConfig struct {
|
||||
User string `yaml:"user,omitempty"`
|
||||
Key string `yaml:"key,omitempty"`
|
||||
@ -678,38 +648,6 @@ func applyFileConfig(cfg *Config, file fileConfig) {
|
||||
cfg.AWSMacHostID = file.AWS.MacHostID
|
||||
}
|
||||
}
|
||||
if file.Azure != nil {
|
||||
if file.Azure.SubscriptionID != "" {
|
||||
cfg.AzureSubscription = file.Azure.SubscriptionID
|
||||
}
|
||||
if file.Azure.TenantID != "" {
|
||||
cfg.AzureTenant = file.Azure.TenantID
|
||||
}
|
||||
if file.Azure.ClientID != "" {
|
||||
cfg.AzureClientID = file.Azure.ClientID
|
||||
}
|
||||
if file.Azure.Location != "" {
|
||||
cfg.AzureLocation = file.Azure.Location
|
||||
}
|
||||
if file.Azure.ResourceGroup != "" {
|
||||
cfg.AzureResourceGroup = file.Azure.ResourceGroup
|
||||
}
|
||||
if file.Azure.Image != "" {
|
||||
cfg.AzureImage = file.Azure.Image
|
||||
}
|
||||
if file.Azure.VNet != "" {
|
||||
cfg.AzureVNet = file.Azure.VNet
|
||||
}
|
||||
if file.Azure.Subnet != "" {
|
||||
cfg.AzureSubnet = file.Azure.Subnet
|
||||
}
|
||||
if file.Azure.NSG != "" {
|
||||
cfg.AzureNSG = file.Azure.NSG
|
||||
}
|
||||
if len(file.Azure.SSHCIDRs) > 0 {
|
||||
cfg.AzureSSHCIDRs = file.Azure.SSHCIDRs
|
||||
}
|
||||
}
|
||||
if file.SSH != nil {
|
||||
if file.SSH.User != "" {
|
||||
cfg.SSHUser = file.SSH.User
|
||||
@ -1005,18 +943,6 @@ func applyEnv(cfg *Config) {
|
||||
if cidrs := os.Getenv("CRABBOX_AWS_SSH_CIDRS"); cidrs != "" {
|
||||
cfg.AWSSSHCIDRs = splitCommaList(cidrs)
|
||||
}
|
||||
cfg.AzureSubscription = getenv("CRABBOX_AZURE_SUBSCRIPTION_ID", getenv("AZURE_SUBSCRIPTION_ID", cfg.AzureSubscription))
|
||||
cfg.AzureTenant = getenv("CRABBOX_AZURE_TENANT_ID", getenv("AZURE_TENANT_ID", cfg.AzureTenant))
|
||||
cfg.AzureClientID = getenv("CRABBOX_AZURE_CLIENT_ID", getenv("AZURE_CLIENT_ID", cfg.AzureClientID))
|
||||
cfg.AzureLocation = getenv("CRABBOX_AZURE_LOCATION", cfg.AzureLocation)
|
||||
cfg.AzureResourceGroup = getenv("CRABBOX_AZURE_RESOURCE_GROUP", cfg.AzureResourceGroup)
|
||||
cfg.AzureImage = getenv("CRABBOX_AZURE_IMAGE", cfg.AzureImage)
|
||||
cfg.AzureVNet = getenv("CRABBOX_AZURE_VNET", cfg.AzureVNet)
|
||||
cfg.AzureSubnet = getenv("CRABBOX_AZURE_SUBNET", cfg.AzureSubnet)
|
||||
cfg.AzureNSG = getenv("CRABBOX_AZURE_NSG", cfg.AzureNSG)
|
||||
if cidrs := os.Getenv("CRABBOX_AZURE_SSH_CIDRS"); cidrs != "" {
|
||||
cfg.AzureSSHCIDRs = splitCommaList(cidrs)
|
||||
}
|
||||
cfg.SSHUser = getenv("CRABBOX_SSH_USER", cfg.SSHUser)
|
||||
cfg.SSHKey = getenv("CRABBOX_SSH_KEY", cfg.SSHKey)
|
||||
cfg.SSHPort = getenv("CRABBOX_SSH_PORT", cfg.SSHPort)
|
||||
@ -1183,9 +1109,6 @@ func serverTypeForConfig(cfg Config) string {
|
||||
if cfg.Provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForConfig(cfg)[0]
|
||||
}
|
||||
if cfg.Provider == "azure" {
|
||||
return azureVMSizeCandidatesForConfig(cfg)[0]
|
||||
}
|
||||
return serverTypeForClass(cfg.Class)
|
||||
}
|
||||
|
||||
@ -1199,9 +1122,6 @@ func serverTypeForProviderClass(provider, class string) string {
|
||||
if provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForClass(class)[0]
|
||||
}
|
||||
if provider == "azure" {
|
||||
return azureVMSizeCandidatesForClass(class)[0]
|
||||
}
|
||||
return serverTypeForClass(class)
|
||||
}
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ func (a App) configShow(args []string) error {
|
||||
func (a App) configSetBroker(args []string) error {
|
||||
fs := newFlagSet("config set-broker", a.Stderr)
|
||||
url := fs.String("url", "", "broker URL")
|
||||
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", "", "default provider: hetzner or aws")
|
||||
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
|
||||
adminTokenStdin := fs.Bool("admin-token-stdin", false, "read broker admin token from stdin")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
|
||||
223
internal/cli/control_ws.go
Normal file
223
internal/cli/control_ws.go
Normal file
@ -0,0 +1,223 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
const coordinatorControlDialTimeout = 1500 * time.Millisecond
|
||||
|
||||
type coordinatorControlConn struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
type coordinatorControlMessage struct {
|
||||
Type string `json:"type"`
|
||||
Protocol int `json:"protocol,omitempty"`
|
||||
ClientID string `json:"clientID,omitempty"`
|
||||
RunID string `json:"runID,omitempty"`
|
||||
Events []CoordinatorRunEvent `json:"events,omitempty"`
|
||||
NextSeq int `json:"nextSeq,omitempty"`
|
||||
LeaseID string `json:"leaseID,omitempty"`
|
||||
OK bool `json:"ok,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
IdleTimeoutSeconds int `json:"idleTimeoutSeconds,omitempty"`
|
||||
Telemetry *LeaseTelemetry `json:"telemetry,omitempty"`
|
||||
}
|
||||
|
||||
func dialCoordinatorControl(ctx context.Context, coord *CoordinatorClient) (*coordinatorControlConn, error) {
|
||||
endpoint, err := coordinatorControlURL(coord.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := http.Header{}
|
||||
coord.addRequestHeaders(headers)
|
||||
opts := &websocket.DialOptions{
|
||||
HTTPHeader: headers,
|
||||
}
|
||||
if coord.Client != nil {
|
||||
opts.HTTPClient = coord.Client
|
||||
}
|
||||
conn, resp, err := websocket.Dial(ctx, endpoint, opts)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &coordinatorControlConn{conn: conn}, nil
|
||||
}
|
||||
|
||||
func coordinatorControlURL(baseURL string) (string, error) {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch base.Scheme {
|
||||
case "http":
|
||||
base.Scheme = "ws"
|
||||
case "https":
|
||||
base.Scheme = "wss"
|
||||
case "ws", "wss":
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported coordinator scheme %q", base.Scheme)
|
||||
}
|
||||
base.Path = strings.TrimRight(base.Path, "/") + "/v1/control"
|
||||
base.RawQuery = ""
|
||||
base.Fragment = ""
|
||||
return base.String(), nil
|
||||
}
|
||||
|
||||
func (c *coordinatorControlConn) close() {
|
||||
if c == nil || c.conn == nil {
|
||||
return
|
||||
}
|
||||
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
|
||||
func (c *coordinatorControlConn) write(ctx context.Context, payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.conn.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
|
||||
func (c *coordinatorControlConn) read(ctx context.Context) (coordinatorControlMessage, error) {
|
||||
typ, data, err := c.conn.Read(ctx)
|
||||
if err != nil {
|
||||
return coordinatorControlMessage{}, err
|
||||
}
|
||||
if typ != websocket.MessageText {
|
||||
return coordinatorControlMessage{}, fmt.Errorf("control websocket sent non-text frame")
|
||||
}
|
||||
var msg coordinatorControlMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return coordinatorControlMessage{}, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func followRunControlWebSocket(ctx context.Context, coord *CoordinatorClient, runID string, after int, poll time.Duration, stdout, stderr io.Writer) (int, bool, bool, error) {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, coordinatorControlDialTimeout)
|
||||
control, err := dialCoordinatorControl(dialCtx, coord)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return after, false, false, ctx.Err()
|
||||
}
|
||||
return after, false, false, nil
|
||||
}
|
||||
defer control.close()
|
||||
nextAfter := after
|
||||
writeCtx, writeCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err = control.write(writeCtx, map[string]any{
|
||||
"type": "subscribe_run",
|
||||
"runID": runID,
|
||||
"after": nextAfter,
|
||||
"limit": 100,
|
||||
})
|
||||
writeCancel()
|
||||
if err != nil {
|
||||
return nextAfter, false, true, nil
|
||||
}
|
||||
for {
|
||||
readCtx, readCancel := context.WithTimeout(ctx, poll)
|
||||
msg, err := control.read(readCtx)
|
||||
readCancel()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nextAfter, false, true, ctx.Err()
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
done, err := coordinatorRunDone(ctx, coord, runID)
|
||||
if err != nil {
|
||||
return nextAfter, false, true, err
|
||||
}
|
||||
if done {
|
||||
return nextAfter, true, true, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
done, doneErr := coordinatorRunDone(ctx, coord, runID)
|
||||
if doneErr != nil {
|
||||
return nextAfter, false, true, doneErr
|
||||
}
|
||||
if done {
|
||||
return nextAfter, true, true, nil
|
||||
}
|
||||
return nextAfter, false, true, nil
|
||||
}
|
||||
switch msg.Type {
|
||||
case "hello", "pong", "heartbeat":
|
||||
continue
|
||||
case "error":
|
||||
return nextAfter, false, true, nil
|
||||
case "run_events":
|
||||
for _, event := range msg.Events {
|
||||
if event.Seq <= nextAfter {
|
||||
continue
|
||||
}
|
||||
nextAfter = event.Seq
|
||||
printAttachEvent(stdout, stderr, event)
|
||||
}
|
||||
ackCtx, ackCancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
_ = control.write(ackCtx, map[string]any{
|
||||
"type": "ack",
|
||||
"runID": runID,
|
||||
"seq": nextAfter,
|
||||
})
|
||||
ackCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func coordinatorRunDone(ctx context.Context, coord *CoordinatorClient, runID string) (bool, error) {
|
||||
run, err := coord.Run(ctx, runID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return run.State != "running", nil
|
||||
}
|
||||
|
||||
func (c *coordinatorControlConn) heartbeat(ctx context.Context, leaseID string, idleTimeout *time.Duration, telemetry *LeaseTelemetry) error {
|
||||
payload := coordinatorControlMessage{
|
||||
Type: "heartbeat",
|
||||
LeaseID: leaseID,
|
||||
Telemetry: telemetry,
|
||||
}
|
||||
if idleTimeout != nil && *idleTimeout > 0 {
|
||||
payload.IdleTimeoutSeconds = int(idleTimeout.Seconds())
|
||||
}
|
||||
if err := c.write(ctx, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
msg, err := c.read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Type != "heartbeat" {
|
||||
continue
|
||||
}
|
||||
if !msg.OK {
|
||||
if msg.Error != "" {
|
||||
return fmt.Errorf("control heartbeat failed: %s", msg.Error)
|
||||
}
|
||||
return fmt.Errorf("control heartbeat failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -157,17 +157,13 @@ type CoordinatorWebVNCEvent struct {
|
||||
}
|
||||
|
||||
type CoordinatorWebVNCStatus struct {
|
||||
LeaseID string `json:"leaseID"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
BridgeConnected bool `json:"bridgeConnected"`
|
||||
ViewerConnected bool `json:"viewerConnected"`
|
||||
ViewerCount int `json:"viewerCount,omitempty"`
|
||||
ObserverCount int `json:"observerCount,omitempty"`
|
||||
AvailableViewerSlots int `json:"availableViewerSlots,omitempty"`
|
||||
ControllerLabel string `json:"controllerLabel,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
|
||||
LeaseID string `json:"leaseID"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
BridgeConnected bool `json:"bridgeConnected"`
|
||||
ViewerConnected bool `json:"viewerConnected"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorWebVNCReset struct {
|
||||
@ -302,38 +298,6 @@ type CoordinatorRunEventInput struct {
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadRequest struct {
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Files []CoordinatorArtifactUploadInput `json:"files"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadInput struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadResponse struct {
|
||||
Backend string `json:"backend"`
|
||||
Bucket string `json:"bucket"`
|
||||
Prefix string `json:"prefix"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Files []CoordinatorArtifactUploadGrant `json:"files"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadGrant struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Upload struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
} `json:"upload"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type TestResultSummary struct {
|
||||
Format string `json:"format"`
|
||||
Files []string `json:"files"`
|
||||
@ -492,8 +456,6 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
|
||||
"awsRootGB": cfg.AWSRootGB,
|
||||
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
|
||||
"awsMacHostID": cfg.AWSMacHostID,
|
||||
"azureLocation": cfg.AzureLocation,
|
||||
"azureImage": cfg.AzureImage,
|
||||
"sshUser": cfg.SSHUser,
|
||||
"sshPort": cfg.SSHPort,
|
||||
"sshFallbackPorts": cfg.SSHFallbackPorts,
|
||||
@ -838,12 +800,6 @@ func (c *CoordinatorClient) AppendRunEvent(ctx context.Context, runID string, in
|
||||
return res.Event, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) CreateArtifactUploads(ctx context.Context, input CoordinatorArtifactUploadRequest) (CoordinatorArtifactUploadResponse, error) {
|
||||
var res CoordinatorArtifactUploadResponse
|
||||
err := c.do(ctx, http.MethodPost, "/v1/artifacts/uploads", input, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) SyncExternalRunners(ctx context.Context, provider string, runners []CoordinatorExternalRunner) (CoordinatorExternalRunnerSyncResponse, error) {
|
||||
var res CoordinatorExternalRunnerSyncResponse
|
||||
err := c.do(ctx, http.MethodPost, "/v1/runners/sync", map[string]any{
|
||||
@ -941,16 +897,7 @@ func (c *CoordinatorClient) doHTTP(ctx context.Context, method, path string, dat
|
||||
if hasBody {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
c.addAccessHeaders(req.Header)
|
||||
if owner := localCoordinatorOwner(); owner != "" {
|
||||
req.Header.Set("X-Crabbox-Owner", owner)
|
||||
}
|
||||
if org := os.Getenv("CRABBOX_ORG"); org != "" {
|
||||
req.Header.Set("X-Crabbox-Org", org)
|
||||
}
|
||||
c.addRequestHeaders(req.Header)
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -959,6 +906,19 @@ func (c *CoordinatorClient) doHTTP(ctx context.Context, method, path string, dat
|
||||
return decodeCoordinatorResponse(method, path, resp.StatusCode, resp.Body, out)
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) addRequestHeaders(headers http.Header) {
|
||||
if c.Token != "" {
|
||||
headers.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
c.addAccessHeaders(headers)
|
||||
if owner := localCoordinatorOwner(); owner != "" {
|
||||
headers.Set("X-Crabbox-Owner", owner)
|
||||
}
|
||||
if org := os.Getenv("CRABBOX_ORG"); org != "" {
|
||||
headers.Set("X-Crabbox-Org", org)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) doCurl(ctx context.Context, method, path string, data []byte, hasBody bool, out any) error {
|
||||
config, cleanup, err := c.curlConfig(method, path, data, hasBody)
|
||||
if err != nil {
|
||||
|
||||
@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
func TestCoordinatorMachineIDAcceptsStringOrNumber(t *testing.T) {
|
||||
@ -295,6 +297,10 @@ func TestCoordinatorAppendRunTelemetry(t *testing.T) {
|
||||
func TestCoordinatorHeartbeatTouchesImmediately(t *testing.T) {
|
||||
touches := make(chan struct{}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/control" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
@ -318,6 +324,10 @@ func TestCoordinatorHeartbeatTouchesImmediately(t *testing.T) {
|
||||
func TestCoordinatorHeartbeatIncludesTelemetry(t *testing.T) {
|
||||
bodies := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/control" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
@ -346,6 +356,59 @@ func TestCoordinatorHeartbeatIncludesTelemetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorHeartbeatUsesControlWebSocket(t *testing.T) {
|
||||
bodies := make(chan string, 1)
|
||||
httpHeartbeats := make(chan struct{}, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/control":
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("accept control websocket: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
_, data, err := conn.Read(r.Context())
|
||||
if err != nil {
|
||||
t.Errorf("read control heartbeat: %v", err)
|
||||
return
|
||||
}
|
||||
bodies <- string(data)
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, []byte(`{"type":"heartbeat","leaseID":"cbx_123","ok":true,"expiresAt":"2026-05-01T00:30:00Z"}`))
|
||||
<-r.Context().Done()
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/leases/cbx_123/heartbeat":
|
||||
httpHeartbeats <- struct{}{}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","expiresAt":"2026-05-01T00:30:00Z"}}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
load := 0.77
|
||||
collector := func(context.Context) (*LeaseTelemetry, error) {
|
||||
return &LeaseTelemetry{Load1: &load}, nil
|
||||
}
|
||||
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
|
||||
stop := startCoordinatorHeartbeat(context.Background(), &client, "cbx_123", 30*time.Minute, nil, collector, io.Discard)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case body := <-bodies:
|
||||
if !strings.Contains(body, `"type":"heartbeat"`) || !strings.Contains(body, `"load1":0.77`) {
|
||||
t.Fatalf("control heartbeat body=%s", body)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not use control websocket")
|
||||
}
|
||||
select {
|
||||
case <-httpHeartbeats:
|
||||
t.Fatal("heartbeat fell back to HTTP despite websocket success")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorLeaseWatchCancelsWhenLeaseReleased(t *testing.T) {
|
||||
oldInterval := coordinatorLeaseWatchInterval
|
||||
coordinatorLeaseWatchInterval = 10 * time.Millisecond
|
||||
@ -379,8 +442,6 @@ func TestCoordinatorLeaseWatchCancelsWhenLeaseReleased(t *testing.T) {
|
||||
func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
var body struct {
|
||||
AWSSSHCIDRs []string `json:"awsSSHCIDRs"`
|
||||
AzureLocation string `json:"azureLocation"`
|
||||
AzureImage string `json:"azureImage"`
|
||||
SSHFallbackPorts []string `json:"sshFallbackPorts"`
|
||||
ServerTypeExplicit bool `json:"serverTypeExplicit"`
|
||||
Capacity map[string]any
|
||||
@ -403,8 +464,6 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
ServerType: "t3.small",
|
||||
ServerTypeExplicit: true,
|
||||
AWSSSHCIDRs: []string{"198.51.100.7/32"},
|
||||
AzureLocation: "eastus",
|
||||
AzureImage: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
|
||||
SSHFallbackPorts: []string{"22", "2022"},
|
||||
Capacity: CapacityConfig{
|
||||
Market: "spot",
|
||||
@ -419,12 +478,6 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
if len(body.AWSSSHCIDRs) != 1 || body.AWSSSHCIDRs[0] != "198.51.100.7/32" {
|
||||
t.Fatalf("awsSSHCIDRs=%v", body.AWSSSHCIDRs)
|
||||
}
|
||||
if body.AzureLocation != "eastus" {
|
||||
t.Fatalf("azureLocation=%q", body.AzureLocation)
|
||||
}
|
||||
if body.AzureImage != "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest" {
|
||||
t.Fatalf("azureImage=%q", body.AzureImage)
|
||||
}
|
||||
if len(body.SSHFallbackPorts) != 2 || body.SSHFallbackPorts[0] != "22" || body.SSHFallbackPorts[1] != "2022" {
|
||||
t.Fatalf("sshFallbackPorts=%v", body.SSHFallbackPorts)
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
func (a App) desktopLaunch(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("desktop launch", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
browser := fs.Bool("browser", false, "launch the target browser")
|
||||
url := fs.String("url", "", "URL to pass to the launched browser")
|
||||
@ -54,7 +54,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
if *webvnc && (isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider)) {
|
||||
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws desktop leases")
|
||||
}
|
||||
if *id == "" && !isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (a App) desktopDoctor(ctx context.Context, args []string) error {
|
||||
@ -32,11 +31,12 @@ func (a App) desktopDoctor(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(a.Stdout, "portal failed webvnc %v\n", err)
|
||||
printRescue(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "portal ok webvnc bridge=%t viewers=%d observers=%d slots=%d\n", status.BridgeConnected, status.ViewerCount, status.ObserverCount, status.AvailableViewerSlots)
|
||||
fmt.Fprintf(a.Stdout, "portal ok webvnc bridge=%t viewer=%t\n", status.BridgeConnected, status.ViewerConnected)
|
||||
if status.ViewerConnected {
|
||||
printRescue(a.Stdout, rescueVNCStaleViewer, "close stale WebVNC tabs or reset this lease's WebVNC session", webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
if !status.BridgeConnected {
|
||||
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else if webVNCObserverSlotsExhausted(status) {
|
||||
printRescue(a.Stdout, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -147,11 +147,6 @@ func (a App) desktopCommandTarget(ctx context.Context, name string, args []strin
|
||||
if strings.HasSuffix(name, "key") {
|
||||
fs.String("keys", "", "xdotool key sequence")
|
||||
}
|
||||
if name == "artifacts video" {
|
||||
fs.String("output", "", "local MP4 output path")
|
||||
fs.Duration("duration", 10*time.Second, "video capture duration")
|
||||
fs.Float64("fps", 15, "video frames per second")
|
||||
}
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return SSHTarget{}, Config{}, "", err
|
||||
}
|
||||
@ -221,15 +216,12 @@ func desktopTextArgOrStdin(stderr io.Writer, args []string, name string) (string
|
||||
}
|
||||
|
||||
func stringFlagValue(args []string, name string) (string, bool) {
|
||||
prefixes := []string{"--" + name + "=", "-" + name + "="}
|
||||
names := map[string]bool{"--" + name: true, "-" + name: true}
|
||||
prefix := "--" + name + "="
|
||||
for i, arg := range args {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix), true
|
||||
}
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix), true
|
||||
}
|
||||
if names[arg] && i+1 < len(args) {
|
||||
if arg == "--"+name && i+1 < len(args) {
|
||||
return args[i+1], true
|
||||
}
|
||||
}
|
||||
@ -245,30 +237,6 @@ func intFlagValue(args []string, name string) (int, bool) {
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
func floatFlagValue(args []string, name string, fallback float64) float64 {
|
||||
value, ok := stringFlagValue(args, name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func durationFlagValue(args []string, name string, fallback time.Duration) time.Duration {
|
||||
value, ok := stringFlagValue(args, name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
func desktopShouldPasteForType(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
|
||||
@ -106,30 +106,6 @@ func TestDesktopKeySequenceArgSkipsLeaseID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringFlagValueAcceptsGoFlagForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "double dash space", args: []string{"--output", "screen.mp4"}, want: "screen.mp4"},
|
||||
{name: "double dash equals", args: []string{"--output=screen.mp4"}, want: "screen.mp4"},
|
||||
{name: "single dash space", args: []string{"-output", "screen.mp4"}, want: "screen.mp4"},
|
||||
{name: "single dash equals", args: []string{"-output=screen.mp4"}, want: "screen.mp4"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := stringFlagValue(tt.args, "output")
|
||||
if !ok {
|
||||
t.Fatal("missing flag")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("value=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
|
||||
got := desktopLaunchWebVNCArgs(
|
||||
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Network: NetworkTailscale},
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
|
||||
func (a App) doctor(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("doctor", a.Stderr)
|
||||
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, azure, or ssh")
|
||||
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "remote lease id to inspect")
|
||||
targetFlags := registerTargetFlags(fs, defaultConfig())
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -138,20 +138,6 @@ func (a App) doctor(ctx context.Context, args []string) error {
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "ok aws crabbox_servers=%d region=%s default_type=%s\n", len(servers), cfg.AWSRegion, cfg.ServerType)
|
||||
}
|
||||
case "azure":
|
||||
client, err := NewAzureClient(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stdout, "failed azure %v\n", err)
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
servers, err := client.ListCrabboxServers(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stdout, "failed azure %v\n", err)
|
||||
ok = false
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "ok azure crabbox_servers=%d location=%s default_type=%s\n", len(servers), cfg.AzureLocation, cfg.ServerType)
|
||||
}
|
||||
default:
|
||||
client, err := newHetznerClient()
|
||||
if err != nil {
|
||||
|
||||
@ -38,13 +38,7 @@ func TestFlagWasSet(t *testing.T) {
|
||||
if !flagWasSet(fs, "id") {
|
||||
t.Fatal("id should be marked set")
|
||||
}
|
||||
if !FlagWasSet(fs, "id") {
|
||||
t.Fatal("exported id check should be marked set")
|
||||
}
|
||||
if flagWasSet(fs, "json") {
|
||||
t.Fatal("json should not be marked set")
|
||||
}
|
||||
if FlagWasSet(fs, "json") {
|
||||
t.Fatal("exported json check should not be marked set")
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,6 +152,13 @@ func (a App) attach(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
nextAfter := *after
|
||||
if wsAfter, done, used, err := followRunControlWebSocket(ctx, coord, *runID, nextAfter, *poll, a.Stdout, a.Stderr); err != nil {
|
||||
return err
|
||||
} else if done {
|
||||
return nil
|
||||
} else if used {
|
||||
nextAfter = wsAfter
|
||||
}
|
||||
for {
|
||||
events, err := coord.RunEvents(ctx, *runID, nextAfter, 100)
|
||||
if err != nil {
|
||||
|
||||
@ -7,6 +7,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
func TestEventsCommandPassesPagination(t *testing.T) {
|
||||
@ -39,6 +41,8 @@ func TestAttachCommandReplaysOutputAndStopsWhenRunFinished(t *testing.T) {
|
||||
eventCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/control":
|
||||
http.NotFound(w, r)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123/events":
|
||||
eventCalls++
|
||||
if eventCalls == 1 {
|
||||
@ -80,3 +84,62 @@ func TestAttachCommandReplaysOutputAndStopsWhenRunFinished(t *testing.T) {
|
||||
t.Fatalf("eventCalls=%d, want 2", eventCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCommandStreamsOverControlWebSocket(t *testing.T) {
|
||||
controlCalls := 0
|
||||
eventCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/control":
|
||||
controlCalls++
|
||||
conn, err := websocket.Accept(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("accept control websocket: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
for {
|
||||
_, data, err := conn.Read(r.Context())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Errorf("control message JSON: %v", err)
|
||||
return
|
||||
}
|
||||
if msg["type"] == "subscribe_run" {
|
||||
if msg["runID"] != "run_123" {
|
||||
t.Errorf("runID=%v", msg["runID"])
|
||||
}
|
||||
_ = conn.Write(r.Context(), websocket.MessageText, []byte(`{"type":"run_events","runID":"run_123","events":[{"runID":"run_123","seq":1,"type":"stdout","stream":"stdout","data":"hello ws\n","createdAt":"2026-05-02T00:00:00Z"}],"nextSeq":1}`))
|
||||
}
|
||||
}
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123":
|
||||
_, _ = w.Write([]byte(`{"run":{"id":"run_123","leaseID":"cbx_123","owner":"peter@example.com","org":"openclaw","provider":"aws","class":"standard","serverType":"t3.small","command":["true"],"state":"succeeded","phase":"finished","logBytes":0,"logTruncated":false,"startedAt":"2026-05-02T00:00:00Z"}}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123/events":
|
||||
eventCalls++
|
||||
_, _ = w.Write([]byte(`{"events":[]}`))
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("CRABBOX_COORDINATOR", server.URL)
|
||||
t.Setenv("CRABBOX_COORDINATOR_TOKEN", "")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: &stderr}
|
||||
if err := app.attach(context.Background(), []string{"run_123", "--poll", "1ms"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stdout.String() != "hello ws\n" {
|
||||
t.Fatalf("stdout=%q", stdout.String())
|
||||
}
|
||||
if controlCalls != 1 {
|
||||
t.Fatalf("controlCalls=%d, want 1", controlCalls)
|
||||
}
|
||||
if eventCalls != 0 {
|
||||
t.Fatalf("HTTP eventCalls=%d, want websocket attach to avoid polling events", eventCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
func (a App) inspect(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("inspect", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
|
||||
@ -57,7 +57,7 @@ func EnsureTestboxKey(leaseID string) (string, string, error) {
|
||||
}
|
||||
|
||||
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
|
||||
if (cfg.Provider == "aws" || cfg.Provider == "azure") && cfg.TargetOS == targetWindows {
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows {
|
||||
return ensureTestboxKeyWithType(leaseID, "rsa")
|
||||
}
|
||||
return ensureTestboxKey(leaseID)
|
||||
|
||||
@ -269,16 +269,6 @@ func commandOutput(ctx context.Context, name string, args ...string) (string, er
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func commandOutputWithEnv(ctx context.Context, env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = env
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err := cmd.Run()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func tailForError(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
const limit = 4096
|
||||
|
||||
@ -305,7 +305,7 @@ func coordinatorMachineOrphanField(labels map[string]string, activeLeaseIDs map[
|
||||
func (a App) cleanup(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("machine cleanup", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
dryRun := fs.Bool("dry-run", false, "only print")
|
||||
providerFlags := registerProviderFlags(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
|
||||
@ -311,11 +311,11 @@ func normalizeProviderName(name string) string {
|
||||
}
|
||||
|
||||
func providerHelpAll() string {
|
||||
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo"
|
||||
return "provider: hetzner, aws, ssh, blacksmith-testbox, daytona, or islo"
|
||||
}
|
||||
|
||||
func providerHelpSSH() string {
|
||||
return "provider: hetzner, aws, azure, ssh, or daytona"
|
||||
return "provider: hetzner, aws, ssh, or daytona"
|
||||
}
|
||||
|
||||
func isBlacksmithProvider(provider string) bool {
|
||||
|
||||
@ -8,37 +8,12 @@ import (
|
||||
func init() {
|
||||
RegisterProvider(testHetznerProvider{})
|
||||
RegisterProvider(testAWSProvider{})
|
||||
RegisterProvider(testAzureProvider{})
|
||||
RegisterProvider(testStaticSSHProvider{})
|
||||
RegisterProvider(testBlacksmithProvider{})
|
||||
RegisterProvider(testDaytonaProvider{})
|
||||
RegisterProvider(testIsloProvider{})
|
||||
}
|
||||
|
||||
type testAzureProvider struct{}
|
||||
|
||||
func (testAzureProvider) Name() string { return "azure" }
|
||||
func (testAzureProvider) Aliases() []string { return nil }
|
||||
func (testAzureProvider) Spec() ProviderSpec {
|
||||
return ProviderSpec{
|
||||
Name: "azure",
|
||||
Kind: ProviderKindSSHLease,
|
||||
Targets: []TargetSpec{
|
||||
{OS: targetLinux},
|
||||
{OS: targetWindows, WindowsMode: windowsModeNormal},
|
||||
},
|
||||
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync, FeatureCleanup, FeatureDesktop, FeatureBrowser, FeatureCode, FeatureTailscale},
|
||||
Coordinator: CoordinatorSupported,
|
||||
}
|
||||
}
|
||||
func (testAzureProvider) RegisterFlags(*flag.FlagSet, Config) any { return noProviderFlags{} }
|
||||
func (testAzureProvider) ApplyFlags(*Config, *flag.FlagSet, any) error {
|
||||
return nil
|
||||
}
|
||||
func (p testAzureProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
|
||||
return testSSHBackend{spec: p.Spec()}, nil
|
||||
}
|
||||
|
||||
type testHetznerProvider struct{}
|
||||
|
||||
func (testHetznerProvider) Name() string { return "hetzner" }
|
||||
|
||||
@ -31,21 +31,6 @@ func touchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server,
|
||||
}
|
||||
return server
|
||||
}
|
||||
if cfg.Provider == "azure" || server.Provider == "azure" {
|
||||
client, err := NewAzureClient(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
|
||||
return server
|
||||
}
|
||||
name := server.CloudID
|
||||
if name == "" {
|
||||
name = server.Name
|
||||
}
|
||||
if err := client.SetTags(ctx, name, server.Labels); err != nil {
|
||||
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
|
||||
}
|
||||
return server
|
||||
}
|
||||
client, err := newHetznerClient()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
|
||||
|
||||
@ -13,12 +13,10 @@ const (
|
||||
rescueInputStackDead = "input stack dead"
|
||||
rescueVNCBridgeDisconnected = "VNC bridge disconnected"
|
||||
rescueVNCBridgeNotRunning = "WebVNC daemon not running"
|
||||
rescueVNCObserverSlotsFull = "WebVNC observer slots exhausted"
|
||||
rescueVNCStaleViewer = "WebVNC viewer already active"
|
||||
rescueVNCTargetUnreachable = "VNC target unreachable"
|
||||
rescueWindowManagerMissing = "window manager missing"
|
||||
rescueScreenshotCaptureBroken = "screenshot capture broken"
|
||||
rescueArtifactCaptureFailed = "artifact capture failed"
|
||||
)
|
||||
|
||||
type rescueContext struct {
|
||||
|
||||
@ -852,16 +852,42 @@ func startCoordinatorHeartbeat(ctx context.Context, coord *CoordinatorClient, le
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
var control *coordinatorControlConn
|
||||
defer func() {
|
||||
if control != nil {
|
||||
control.close()
|
||||
}
|
||||
}()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
telemetry := collectLeaseTelemetryBestEffort(rootCtx, telemetryCollector)
|
||||
callCtx, heartbeatCancel := context.WithTimeout(rootCtx, 20*time.Second)
|
||||
var err error
|
||||
var idleTimeoutOverride *time.Duration
|
||||
if updateIdleTimeout != nil {
|
||||
_, err = coord.UpdateLeaseIdleTimeoutWithTelemetry(callCtx, leaseID, *updateIdleTimeout, telemetry)
|
||||
idleTimeoutOverride = updateIdleTimeout
|
||||
}
|
||||
if control == nil {
|
||||
dialCtx, dialCancel := context.WithTimeout(callCtx, coordinatorControlDialTimeout)
|
||||
control, _ = dialCoordinatorControl(dialCtx, coord)
|
||||
dialCancel()
|
||||
}
|
||||
if control != nil {
|
||||
err = control.heartbeat(callCtx, leaseID, idleTimeoutOverride, telemetry)
|
||||
if err != nil {
|
||||
control.close()
|
||||
control = nil
|
||||
}
|
||||
}
|
||||
if control == nil {
|
||||
if updateIdleTimeout != nil {
|
||||
_, err = coord.UpdateLeaseIdleTimeoutWithTelemetry(callCtx, leaseID, *updateIdleTimeout, telemetry)
|
||||
} else {
|
||||
_, err = coord.TouchLeaseWithTelemetry(callCtx, leaseID, telemetry)
|
||||
}
|
||||
} else {
|
||||
_, err = coord.TouchLeaseWithTelemetry(callCtx, leaseID, telemetry)
|
||||
err = nil
|
||||
}
|
||||
heartbeatCancel()
|
||||
if err != nil && rootCtx.Err() == nil {
|
||||
@ -1107,9 +1133,6 @@ func deleteServer(ctx context.Context, cfg Config, server Server) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.Provider == "azure" || server.Provider == "azure" {
|
||||
return deleteAzureServer(ctx, cfg, server)
|
||||
}
|
||||
client, err := newHetznerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
runEventOutputChunkBytes = 4096
|
||||
runEventOutputChunkBytes = 16 * 1024
|
||||
runEventOutputMaxBytes = 64 * 1024
|
||||
runEventOutputQueueSize = 32
|
||||
runEventOutputPostWait = 2 * time.Second
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
func (a App) screenshot(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("screenshot", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
output := fs.String("output", "", "local PNG output path")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
|
||||
@ -329,20 +329,23 @@ func runSSHInput(ctx context.Context, target SSHTarget, remote string, input io.
|
||||
|
||||
func runSSHStream(ctx context.Context, target SSHTarget, remote string, stdout, stderr io.Writer) int {
|
||||
remote = wrapRemoteForTarget(target, remote)
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(target, remote)...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if ok := asExitError(err, &exitErr); ok {
|
||||
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
|
||||
return status.ExitStatus()
|
||||
lastCode := 7
|
||||
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
|
||||
probe := target
|
||||
probe.Port = port
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(probe, remote)...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
lastCode = exitCode(err)
|
||||
if !shouldRetrySSHPort(err) {
|
||||
return lastCode
|
||||
}
|
||||
}
|
||||
return 7
|
||||
return lastCode
|
||||
}
|
||||
|
||||
func sshArgs(target SSHTarget, remote string) []string {
|
||||
@ -380,7 +383,7 @@ func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts
|
||||
} else {
|
||||
args = append(args,
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPersist=60s",
|
||||
"-o", "ControlPersist=10m",
|
||||
"-o", "ControlPath="+sshControlPath(target),
|
||||
)
|
||||
}
|
||||
|
||||
@ -223,7 +223,7 @@ func TestSSHArgsIncludeReliabilityOptions(t *testing.T) {
|
||||
"ServerAliveInterval=15",
|
||||
"ServerAliveCountMax=2",
|
||||
"ControlMaster=auto",
|
||||
"ControlPersist=60s",
|
||||
"ControlPersist=10m",
|
||||
"ControlPath=",
|
||||
"crabbox-ssh-",
|
||||
"-%C",
|
||||
@ -279,6 +279,54 @@ func TestShouldRetrySSHPortOnlyForTransportExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSSHStreamRetriesFallbackPorts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sshPath := filepath.Join(dir, "ssh")
|
||||
portsPath := filepath.Join(dir, "ports")
|
||||
script := `#!/bin/sh
|
||||
port=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "-p" ]; then
|
||||
shift
|
||||
port="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
printf '%s\n' "$port" >> "$CRABBOX_FAKE_SSH_PORTS"
|
||||
if [ "$port" = "2222" ]; then
|
||||
exit 255
|
||||
fi
|
||||
printf 'ok\n'
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(sshPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("CRABBOX_FAKE_SSH_PORTS", portsPath)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runSSHStream(context.Background(), SSHTarget{
|
||||
User: "crabbox",
|
||||
Host: "203.0.113.10",
|
||||
Port: "2222",
|
||||
FallbackPorts: []string{"22"},
|
||||
}, "true", &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("runSSHStream exit=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
if stdout.String() != "ok\n" {
|
||||
t.Fatalf("stdout=%q want ok", stdout.String())
|
||||
}
|
||||
ports, err := os.ReadFile(portsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(ports) != "2222\n22\n" {
|
||||
t.Fatalf("ports=%q want fallback sequence", string(ports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
|
||||
target := SSHTarget{
|
||||
User: "tok_live_secret",
|
||||
|
||||
@ -115,7 +115,7 @@ func validateProviderTarget(cfg Config) error {
|
||||
return err
|
||||
}
|
||||
if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) {
|
||||
return exit(2, "%s", unsupportedManagedTargetMessageForConfig(provider.Name(), cfg))
|
||||
return exit(2, "%s", unsupportedManagedTargetMessage(provider.Name(), cfg.TargetOS))
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
|
||||
if cfg.AWSMacHostID == "" && cfg.Coordinator == "" {
|
||||
@ -143,20 +143,6 @@ func providerSpecSupportsTarget(spec ProviderSpec, targetOS, windowsMode string)
|
||||
}
|
||||
|
||||
func unsupportedManagedTargetMessage(provider, target string) string {
|
||||
return unsupportedManagedTargetMessageForConfig(provider, Config{TargetOS: target, WindowsMode: windowsModeNormal})
|
||||
}
|
||||
|
||||
func unsupportedManagedTargetMessageForConfig(provider string, cfg Config) string {
|
||||
target := cfg.TargetOS
|
||||
if provider == "azure" && target == targetWindows && cfg.WindowsMode == windowsModeWSL2 {
|
||||
return "provider=azure supports native Windows only; use provider=aws for managed Windows WSL2 or provider=ssh for existing Windows WSL2 hosts"
|
||||
}
|
||||
if provider == "azure" {
|
||||
if target == targetMacOS {
|
||||
return "provider=azure managed provisioning supports target=linux and native Windows only; use provider=aws with an EC2 Mac Dedicated Host or provider=ssh for existing macOS hosts"
|
||||
}
|
||||
return "provider=azure managed provisioning supports target=linux and native Windows only"
|
||||
}
|
||||
switch target {
|
||||
case targetWindows:
|
||||
return sprintf("provider=%s managed provisioning supports target=linux only; use provider=aws for managed Windows or provider=ssh for existing Windows hosts", provider)
|
||||
|
||||
@ -51,43 +51,6 @@ func TestValidateProviderTargetAllowsAWSNativeWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderTargetAllowsAzureNativeWindowsOnly(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "azure"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WindowsMode = windowsModeNormal
|
||||
if err := validateProviderTarget(cfg); err != nil {
|
||||
t.Fatalf("native err=%v", err)
|
||||
}
|
||||
|
||||
cfg.WindowsMode = windowsModeWSL2
|
||||
err := validateProviderTarget(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "native Windows only") {
|
||||
t.Fatalf("wsl2 err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestedCapabilitiesRejectsAzureWindowsDesktop(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"desktop": func(cfg *Config) { cfg.Desktop = true },
|
||||
"browser": func(cfg *Config) { cfg.Browser = true },
|
||||
"code": func(cfg *Config) { cfg.Code = true },
|
||||
"tailscale": func(cfg *Config) { cfg.Tailscale.Enabled = true },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "azure"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WindowsMode = windowsModeNormal
|
||||
mutate(&cfg)
|
||||
err := validateRequestedCapabilities(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "SSH, sync, and run") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderTargetAllowsStaticNonLinux(t *testing.T) {
|
||||
for _, target := range []string{targetMacOS, targetWindows} {
|
||||
cfg := baseConfig()
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
func (a App) vnc(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("vnc", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
localPort := fs.String("local-port", "", "local VNC tunnel port")
|
||||
|
||||
@ -39,7 +39,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(fs.Output(), "")
|
||||
fmt.Fprintln(fs.Output(), "Bridge flags:")
|
||||
fmt.Fprintln(fs.Output(), " --id <lease-id-or-slug>")
|
||||
fmt.Fprintln(fs.Output(), " --provider hetzner|aws|azure")
|
||||
fmt.Fprintln(fs.Output(), " --provider hetzner|aws")
|
||||
fmt.Fprintln(fs.Output(), " --target linux|macos|windows")
|
||||
fmt.Fprintln(fs.Output(), " --windows-mode normal|wsl2")
|
||||
fmt.Fprintln(fs.Output(), " --static-host <host>")
|
||||
@ -51,7 +51,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
fmt.Fprintln(fs.Output(), " --open")
|
||||
fmt.Fprintln(fs.Output(), " --reclaim")
|
||||
}
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
localPort := fs.String("local-port", "", "local VNC tunnel port")
|
||||
@ -83,7 +83,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
return exit(2, "webvnc currently supports coordinator-backed hetzner/aws desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
@ -137,16 +137,26 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
portal := webVNCPortalURL(coord.BaseURL, leaseID, username, password)
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
opened := false
|
||||
return serveWebVNCBridgePool(ctx, webVNCBridgePoolConfig{
|
||||
Coord: coord,
|
||||
LeaseID: leaseID,
|
||||
Host: connHost,
|
||||
Port: connPort,
|
||||
PoolSize: defaultWebVNCBridgePoolSize,
|
||||
RescueCtx: rescueCtx,
|
||||
NativeVNC: nativeVNCOpenCommand(cfg, target, leaseID),
|
||||
Log: a.Stdout,
|
||||
OnReady: func() error {
|
||||
connectedOnce := false
|
||||
attempt := 0
|
||||
for {
|
||||
bridge, err := connectWebVNCBridge(ctx, coord, leaseID, connHost, connPort)
|
||||
if err != nil {
|
||||
if !connectedOnce {
|
||||
printRescueWithFallback(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
return err
|
||||
}
|
||||
attempt++
|
||||
delay := webVNCReconnectDelay(attempt)
|
||||
printRescueWithFallback(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx))
|
||||
fmt.Fprintf(a.Stdout, "bridge: reconnecting in %s\n", delay)
|
||||
if err := waitWebVNCReconnect(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
connectedOnce = true
|
||||
if attempt == 0 {
|
||||
fmt.Fprintln(a.Stdout, "bridge: connected; keep this process running while using WebVNC")
|
||||
fmt.Fprintf(a.Stdout, "webvnc: %s\n", portal)
|
||||
if strings.TrimSpace(password) != "" {
|
||||
@ -155,131 +165,36 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(a.Stdout, "username: %s\n", strings.TrimSpace(username))
|
||||
}
|
||||
}
|
||||
if *openPortal && !opened {
|
||||
if err := openLocalURL(portal); err != nil {
|
||||
return err
|
||||
}
|
||||
opened = true
|
||||
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const defaultWebVNCBridgePoolSize = 4
|
||||
|
||||
type webVNCBridgePoolConfig struct {
|
||||
Coord *CoordinatorClient
|
||||
LeaseID string
|
||||
Host string
|
||||
Port string
|
||||
PoolSize int
|
||||
RescueCtx rescueContext
|
||||
NativeVNC string
|
||||
Log io.Writer
|
||||
OnReady func() error
|
||||
}
|
||||
|
||||
type webVNCBridgePoolEvent struct {
|
||||
Kind string
|
||||
Slot int
|
||||
Attempt int
|
||||
Err error
|
||||
}
|
||||
|
||||
func serveWebVNCBridgePool(ctx context.Context, cfg webVNCBridgePoolConfig) error {
|
||||
if cfg.PoolSize < 1 {
|
||||
cfg.PoolSize = 1
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
events := make(chan webVNCBridgePoolEvent, cfg.PoolSize)
|
||||
for slot := 0; slot < cfg.PoolSize; slot++ {
|
||||
go serveWebVNCBridgeSlot(ctx, cfg, slot, events)
|
||||
}
|
||||
ready := false
|
||||
initialFailures := make(map[int]bool)
|
||||
var firstErr error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
case event := <-events:
|
||||
switch event.Kind {
|
||||
case "ready":
|
||||
if !ready {
|
||||
ready = true
|
||||
if cfg.OnReady != nil {
|
||||
if err := cfg.OnReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case "initial-error":
|
||||
if !ready {
|
||||
initialFailures[event.Slot] = true
|
||||
if firstErr == nil {
|
||||
firstErr = event.Err
|
||||
}
|
||||
if len(initialFailures) >= cfg.PoolSize {
|
||||
printRescueWithFallback(cfg.Log, rescueVNCBridgeDisconnected, firstErr.Error(), cfg.NativeVNC, webVNCStatusRescueCommand(cfg.RescueCtx), webVNCResetRescueCommand(cfg.RescueCtx))
|
||||
return firstErr
|
||||
}
|
||||
}
|
||||
case "retry":
|
||||
if ready && event.Err != nil {
|
||||
printRescueWithFallback(cfg.Log, classifyWebVNCBridgeProblem(event.Err), event.Err.Error(), cfg.NativeVNC, webVNCStatusRescueCommand(cfg.RescueCtx), webVNCResetRescueCommand(cfg.RescueCtx))
|
||||
fmt.Fprintf(cfg.Log, "bridge[%d]: reconnecting in %s\n", event.Slot+1, webVNCReconnectDelay(event.Attempt))
|
||||
}
|
||||
case "fatal":
|
||||
if event.Err != nil {
|
||||
return event.Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "bridge: reconnected after viewer reset (attempt %d)\n", attempt+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveWebVNCBridgeSlot(ctx context.Context, cfg webVNCBridgePoolConfig, slot int, events chan<- webVNCBridgePoolEvent) {
|
||||
connectedOnce := false
|
||||
attempt := 0
|
||||
for {
|
||||
bridge, err := connectWebVNCBridge(ctx, cfg.Coord, cfg.LeaseID, cfg.Host, cfg.Port)
|
||||
if err != nil {
|
||||
attempt, kind := nextWebVNCBridgeFailure(connectedOnce, attempt)
|
||||
events <- webVNCBridgePoolEvent{Kind: kind, Slot: slot, Attempt: attempt, Err: err}
|
||||
if err := waitWebVNCReconnect(ctx, webVNCReconnectDelay(attempt)); err != nil {
|
||||
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
|
||||
return
|
||||
if *openPortal && !opened {
|
||||
if err := openLocalURL(portal); err != nil {
|
||||
bridge.Close()
|
||||
return err
|
||||
}
|
||||
continue
|
||||
opened = true
|
||||
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
|
||||
}
|
||||
connectedOnce = true
|
||||
attempt = 0
|
||||
events <- webVNCBridgePoolEvent{Kind: "ready", Slot: slot}
|
||||
err = bridge.Serve(ctx)
|
||||
if !retryableWebVNCBridgeError(err) {
|
||||
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
|
||||
return
|
||||
return err
|
||||
}
|
||||
attempt++
|
||||
events <- webVNCBridgePoolEvent{Kind: "retry", Slot: slot, Attempt: attempt, Err: err}
|
||||
if err := waitWebVNCReconnect(ctx, webVNCReconnectDelay(attempt)); err != nil {
|
||||
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
|
||||
return
|
||||
delay := webVNCReconnectDelay(attempt)
|
||||
if err != nil {
|
||||
printRescueWithFallback(a.Stdout, classifyWebVNCBridgeProblem(err), err.Error(), nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
fmt.Fprintf(a.Stdout, "bridge: reconnecting in %s\n", delay)
|
||||
} else {
|
||||
printRescueWithFallback(a.Stdout, rescueVNCBridgeDisconnected, "viewer closed", nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx))
|
||||
fmt.Fprintf(a.Stdout, "bridge: reconnecting in %s\n", delay)
|
||||
}
|
||||
if err := waitWebVNCReconnect(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextWebVNCBridgeFailure(connectedOnce bool, attempt int) (int, string) {
|
||||
attempt++
|
||||
if !connectedOnce && attempt == 1 {
|
||||
return attempt, "initial-error"
|
||||
}
|
||||
return attempt, "retry"
|
||||
}
|
||||
|
||||
func (a App) webVNCDaemonCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return exit(2, "usage: crabbox webvnc daemon start|status|stop --id <lease-id-or-slug>")
|
||||
@ -300,10 +215,10 @@ func (a App) webVNCDaemonCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) webVNCDaemonStart(ctx context.Context, args []string) error {
|
||||
func (a App) webVNCDaemonStart(_ context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("webvnc daemon start", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
localPort := fs.String("local-port", "", "local VNC tunnel port")
|
||||
openPortal := fs.Bool("open", false, "open the web portal VNC page")
|
||||
@ -322,34 +237,7 @@ func (a App) webVNCDaemonStart(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
target := SSHTarget{TargetOS: cfg.TargetOS, WindowsMode: cfg.WindowsMode}
|
||||
bridgeID := *id
|
||||
if !isBlacksmithProvider(cfg.Provider) && !isStaticProvider(cfg.Provider) {
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if useCoordinator && coord != nil && coord.Token != "" {
|
||||
server, resolvedTarget, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
if server.Provider != "" {
|
||||
cfg.Provider = server.Provider
|
||||
}
|
||||
if resolvedTarget.TargetOS != "" {
|
||||
cfg.TargetOS = resolvedTarget.TargetOS
|
||||
}
|
||||
if resolvedTarget.WindowsMode != "" {
|
||||
cfg.WindowsMode = resolvedTarget.WindowsMode
|
||||
}
|
||||
target = resolvedTarget
|
||||
bridgeID = leaseID
|
||||
}
|
||||
}
|
||||
daemonArgs := webVNCBridgeArgs(cfg, target, bridgeID, *openPortal)
|
||||
daemonArgs := webVNCBridgeArgs(cfg, target, *id, *openPortal)
|
||||
if strings.TrimSpace(*localPort) != "" {
|
||||
daemonArgs = append(daemonArgs, "--local-port", strings.TrimSpace(*localPort))
|
||||
}
|
||||
@ -388,7 +276,7 @@ func (a App) webVNCDaemonStopCommand(args []string) error {
|
||||
func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("webvnc status", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
localPort := fs.String("local-port", "", "local VNC tunnel port")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
@ -405,7 +293,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "webvnc status currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
return exit(2, "webvnc status currently supports coordinator-backed hetzner/aws desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
@ -465,10 +353,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(a.Stdout, "portal bridge: unknown (%v)\n", statusErr)
|
||||
printRescue(a.Stdout, rescueVNCBridgeDisconnected, statusErr.Error(), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "portal bridge: connected=%t viewers=%d observers=%d slots=%d\n", status.BridgeConnected, status.ViewerCount, status.ObserverCount, status.AvailableViewerSlots)
|
||||
if strings.TrimSpace(status.ControllerLabel) != "" {
|
||||
fmt.Fprintf(a.Stdout, "portal controller: %s\n", strings.TrimSpace(status.ControllerLabel))
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "portal bridge: connected=%t viewer=%t\n", status.BridgeConnected, status.ViewerConnected)
|
||||
if strings.TrimSpace(status.Message) != "" {
|
||||
fmt.Fprintf(a.Stdout, "portal message: %s\n", status.Message)
|
||||
}
|
||||
@ -488,10 +373,10 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(cfg, target, leaseID))
|
||||
if statusErr == nil && !status.BridgeConnected {
|
||||
if statusErr == nil && status.ViewerConnected {
|
||||
printRescue(a.Stdout, rescueVNCStaleViewer, "close stale WebVNC tabs or reset this lease's WebVNC session", webVNCResetRescueCommand(rescueCtx))
|
||||
} else if statusErr == nil && !status.BridgeConnected {
|
||||
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else if statusErr == nil && webVNCObserverSlotsExhausted(status) {
|
||||
printRescue(a.Stdout, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -499,7 +384,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
|
||||
func (a App) webVNCResetCommand(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("webvnc reset", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
openPortal := fs.Bool("open", false, "open the web portal VNC page")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
@ -516,7 +401,7 @@ func (a App) webVNCResetCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "webvnc reset currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
return exit(2, "webvnc reset currently supports coordinator-backed hetzner/aws desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
@ -1015,16 +900,6 @@ func classifyWebVNCBridgeProblem(err error) string {
|
||||
return rescueVNCBridgeDisconnected
|
||||
}
|
||||
|
||||
func webVNCObserverSlotsExhausted(status CoordinatorWebVNCStatus) bool {
|
||||
if !status.BridgeConnected || status.AvailableViewerSlots != 0 {
|
||||
return false
|
||||
}
|
||||
if status.ViewerCount > 0 {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(status.Message, "available WebVNC observer slot")
|
||||
}
|
||||
|
||||
func webVNCReconnectDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
|
||||
@ -173,53 +173,6 @@ func TestClassifyWebVNCBridgeProblem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextWebVNCBridgeFailureBacksOffInitialFailures(t *testing.T) {
|
||||
attempt, kind := nextWebVNCBridgeFailure(false, 0)
|
||||
if attempt != 1 || kind != "initial-error" {
|
||||
t.Fatalf("first failure attempt=%d kind=%q", attempt, kind)
|
||||
}
|
||||
attempt, kind = nextWebVNCBridgeFailure(false, attempt)
|
||||
if attempt != 2 || kind != "retry" {
|
||||
t.Fatalf("second initial failure attempt=%d kind=%q", attempt, kind)
|
||||
}
|
||||
if got := webVNCReconnectDelay(attempt); got != time.Second {
|
||||
t.Fatalf("second initial failure delay=%s, want 1s", got)
|
||||
}
|
||||
attempt, kind = nextWebVNCBridgeFailure(true, 0)
|
||||
if attempt != 1 || kind != "retry" {
|
||||
t.Fatalf("post-connect failure attempt=%d kind=%q", attempt, kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebVNCObserverSlotsExhausted(t *testing.T) {
|
||||
if !webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
ViewerCount: 4,
|
||||
AvailableViewerSlots: 0,
|
||||
}) {
|
||||
t.Fatal("expected full viewer pool to be exhausted")
|
||||
}
|
||||
if !webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
AvailableViewerSlots: 0,
|
||||
Message: "waiting for an available WebVNC observer slot",
|
||||
}) {
|
||||
t.Fatal("expected exhausted status message to be exhausted")
|
||||
}
|
||||
if webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
}) {
|
||||
t.Fatal("old bridge-only status must not be treated as exhausted")
|
||||
}
|
||||
if webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
ViewerCount: 1,
|
||||
AvailableViewerSlots: 2,
|
||||
}) {
|
||||
t.Fatal("available slots must not be exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryBridgeTicketInQuery(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
|
||||
@ -2,7 +2,6 @@ package all
|
||||
|
||||
import (
|
||||
_ "github.com/openclaw/crabbox/internal/providers/aws"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/azure"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/blacksmith"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/daytona"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/hetzner"
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
"github.com/openclaw/crabbox/internal/providers/shared"
|
||||
)
|
||||
|
||||
type Config = core.Config
|
||||
type Runtime = core.Runtime
|
||||
type ProviderSpec = core.ProviderSpec
|
||||
type Backend = core.Backend
|
||||
type AcquireRequest = core.AcquireRequest
|
||||
type ResolveRequest = core.ResolveRequest
|
||||
type ListRequest = core.ListRequest
|
||||
type LeaseView = core.LeaseView
|
||||
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
|
||||
type TouchRequest = core.TouchRequest
|
||||
type CleanupRequest = core.CleanupRequest
|
||||
type LeaseTarget = core.LeaseTarget
|
||||
type Server = core.Server
|
||||
type SSHTarget = core.SSHTarget
|
||||
|
||||
type azureLeaseBackend struct{ shared.DirectSSHBackend }
|
||||
|
||||
func NewAzureLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
|
||||
cfg.Provider = "azure"
|
||||
return &azureLeaseBackend{DirectSSHBackend: shared.DirectSSHBackend{SpecValue: spec, Cfg: cfg, RT: rt}}
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
|
||||
return acquireAttemptsRetry(b.RT, req.Keep, func() (LeaseTarget, error) {
|
||||
return b.acquireOnce(ctx, req.Keep)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) acquireOnce(ctx context.Context, keep bool) (LeaseTarget, error) {
|
||||
if b.Cfg.Tailscale.Enabled && b.Cfg.Tailscale.AuthKey == "" {
|
||||
return LeaseTarget{}, exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", b.Cfg.Tailscale.AuthKeyEnv)
|
||||
}
|
||||
client, err := newAzureClient(ctx, b.Cfg)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
leaseID := newLeaseID()
|
||||
servers, err := client.ListCrabboxServers(ctx)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
slug := allocateDirectLeaseSlug(leaseID, servers)
|
||||
cfg := b.Cfg
|
||||
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
cfg.SSHKey = keyPath
|
||||
cfg.ProviderKey = providerKeyForLease(leaseID)
|
||||
fmt.Fprintf(b.RT.Stderr, "provisioning provider=azure lease=%s slug=%s class=%s preferred_type=%s location=%s rg=%s keep=%v\n",
|
||||
leaseID, slug, cfg.Class, cfg.ServerType, cfg.AzureLocation, cfg.AzureResourceGroup, keep)
|
||||
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
|
||||
fmt.Fprintf(b.RT.Stderr, format, args...)
|
||||
})
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
fmt.Fprintf(b.RT.Stderr, "provisioned lease=%s server=%s type=%s\n", leaseID, server.DisplayID(), cfg.ServerType)
|
||||
server, err = client.WaitForServerIP(ctx, server.CloudID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
|
||||
if err := waitForSSHReady(ctx, &target, b.RT.Stderr, "bootstrap", bootstrapWaitTimeout(cfg)); err != nil {
|
||||
_ = client.DeleteServer(context.Background(), server.CloudID)
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
server.Labels["state"] = "ready"
|
||||
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
|
||||
fmt.Fprintf(b.RT.Stderr, "warning: set tags: %v\n", err)
|
||||
}
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
|
||||
client, err := newAzureClient(ctx, b.Cfg)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if strings.HasPrefix(req.ID, "crabbox-") {
|
||||
server, err := client.GetServer(ctx, req.ID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if !isCrabboxAzureLease(server) {
|
||||
return LeaseTarget{}, exit(4, "lease/server not found: %s (vm exists but is not Crabbox-managed)", req.ID)
|
||||
}
|
||||
leaseID := blank(server.Labels["lease"], req.ID)
|
||||
target := sshTargetFromConfig(b.Cfg, server.PublicNet.IPv4.IP)
|
||||
useStoredTestboxKey(&target, leaseID)
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
servers, err := client.ListCrabboxServers(ctx)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if server, leaseID, err := findServerByAlias(servers, req.ID); err != nil {
|
||||
return LeaseTarget{}, err
|
||||
} else if leaseID != "" {
|
||||
target := sshTargetFromConfig(b.Cfg, server.PublicNet.IPv4.IP)
|
||||
useStoredTestboxKey(&target, leaseID)
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
return LeaseTarget{}, exit(4, "lease/server not found: %s", req.ID)
|
||||
}
|
||||
|
||||
func isCrabboxAzureLease(server Server) bool {
|
||||
if server.Labels == nil {
|
||||
return false
|
||||
}
|
||||
if server.Labels["crabbox"] != "true" {
|
||||
return false
|
||||
}
|
||||
if provider := server.Labels["provider"]; provider != "" && provider != "azure" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
|
||||
_ = req
|
||||
client, err := newAzureClient(ctx, b.Cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ListCrabboxServers(ctx)
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
|
||||
if err := deleteServer(ctx, b.Cfg, req.Lease.Server); err != nil {
|
||||
return err
|
||||
}
|
||||
removeLeaseClaim(req.Lease.LeaseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
|
||||
return b.DirectSSHBackend.Touch(ctx, req.Lease.Server, req.State), nil
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) error {
|
||||
servers, err := b.List(ctx, ListRequest{Options: req.Options})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.CleanupServers(ctx, req, servers)
|
||||
}
|
||||
|
||||
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {
|
||||
return shared.AcquireAttemptsRetry(rt, keep, acquire)
|
||||
}
|
||||
|
||||
func exit(code int, format string, args ...any) core.ExitError {
|
||||
return core.Exit(code, format, args...)
|
||||
}
|
||||
|
||||
func newAzureClient(ctx context.Context, cfg Config) (*core.AzureClient, error) {
|
||||
return core.NewAzureClient(ctx, cfg)
|
||||
}
|
||||
|
||||
func newLeaseID() string { return core.NewLeaseID() }
|
||||
func allocateDirectLeaseSlug(id string, servers []Server) string {
|
||||
return core.AllocateDirectLeaseSlug(id, servers)
|
||||
}
|
||||
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
|
||||
return core.EnsureTestboxKeyForConfig(cfg, leaseID)
|
||||
}
|
||||
func providerKeyForLease(leaseID string) string { return core.ProviderKeyForLease(leaseID) }
|
||||
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
|
||||
return core.SSHTargetFromConfig(cfg, host)
|
||||
}
|
||||
func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
|
||||
return core.WaitForSSHReady(ctx, target, stderr, phase, timeout)
|
||||
}
|
||||
func bootstrapWaitTimeout(cfg Config) time.Duration { return core.BootstrapWaitTimeout(cfg) }
|
||||
func deleteServer(ctx context.Context, cfg Config, server Server) error {
|
||||
return core.DeleteServer(ctx, cfg, server)
|
||||
}
|
||||
func blank(value, fallback string) string { return core.Blank(value, fallback) }
|
||||
func useStoredTestboxKey(target *SSHTarget, leaseID string) {
|
||||
if keyPath, err := core.TestboxKeyPath(leaseID); err == nil {
|
||||
if _, statErr := os.Stat(keyPath); statErr == nil {
|
||||
target.Key = keyPath
|
||||
}
|
||||
}
|
||||
}
|
||||
func findServerByAlias(servers []Server, id string) (Server, string, error) {
|
||||
return core.FindServerByAlias(servers, id)
|
||||
}
|
||||
func removeLeaseClaim(leaseID string) { core.RemoveLeaseClaim(leaseID) }
|
||||
@ -1,35 +0,0 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterProvider(Provider{})
|
||||
}
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (Provider) Name() string { return "azure" }
|
||||
func (Provider) Aliases() []string { return nil }
|
||||
func (Provider) Spec() core.ProviderSpec {
|
||||
return core.ProviderSpec{
|
||||
Name: "azure",
|
||||
Kind: core.ProviderKindSSHLease,
|
||||
Targets: []core.TargetSpec{
|
||||
{OS: core.TargetLinux},
|
||||
{OS: core.TargetWindows, WindowsMode: "normal"},
|
||||
},
|
||||
Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup, core.FeatureDesktop, core.FeatureBrowser, core.FeatureCode, core.FeatureTailscale},
|
||||
Coordinator: core.CoordinatorSupported,
|
||||
}
|
||||
}
|
||||
func (Provider) RegisterFlags(*flag.FlagSet, core.Config) any { return core.NoProviderFlags() }
|
||||
func (Provider) ApplyFlags(*core.Config, *flag.FlagSet, any) error {
|
||||
return nil
|
||||
}
|
||||
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
|
||||
return NewAzureLeaseBackend(p.Spec(), cfg, rt), nil
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
)
|
||||
|
||||
func TestIsCrabboxAzureLeaseRequiresProviderTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{name: "nil labels", labels: nil, want: false},
|
||||
{name: "no crabbox tag", labels: map[string]string{"managed_by": "crabbox"}, want: false},
|
||||
{name: "different provider", labels: map[string]string{"crabbox": "true", "provider": "aws"}, want: false},
|
||||
{name: "tagged azure", labels: map[string]string{"crabbox": "true", "provider": "azure"}, want: true},
|
||||
{name: "tagged no provider", labels: map[string]string{"crabbox": "true"}, want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := core.Server{Labels: tc.labels}
|
||||
if got := isCrabboxAzureLease(s); got != tc.want {
|
||||
t.Fatalf("labels=%+v got %v want %v", tc.labels, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRegistered(t *testing.T) {
|
||||
provider, err := core.ProviderFor("azure")
|
||||
if err != nil {
|
||||
t.Fatalf("expected azure provider to be registered: %v", err)
|
||||
}
|
||||
if got := provider.Name(); got != "azure" {
|
||||
t.Fatalf("provider name = %q, want %q", got, "azure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSpec(t *testing.T) {
|
||||
spec := Provider{}.Spec()
|
||||
if spec.Name != "azure" {
|
||||
t.Fatalf("spec.Name = %q, want azure", spec.Name)
|
||||
}
|
||||
if spec.Kind != core.ProviderKindSSHLease {
|
||||
t.Fatalf("spec.Kind = %q, want %q", spec.Kind, core.ProviderKindSSHLease)
|
||||
}
|
||||
if spec.Coordinator != core.CoordinatorSupported {
|
||||
t.Fatalf("spec.Coordinator = %q, want %q", spec.Coordinator, core.CoordinatorSupported)
|
||||
}
|
||||
wantTargets := []core.TargetSpec{
|
||||
{OS: core.TargetLinux},
|
||||
{OS: core.TargetWindows, WindowsMode: "normal"},
|
||||
}
|
||||
if len(spec.Targets) != len(wantTargets) {
|
||||
t.Fatalf("spec.Targets = %+v, want %+v", spec.Targets, wantTargets)
|
||||
}
|
||||
for i, want := range wantTargets {
|
||||
if spec.Targets[i] != want {
|
||||
t.Fatalf("spec.Targets[%d] = %+v, want %+v", i, spec.Targets[i], want)
|
||||
}
|
||||
}
|
||||
wantFeatures := []core.Feature{
|
||||
core.FeatureSSH,
|
||||
core.FeatureCrabboxSync,
|
||||
core.FeatureCleanup,
|
||||
core.FeatureDesktop,
|
||||
core.FeatureBrowser,
|
||||
core.FeatureCode,
|
||||
core.FeatureTailscale,
|
||||
}
|
||||
if len(spec.Features) != len(wantFeatures) {
|
||||
t.Fatalf("spec.Features = %+v, want %+v", spec.Features, wantFeatures)
|
||||
}
|
||||
for i, f := range wantFeatures {
|
||||
if spec.Features[i] != f {
|
||||
t.Fatalf("spec.Features[%d] = %q, want %q", i, spec.Features[i], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,284 +0,0 @@
|
||||
import { AwsClient } from "aws4fetch";
|
||||
|
||||
import type { Env } from "./types";
|
||||
|
||||
export interface ArtifactUploadRequest {
|
||||
files?: ArtifactUploadFile[];
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadFile {
|
||||
name?: string;
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadGrant {
|
||||
name: string;
|
||||
key: string;
|
||||
upload: {
|
||||
method: "PUT";
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
expiresAt: string;
|
||||
};
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadResponse {
|
||||
backend: string;
|
||||
bucket: string;
|
||||
prefix: string;
|
||||
expiresAt: string;
|
||||
files: ArtifactUploadGrant[];
|
||||
}
|
||||
|
||||
interface ArtifactConfig {
|
||||
backend: "s3" | "r2";
|
||||
bucket: string;
|
||||
prefix: string;
|
||||
baseURL: string;
|
||||
endpointURL: string;
|
||||
region: string;
|
||||
accessKeyID: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken: string;
|
||||
uploadExpiresSeconds: number;
|
||||
urlExpiresSeconds: number;
|
||||
}
|
||||
|
||||
const defaultUploadExpiresSeconds = 15 * 60;
|
||||
const defaultURLExpiresSeconds = 7 * 24 * 60 * 60;
|
||||
const maxArtifactFiles = 100;
|
||||
const maxArtifactFileBytes = 1024 * 1024 * 1024;
|
||||
const maxArtifactBatchBytes = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
export async function artifactUploadResponse(
|
||||
env: Env,
|
||||
request: ArtifactUploadRequest,
|
||||
owner: string,
|
||||
): Promise<ArtifactUploadResponse> {
|
||||
const config = artifactConfig(env);
|
||||
const files = normalizeArtifactFiles(request.files ?? []);
|
||||
if (files.length === 0) {
|
||||
throw new Error("artifacts upload request requires at least one file");
|
||||
}
|
||||
const prefix = artifactPrefix(config.prefix, owner, request.prefix);
|
||||
const now = new Date();
|
||||
const uploadExpiresAt = new Date(
|
||||
now.getTime() + config.uploadExpiresSeconds * 1000,
|
||||
).toISOString();
|
||||
const grants = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const key = artifactObjectKey(prefix, file.name);
|
||||
const headers = artifactUploadHeaders(file);
|
||||
return {
|
||||
name: file.name,
|
||||
key,
|
||||
upload: {
|
||||
method: "PUT" as const,
|
||||
url: await presignArtifactURL(config, "PUT", key, config.uploadExpiresSeconds, headers),
|
||||
headers,
|
||||
expiresAt: uploadExpiresAt,
|
||||
},
|
||||
url: await artifactReadURL(config, key),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
backend: config.backend,
|
||||
bucket: config.bucket,
|
||||
prefix,
|
||||
expiresAt: uploadExpiresAt,
|
||||
files: grants,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactConfig(env: Env): ArtifactConfig {
|
||||
const backend = normalizedBackend(env.CRABBOX_ARTIFACTS_BACKEND);
|
||||
const bucket = trimmed(env.CRABBOX_ARTIFACTS_BUCKET);
|
||||
const accessKeyID = trimmed(env.CRABBOX_ARTIFACTS_ACCESS_KEY_ID);
|
||||
const secretAccessKey = trimmed(env.CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY);
|
||||
if (!backend || !bucket || !accessKeyID || !secretAccessKey) {
|
||||
throw new Error(
|
||||
"artifact broker is not configured; set CRABBOX_ARTIFACTS_BACKEND, BUCKET, ACCESS_KEY_ID, and SECRET_ACCESS_KEY",
|
||||
);
|
||||
}
|
||||
const endpointURL = stripTrailingSlash(env.CRABBOX_ARTIFACTS_ENDPOINT_URL);
|
||||
if (backend === "r2" && !endpointURL) {
|
||||
throw new Error("artifact broker r2 backend requires CRABBOX_ARTIFACTS_ENDPOINT_URL");
|
||||
}
|
||||
const region = trimmed(env.CRABBOX_ARTIFACTS_REGION) || (backend === "r2" ? "auto" : "us-east-1");
|
||||
return {
|
||||
backend,
|
||||
bucket,
|
||||
prefix: trimmed(env.CRABBOX_ARTIFACTS_PREFIX) || "crabbox-artifacts",
|
||||
baseURL: stripTrailingSlash(env.CRABBOX_ARTIFACTS_BASE_URL),
|
||||
endpointURL,
|
||||
region,
|
||||
accessKeyID,
|
||||
secretAccessKey,
|
||||
sessionToken: trimmed(env.CRABBOX_ARTIFACTS_SESSION_TOKEN),
|
||||
uploadExpiresSeconds: positiveInt(
|
||||
env.CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS,
|
||||
defaultUploadExpiresSeconds,
|
||||
),
|
||||
urlExpiresSeconds: positiveInt(
|
||||
env.CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS,
|
||||
defaultURLExpiresSeconds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedBackend(value: string | undefined): "s3" | "r2" | "" {
|
||||
switch (trimmed(value).toLowerCase()) {
|
||||
case "s3":
|
||||
case "aws":
|
||||
case "aws-s3":
|
||||
return "s3";
|
||||
case "r2":
|
||||
case "cloudflare-r2":
|
||||
return "r2";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArtifactFiles(files: ArtifactUploadFile[]): Required<ArtifactUploadFile>[] {
|
||||
if (files.length > maxArtifactFiles) {
|
||||
throw new Error(`artifacts upload request supports at most ${maxArtifactFiles} files`);
|
||||
}
|
||||
let totalSize = 0;
|
||||
const normalized = files.map((file) => {
|
||||
const name = normalizeArtifactName(file.name);
|
||||
const size = Number(file.size ?? 0);
|
||||
if (!Number.isFinite(size) || size < 0 || size > maxArtifactFileBytes) {
|
||||
throw new Error(`invalid artifact size for ${name}`);
|
||||
}
|
||||
totalSize += size;
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
contentType: normalizeContentType(file.contentType),
|
||||
sha256: normalizeHash(file.sha256),
|
||||
};
|
||||
});
|
||||
if (totalSize > maxArtifactBatchBytes) {
|
||||
throw new Error(`artifacts upload request supports at most ${maxArtifactBatchBytes} bytes`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeArtifactName(value: string | undefined): string {
|
||||
const name = trimmed(value).replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
const parts = name.split("/").filter(Boolean);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error(`invalid artifact name: ${value ?? ""}`);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function normalizeContentType(value: string | undefined): string {
|
||||
return trimmed(value).slice(0, 200);
|
||||
}
|
||||
|
||||
function normalizeHash(value: string | undefined): string {
|
||||
const hash = trimmed(value).toLowerCase();
|
||||
return /^[a-f0-9]{64}$/.test(hash) ? hash : "";
|
||||
}
|
||||
|
||||
function artifactPrefix(
|
||||
configPrefix: string,
|
||||
owner: string,
|
||||
requestPrefix: string | undefined,
|
||||
): string {
|
||||
const parts = [
|
||||
normalizePrefixPart(configPrefix),
|
||||
normalizePrefixPart(owner),
|
||||
normalizePrefixPart(requestPrefix),
|
||||
].filter(Boolean);
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function normalizePrefixPart(value: string | undefined): string {
|
||||
return trimmed(value)
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter((part) => part && part !== "." && part !== "..")
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function artifactObjectKey(prefix: string, name: string): string {
|
||||
return [prefix, name].filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
function artifactUploadHeaders(file: Required<ArtifactUploadFile>): Record<string, string> {
|
||||
return {
|
||||
...(file.contentType ? { "content-type": file.contentType } : {}),
|
||||
"content-length": String(file.size),
|
||||
};
|
||||
}
|
||||
|
||||
async function artifactReadURL(config: ArtifactConfig, key: string): Promise<string> {
|
||||
if (config.baseURL) {
|
||||
return joinURLPath(config.baseURL, pathEscapeSegments(key));
|
||||
}
|
||||
return presignArtifactURL(config, "GET", key, config.urlExpiresSeconds);
|
||||
}
|
||||
|
||||
async function presignArtifactURL(
|
||||
config: ArtifactConfig,
|
||||
method: "GET" | "PUT",
|
||||
key: string,
|
||||
expiresSeconds: number,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<string> {
|
||||
const client = new AwsClient({
|
||||
accessKeyId: config.accessKeyID,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
service: "s3",
|
||||
region: config.region,
|
||||
...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
|
||||
});
|
||||
const url = new URL(artifactS3ObjectURL(config, key));
|
||||
url.searchParams.set("X-Amz-Expires", String(expiresSeconds));
|
||||
const signed = await client.sign(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
aws: { signQuery: true, allHeaders: true },
|
||||
});
|
||||
return signed.url;
|
||||
}
|
||||
|
||||
function artifactS3ObjectURL(config: ArtifactConfig, key: string): string {
|
||||
const encodedKey = pathEscapeSegments(key);
|
||||
if (config.endpointURL) {
|
||||
return joinURLPath(config.endpointURL, `${config.bucket}/${encodedKey}`);
|
||||
}
|
||||
if (config.region === "us-east-1") {
|
||||
return `https://${config.bucket}.s3.amazonaws.com/${encodedKey}`;
|
||||
}
|
||||
return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${encodedKey}`;
|
||||
}
|
||||
|
||||
function joinURLPath(base: string, suffix: string): string {
|
||||
return `${stripTrailingSlash(base)}/${suffix.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
function pathEscapeSegments(value: string): string {
|
||||
return value.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string | undefined): string {
|
||||
return trimmed(value).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function positiveInt(value: string | undefined, fallback: number): number {
|
||||
const n = Number.parseInt(trimmed(value), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
@ -1,825 +0,0 @@
|
||||
import { azureWindowsBootstrapPowerShell, cloudInit } from "./bootstrap";
|
||||
import { azureLocationFor, azureVMSizeCandidatesForTargetClass, type LeaseConfig } from "./config";
|
||||
import { leaseProviderLabels } from "./provider-labels";
|
||||
import { leaseProviderName } from "./slug";
|
||||
import type { Env, ProviderMachine } from "./types";
|
||||
|
||||
const ADDRESS_SPACE = "10.42.0.0/16";
|
||||
const SUBNET_CIDR = "10.42.0.0/24";
|
||||
const API_VERSIONS = {
|
||||
resources: "2021-04-01",
|
||||
network: "2024-05-01",
|
||||
compute: "2024-07-01",
|
||||
disks: "2024-03-02",
|
||||
};
|
||||
const DELETE_RETRY_ATTEMPTS = 13;
|
||||
const DELETE_RETRY_DELAY_MS = 15_000;
|
||||
const DEFAULT_AZURE_LINUX_IMAGE = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest";
|
||||
const DEFAULT_AZURE_WINDOWS_IMAGE =
|
||||
"MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest";
|
||||
|
||||
interface TokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface AzureVM {
|
||||
id?: string;
|
||||
name?: string;
|
||||
location?: string;
|
||||
tags?: Record<string, string>;
|
||||
properties?: {
|
||||
provisioningState?: string;
|
||||
hardwareProfile?: { vmSize?: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface AzurePublicIP {
|
||||
id?: string;
|
||||
name?: string;
|
||||
properties?: { ipAddress?: string };
|
||||
}
|
||||
|
||||
interface AzureSecurityRule {
|
||||
name?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AzureSKU {
|
||||
name?: string;
|
||||
resourceType?: string;
|
||||
capabilities?: { name?: string; value?: string }[];
|
||||
}
|
||||
|
||||
export class AzureClient {
|
||||
private readonly tenant: string;
|
||||
private readonly clientID: string;
|
||||
private readonly secret: string;
|
||||
readonly subscription: string;
|
||||
readonly resourceGroup: string;
|
||||
readonly vnet: string;
|
||||
readonly subnet: string;
|
||||
readonly nsg: string;
|
||||
readonly image: string;
|
||||
readonly sshCIDRs: string[];
|
||||
readonly defaultLocation: string;
|
||||
private cache?: TokenCache;
|
||||
private ephemeralOSSupport?: Map<string, boolean>;
|
||||
fetcher: typeof fetch = (input, init) => fetch(input, init);
|
||||
|
||||
constructor(env: Env) {
|
||||
if (!env.AZURE_TENANT_ID) throw new Error("AZURE_TENANT_ID secret is required");
|
||||
if (!env.AZURE_CLIENT_ID) throw new Error("AZURE_CLIENT_ID secret is required");
|
||||
if (!env.AZURE_CLIENT_SECRET) throw new Error("AZURE_CLIENT_SECRET secret is required");
|
||||
if (!env.AZURE_SUBSCRIPTION_ID) throw new Error("AZURE_SUBSCRIPTION_ID secret is required");
|
||||
this.tenant = env.AZURE_TENANT_ID;
|
||||
this.clientID = env.AZURE_CLIENT_ID;
|
||||
this.secret = env.AZURE_CLIENT_SECRET;
|
||||
this.subscription = env.AZURE_SUBSCRIPTION_ID;
|
||||
this.resourceGroup = env.CRABBOX_AZURE_RESOURCE_GROUP?.trim() || "crabbox-leases";
|
||||
this.vnet = env.CRABBOX_AZURE_VNET?.trim() || "crabbox-vnet";
|
||||
this.subnet = env.CRABBOX_AZURE_SUBNET?.trim() || "crabbox-subnet";
|
||||
this.nsg = env.CRABBOX_AZURE_NSG?.trim() || "crabbox-nsg";
|
||||
this.image = env.CRABBOX_AZURE_IMAGE?.trim() || DEFAULT_AZURE_LINUX_IMAGE;
|
||||
this.sshCIDRs = (env.CRABBOX_AZURE_SSH_CIDRS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (this.sshCIDRs.length === 0) this.sshCIDRs.push("0.0.0.0/0");
|
||||
this.defaultLocation = env.CRABBOX_AZURE_LOCATION?.trim() || "eastus";
|
||||
}
|
||||
|
||||
async listCrabboxServers(): Promise<ProviderMachine[]> {
|
||||
const response = await this.arm<{ value: AzureVM[] }>(
|
||||
"GET",
|
||||
`/resourceGroups/${this.resourceGroup}/providers/Microsoft.Compute/virtualMachines`,
|
||||
API_VERSIONS.compute,
|
||||
).catch((error) => {
|
||||
if (isNotFound(error)) return { value: [] as AzureVM[] };
|
||||
throw error;
|
||||
});
|
||||
const tagged = (response.value ?? []).filter((vm) => vm.tags?.["crabbox"] === "true");
|
||||
const ips = await Promise.all(
|
||||
tagged.map((vm) =>
|
||||
vm.name ? this.publicIP(`${vm.name}-pip`).catch(() => "") : Promise.resolve(""),
|
||||
),
|
||||
);
|
||||
return tagged.map((vm, index) => toMachine(vm, ips[index] ?? ""));
|
||||
}
|
||||
|
||||
async createServerWithFallback(
|
||||
config: LeaseConfig,
|
||||
leaseID: string,
|
||||
slug: string,
|
||||
owner: string,
|
||||
): Promise<{ server: ProviderMachine; serverType: string; market: string }> {
|
||||
const location = azureLocationFor(
|
||||
{ CRABBOX_AZURE_LOCATION: this.defaultLocation },
|
||||
config.azureLocation,
|
||||
);
|
||||
await this.ensureSharedInfra(location, config);
|
||||
const candidates =
|
||||
config.serverTypeExplicit && config.serverType
|
||||
? [config.serverType]
|
||||
: prependUnique(
|
||||
config.serverType,
|
||||
azureVMSizeCandidatesForTargetClass(config.target, config.class, config.windowsMode),
|
||||
);
|
||||
const failures: string[] = [];
|
||||
for (const vmSize of candidates) {
|
||||
try {
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- SKU fallback must stay sequential.
|
||||
const server = await this.createVM(
|
||||
{ ...config, serverType: vmSize },
|
||||
location,
|
||||
leaseID,
|
||||
slug,
|
||||
owner,
|
||||
);
|
||||
return { server, serverType: vmSize, market: config.capacityMarket };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failures.push(`${vmSize}: ${message}`);
|
||||
if (!isRetryableProvisioningError(message)) break;
|
||||
}
|
||||
}
|
||||
if (config.capacityMarket === "spot" && config.capacityFallback.startsWith("on-demand")) {
|
||||
for (const vmSize of candidates) {
|
||||
try {
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- market fallback must preserve ordered capacity preference.
|
||||
const server = await this.createVM(
|
||||
{ ...config, capacityMarket: "on-demand", serverType: vmSize },
|
||||
location,
|
||||
leaseID,
|
||||
slug,
|
||||
owner,
|
||||
);
|
||||
return { server, serverType: vmSize, market: "on-demand" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failures.push(`on-demand ${vmSize}: ${message}`);
|
||||
if (!isRetryableProvisioningError(message)) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(failures.join("; "));
|
||||
}
|
||||
|
||||
async deleteServer(name: string): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- delete retries must wait for Azure dependency locks.
|
||||
const result = await this.deleteServerOnce(name);
|
||||
if (result.errors.length === 0) return;
|
||||
if (!result.retry || attempt >= DELETE_RETRY_ATTEMPTS - 1) {
|
||||
throw new Error(result.errors.join("; "));
|
||||
}
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- the next delete attempt depends on this delay.
|
||||
await sleep(DELETE_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteServerOnce(name: string): Promise<{ errors: string[]; retry: boolean }> {
|
||||
const result = { errors: [] as string[], retry: false };
|
||||
await this.deleteResource("vm", vmPath(this.resourceGroup, name), API_VERSIONS.compute, result);
|
||||
await this.deleteResource(
|
||||
"nic",
|
||||
networkPath(this.resourceGroup, "networkInterfaces", `${name}-nic`),
|
||||
API_VERSIONS.network,
|
||||
result,
|
||||
);
|
||||
await this.deleteResource(
|
||||
"pip",
|
||||
networkPath(this.resourceGroup, "publicIPAddresses", `${name}-pip`),
|
||||
API_VERSIONS.network,
|
||||
result,
|
||||
);
|
||||
await this.deleteResource(
|
||||
"disk",
|
||||
`/resourceGroups/${this.resourceGroup}/providers/Microsoft.Compute/disks/${name}-osdisk`,
|
||||
API_VERSIONS.disks,
|
||||
result,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async deleteResource(
|
||||
kind: string,
|
||||
path: string,
|
||||
apiVersion: string,
|
||||
result: { errors: string[]; retry: boolean },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.arm("DELETE", path, apiVersion);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return;
|
||||
result.errors.push(`delete ${kind}: ${errorMessage(error)}`);
|
||||
result.retry ||= isRetryableDeleteError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSharedInfra(location: string, config: LeaseConfig): Promise<void> {
|
||||
const tags = { crabbox: "true", managed_by: "crabbox" };
|
||||
const rg = await this.arm<{ tags?: Record<string, string> }>(
|
||||
"GET",
|
||||
`/resourceGroups/${this.resourceGroup}`,
|
||||
API_VERSIONS.resources,
|
||||
).catch((error) => {
|
||||
if (isNotFound(error)) return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (rg) {
|
||||
if (rg.tags?.["managed_by"] !== "crabbox") {
|
||||
throw new Error(`azure resource group ${this.resourceGroup} is not Crabbox-managed`);
|
||||
}
|
||||
} else {
|
||||
await this.arm("PUT", `/resourceGroups/${this.resourceGroup}`, API_VERSIONS.resources, {
|
||||
location,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
const vnet = await this.arm<{ tags?: Record<string, string> }>(
|
||||
"GET",
|
||||
networkPath(this.resourceGroup, "virtualNetworks", this.vnet),
|
||||
API_VERSIONS.network,
|
||||
).catch((error) => {
|
||||
if (isNotFound(error)) return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (vnet) {
|
||||
if (vnet.tags?.["managed_by"] !== "crabbox") {
|
||||
throw new Error(`azure vnet ${this.vnet} is not Crabbox-managed`);
|
||||
}
|
||||
} else {
|
||||
await this.arm(
|
||||
"PUT",
|
||||
networkPath(this.resourceGroup, "virtualNetworks", this.vnet),
|
||||
API_VERSIONS.network,
|
||||
{
|
||||
location,
|
||||
tags,
|
||||
properties: {
|
||||
addressSpace: { addressPrefixes: [ADDRESS_SPACE] },
|
||||
subnets: [{ name: this.subnet, properties: { addressPrefix: SUBNET_CIDR } }],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const nsg = await this.arm<{
|
||||
tags?: Record<string, string>;
|
||||
properties?: { securityRules?: AzureSecurityRule[] };
|
||||
}>(
|
||||
"GET",
|
||||
networkPath(this.resourceGroup, "networkSecurityGroups", this.nsg),
|
||||
API_VERSIONS.network,
|
||||
).catch((error) => {
|
||||
if (isNotFound(error)) return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (nsg && nsg.tags?.["managed_by"] !== "crabbox") {
|
||||
throw new Error(`azure nsg ${this.nsg} is not Crabbox-managed`);
|
||||
}
|
||||
const preserved = preserveNonCrabboxRules(nsg?.properties?.securityRules ?? []);
|
||||
const usedPriorities = usedNSGPriorities(preserved);
|
||||
const rules = [...preserved, ...this.buildSSHRules(config, usedPriorities)];
|
||||
await this.arm(
|
||||
"PUT",
|
||||
networkPath(this.resourceGroup, "networkSecurityGroups", this.nsg),
|
||||
API_VERSIONS.network,
|
||||
{
|
||||
location,
|
||||
tags,
|
||||
properties: { securityRules: rules },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private buildSSHRules(config: LeaseConfig, usedPriorities: Set<number>) {
|
||||
const ports = [config.sshPort, ...config.sshFallbackPorts].filter(Boolean);
|
||||
const rules = [];
|
||||
for (const port of ports) {
|
||||
for (let index = 0; index < this.sshCIDRs.length; index += 1) {
|
||||
const priority = nextNSGPriority(usedPriorities);
|
||||
rules.push({
|
||||
name: `crabbox-ssh-${port}-${index}`,
|
||||
properties: {
|
||||
priority,
|
||||
direction: "Inbound",
|
||||
access: "Allow",
|
||||
protocol: "Tcp",
|
||||
sourceAddressPrefix: this.sshCIDRs[index],
|
||||
sourcePortRange: "*",
|
||||
destinationAddressPrefix: "*",
|
||||
destinationPortRange: port,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
private async createVM(
|
||||
config: LeaseConfig,
|
||||
location: string,
|
||||
leaseID: string,
|
||||
slug: string,
|
||||
owner: string,
|
||||
): Promise<ProviderMachine> {
|
||||
const name = leaseProviderName(leaseID, slug);
|
||||
try {
|
||||
return await this.createVMUnchecked(config, location, leaseID, slug, owner, name);
|
||||
} catch (error) {
|
||||
await this.deleteServer(name).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createVMUnchecked(
|
||||
config: LeaseConfig,
|
||||
location: string,
|
||||
leaseID: string,
|
||||
slug: string,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<ProviderMachine> {
|
||||
const tags = azureTagsFromLabels(
|
||||
leaseProviderLabels(config, leaseID, slug, owner, "azure", new Date(), {
|
||||
market: config.capacityMarket,
|
||||
}),
|
||||
);
|
||||
await this.arm(
|
||||
"PUT",
|
||||
networkPath(this.resourceGroup, "publicIPAddresses", `${name}-pip`),
|
||||
API_VERSIONS.network,
|
||||
{
|
||||
location,
|
||||
tags,
|
||||
sku: { name: "Standard" },
|
||||
properties: { publicIPAllocationMethod: "Static" },
|
||||
},
|
||||
);
|
||||
const subnetID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/virtualNetworks/${this.vnet}/subnets/${this.subnet}`;
|
||||
const nsgID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/networkSecurityGroups/${this.nsg}`;
|
||||
const pipID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/publicIPAddresses/${name}-pip`;
|
||||
const nicID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/networkInterfaces/${name}-nic`;
|
||||
await this.arm(
|
||||
"PUT",
|
||||
networkPath(this.resourceGroup, "networkInterfaces", `${name}-nic`),
|
||||
API_VERSIONS.network,
|
||||
{
|
||||
location,
|
||||
tags,
|
||||
properties: {
|
||||
ipConfigurations: [
|
||||
{
|
||||
name: "ipconfig",
|
||||
properties: {
|
||||
privateIPAllocationMethod: "Dynamic",
|
||||
subnet: { id: subnetID },
|
||||
publicIPAddress: { id: pipID },
|
||||
},
|
||||
},
|
||||
],
|
||||
networkSecurityGroup: { id: nsgID },
|
||||
},
|
||||
},
|
||||
);
|
||||
const image = parseImageRef(this.imageForConfig(config));
|
||||
const customData = btoa(
|
||||
config.target === "windows" ? azureWindowsBootstrapPowerShell(config) : cloudInit(config),
|
||||
);
|
||||
const osDisk: Record<string, unknown> = {
|
||||
name: `${name}-osdisk`,
|
||||
createOption: "FromImage",
|
||||
};
|
||||
if (await this.supportsEphemeralOS(config.serverType, location)) {
|
||||
osDisk["caching"] = "ReadOnly";
|
||||
osDisk["diffDiskSettings"] = { option: "Local" };
|
||||
} else {
|
||||
osDisk["caching"] = "ReadWrite";
|
||||
osDisk["managedDisk"] = { storageAccountType: "StandardSSD_LRS" };
|
||||
}
|
||||
const vmProperties: Record<string, unknown> = {
|
||||
hardwareProfile: { vmSize: config.serverType },
|
||||
storageProfile: {
|
||||
imageReference: image,
|
||||
osDisk,
|
||||
},
|
||||
osProfile: this.osProfile(config, name, leaseID, customData),
|
||||
networkProfile: { networkInterfaces: [{ id: nicID }] },
|
||||
};
|
||||
if (config.capacityMarket === "spot") {
|
||||
vmProperties["priority"] = "Spot";
|
||||
vmProperties["evictionPolicy"] = "Delete";
|
||||
}
|
||||
await this.arm("PUT", vmPath(this.resourceGroup, name), API_VERSIONS.compute, {
|
||||
location,
|
||||
tags,
|
||||
properties: vmProperties,
|
||||
});
|
||||
if (config.target === "windows") {
|
||||
await this.installWindowsBootstrapExtension(location, name, tags);
|
||||
}
|
||||
const ip = await this.publicIP(`${name}-pip`);
|
||||
const vm = await this.arm<AzureVM>(
|
||||
"GET",
|
||||
vmPath(this.resourceGroup, name),
|
||||
API_VERSIONS.compute,
|
||||
);
|
||||
return toMachine(vm, ip);
|
||||
}
|
||||
|
||||
private imageForConfig(config: LeaseConfig): string {
|
||||
const image = config.azureImage || this.image;
|
||||
if (config.target === "windows" && image === DEFAULT_AZURE_LINUX_IMAGE) {
|
||||
return DEFAULT_AZURE_WINDOWS_IMAGE;
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private osProfile(
|
||||
config: LeaseConfig,
|
||||
name: string,
|
||||
leaseID: string,
|
||||
customData: string,
|
||||
): Record<string, unknown> {
|
||||
if (config.target !== "windows") {
|
||||
return {
|
||||
computerName: name,
|
||||
adminUsername: config.sshUser,
|
||||
customData,
|
||||
linuxConfiguration: {
|
||||
disablePasswordAuthentication: true,
|
||||
ssh: {
|
||||
publicKeys: [
|
||||
{
|
||||
path: `/home/${config.sshUser}/.ssh/authorized_keys`,
|
||||
keyData: config.sshPublicKey,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
computerName: azureComputerName(name, leaseID, config.target),
|
||||
adminUsername: "crabadmin",
|
||||
adminPassword: azureRandomAdminPassword(),
|
||||
allowExtensionOperations: true,
|
||||
customData,
|
||||
windowsConfiguration: {
|
||||
provisionVMAgent: true,
|
||||
enableAutomaticUpdates: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async installWindowsBootstrapExtension(
|
||||
location: string,
|
||||
vmName: string,
|
||||
tags: Record<string, string>,
|
||||
): Promise<void> {
|
||||
await this.arm(
|
||||
"PUT",
|
||||
`${vmPath(this.resourceGroup, vmName)}/extensions/crabbox-bootstrap`,
|
||||
API_VERSIONS.compute,
|
||||
{
|
||||
location,
|
||||
tags,
|
||||
properties: {
|
||||
publisher: "Microsoft.Compute",
|
||||
type: "CustomScriptExtension",
|
||||
typeHandlerVersion: "1.10",
|
||||
autoUpgradeMinorVersion: true,
|
||||
settings: { timestamp: Math.trunc(Date.now() / 1000) },
|
||||
protectedSettings: {
|
||||
commandToExecute: azureWindowsBootstrapCommand(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async publicIP(name: string): Promise<string> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- public IP polling must wait between Azure reads.
|
||||
const pip = await this.arm<AzurePublicIP>(
|
||||
"GET",
|
||||
networkPath(this.resourceGroup, "publicIPAddresses", name),
|
||||
API_VERSIONS.network,
|
||||
);
|
||||
if (pip.properties?.ipAddress) return pip.properties.ipAddress;
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- this delay is the polling interval.
|
||||
await sleep(2_000);
|
||||
}
|
||||
throw new Error(`timed out waiting for public ip: ${name}`);
|
||||
}
|
||||
|
||||
private async arm<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
apiVersion: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const token = await this.token();
|
||||
const url = `https://management.azure.com/subscriptions/${this.subscription}${path}?api-version=${apiVersion}`;
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
const response = await this.fetcher(url, init);
|
||||
if (!response.ok && response.status !== 201 && response.status !== 202) {
|
||||
throw new Error(
|
||||
`azure ${method} ${path}: http ${response.status}: ${await safeBody(response)}`,
|
||||
);
|
||||
}
|
||||
const initialText = await response.text();
|
||||
if (response.status === 201 || response.status === 202) {
|
||||
await this.awaitLRO(response, token);
|
||||
if (method === "DELETE") return undefined as T;
|
||||
// 201 typically returns the resource in the initial body; 202 returns nothing,
|
||||
// so re-GET the resource to read its post-provision state.
|
||||
if (initialText) return JSON.parse(initialText) as T;
|
||||
const refetch = await this.fetcher(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!refetch.ok) {
|
||||
throw new Error(
|
||||
`azure ${method} ${path}: refetch http ${refetch.status}: ${await safeBody(refetch)}`,
|
||||
);
|
||||
}
|
||||
const refetchText = await refetch.text();
|
||||
return refetchText ? (JSON.parse(refetchText) as T) : (undefined as T);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return initialText ? (JSON.parse(initialText) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
private async supportsEphemeralOS(vmSize: string, location: string): Promise<boolean> {
|
||||
if (!this.ephemeralOSSupport) {
|
||||
try {
|
||||
this.ephemeralOSSupport = await this.loadEphemeralOSSupport(location);
|
||||
} catch {
|
||||
return azureSupportsEphemeralOS(vmSize);
|
||||
}
|
||||
}
|
||||
return this.ephemeralOSSupport.get(vmSize) ?? azureSupportsEphemeralOS(vmSize);
|
||||
}
|
||||
|
||||
private async loadEphemeralOSSupport(location: string): Promise<Map<string, boolean>> {
|
||||
const token = await this.token();
|
||||
const url = new URL(
|
||||
`https://management.azure.com/subscriptions/${this.subscription}/providers/Microsoft.Compute/skus`,
|
||||
);
|
||||
url.searchParams.set("api-version", API_VERSIONS.compute);
|
||||
url.searchParams.set("$filter", `location eq '${location}'`);
|
||||
const response = await this.fetcher(url.toString(), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`azure GET resource skus: http ${response.status}: ${await safeBody(response)}`,
|
||||
);
|
||||
}
|
||||
const json = (await response.json()) as { value?: AzureSKU[] };
|
||||
const support = new Map<string, boolean>();
|
||||
for (const sku of json.value ?? []) {
|
||||
if (!sku.name || sku.resourceType !== "virtualMachines") continue;
|
||||
support.set(sku.name, azureSKUCapabilityTrue(sku.capabilities, "EphemeralOSDiskSupported"));
|
||||
}
|
||||
return support;
|
||||
}
|
||||
|
||||
private async awaitLRO(response: Response, token: string): Promise<void> {
|
||||
const asyncURL =
|
||||
response.headers.get("azure-asyncoperation") ?? response.headers.get("location");
|
||||
if (!asyncURL) return;
|
||||
const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "", 10);
|
||||
const interval = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 3_000;
|
||||
const deadline = Date.now() + 20 * 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- LRO must wait between status reads.
|
||||
await sleep(interval);
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- LRO polling is sequential.
|
||||
const poll = await this.fetcher(asyncURL, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!poll.ok) {
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- only reached on error to format diagnostic.
|
||||
const detail = await safeBody(poll);
|
||||
throw new Error(`azure LRO poll: http ${poll.status}: ${detail}`);
|
||||
}
|
||||
// oxlint-disable-next-line eslint/no-await-in-loop -- reading the LRO status payload is part of polling.
|
||||
const text = await poll.text();
|
||||
const status = text ? (JSON.parse(text) as { status?: string }).status?.toLowerCase() : "";
|
||||
if (status === "succeeded") return;
|
||||
if (status === "failed" || status === "canceled") {
|
||||
throw new Error(`azure LRO ${status}: ${text}`);
|
||||
}
|
||||
}
|
||||
throw new Error("azure long-running operation timed out");
|
||||
}
|
||||
|
||||
private async token(): Promise<string> {
|
||||
if (this.cache && this.cache.expiresAt > Date.now() + 30_000) return this.cache.token;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: this.clientID,
|
||||
client_secret: this.secret,
|
||||
scope: "https://management.azure.com/.default",
|
||||
});
|
||||
const response = await this.fetcher(
|
||||
`https://login.microsoftonline.com/${this.tenant}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`azure token: http ${response.status}: ${await safeBody(response)}`);
|
||||
}
|
||||
const json = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||
if (!json.access_token) throw new Error("azure token response missing access_token");
|
||||
this.cache = {
|
||||
token: json.access_token,
|
||||
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
|
||||
};
|
||||
return this.cache.token;
|
||||
}
|
||||
}
|
||||
|
||||
function azureWindowsBootstrapCommand(): string {
|
||||
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$p=Join-Path $env:SystemDrive 'AzureData\\CustomData.bin'; $d=Join-Path $env:SystemDrive 'AzureData\\crabbox-bootstrap.ps1'; Copy-Item -Force $p $d; & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $d"`;
|
||||
}
|
||||
|
||||
function azureRandomAdminPassword(): string {
|
||||
const bytes = new Uint8Array(18);
|
||||
crypto.getRandomValues(bytes);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return `Cb1!${btoa(binary).slice(0, 18)}`;
|
||||
}
|
||||
|
||||
function azureComputerName(vmName: string, leaseID: string, target: string): string {
|
||||
if (target !== "windows") return vmName;
|
||||
const suffix = (leaseID || vmName)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "")
|
||||
.slice(0, 12);
|
||||
return `cbx${suffix || "windows"}`;
|
||||
}
|
||||
|
||||
function vmPath(rg: string, name: string): string {
|
||||
return `/resourceGroups/${rg}/providers/Microsoft.Compute/virtualMachines/${name}`;
|
||||
}
|
||||
|
||||
function networkPath(rg: string, kind: string, name: string): string {
|
||||
return `/resourceGroups/${rg}/providers/Microsoft.Network/${kind}/${name}`;
|
||||
}
|
||||
|
||||
function parseImageRef(value: string): {
|
||||
publisher: string;
|
||||
offer: string;
|
||||
sku: string;
|
||||
version: string;
|
||||
} {
|
||||
const parts = value.split(":");
|
||||
if (parts.length !== 4) {
|
||||
throw new Error(`azure image must be Publisher:Offer:SKU:Version, got ${value}`);
|
||||
}
|
||||
return { publisher: parts[0]!, offer: parts[1]!, sku: parts[2]!, version: parts[3]! };
|
||||
}
|
||||
|
||||
function toMachine(vm: AzureVM, ip: string): ProviderMachine {
|
||||
return {
|
||||
provider: "azure",
|
||||
id: 0,
|
||||
cloudID: vm.name ?? "",
|
||||
name: vm.name ?? "",
|
||||
status: vm.properties?.provisioningState ?? "",
|
||||
serverType: vm.properties?.hardwareProfile?.vmSize ?? "",
|
||||
host: ip,
|
||||
labels: azureLabelsFromTags(vm.tags ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function azureTagsFromLabels(labels: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(labels).map(([key, value]) => [azureLabelToTagKey(key), value]),
|
||||
);
|
||||
}
|
||||
|
||||
export function azureLabelsFromTags(tags: Record<string, string>): Record<string, string> {
|
||||
const labels = Object.fromEntries(
|
||||
Object.entries(tags).map(([key, value]) => [azureTagToLabelKey(key), value]),
|
||||
);
|
||||
if (!labels["windows_mode"] && labels["crabbox_windows_mode"]) {
|
||||
labels["windows_mode"] = labels["crabbox_windows_mode"];
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function azureLabelToTagKey(key: string): string {
|
||||
return key.toLowerCase().startsWith("windows") ? `crabbox_${key}` : key;
|
||||
}
|
||||
|
||||
function azureTagToLabelKey(key: string): string {
|
||||
return key.startsWith("crabbox_windows") ? key.replace(/^crabbox_/, "") : key;
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
const message = errorMessage(error);
|
||||
return message.includes("http 404") || message.includes("ResourceNotFound");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function isRetryableDeleteError(error: unknown): boolean {
|
||||
const message = errorMessage(error);
|
||||
return (
|
||||
message.includes("NicReservedForAnotherVm") ||
|
||||
message.includes("PublicIPAddressCannotBeDeleted") ||
|
||||
message.includes("InUse") ||
|
||||
message.includes("AnotherOperationInProgress") ||
|
||||
(message.includes("OperationNotAllowed") && message.includes("retry after"))
|
||||
);
|
||||
}
|
||||
|
||||
export function preserveNonCrabboxRules(rules: AzureSecurityRule[]): AzureSecurityRule[] {
|
||||
return rules.filter((rule) => !rule.name?.startsWith("crabbox-ssh-"));
|
||||
}
|
||||
|
||||
function usedNSGPriorities(rules: AzureSecurityRule[]): Set<number> {
|
||||
const used = new Set<number>();
|
||||
for (const rule of rules) {
|
||||
const priority = rule.properties?.["priority"];
|
||||
if (typeof priority === "number") used.add(priority);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
function nextNSGPriority(used: Set<number>): number {
|
||||
for (let priority = 100; priority <= 4096; priority += 1) {
|
||||
if (!used.has(priority)) {
|
||||
used.add(priority);
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
throw new Error("azure nsg: no available security rule priorities");
|
||||
}
|
||||
|
||||
export function azureSupportsEphemeralOS(vmSize: string): boolean {
|
||||
const normalized = vmSize.toLowerCase();
|
||||
if (normalized.startsWith("standard_f") && normalized.endsWith("s_v2")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(normalized.startsWith("standard_d") || normalized.startsWith("standard_e")) &&
|
||||
(normalized.includes("ds_v5") || normalized.includes("ds_v6"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function azureSKUCapabilityTrue(
|
||||
capabilities: { name?: string; value?: string }[] | undefined,
|
||||
name: string,
|
||||
): boolean {
|
||||
return (
|
||||
capabilities?.some(
|
||||
(capability) => capability.name === name && capability.value?.toLowerCase() === "true",
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function isRetryableProvisioningError(message: string): boolean {
|
||||
return (
|
||||
message.includes("SkuNotAvailable") ||
|
||||
message.includes("QuotaExceeded") ||
|
||||
message.includes("AllocationFailed") ||
|
||||
message.includes("ZonalAllocationFailed") ||
|
||||
message.includes("OverconstrainedAllocationRequest") ||
|
||||
message.includes("OperationNotAllowed")
|
||||
);
|
||||
}
|
||||
|
||||
function prependUnique(first: string, rest: string[]): string[] {
|
||||
return [first, ...rest.filter((value) => value !== first)];
|
||||
}
|
||||
|
||||
async function safeBody(response: Response): Promise<string> {
|
||||
const text = await response.text();
|
||||
return text.length > 500 ? `${text.slice(0, 500)}...` : text;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -77,8 +77,8 @@ runcmd:
|
||||
mkdir -p ${config.workRoot} /var/cache/crabbox/pnpm /var/cache/crabbox/npm
|
||||
chown -R ${config.sshUser}:${config.sshUser} ${config.workRoot} /var/cache/crabbox
|
||||
install -d /var/lib/crabbox
|
||||
systemctl enable ssh || true
|
||||
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
|
||||
systemctl enable --now ssh
|
||||
systemctl restart ssh
|
||||
${bootstrap}
|
||||
touch /var/lib/crabbox/bootstrapped
|
||||
crabbox-ready
|
||||
@ -94,10 +94,9 @@ tasks:
|
||||
`;
|
||||
}
|
||||
|
||||
function windowsBootstrapHeaderPowerShell(config: LeaseConfig): string {
|
||||
export function windowsBootstrapPowerShell(config: LeaseConfig): string {
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
function Retry($ScriptBlock) {
|
||||
for ($i = 1; $i -le 8; $i++) {
|
||||
@ -118,23 +117,23 @@ $user = ${psQuote(config.sshUser)}
|
||||
$publicKey = ${psQuote(config.sshPublicKey)}
|
||||
$workRoot = ${psQuote(config.workRoot)}
|
||||
$sshPorts = ${windowsSSHPortsPowerShell(config)}
|
||||
$base = "C:\\ProgramData\\crabbox"
|
||||
$setupCompletePath = Join-Path $base "setup-complete"
|
||||
$vncPasswordPath = "C:\\ProgramData\\crabbox\\vnc.password"
|
||||
$windowsUsernamePath = "C:\\ProgramData\\crabbox\\windows.username"
|
||||
$windowsPasswordPath = "C:\\ProgramData\\crabbox\\windows.password"
|
||||
$userVNCStartupPath = "C:\\ProgramData\\crabbox\\start-user-vnc.ps1"
|
||||
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\\Users" $user) "AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup") "crabbox-user-vnc.cmd"
|
||||
$setupCompletePath = "C:\\ProgramData\\crabbox\\setup-complete"
|
||||
$openSSHZip = "$env:TEMP\\OpenSSH-Win64.zip"
|
||||
$gitInstaller = "$env:TEMP\\Git-2.52.0-64-bit.exe"
|
||||
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
|
||||
`;
|
||||
$tightVNCInstaller = "$env:TEMP\\tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
New-Item -ItemType Directory -Force -Path "C:\\ProgramData\\crabbox", $workRoot | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
|
||||
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
|
||||
}
|
||||
|
||||
function windowsBootstrapCorePowerShell(): string {
|
||||
return `
|
||||
if (-not (Test-Path -LiteralPath $passwordPath)) {
|
||||
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $passwordPath
|
||||
}
|
||||
$userPassword = (Get-Content -Raw -Path $passwordPath).Trim()
|
||||
$userPassword = Get-Content -Raw -Path $vncPasswordPath
|
||||
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
|
||||
$userPassword = New-CrabboxPassword
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath -Value $userPassword
|
||||
}
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
@ -143,17 +142,13 @@ if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $usernamePath -Value $user
|
||||
if ($passwordMirrorPath) {
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordMirrorPath -Value $userPassword
|
||||
}
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsUsernamePath -Value $user
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsPasswordPath -Value $userPassword
|
||||
$userSID = (Get-LocalUser -Name $user).SID.Value
|
||||
icacls.exe $workRoot /grant "*\${userSID}:(OI)(CI)F" | Out-Null
|
||||
$userSSHDir = Join-Path (Join-Path "C:\\Users" $user) ".ssh"
|
||||
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
|
||||
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
|
||||
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
|
||||
icacls.exe $userSSHDir /inheritance:r /grant "*\${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*\${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
|
||||
Retry { Invoke-WebRequest -Uri ${psQuote(openSSHWin64ZipURL)} -OutFile $openSSHZip -UseBasicParsing }
|
||||
@ -178,18 +173,9 @@ $inMatch = $false
|
||||
foreach ($line in ($sshdConfig -split "\\r?\\n")) {
|
||||
if ($line -match '^\\s*Match\\s+') { $inMatch = $true }
|
||||
if (-not $inMatch -and $line -match '^\\s*Port\\s+\\d+\\s*$') { continue }
|
||||
if ($enforceKeyAuth -and -not $inMatch -and $line -match '^\\s*(PasswordAuthentication|PubkeyAuthentication)\\s+') { continue }
|
||||
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
|
||||
}
|
||||
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
|
||||
if ($enforceKeyAuth) {
|
||||
$globalLines += "PubkeyAuthentication yes"
|
||||
$globalLines += "PasswordAuthentication no"
|
||||
}
|
||||
if (($matchLines -join [Environment]::NewLine) -notmatch '(?im)^\\s*Match\\s+Group\\s+administrators\\b') {
|
||||
$matchLines += "Match Group administrators"
|
||||
$matchLines += " AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys"
|
||||
}
|
||||
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
|
||||
foreach ($port in $sshPorts) {
|
||||
$ruleName = "crabbox-sshd-$port"
|
||||
@ -209,26 +195,6 @@ foreach ($path in @("C:\\Program Files\\OpenSSH", "C:\\Program Files\\Git\\cmd",
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
`;
|
||||
}
|
||||
|
||||
export function windowsBootstrapPowerShell(config: LeaseConfig): string {
|
||||
return (
|
||||
windowsBootstrapHeaderPowerShell(config) +
|
||||
`
|
||||
$vncPasswordPath = "C:\\ProgramData\\crabbox\\vnc.password"
|
||||
$windowsUsernamePath = "C:\\ProgramData\\crabbox\\windows.username"
|
||||
$windowsPasswordPath = "C:\\ProgramData\\crabbox\\windows.password"
|
||||
$passwordPath = $vncPasswordPath
|
||||
$usernamePath = $windowsUsernamePath
|
||||
$passwordMirrorPath = $windowsPasswordPath
|
||||
$enforceKeyAuth = $false
|
||||
$userVNCStartupPath = "C:\\ProgramData\\crabbox\\start-user-vnc.ps1"
|
||||
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\\Users" $user) "AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup") "crabbox-user-vnc.cmd"
|
||||
$tightVNCInstaller = "$env:TEMP\\tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
` +
|
||||
windowsBootstrapCorePowerShell() +
|
||||
`
|
||||
if (-not (Test-Path -LiteralPath "C:\\Program Files\\TightVNC\\tvnserver.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ${psQuote(tightVNCMSIURL)} -OutFile $tightVNCInstaller -UseBasicParsing }
|
||||
$vncPassword = Get-Content -Raw -Path $vncPasswordPath
|
||||
@ -292,27 +258,7 @@ if (-not (Test-Path -LiteralPath $setupCompletePath)) {
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
|
||||
Restart-Computer -Force
|
||||
}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
export function azureWindowsBootstrapPowerShell(config: LeaseConfig): string {
|
||||
return (
|
||||
windowsBootstrapHeaderPowerShell(config) +
|
||||
`
|
||||
$passwordPath = Join-Path $base "windows.password"
|
||||
$usernamePath = Join-Path $base "windows.username"
|
||||
$passwordMirrorPath = $null
|
||||
$enforceKeyAuth = $true
|
||||
` +
|
||||
windowsBootstrapCorePowerShell() +
|
||||
`
|
||||
Restart-Service sshd -Force
|
||||
git --version | Out-Null
|
||||
tar --version | Out-Null
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
|
||||
`
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
function windowsSSHPortsPowerShell(config: LeaseConfig): string {
|
||||
|
||||
@ -27,8 +27,6 @@ export interface LeaseConfig {
|
||||
awsRootGB: number;
|
||||
awsSSHCIDRs: string[];
|
||||
awsMacHostID: string;
|
||||
azureLocation: string;
|
||||
azureImage: string;
|
||||
capacityMarket: "spot" | "on-demand";
|
||||
capacityStrategy:
|
||||
| "most-available"
|
||||
@ -52,7 +50,7 @@ export interface LeaseConfig {
|
||||
|
||||
export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
const provider = input.provider ?? "hetzner";
|
||||
if (provider !== "hetzner" && provider !== "aws" && provider !== "azure") {
|
||||
if (provider !== "hetzner" && provider !== "aws") {
|
||||
throw new Error(`unsupported provider: ${String(provider)}`);
|
||||
}
|
||||
const target = normalizeTarget(input.target ?? input.targetOS ?? "linux");
|
||||
@ -60,23 +58,13 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
if (
|
||||
target !== "linux" &&
|
||||
!(provider === "aws" && target === "windows") &&
|
||||
!(provider === "aws" && target === "macos") &&
|
||||
!(provider === "azure" && target === "windows" && windowsMode === "normal")
|
||||
!(provider === "aws" && target === "macos")
|
||||
) {
|
||||
if (provider === "hetzner" || provider === "azure") {
|
||||
throw new Error(unsupportedManagedTargetMessage(provider, target, windowsMode));
|
||||
if (provider === "hetzner") {
|
||||
throw new Error(unsupportedManagedTargetMessage(provider, target));
|
||||
}
|
||||
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
|
||||
}
|
||||
if (
|
||||
provider === "azure" &&
|
||||
target === "windows" &&
|
||||
(input.desktop || input.browser || input.code || input.tailscale)
|
||||
) {
|
||||
throw new Error(
|
||||
"brokered azure target=windows currently supports SSH, sync, and run; desktop/browser/code/tailscale require Linux or AWS Windows where supported",
|
||||
);
|
||||
}
|
||||
if (target === "macos") {
|
||||
if (provider !== "aws") {
|
||||
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
|
||||
@ -127,8 +115,6 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
awsRootGB: input.awsRootGB ?? 400,
|
||||
awsSSHCIDRs: validCIDRs(input.awsSSHCIDRs ?? []),
|
||||
awsMacHostID: input.awsMacHostID ?? "",
|
||||
azureLocation: input.azureLocation ?? "",
|
||||
azureImage: input.azureImage ?? "",
|
||||
capacityMarket: input.capacity?.market ?? "spot",
|
||||
capacityStrategy: input.capacity?.strategy ?? "most-available",
|
||||
capacityFallback: input.capacity?.fallback ?? "on-demand-after-120s",
|
||||
@ -167,20 +153,7 @@ function defaultSSHUser(provider: Provider, target: TargetOS, windowsMode: Windo
|
||||
return "crabbox";
|
||||
}
|
||||
|
||||
function unsupportedManagedTargetMessage(
|
||||
provider: Provider,
|
||||
target: TargetOS,
|
||||
windowsMode: WindowsMode,
|
||||
): string {
|
||||
if (provider === "azure" && target === "windows" && windowsMode === "wsl2") {
|
||||
return "brokered azure supports native Windows only; use brokered aws for managed Windows WSL2 or provider=ssh for existing Windows WSL2 hosts";
|
||||
}
|
||||
if (provider === "azure") {
|
||||
if (target === "macos") {
|
||||
return "brokered azure managed provisioning supports target=linux and native Windows only; use brokered aws with an EC2 Mac Dedicated Host or provider=ssh for existing macOS hosts";
|
||||
}
|
||||
return "brokered azure managed provisioning supports target=linux and native Windows only";
|
||||
}
|
||||
function unsupportedManagedTargetMessage(provider: Provider, target: TargetOS): string {
|
||||
if (target === "windows") {
|
||||
return `brokered ${provider} managed provisioning supports target=linux only; use brokered aws for managed Windows or provider=ssh for existing Windows hosts`;
|
||||
}
|
||||
@ -190,13 +163,6 @@ function unsupportedManagedTargetMessage(
|
||||
return `brokered ${provider} managed provisioning supports target=linux only`;
|
||||
}
|
||||
|
||||
export function azureLocationFor(
|
||||
env: { CRABBOX_AZURE_LOCATION?: string },
|
||||
override: string,
|
||||
): string {
|
||||
return override.trim() || env.CRABBOX_AZURE_LOCATION?.trim() || "eastus";
|
||||
}
|
||||
|
||||
export function normalizeTailscaleTags(values: string[]): string[] {
|
||||
return uniqueStrings(
|
||||
values
|
||||
@ -273,9 +239,6 @@ export function serverTypeForProviderClass(provider: Provider, machineClass: str
|
||||
if (provider === "aws") {
|
||||
return awsInstanceTypeCandidatesForClass(machineClass)[0] ?? machineClass;
|
||||
}
|
||||
if (provider === "azure") {
|
||||
return azureVMSizeCandidatesForClass(machineClass)[0] ?? machineClass;
|
||||
}
|
||||
return serverTypeForClass(machineClass);
|
||||
}
|
||||
|
||||
@ -290,124 +253,9 @@ export function serverTypeForConfig(
|
||||
awsInstanceTypeCandidatesForTargetClass(target, machineClass, windowsMode)[0] ?? machineClass
|
||||
);
|
||||
}
|
||||
if (provider === "azure") {
|
||||
return (
|
||||
azureVMSizeCandidatesForTargetClass(target, machineClass, windowsMode)[0] ?? machineClass
|
||||
);
|
||||
}
|
||||
return serverTypeForClass(machineClass);
|
||||
}
|
||||
|
||||
export function azureVMSizeCandidatesForTargetClass(
|
||||
target: TargetOS,
|
||||
machineClass: string,
|
||||
windowsMode: WindowsMode = "normal",
|
||||
): string[] {
|
||||
if (target === "linux") {
|
||||
return azureVMSizeCandidatesForClass(machineClass);
|
||||
}
|
||||
if (target === "windows" && windowsMode === "normal") {
|
||||
return azureWindowsVMSizeCandidatesForClass(machineClass);
|
||||
}
|
||||
return [machineClass];
|
||||
}
|
||||
|
||||
export function azureVMSizeCandidatesForClass(machineClass: string): string[] {
|
||||
switch (machineClass) {
|
||||
case "standard":
|
||||
return [
|
||||
"Standard_D32ads_v6",
|
||||
"Standard_D32ds_v6",
|
||||
"Standard_F32s_v2",
|
||||
"Standard_D32ads_v5",
|
||||
"Standard_D32ds_v5",
|
||||
"Standard_D16ads_v6",
|
||||
"Standard_D16ds_v6",
|
||||
"Standard_F16s_v2",
|
||||
];
|
||||
case "fast":
|
||||
return [
|
||||
"Standard_D64ads_v6",
|
||||
"Standard_D64ds_v6",
|
||||
"Standard_F64s_v2",
|
||||
"Standard_D64ads_v5",
|
||||
"Standard_D64ds_v5",
|
||||
"Standard_D48ads_v6",
|
||||
"Standard_D48ds_v6",
|
||||
"Standard_F48s_v2",
|
||||
"Standard_D32ads_v6",
|
||||
"Standard_D32ds_v6",
|
||||
"Standard_F32s_v2",
|
||||
];
|
||||
case "large":
|
||||
return [
|
||||
"Standard_D96ads_v6",
|
||||
"Standard_D96ds_v6",
|
||||
"Standard_D96ads_v5",
|
||||
"Standard_D96ds_v5",
|
||||
"Standard_D64ads_v6",
|
||||
"Standard_D64ds_v6",
|
||||
"Standard_F64s_v2",
|
||||
"Standard_D48ads_v6",
|
||||
"Standard_D48ds_v6",
|
||||
"Standard_F48s_v2",
|
||||
];
|
||||
case "beast":
|
||||
return [
|
||||
"Standard_D192ds_v6",
|
||||
"Standard_D128ds_v6",
|
||||
"Standard_D96ads_v6",
|
||||
"Standard_D96ds_v6",
|
||||
"Standard_D96ads_v5",
|
||||
"Standard_D96ds_v5",
|
||||
"Standard_D64ads_v6",
|
||||
"Standard_D64ds_v6",
|
||||
"Standard_F64s_v2",
|
||||
];
|
||||
default:
|
||||
return [machineClass];
|
||||
}
|
||||
}
|
||||
|
||||
export function azureWindowsVMSizeCandidatesForClass(machineClass: string): string[] {
|
||||
switch (machineClass) {
|
||||
case "standard":
|
||||
return [
|
||||
"Standard_D2ads_v6",
|
||||
"Standard_D2ds_v6",
|
||||
"Standard_D2ads_v5",
|
||||
"Standard_D2ds_v5",
|
||||
"Standard_D2as_v6",
|
||||
];
|
||||
case "fast":
|
||||
return [
|
||||
"Standard_D4ads_v6",
|
||||
"Standard_D4ds_v6",
|
||||
"Standard_D4ads_v5",
|
||||
"Standard_D4ds_v5",
|
||||
"Standard_D4as_v6",
|
||||
];
|
||||
case "large":
|
||||
return [
|
||||
"Standard_D8ads_v6",
|
||||
"Standard_D8ds_v6",
|
||||
"Standard_D8ads_v5",
|
||||
"Standard_D8ds_v5",
|
||||
"Standard_D8as_v6",
|
||||
];
|
||||
case "beast":
|
||||
return [
|
||||
"Standard_D16ads_v6",
|
||||
"Standard_D16ds_v6",
|
||||
"Standard_D16ads_v5",
|
||||
"Standard_D16ds_v5",
|
||||
"Standard_D8ads_v6",
|
||||
];
|
||||
default:
|
||||
return [machineClass];
|
||||
}
|
||||
}
|
||||
|
||||
export function awsInstanceTypeCandidatesForTargetClass(
|
||||
target: TargetOS,
|
||||
machineClass: string,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -231,12 +231,8 @@ export function portalLeaseDetail(
|
||||
);
|
||||
}
|
||||
|
||||
export function portalShareLease(
|
||||
lease: LeaseRecord,
|
||||
options: { embedded?: boolean } = {},
|
||||
): Response {
|
||||
export function portalShareLease(lease: LeaseRecord): Response {
|
||||
const slug = lease.slug || lease.id;
|
||||
const sharePath = `/portal/leases/${encodeURIComponent(lease.id)}/share${options.embedded ? "?embed=1" : ""}`;
|
||||
const users = Object.entries(lease.share?.users ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
|
||||
const userRows = users.length
|
||||
? users
|
||||
@ -245,7 +241,7 @@ export function portalShareLease(
|
||||
<td>${escapeHTML(user)}</td>
|
||||
<td><span class="pill">${escapeHTML(role)}</span></td>
|
||||
<td>
|
||||
<form method="post" action="${sharePath}">
|
||||
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
|
||||
<input type="hidden" name="action" value="remove-user">
|
||||
<input type="hidden" name="user" value="${escapeHTML(user)}">
|
||||
<button class="button secondary" type="submit">remove</button>
|
||||
@ -257,44 +253,40 @@ export function portalShareLease(
|
||||
: `<tr><td colspan="3" class="empty">no shared users</td></tr>`;
|
||||
return html(
|
||||
`Share ${slug}`,
|
||||
`<main class="portal-shell run-shell share-shell${options.embedded ? " share-shell-embedded" : ""}">
|
||||
${
|
||||
options.embedded
|
||||
? ""
|
||||
: portalHeader({
|
||||
meta: `share ${escapeHTML(slug)} <span class="mono">${escapeHTML(lease.id)}</span>`,
|
||||
actions: `
|
||||
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}">back to lease</a>
|
||||
<a class="button secondary" href="/portal">leases</a>
|
||||
<a class="button secondary" href="/portal/logout">log out</a>
|
||||
`,
|
||||
})
|
||||
}
|
||||
`<main class="portal-shell run-shell">
|
||||
${portalHeader({
|
||||
meta: `share ${escapeHTML(slug)} <span class="mono">${escapeHTML(lease.id)}</span>`,
|
||||
actions: `
|
||||
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}">lease</a>
|
||||
<a class="button secondary" href="/portal">leases</a>
|
||||
<a class="button secondary" href="/portal/logout">log out</a>
|
||||
`,
|
||||
})}
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>org access</h2>
|
||||
<span class="pill">${escapeHTML(lease.share?.org ?? "off")}</span>
|
||||
</div>
|
||||
<form class="share-form" method="post" action="${sharePath}">
|
||||
<form class="share-form" method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
|
||||
<input type="hidden" name="action" value="set-org">
|
||||
<select name="role" aria-label="org role">
|
||||
<option value=""${lease.share?.org ? "" : " selected"}>off</option>
|
||||
<option value="use"${lease.share?.org === "use" ? " selected" : ""}>use</option>
|
||||
<option value="manage"${lease.share?.org === "manage" ? " selected" : ""}>manage</option>
|
||||
</select>
|
||||
<button class="button action" type="submit">save</button>
|
||||
<button class="button" type="submit">save</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>users</h2></div>
|
||||
<form class="share-form" method="post" action="${sharePath}">
|
||||
<form class="share-form" method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
|
||||
<input type="hidden" name="action" value="add-user">
|
||||
<input name="user" type="email" placeholder="friend@example.com" required>
|
||||
<select name="role" aria-label="user role">
|
||||
<option value="use">use</option>
|
||||
<option value="manage">manage</option>
|
||||
</select>
|
||||
<button class="button action" type="submit">add</button>
|
||||
<button class="button" type="submit">add</button>
|
||||
</form>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
@ -303,14 +295,11 @@ export function portalShareLease(
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<form method="post" action="${sharePath}">
|
||||
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
|
||||
<input type="hidden" name="action" value="clear">
|
||||
<button class="button danger" type="submit">clear sharing</button>
|
||||
</form>
|
||||
</main>`,
|
||||
200,
|
||||
"",
|
||||
options.embedded ? { frameAncestors: "'self'" } : {},
|
||||
);
|
||||
}
|
||||
|
||||
@ -523,9 +512,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
const title = `WebVNC ${slug}`;
|
||||
const wsPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/viewer`;
|
||||
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
|
||||
const controlPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/control`;
|
||||
const sharePath = `/portal/leases/${encodeURIComponent(lease.id)}/share`;
|
||||
const embeddedSharePath = `${sharePath}?embed=1`;
|
||||
const bridgeCmd = webVNCBridgeCommand(lease);
|
||||
const fullscreenIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>`;
|
||||
const reconnectIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>`;
|
||||
@ -538,12 +524,11 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
meta: `<span>WebVNC ${escapeHTML(slug)}</span><span class="vnc-dot"></span>${providerBadge(lease.provider)}<span class="vnc-dot"></span>${targetBadge(target, lease.windowsMode)}<span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span>`,
|
||||
actions: `
|
||||
<span id="status" class="status-pill">waiting for bridge</span>
|
||||
<button id="vnc-takeover" class="button secondary vnc-control" type="button" hidden>take control</button>
|
||||
<button id="vnc-copy-remote" class="icon-btn" type="button" title="copy remote clipboard" aria-label="copy remote clipboard" disabled>${copyIcon}</button>
|
||||
<button id="vnc-paste" class="icon-btn" type="button" title="paste clipboard" aria-label="paste clipboard">${pasteIcon}</button>
|
||||
<button id="vnc-reconnect" class="icon-btn" type="button" title="reconnect" aria-label="reconnect">${reconnectIcon}</button>
|
||||
<button id="vnc-fullscreen" class="icon-btn" type="button" title="fullscreen" aria-label="toggle fullscreen">${fullscreenIcon}</button>
|
||||
<button id="vnc-share" class="button secondary" type="button">share</button>
|
||||
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}/share">share</a>
|
||||
<a class="button secondary" href="/portal">leases</a>
|
||||
<a class="button secondary" href="/portal/logout">log out</a>
|
||||
`,
|
||||
@ -554,13 +539,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
<code id="vnc-bridge-cmd" class="vnc-bridge-cmd">${escapeHTML(bridgeCmd)}</code>
|
||||
<button id="vnc-copy" class="icon-btn" type="button" title="copy command" aria-label="copy bridge command">${copyIcon}</button>
|
||||
</footer>
|
||||
<dialog id="vnc-share-dialog" class="vnc-share-dialog" aria-label="Share lease">
|
||||
<div class="vnc-share-head">
|
||||
<div><strong>share ${escapeHTML(slug)}</strong><small>${escapeHTML(lease.id)}</small></div>
|
||||
<button id="vnc-share-close" class="icon-btn" type="button" title="close share" aria-label="close share">×</button>
|
||||
</div>
|
||||
<iframe id="vnc-share-frame" class="vnc-share-frame" title="Share ${escapeHTML(slug)}" loading="lazy"></iframe>
|
||||
</dialog>
|
||||
</main>
|
||||
<script type="module" nonce="${nonce}">
|
||||
import RFBModule from ${JSON.stringify(novncModuleURL)};
|
||||
@ -570,11 +548,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
const wsURL = new URL(${JSON.stringify(wsPath)}, window.location.href);
|
||||
wsURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const statusURL = new URL(${JSON.stringify(statusPath)}, window.location.href);
|
||||
const controlURL = new URL(${JSON.stringify(controlPath)}, window.location.href);
|
||||
const shareURL = new URL(${JSON.stringify(embeddedSharePath)}, window.location.href);
|
||||
const viewerID = "viewer_" + (crypto.randomUUID?.() || String(Date.now()) + Math.random()).replace(/[^A-Za-z0-9_.:-]/g, "");
|
||||
wsURL.searchParams.set("viewer", viewerID);
|
||||
statusURL.searchParams.set("viewer", viewerID);
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
const target = ${JSON.stringify(target)};
|
||||
const username = fragment.get("username") || "";
|
||||
@ -593,9 +566,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
let connected = false;
|
||||
let stopped = false;
|
||||
let remoteClipboardText = "";
|
||||
let statusTimer;
|
||||
let controllerLabel = "";
|
||||
let isController = false;
|
||||
function retryDelay() {
|
||||
return Math.min(5000, 500 * 2 ** retryAttempt);
|
||||
}
|
||||
@ -630,38 +600,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function applyCollaborationState(state) {
|
||||
if (!state) return;
|
||||
const role = state.viewerRole || "none";
|
||||
const takeoverBtn = document.getElementById("vnc-takeover");
|
||||
const previousControllerLabel = controllerLabel;
|
||||
controllerLabel = state.controllerLabel || "";
|
||||
const controlling = role === "controller";
|
||||
const connectedViewer = role === "controller" || role === "observer";
|
||||
isController = controlling;
|
||||
if (rfb) {
|
||||
rfb.viewOnly = !controlling;
|
||||
}
|
||||
if (takeoverBtn) {
|
||||
takeoverBtn.hidden = !connectedViewer;
|
||||
takeoverBtn.disabled = controlling || !connectedViewer;
|
||||
takeoverBtn.dataset.role = controlling ? "controller" : "observer";
|
||||
takeoverBtn.textContent = controlling ? "you control" : "take control";
|
||||
takeoverBtn.title = controlling
|
||||
? "You are controlling this session"
|
||||
: controllerLabel
|
||||
? "Currently observing; " + controllerLabel + " controls"
|
||||
: "Currently observing";
|
||||
}
|
||||
if (!controlling && connectedViewer && previousControllerLabel && controllerLabel && previousControllerLabel !== controllerLabel) {
|
||||
setStatus(controllerLabel + " took control", "warn");
|
||||
}
|
||||
}
|
||||
async function refreshCollaborationState() {
|
||||
const state = await bridgeState();
|
||||
applyCollaborationState(state);
|
||||
return state;
|
||||
}
|
||||
function scheduleRetry(label) {
|
||||
if (stopped) return;
|
||||
const delay = retryDelay();
|
||||
@ -680,23 +618,19 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
scheduleRetry(state.message || "WebVNC daemon not running; run the bridge command below");
|
||||
return;
|
||||
}
|
||||
if (state && state.availableViewerSlots === 0) {
|
||||
scheduleRetry(state.message || "waiting for an available WebVNC observer slot");
|
||||
if (state?.viewerConnected) {
|
||||
scheduleRetry("WebVNC viewer already active; close stale WebVNC tabs or run reset");
|
||||
return;
|
||||
}
|
||||
setStatus(retryAttempt ? "bridge connected; opening viewer" : "connecting");
|
||||
rfb = new RFB(screen, wsURL.toString(), options);
|
||||
rfb.showDotCursor = true;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
rfb.viewOnly = true;
|
||||
rfb.viewOnly = false;
|
||||
rfb.addEventListener("connect", () => {
|
||||
connected = true;
|
||||
retryAttempt = 0;
|
||||
setStatus("connected", "ok");
|
||||
void refreshCollaborationState();
|
||||
window.clearInterval(statusTimer);
|
||||
statusTimer = window.setInterval(refreshCollaborationState, 1500);
|
||||
});
|
||||
rfb.addEventListener("clipboard", (event) => {
|
||||
remoteClipboardText = event.detail?.text || "";
|
||||
@ -733,25 +667,8 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopped = true;
|
||||
window.clearTimeout(retryTimer);
|
||||
window.clearInterval(statusTimer);
|
||||
rfb?.disconnect();
|
||||
});
|
||||
const takeoverBtn = document.getElementById("vnc-takeover");
|
||||
takeoverBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
const response = await fetch(controlURL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ viewerID }),
|
||||
});
|
||||
const state = response.ok ? await response.json() : undefined;
|
||||
if (!response.ok) throw new Error(state?.message || "takeover failed");
|
||||
applyCollaborationState(state);
|
||||
setStatus("you took control", "ok");
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error), "bad");
|
||||
}
|
||||
});
|
||||
const reconnectBtn = document.getElementById("vnc-reconnect");
|
||||
reconnectBtn?.addEventListener("click", () => {
|
||||
window.clearTimeout(retryTimer);
|
||||
@ -768,24 +685,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
});
|
||||
const shareBtn = document.getElementById("vnc-share");
|
||||
const shareDialog = document.getElementById("vnc-share-dialog");
|
||||
const shareFrame = document.getElementById("vnc-share-frame");
|
||||
const shareCloseBtn = document.getElementById("vnc-share-close");
|
||||
shareBtn?.addEventListener("click", () => {
|
||||
if (shareFrame && !shareFrame.src) {
|
||||
shareFrame.src = shareURL.toString();
|
||||
}
|
||||
if (shareDialog?.showModal) {
|
||||
shareDialog.showModal();
|
||||
} else {
|
||||
window.location.href = ${JSON.stringify(sharePath)};
|
||||
}
|
||||
});
|
||||
shareCloseBtn?.addEventListener("click", () => shareDialog?.close());
|
||||
shareDialog?.addEventListener("click", (event) => {
|
||||
if (event.target === shareDialog) shareDialog.close();
|
||||
});
|
||||
async function readClipboardText() {
|
||||
if (navigator.clipboard?.readText) {
|
||||
try {
|
||||
@ -813,10 +712,6 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
setStatus("connect before paste", "warn");
|
||||
return;
|
||||
}
|
||||
if (!isController) {
|
||||
setStatus(controllerLabel ? controllerLabel + " is controlling" : "observer mode", "warn");
|
||||
return;
|
||||
}
|
||||
const text = await readClipboardText();
|
||||
if (!text) return;
|
||||
try {
|
||||
@ -1708,13 +1603,7 @@ function resultsSummary(run: RunRecord): string {
|
||||
</dl>`;
|
||||
}
|
||||
|
||||
function html(
|
||||
title: string,
|
||||
body: string,
|
||||
status = 200,
|
||||
nonce = "",
|
||||
options: { frameAncestors?: string } = {},
|
||||
): Response {
|
||||
function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
const pageNonce = nonce || scriptNonce();
|
||||
const scriptSource = `'self' 'nonce-${pageNonce}'`;
|
||||
return new Response(
|
||||
@ -1763,8 +1652,6 @@ function html(
|
||||
.button { display:inline-flex; align-items:center; justify-content:center; min-height:28px; padding:0 10px; border-radius:7px; background:var(--accent); color:#001018; text-decoration:none; font-size:12px; font-weight:700; white-space:nowrap; }
|
||||
.button.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); font-weight:500; }
|
||||
.button.secondary:hover { background:#1b1f24; border-color:#3a4046; }
|
||||
.button.action { min-width:56px; border:1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); background:color-mix(in srgb, var(--accent) 10%, transparent); color:#bae6fd; }
|
||||
.button.action:hover { background:color-mix(in srgb, var(--accent) 16%, transparent); border-color:color-mix(in srgb, var(--accent) 58%, var(--line)); }
|
||||
.button:disabled { opacity:0.45; cursor:not-allowed; }
|
||||
.button.danger { border:1px solid color-mix(in srgb, var(--bad) 42%, var(--line)); background:color-mix(in srgb, var(--bad) 18%, transparent); color:#fecaca; cursor:pointer; }
|
||||
.lease-link { display:block; min-width:0; text-decoration:none; overflow:hidden; text-overflow:ellipsis; }
|
||||
@ -1856,12 +1743,8 @@ function html(
|
||||
.table-search { width:100%; height:28px; padding:0 9px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; color:var(--fg); font:inherit; font-size:12px; }
|
||||
.table-search::placeholder { color:#6b7280; }
|
||||
.table-search:focus { outline:2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset:1px; border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); }
|
||||
.share-shell { height:auto; min-height:100dvh; align-content:start; grid-auto-rows:max-content; }
|
||||
.share-shell-embedded { width:100%; min-height:0; padding:0; gap:8px; }
|
||||
.share-shell .panel { align-self:start; }
|
||||
.share-form { display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--line-soft); background:color-mix(in srgb, var(--panel-2) 44%, transparent); }
|
||||
.share-form input,.share-form select { height:32px; min-width:0; padding:0 10px; border:1px solid var(--line); border-radius:7px; background:#0b0d0f; color:var(--fg); font:inherit; font-size:12px; }
|
||||
.share-form input:focus,.share-form select:focus { outline:2px solid color-mix(in srgb, var(--accent) 34%, transparent); outline-offset:1px; border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); }
|
||||
.share-form { display:flex; align-items:center; gap:8px; padding:10px; border-bottom:1px solid var(--line-soft); }
|
||||
.share-form input,.share-form select { height:30px; min-width:0; padding:0 9px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; color:var(--fg); font:inherit; font-size:12px; }
|
||||
.share-form input { flex:1; }
|
||||
.table-filters { display:flex; align-items:center; gap:3px; min-width:0; overflow-x:auto; padding:2px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; scrollbar-width:none; }
|
||||
.table-filters::-webkit-scrollbar { display:none; }
|
||||
@ -1910,9 +1793,6 @@ function html(
|
||||
.status-pill[data-tone="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 35%, var(--line)); }
|
||||
.status-pill[data-tone="warn"] { color:var(--warn); border-color:color-mix(in srgb, var(--warn) 35%, var(--line)); }
|
||||
.status-pill[data-tone="bad"] { color:var(--bad); border-color:color-mix(in srgb, var(--bad) 45%, var(--line)); }
|
||||
.vnc-control { min-width:112px; transition:background 0.15s,border-color 0.15s,color 0.15s; }
|
||||
.vnc-control[data-role="controller"]:disabled { opacity:1; cursor:default; color:var(--fg); border-color:var(--line); background:var(--panel-2); }
|
||||
.vnc-control[data-role="observer"] { color:#bae6fd; border-color:color-mix(in srgb, var(--accent) 38%, var(--line)); background:color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.icon-btn { display:inline-flex; align-items:center; justify-content:center; width:32px; height:32px; padding:0; border-radius:8px; background:transparent; color:var(--fg); border:1px solid var(--line); cursor:pointer; transition:background 0.15s, border-color 0.15s, color 0.15s; }
|
||||
.icon-btn:hover { background:#1b1f24; border-color:#3a4046; }
|
||||
.icon-btn:active { background:#22272d; }
|
||||
@ -1929,12 +1809,6 @@ function html(
|
||||
.vnc-bridge { display:flex; align-items:center; gap:10px; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:var(--panel); }
|
||||
.vnc-bridge-label { font-size:10px; text-transform:uppercase; letter-spacing:0.08em; color:var(--muted); flex-shrink:0; padding-left:4px; }
|
||||
.vnc-bridge-cmd { display:block; flex:1; min-width:0; padding:6px 10px; border:none; border-radius:5px; background:transparent; color:#d1fae5; font-family:var(--mono); font-size:13px; overflow-x:auto; white-space:nowrap; }
|
||||
.vnc-share-dialog { width:min(760px, calc(100vw - 36px)); max-height:min(640px, calc(100dvh - 48px)); padding:0; border:1px solid var(--line); border-radius:10px; background:var(--panel); color:var(--fg); box-shadow:0 24px 90px rgba(0,0,0,0.58); overflow:hidden; }
|
||||
.vnc-share-dialog::backdrop { background:rgba(0,0,0,0.58); backdrop-filter:blur(2px); }
|
||||
.vnc-share-head { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:8px 10px 8px 14px; border-bottom:1px solid var(--line); background:var(--panel-2); }
|
||||
.vnc-share-head strong { display:block; font-size:13px; }
|
||||
.vnc-share-head small { display:block; margin-top:1px; color:var(--muted); font-family:var(--mono); font-size:11px; }
|
||||
.vnc-share-frame { display:block; width:100%; height:min(540px, calc(100dvh - 104px)); border:0; background:var(--bg); }
|
||||
.commands { padding:12px; display:grid; gap:8px; }
|
||||
.command-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
|
||||
.command-row > div { min-width:0; overflow:hidden; }
|
||||
@ -1973,8 +1847,6 @@ function html(
|
||||
.vnc-meta p .vnc-id { display:none; }
|
||||
.portal-actions { gap:6px; }
|
||||
.portal-actions .button { min-height:30px; padding:0 10px; }
|
||||
.vnc-share-dialog { width:calc(100vw - 20px); }
|
||||
.vnc-share-frame { height:calc(100dvh - 104px); }
|
||||
.vnc-bridge-label { display:none; }
|
||||
}
|
||||
</style>
|
||||
@ -1988,8 +1860,7 @@ function html(
|
||||
"default-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"connect-src 'self' ws: wss:",
|
||||
"frame-src 'self'",
|
||||
`frame-ancestors ${options.frameAncestors ?? "'none'"}`,
|
||||
"frame-ancestors 'none'",
|
||||
"img-src 'self' data: blob:",
|
||||
`script-src ${scriptSource}`,
|
||||
"style-src 'unsafe-inline'",
|
||||
|
||||
@ -16,17 +16,6 @@ export interface Env {
|
||||
CRABBOX_CAPACITY_AVAILABILITY_ZONES?: string;
|
||||
CRABBOX_CAPACITY_HINTS?: string;
|
||||
CRABBOX_CAPACITY_LARGE_CLASSES?: string;
|
||||
AZURE_TENANT_ID?: string;
|
||||
AZURE_CLIENT_ID?: string;
|
||||
AZURE_CLIENT_SECRET?: string;
|
||||
AZURE_SUBSCRIPTION_ID?: string;
|
||||
CRABBOX_AZURE_LOCATION?: string;
|
||||
CRABBOX_AZURE_RESOURCE_GROUP?: string;
|
||||
CRABBOX_AZURE_IMAGE?: string;
|
||||
CRABBOX_AZURE_VNET?: string;
|
||||
CRABBOX_AZURE_SUBNET?: string;
|
||||
CRABBOX_AZURE_NSG?: string;
|
||||
CRABBOX_AZURE_SSH_CIDRS?: string;
|
||||
CRABBOX_SHARED_TOKEN?: string;
|
||||
CRABBOX_ADMIN_TOKEN?: string;
|
||||
CRABBOX_SESSION_SECRET?: string;
|
||||
@ -53,17 +42,6 @@ export interface Env {
|
||||
CRABBOX_TAILSCALE_CLIENT_SECRET?: string;
|
||||
CRABBOX_TAILSCALE_TAILNET?: string;
|
||||
CRABBOX_TAILSCALE_TAGS?: string;
|
||||
CRABBOX_ARTIFACTS_BACKEND?: string;
|
||||
CRABBOX_ARTIFACTS_BUCKET?: string;
|
||||
CRABBOX_ARTIFACTS_PREFIX?: string;
|
||||
CRABBOX_ARTIFACTS_BASE_URL?: string;
|
||||
CRABBOX_ARTIFACTS_REGION?: string;
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL?: string;
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID?: string;
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY?: string;
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN?: string;
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS?: string;
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS?: string;
|
||||
}
|
||||
|
||||
export interface LeaseRequest {
|
||||
@ -96,8 +74,6 @@ export interface LeaseRequest {
|
||||
awsRootGB?: number;
|
||||
awsSSHCIDRs?: string[];
|
||||
awsMacHostID?: string;
|
||||
azureLocation?: string;
|
||||
azureImage?: string;
|
||||
capacity?: {
|
||||
market?: "spot" | "on-demand";
|
||||
strategy?: "most-available" | "price-capacity-optimized" | "capacity-optimized" | "sequential";
|
||||
@ -117,7 +93,7 @@ export interface LeaseRequest {
|
||||
sshPublicKey?: string;
|
||||
}
|
||||
|
||||
export type Provider = "hetzner" | "aws" | "azure";
|
||||
export type Provider = "hetzner" | "aws";
|
||||
export type TargetOS = "linux" | "macos" | "windows";
|
||||
export type WindowsMode = "normal" | "wsl2";
|
||||
|
||||
|
||||
@ -1,375 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AzureClient,
|
||||
azureLabelsFromTags,
|
||||
azureSupportsEphemeralOS,
|
||||
azureTagsFromLabels,
|
||||
isRetryableDeleteError,
|
||||
isRetryableProvisioningError,
|
||||
preserveNonCrabboxRules,
|
||||
} from "../src/azure";
|
||||
import type { LeaseConfig } from "../src/config";
|
||||
import type { Env } from "../src/types";
|
||||
|
||||
const baseEnv: Env = {
|
||||
FLEET: {} as DurableObjectNamespace,
|
||||
HETZNER_TOKEN: "",
|
||||
AZURE_TENANT_ID: "tenant",
|
||||
AZURE_CLIENT_ID: "client",
|
||||
AZURE_CLIENT_SECRET: "secret",
|
||||
AZURE_SUBSCRIPTION_ID: "sub",
|
||||
};
|
||||
|
||||
describe("azure provider", () => {
|
||||
it("classifies Azure capacity and quota errors as retryable", () => {
|
||||
expect(isRetryableProvisioningError("SkuNotAvailable: D8s_v5 not available")).toBe(true);
|
||||
expect(isRetryableProvisioningError("QuotaExceeded for cores")).toBe(true);
|
||||
expect(isRetryableProvisioningError("AllocationFailed")).toBe(true);
|
||||
expect(isRetryableProvisioningError("OverconstrainedAllocationRequest")).toBe(true);
|
||||
expect(isRetryableProvisioningError("ResourceNotFound")).toBe(false);
|
||||
expect(isRetryableProvisioningError("")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies transient Azure delete dependency errors as retryable", () => {
|
||||
expect(isRetryableDeleteError("NicReservedForAnotherVm retry after 180 seconds")).toBe(true);
|
||||
expect(isRetryableDeleteError("PublicIPAddressCannotBeDeleted because it is in use")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isRetryableDeleteError("AnotherOperationInProgress")).toBe(true);
|
||||
expect(isRetryableDeleteError("plain validation error")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps Azure-reserved Windows tag prefixes without changing internal labels", () => {
|
||||
const tags = azureTagsFromLabels({ crabbox: "true", windows_mode: "normal" });
|
||||
expect(tags.windows_mode).toBeUndefined();
|
||||
expect(tags.crabbox_windows_mode).toBe("normal");
|
||||
expect(azureLabelsFromTags(tags).windows_mode).toBe("normal");
|
||||
});
|
||||
|
||||
it("continues deleting per-lease resources after a delete failure", async () => {
|
||||
const client = new AzureClient(baseEnv);
|
||||
const deletes: string[] = [];
|
||||
const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (init?.method === "DELETE") {
|
||||
deletes.push(url);
|
||||
if (url.includes("/virtualMachines/crabbox-blue-lobster?")) {
|
||||
return Promise.resolve(new Response("busy", { status: 409 }));
|
||||
}
|
||||
if (url.includes("/networkInterfaces/crabbox-blue-lobster-nic?")) {
|
||||
return Promise.resolve(new Response("missing", { status: 404 }));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
client.fetcher = fakeFetch;
|
||||
|
||||
await expect(client.deleteServer("crabbox-blue-lobster")).rejects.toThrow(/delete vm/);
|
||||
expect(deletes.some((url) => url.includes("/virtualMachines/crabbox-blue-lobster?"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
deletes.some((url) => url.includes("/networkInterfaces/crabbox-blue-lobster-nic?")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
deletes.some((url) => url.includes("/publicIPAddresses/crabbox-blue-lobster-pip?")),
|
||||
).toBe(true);
|
||||
expect(deletes.some((url) => url.includes("/disks/crabbox-blue-lobster-osdisk?"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats successful async Azure deletes as complete without refetching deleted resources", async () => {
|
||||
const client = new AzureClient(baseEnv);
|
||||
const deletes: string[] = [];
|
||||
const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (init?.method === "DELETE") {
|
||||
deletes.push(url);
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
if (
|
||||
url.includes("/virtualMachines/") ||
|
||||
url.includes("/networkInterfaces/") ||
|
||||
url.includes("/publicIPAddresses/") ||
|
||||
url.includes("/disks/")
|
||||
) {
|
||||
return Promise.resolve(new Response("deleted", { status: 404 }));
|
||||
}
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
client.fetcher = fakeFetch;
|
||||
|
||||
await expect(client.deleteServer("crabbox-blue-lobster")).resolves.toBeUndefined();
|
||||
expect(deletes).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("requires the four Azure SP secrets", () => {
|
||||
expect(() => new AzureClient({ ...baseEnv, AZURE_TENANT_ID: undefined })).toThrow(
|
||||
/AZURE_TENANT_ID/,
|
||||
);
|
||||
expect(() => new AzureClient({ ...baseEnv, AZURE_CLIENT_ID: undefined })).toThrow(
|
||||
/AZURE_CLIENT_ID/,
|
||||
);
|
||||
expect(() => new AzureClient({ ...baseEnv, AZURE_CLIENT_SECRET: undefined })).toThrow(
|
||||
/AZURE_CLIENT_SECRET/,
|
||||
);
|
||||
expect(() => new AzureClient({ ...baseEnv, AZURE_SUBSCRIPTION_ID: undefined })).toThrow(
|
||||
/AZURE_SUBSCRIPTION_ID/,
|
||||
);
|
||||
});
|
||||
|
||||
it("applies CRABBOX_AZURE_* defaults", () => {
|
||||
const client = new AzureClient(baseEnv);
|
||||
expect(client.resourceGroup).toBe("crabbox-leases");
|
||||
expect(client.vnet).toBe("crabbox-vnet");
|
||||
expect(client.subnet).toBe("crabbox-subnet");
|
||||
expect(client.nsg).toBe("crabbox-nsg");
|
||||
expect(client.image).toContain("Canonical");
|
||||
expect(client.sshCIDRs).toEqual(["0.0.0.0/0"]);
|
||||
expect(client.defaultLocation).toBe("eastus");
|
||||
});
|
||||
|
||||
it("creates Windows VMs with Windows OS profile and bootstrap extension", async () => {
|
||||
const client = new AzureClient(baseEnv);
|
||||
const bodies: unknown[] = [];
|
||||
const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (init?.body) bodies.push(JSON.parse(String(init.body)));
|
||||
if (url.includes("/resourceGroups/crabbox-leases?")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ tags: { managed_by: "crabbox" } }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (url.includes("/virtualNetworks/crabbox-vnet?")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ tags: { managed_by: "crabbox" } }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (url.includes("/networkSecurityGroups/crabbox-nsg?") && init?.method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ tags: { managed_by: "crabbox" }, properties: { securityRules: [] } }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (url.includes("/providers/Microsoft.Compute/skus?")) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
name: "Standard_D2ads_v6",
|
||||
resourceType: "virtualMachines",
|
||||
capabilities: [{ name: "EphemeralOSDiskSupported", value: "True" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (url.includes("/publicIPAddresses/") && init?.method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ properties: { ipAddress: "192.0.2.10" } }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.includes("/virtualMachines/") && init?.method === "GET") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
name: "crabbox-blue-lobster",
|
||||
tags: { crabbox: "true" },
|
||||
properties: {
|
||||
provisioningState: "Succeeded",
|
||||
hardwareProfile: { vmSize: "Standard_D2ads_v6" },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
client.fetcher = fakeFetch;
|
||||
const config: LeaseConfig = {
|
||||
provider: "azure",
|
||||
target: "windows",
|
||||
windowsMode: "normal",
|
||||
desktop: false,
|
||||
browser: false,
|
||||
code: false,
|
||||
tailscale: false,
|
||||
tailscaleTags: ["tag:crabbox"],
|
||||
tailscaleHostname: "",
|
||||
tailscaleAuthKey: "",
|
||||
tailscaleExitNode: "",
|
||||
tailscaleExitNodeAllowLanAccess: false,
|
||||
profile: "default",
|
||||
class: "standard",
|
||||
serverType: "Standard_D2ads_v6",
|
||||
serverTypeExplicit: true,
|
||||
location: "fsn1",
|
||||
image: "ubuntu-24.04",
|
||||
awsRegion: "eu-west-1",
|
||||
awsAMI: "",
|
||||
awsSGID: "",
|
||||
awsSubnetID: "",
|
||||
awsProfile: "",
|
||||
awsRootGB: 400,
|
||||
awsSSHCIDRs: [],
|
||||
awsMacHostID: "",
|
||||
azureLocation: "eastus",
|
||||
azureImage: "",
|
||||
capacityMarket: "spot",
|
||||
capacityStrategy: "most-available",
|
||||
capacityFallback: "on-demand-after-120s",
|
||||
capacityRegions: [],
|
||||
capacityAvailabilityZones: [],
|
||||
capacityHints: true,
|
||||
sshUser: "crabbox",
|
||||
sshPort: "2222",
|
||||
sshFallbackPorts: ["22"],
|
||||
providerKey: "crabbox-cbx",
|
||||
workRoot: "C:\\crabbox",
|
||||
ttlSeconds: 5400,
|
||||
idleTimeoutSeconds: 1800,
|
||||
keep: false,
|
||||
sshPublicKey: "ssh-rsa test",
|
||||
};
|
||||
await client.createServerWithFallback(config, "cbx_123456789abc", "blue-lobster", "owner");
|
||||
|
||||
const vmBody = bodies.find(
|
||||
(body): body is { properties: { osProfile: Record<string, unknown> } } =>
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"properties" in body &&
|
||||
JSON.stringify(body).includes("windowsConfiguration"),
|
||||
);
|
||||
expect(vmBody?.properties.osProfile).toMatchObject({
|
||||
computerName: "cbxcbx123456789",
|
||||
adminUsername: "crabadmin",
|
||||
allowExtensionOperations: true,
|
||||
windowsConfiguration: { provisionVMAgent: true, enableAutomaticUpdates: false },
|
||||
});
|
||||
expect(String(vmBody?.properties.osProfile.customData ?? "")).toBeTruthy();
|
||||
expect(JSON.stringify(vmBody)).toContain("MicrosoftWindowsServer");
|
||||
const extensionBody = bodies.find((body) =>
|
||||
JSON.stringify(body).includes("CustomScriptExtension"),
|
||||
);
|
||||
expect(JSON.stringify(extensionBody)).toContain("AzureData\\\\CustomData.bin");
|
||||
});
|
||||
|
||||
it("honors CRABBOX_AZURE_* overrides", () => {
|
||||
const client = new AzureClient({
|
||||
...baseEnv,
|
||||
CRABBOX_AZURE_RESOURCE_GROUP: "custom-rg",
|
||||
CRABBOX_AZURE_LOCATION: "westus2",
|
||||
CRABBOX_AZURE_SSH_CIDRS: "10.0.0.0/8, 192.168.0.0/16",
|
||||
});
|
||||
expect(client.resourceGroup).toBe("custom-rg");
|
||||
expect(client.defaultLocation).toBe("westus2");
|
||||
expect(client.sshCIDRs).toEqual(["10.0.0.0/8", "192.168.0.0/16"]);
|
||||
});
|
||||
|
||||
it("caches the client_credentials token across calls", async () => {
|
||||
const client = new AzureClient(baseEnv);
|
||||
let tokenMints = 0;
|
||||
const fakeFetch = ((input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
tokenMints += 1;
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ value: [] }), { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
client.fetcher = fakeFetch;
|
||||
await client.listCrabboxServers();
|
||||
await client.listCrabboxServers();
|
||||
expect(tokenMints).toBe(1);
|
||||
});
|
||||
|
||||
it("drops crabbox-ssh-* rules and preserves operator rules", () => {
|
||||
const kept = preserveNonCrabboxRules([
|
||||
{ name: "crabbox-ssh-2222-0", properties: { destinationPortRange: "2222" } },
|
||||
{ name: "operator-https", properties: { destinationPortRange: "443" } },
|
||||
]);
|
||||
expect(kept).toEqual([{ name: "operator-https", properties: { destinationPortRange: "443" } }]);
|
||||
});
|
||||
|
||||
it("uses a conservative ephemeral OS disk fallback", () => {
|
||||
expect(azureSupportsEphemeralOS("Standard_D2as_v5")).toBe(false);
|
||||
expect(azureSupportsEphemeralOS("Standard_D2s_v5")).toBe(false);
|
||||
expect(azureSupportsEphemeralOS("Standard_D2ads_v5")).toBe(true);
|
||||
expect(azureSupportsEphemeralOS("Standard_D2ads_v6")).toBe(true);
|
||||
expect(azureSupportsEphemeralOS("Standard_F2s_v2")).toBe(true);
|
||||
expect(azureSupportsEphemeralOS("Standard_D48ads_v6")).toBe(true);
|
||||
expect(azureSupportsEphemeralOS("Standard_F48s_v2")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters listCrabboxServers by crabbox=true tag", async () => {
|
||||
const client = new AzureClient(baseEnv);
|
||||
const fakeFetch = ((input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
if (url.includes("/virtualMachines?")) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
name: "kept",
|
||||
tags: { crabbox: "true" },
|
||||
properties: { provisioningState: "Succeeded" },
|
||||
},
|
||||
{
|
||||
name: "stranger",
|
||||
tags: { other: "thing" },
|
||||
properties: { provisioningState: "Succeeded" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (url.includes("/publicIPAddresses/kept-pip?")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ properties: { ipAddress: "1.2.3.4" } }), { status: 200 }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as typeof fetch;
|
||||
client.fetcher = fakeFetch;
|
||||
const machines = await client.listCrabboxServers();
|
||||
expect(machines).toHaveLength(1);
|
||||
expect(machines[0]?.name).toBe("kept");
|
||||
expect(machines[0]?.host).toBe("1.2.3.4");
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
awsUserData,
|
||||
azureWindowsBootstrapPowerShell,
|
||||
cloudInit,
|
||||
windowsBootstrapPowerShell,
|
||||
} from "../src/bootstrap";
|
||||
import { awsUserData, cloudInit, windowsBootstrapPowerShell } from "../src/bootstrap";
|
||||
import type { LeaseConfig } from "../src/config";
|
||||
|
||||
const config: LeaseConfig = {
|
||||
@ -62,13 +57,8 @@ describe("cloud-init bootstrap", () => {
|
||||
expect(got).toContain("test -f /var/lib/crabbox/bootstrapped");
|
||||
expect(got).toContain("test -w /work/crabbox");
|
||||
expect(got).toContain(" Port 2222\n Port 22");
|
||||
expect(got).toContain("systemctl enable ssh || true");
|
||||
expect(got).toContain(
|
||||
"timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true",
|
||||
);
|
||||
expect(got).toContain("touch /var/lib/crabbox/bootstrapped");
|
||||
expect(got).not.toContain("\npackages:\n");
|
||||
expect(got).not.toContain("systemctl enable --now ssh");
|
||||
expect(got).not.toContain("go version");
|
||||
expect(got).not.toContain("golang-go");
|
||||
expect(got).not.toContain("go.dev/dl/go");
|
||||
@ -201,7 +191,6 @@ describe("cloud-init bootstrap", () => {
|
||||
expect(got).toContain("OpenSSH-Win64.zip");
|
||||
expect(got).toContain("install-sshd.ps1");
|
||||
expect(got).toContain("administrators_authorized_keys");
|
||||
expect(got).toContain("Match Group administrators");
|
||||
expect(got).toContain("$sshPorts = @('2222', '22')");
|
||||
expect(got).toContain("sshd_config");
|
||||
expect(got).toContain("Port $port");
|
||||
@ -222,27 +211,6 @@ describe("cloud-init bootstrap", () => {
|
||||
expect(got).toContain("Restart-Computer -Force");
|
||||
});
|
||||
|
||||
it("builds Azure Windows extension bootstrap without restart", () => {
|
||||
const input = {
|
||||
...config,
|
||||
provider: "azure",
|
||||
target: "windows",
|
||||
workRoot: "C:\\crabbox",
|
||||
sshPublicKey: "ssh-rsa test",
|
||||
} as const;
|
||||
const got = azureWindowsBootstrapPowerShell(input);
|
||||
expect(got).toContain("OpenSSH-Win64.zip");
|
||||
expect(got).toContain("Git-2.52.0-64-bit.exe");
|
||||
expect(got).toContain("administrators_authorized_keys");
|
||||
expect(got).toContain("Match Group administrators");
|
||||
expect(got).toContain("$sshPorts = @('2222', '22')");
|
||||
expect(got).toContain("PasswordAuthentication no");
|
||||
expect(got).toContain("Restart-Service sshd -Force");
|
||||
expect(got).toContain("Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath");
|
||||
expect(got).not.toContain("Restart-Computer");
|
||||
expect(got).not.toContain("tightvnc");
|
||||
});
|
||||
|
||||
it("builds macOS user data for managed screen sharing", () => {
|
||||
const got = awsUserData({
|
||||
...config,
|
||||
|
||||
@ -5,9 +5,6 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
awsInstanceTypeCandidatesForClass,
|
||||
awsInstanceTypeCandidatesForTargetClass,
|
||||
azureWindowsVMSizeCandidatesForClass,
|
||||
azureVMSizeCandidatesForClass,
|
||||
azureVMSizeCandidatesForTargetClass,
|
||||
leaseConfig,
|
||||
serverTypeCandidatesForClass,
|
||||
serverTypeForClass,
|
||||
@ -47,29 +44,6 @@ describe("machine class config", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps known classes to preferred Azure candidates", () => {
|
||||
expect(serverTypeForProviderClass("azure", "standard")).toBe("Standard_D32ads_v6");
|
||||
expect(azureVMSizeCandidatesForClass("standard")).toEqual([
|
||||
"Standard_D32ads_v6",
|
||||
"Standard_D32ds_v6",
|
||||
"Standard_F32s_v2",
|
||||
"Standard_D32ads_v5",
|
||||
"Standard_D32ds_v5",
|
||||
"Standard_D16ads_v6",
|
||||
"Standard_D16ds_v6",
|
||||
"Standard_F16s_v2",
|
||||
]);
|
||||
expect(azureVMSizeCandidatesForTargetClass("linux", "standard")).toEqual(
|
||||
azureVMSizeCandidatesForClass("standard"),
|
||||
);
|
||||
expect(azureVMSizeCandidatesForTargetClass("windows", "standard")).toEqual(
|
||||
azureWindowsVMSizeCandidatesForClass("standard"),
|
||||
);
|
||||
expect(azureVMSizeCandidatesForTargetClass("windows", "standard", "wsl2")).toEqual([
|
||||
"standard",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps AWS Windows and macOS classes to compatible families", () => {
|
||||
expect(awsInstanceTypeCandidatesForTargetClass("windows", "standard")).toEqual([
|
||||
"m7i.large",
|
||||
@ -81,18 +55,11 @@ describe("machine class config", () => {
|
||||
|
||||
it("matches the Go CLI machine class tables", () => {
|
||||
const go = readFileSync(new URL("../../internal/cli/config.go", import.meta.url), "utf8");
|
||||
const goAzure = readFileSync(new URL("../../internal/cli/azure.go", import.meta.url), "utf8");
|
||||
const classes = ["standard", "fast", "large", "beast"];
|
||||
const hetzner = parseGoStringArrayCases(goFunctionBody(go, "serverTypeCandidatesForClass"));
|
||||
const awsLinux = parseGoStringArrayCases(
|
||||
goFunctionBody(go, "awsInstanceTypeCandidatesForClass"),
|
||||
);
|
||||
const azureLinux = parseGoStringArrayCases(
|
||||
goFunctionBody(goAzure, "azureVMSizeCandidatesForClass"),
|
||||
);
|
||||
const azureWindows = parseGoStringArrayCases(
|
||||
goFunctionBody(goAzure, "azureWindowsVMSizeCandidatesForClass"),
|
||||
);
|
||||
const awsTarget = goFunctionBody(go, "awsInstanceTypeCandidatesForTargetModeClass");
|
||||
const awsWSL2 = parseGoStringArrayCases(
|
||||
goSwitchAfter(awsTarget, "if windowsMode == windowsModeWSL2"),
|
||||
@ -102,9 +69,6 @@ describe("machine class config", () => {
|
||||
for (const name of classes) {
|
||||
expect(serverTypeCandidatesForClass(name)).toEqual(hetzner[name]);
|
||||
expect(awsInstanceTypeCandidatesForClass(name)).toEqual(awsLinux[name]);
|
||||
expect(azureVMSizeCandidatesForClass(name)).toEqual(azureLinux[name]);
|
||||
expect(azureWindowsVMSizeCandidatesForClass(name)).toEqual(azureWindows[name]);
|
||||
expect(azureVMSizeCandidatesForTargetClass("windows", name)).toEqual(azureWindows[name]);
|
||||
expect(awsInstanceTypeCandidatesForTargetClass("windows", name)).toEqual(awsWindows[name]);
|
||||
expect(awsInstanceTypeCandidatesForTargetClass("windows", name, "wsl2")).toEqual(
|
||||
awsWSL2[name],
|
||||
@ -239,48 +203,6 @@ describe("lease config", () => {
|
||||
expect(config.awsRegion).toBe("eu-west-1");
|
||||
});
|
||||
|
||||
it("uses Azure defaults when requested", () => {
|
||||
const config = leaseConfig({
|
||||
provider: "azure",
|
||||
azureLocation: "eastus",
|
||||
azureImage: "Canonical:offer:sku:latest",
|
||||
sshPublicKey: "ssh-ed25519 test",
|
||||
});
|
||||
expect(config.serverType).toBe("Standard_D192ds_v6");
|
||||
expect(config.azureLocation).toBe("eastus");
|
||||
expect(config.azureImage).toBe("Canonical:offer:sku:latest");
|
||||
});
|
||||
|
||||
it("allows Azure native Windows leases", () => {
|
||||
const config = leaseConfig({
|
||||
provider: "azure",
|
||||
target: "windows",
|
||||
sshPublicKey: "ssh-rsa test",
|
||||
});
|
||||
expect(config.serverType).toBe("Standard_D16ads_v6");
|
||||
expect(config.workRoot).toBe("C:\\crabbox");
|
||||
expect(config.windowsMode).toBe("normal");
|
||||
expect(config.sshUser).toBe("crabbox");
|
||||
expect(() =>
|
||||
leaseConfig({
|
||||
provider: "azure",
|
||||
target: "windows",
|
||||
windowsMode: "wsl2",
|
||||
sshPublicKey: "ssh-rsa test",
|
||||
}),
|
||||
).toThrow("native Windows only");
|
||||
for (const capability of ["desktop", "browser", "code", "tailscale"] as const) {
|
||||
expect(() =>
|
||||
leaseConfig({
|
||||
provider: "azure",
|
||||
target: "windows",
|
||||
[capability]: true,
|
||||
sshPublicKey: "ssh-rsa test",
|
||||
}),
|
||||
).toThrow("SSH, sync, and run");
|
||||
}
|
||||
});
|
||||
|
||||
it("records linux target defaults and rejects unsupported brokered non-linux targets", () => {
|
||||
const config = leaseConfig({ sshPublicKey: "ssh-ed25519 test" });
|
||||
expect(config.target).toBe("linux");
|
||||
|
||||
@ -61,6 +61,40 @@ class MemoryStorage {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWebSocket {
|
||||
readyState = WebSocket.OPEN;
|
||||
private attachment: unknown;
|
||||
private readonly sent: string[] = [];
|
||||
|
||||
constructor(attachment?: unknown) {
|
||||
this.attachment = attachment;
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
accept(): void {}
|
||||
|
||||
addEventListener(): void {}
|
||||
|
||||
serializeAttachment(attachment: unknown): void {
|
||||
this.attachment = attachment;
|
||||
}
|
||||
|
||||
deserializeAttachment(): unknown {
|
||||
return this.attachment;
|
||||
}
|
||||
|
||||
sentJSON(): unknown[] {
|
||||
return this.sent.map((value) => JSON.parse(value) as unknown);
|
||||
}
|
||||
}
|
||||
|
||||
describe("fleet lease identity and idle", () => {
|
||||
it("creates leases through the public route with slug and idle metadata", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
@ -192,24 +226,7 @@ describe("fleet lease identity and idle", () => {
|
||||
request("GET", "/portal/leases/blue-lobster/share", { headers: friendHeaders }),
|
||||
);
|
||||
expect(friendSharePage.status).toBe(200);
|
||||
const friendShareBody = await friendSharePage.text();
|
||||
expect(friendShareBody).toContain("share blue-lobster");
|
||||
expect(friendShareBody).toContain("share-shell");
|
||||
expect(friendShareBody).toContain("back to lease");
|
||||
expect(friendShareBody).toContain('class="button action" type="submit">save</button>');
|
||||
expect(friendShareBody).toContain('class="button action" type="submit">add</button>');
|
||||
|
||||
const embeddedSharePage = await fleet.fetch(
|
||||
request("GET", "/portal/leases/blue-lobster/share?embed=1", { headers: friendHeaders }),
|
||||
);
|
||||
expect(embeddedSharePage.status).toBe(200);
|
||||
expect(embeddedSharePage.headers.get("content-security-policy")).toContain(
|
||||
"frame-ancestors 'self'",
|
||||
);
|
||||
const embeddedShareBody = await embeddedSharePage.text();
|
||||
expect(embeddedShareBody).toContain("share-shell-embedded");
|
||||
expect(embeddedShareBody).toContain("/portal/leases/cbx_000000000001/share?embed=1");
|
||||
expect(embeddedShareBody).not.toContain("back to lease");
|
||||
expect(await friendSharePage.text()).toContain("share blue-lobster");
|
||||
|
||||
const stranger = await fleet.fetch(
|
||||
request("GET", "/v1/leases/blue-lobster", { headers: strangerHeaders }),
|
||||
@ -1674,17 +1691,12 @@ describe("fleet lease identity and idle", () => {
|
||||
expect(pageBody).toContain("function scheduleRetry");
|
||||
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
|
||||
expect(pageBody).toContain("/portal/leases/cbx_000000000001/share");
|
||||
expect(pageBody).toContain("/portal/leases/cbx_000000000001/share?embed=1");
|
||||
expect(pageBody).toContain("vnc-share-dialog");
|
||||
expect(pageBody).toContain("vnc-share-frame");
|
||||
expect(pageBody).toContain('document.getElementById("vnc-share")');
|
||||
expect(pageBody).toContain("vnc-copy-remote");
|
||||
expect(pageBody).toContain("vnc-paste");
|
||||
expect(pageBody).toContain("vnc-copy");
|
||||
expect(pageBody).toContain('addEventListener("clipboard"');
|
||||
expect(pageBody).toContain("remote clipboard ready");
|
||||
expect(pageBody).toContain("clipboardPasteFrom");
|
||||
expect(pageBody).toContain("rfb.showDotCursor = true");
|
||||
expect(pageBody).toContain('target === "macos"');
|
||||
expect(pageBody).toContain("MetaLeft");
|
||||
expect(pageBody).toContain("ControlLeft");
|
||||
@ -1692,15 +1704,9 @@ describe("fleet lease identity and idle", () => {
|
||||
expect(pageBody).toContain('data-provider="hetzner"');
|
||||
expect(pageBody).toContain('data-target="linux"');
|
||||
expect(pageBody).toContain("WebVNC daemon not running; run the bridge command below");
|
||||
expect(pageBody).toContain("waiting for an available WebVNC observer slot");
|
||||
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/control");
|
||||
expect(pageBody).toContain("vnc-takeover");
|
||||
expect(pageBody).toContain("vnc-control");
|
||||
expect(pageBody).toContain("take control");
|
||||
expect(pageBody).toContain("you control");
|
||||
expect(pageBody).not.toContain("vnc-role");
|
||||
expect(pageBody).not.toContain("status-pill vnc-role");
|
||||
expect(pageBody).toContain("rfb.viewOnly = !controlling");
|
||||
expect(pageBody).toContain(
|
||||
"WebVNC viewer already active; close stale WebVNC tabs or run reset",
|
||||
);
|
||||
expect(pageBody).toContain('fragment.get("username")');
|
||||
expect(pageBody).toContain('types.includes("username")');
|
||||
expect(pageBody).not.toContain("cdn.jsdelivr.net");
|
||||
@ -1811,30 +1817,20 @@ describe("fleet lease identity and idle", () => {
|
||||
it("resets the WebVNC bridge when the viewer goes away", () => {
|
||||
const buffers = new Map<string, WebVNCBuffer>();
|
||||
buffers.set("cbx_000000000001", { chunks: ["RFB 003.008\n"], bytes: 12 });
|
||||
buffers.set("cbx_000000000001:agent_a", { chunks: ["RFB 003.008\n"], bytes: 12 });
|
||||
const closed: Array<{ code: number; reason: string }> = [];
|
||||
const agents = new Map<string, Map<string, WebSocket>>();
|
||||
agents.set(
|
||||
"cbx_000000000001",
|
||||
new Map([
|
||||
[
|
||||
"agent_a",
|
||||
{
|
||||
readyState: WebSocket.OPEN,
|
||||
close(code: number, reason: string) {
|
||||
closed.push({ code, reason });
|
||||
},
|
||||
} as WebSocket,
|
||||
],
|
||||
]),
|
||||
);
|
||||
const agents = new Map<string, WebSocket>();
|
||||
agents.set("cbx_000000000001", {
|
||||
readyState: WebSocket.OPEN,
|
||||
close(code: number, reason: string) {
|
||||
closed.push({ code, reason });
|
||||
},
|
||||
} as WebSocket);
|
||||
|
||||
resetWebVNCBridge(agents, buffers, "cbx_000000000001", 1011, "WebVNC viewer disconnected");
|
||||
|
||||
expect(closed).toEqual([{ code: 1011, reason: "WebVNC viewer disconnected" }]);
|
||||
expect(agents.has("cbx_000000000001")).toBe(false);
|
||||
expect(buffers.has("cbx_000000000001")).toBe(false);
|
||||
expect(buffers.has("cbx_000000000001:agent_a")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps pool inventory admin-only", async () => {
|
||||
@ -1905,132 +1901,6 @@ describe("fleet lease identity and idle", () => {
|
||||
expect.objectContaining({ id: "ami-000000000001", state: "available" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("mints broker-owned artifact upload URLs without exposing secrets", async () => {
|
||||
const fleet = testFleet(
|
||||
new MemoryStorage(),
|
||||
{},
|
||||
{
|
||||
CRABBOX_ARTIFACTS_BACKEND: "r2",
|
||||
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
|
||||
CRABBOX_ARTIFACTS_PREFIX: "qa",
|
||||
CRABBOX_ARTIFACTS_BASE_URL: "https://artifacts.example.com",
|
||||
CRABBOX_ARTIFACTS_REGION: "auto",
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL: "https://account.r2.cloudflarestorage.com",
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
|
||||
},
|
||||
);
|
||||
|
||||
const response = await fleet.fetch(
|
||||
request("POST", "/v1/artifacts/uploads", {
|
||||
headers: { "x-crabbox-owner": "peter@example.com" },
|
||||
body: {
|
||||
prefix: "pr-42",
|
||||
files: [
|
||||
{
|
||||
name: "screenshots/after.png",
|
||||
size: 123,
|
||||
contentType: "image/png",
|
||||
sha256: await sha256HexForTest("after"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = (await response.json()) as {
|
||||
backend: string;
|
||||
bucket: string;
|
||||
prefix: string;
|
||||
files: Array<{
|
||||
name: string;
|
||||
key: string;
|
||||
url: string;
|
||||
upload: { url: string; headers: Record<string, string> };
|
||||
}>;
|
||||
};
|
||||
expect(body.backend).toBe("r2");
|
||||
expect(body.bucket).toBe("qa-artifacts");
|
||||
expect(body.prefix).toBe("qa/peter@example.com/pr-42");
|
||||
expect(body.files[0].key).toBe("qa/peter@example.com/pr-42/screenshots/after.png");
|
||||
expect(body.files[0].url).toBe(
|
||||
"https://artifacts.example.com/qa/peter%40example.com/pr-42/screenshots/after.png",
|
||||
);
|
||||
expect(body.files[0].upload.headers["content-length"]).toBe("123");
|
||||
expect(body.files[0].upload.headers["content-type"]).toBe("image/png");
|
||||
expect(body.files[0].upload.url).toContain("X-Amz-Signature=");
|
||||
expect(new URL(body.files[0].upload.url).searchParams.get("X-Amz-SignedHeaders")).toContain(
|
||||
"content-length",
|
||||
);
|
||||
expect(JSON.stringify(body)).not.toContain("super-secret");
|
||||
});
|
||||
|
||||
it("reports artifact broker setup errors without provider-specific local credentials", async () => {
|
||||
const fleet = testFleet();
|
||||
const response = await fleet.fetch(
|
||||
request("POST", "/v1/artifacts/uploads", {
|
||||
body: { files: [{ name: "screenshot.png", size: 1 }] },
|
||||
}),
|
||||
);
|
||||
const body = (await response.json()) as { error: string; message: string };
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("artifact_upload_unavailable");
|
||||
expect(body.message).toContain("artifact broker is not configured");
|
||||
});
|
||||
|
||||
it("requires an R2 endpoint before minting artifact upload URLs", async () => {
|
||||
const fleet = testFleet(
|
||||
new MemoryStorage(),
|
||||
{},
|
||||
{
|
||||
CRABBOX_ARTIFACTS_BACKEND: "r2",
|
||||
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
|
||||
},
|
||||
);
|
||||
|
||||
const response = await fleet.fetch(
|
||||
request("POST", "/v1/artifacts/uploads", {
|
||||
body: { files: [{ name: "screenshot.png", size: 1 }] },
|
||||
}),
|
||||
);
|
||||
const body = (await response.json()) as { error: string; message: string };
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("artifact_upload_unavailable");
|
||||
expect(body.message).toContain("CRABBOX_ARTIFACTS_ENDPOINT_URL");
|
||||
});
|
||||
|
||||
it("caps aggregate artifact upload bytes before minting grants", async () => {
|
||||
const fleet = testFleet(
|
||||
new MemoryStorage(),
|
||||
{},
|
||||
{
|
||||
CRABBOX_ARTIFACTS_BACKEND: "r2",
|
||||
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL: "https://account.r2.cloudflarestorage.com",
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
|
||||
},
|
||||
);
|
||||
|
||||
const response = await fleet.fetch(
|
||||
request("POST", "/v1/artifacts/uploads", {
|
||||
body: {
|
||||
files: Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `video-${index}.mp4`,
|
||||
size: 1024 * 1024 * 1024,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const body = (await response.json()) as { error: string; message: string };
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("artifact_upload_unavailable");
|
||||
expect(body.message).toContain("5368709120 bytes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fleet run history", () => {
|
||||
@ -2136,6 +2006,89 @@ describe("fleet run history", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("streams run events and lease heartbeats over a control websocket", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const fleet = testFleet(storage);
|
||||
const headers = {
|
||||
"x-crabbox-owner": "peter@example.com",
|
||||
"x-crabbox-org": "openclaw",
|
||||
};
|
||||
storage.seed(
|
||||
"lease:cbx_000000000001",
|
||||
testLease({
|
||||
id: "cbx_000000000001",
|
||||
slug: "blue-lobster",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
);
|
||||
storage.seed(
|
||||
"run:run_000000000001",
|
||||
testRun({
|
||||
id: "run_000000000001",
|
||||
leaseID: "cbx_000000000001",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
eventCount: 1,
|
||||
}),
|
||||
);
|
||||
storage.seed("runevent:run_000000000001:000000000001", {
|
||||
runID: "run_000000000001",
|
||||
seq: 1,
|
||||
type: "run.started",
|
||||
phase: "starting",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
});
|
||||
const socket = new FakeWebSocket({
|
||||
kind: "control",
|
||||
clientID: "ctrl_1",
|
||||
owner: "peter@example.com",
|
||||
org: "openclaw",
|
||||
subscriptions: {},
|
||||
});
|
||||
(
|
||||
fleet as unknown as {
|
||||
controlSockets: Map<string, WebSocket>;
|
||||
}
|
||||
).controlSockets.set("ctrl_1", socket as unknown as WebSocket);
|
||||
|
||||
await fleet.webSocketMessage(
|
||||
socket as unknown as WebSocket,
|
||||
JSON.stringify({ type: "subscribe_run", runID: "run_000000000001", after: 0 }),
|
||||
);
|
||||
expect(socket.sentJSON()[0]).toMatchObject({
|
||||
type: "run_events",
|
||||
runID: "run_000000000001",
|
||||
nextSeq: 1,
|
||||
events: [{ seq: 1, type: "run.started" }],
|
||||
});
|
||||
|
||||
await fleet.fetch(
|
||||
request("POST", "/v1/runs/run_000000000001/events", {
|
||||
headers,
|
||||
body: { type: "stdout", stream: "stdout", data: "ok\n" },
|
||||
}),
|
||||
);
|
||||
expect(socket.sentJSON()[1]).toMatchObject({
|
||||
type: "run_events",
|
||||
runID: "run_000000000001",
|
||||
nextSeq: 2,
|
||||
events: [{ seq: 2, type: "stdout", data: "ok\n" }],
|
||||
});
|
||||
|
||||
await fleet.webSocketMessage(
|
||||
socket as unknown as WebSocket,
|
||||
JSON.stringify({ type: "heartbeat", leaseID: "blue-lobster", idleTimeoutSeconds: 900 }),
|
||||
);
|
||||
expect(socket.sentJSON()[2]).toMatchObject({
|
||||
type: "heartbeat",
|
||||
leaseID: "cbx_000000000001",
|
||||
ok: true,
|
||||
});
|
||||
expect(storage.value<LeaseRecord>("lease:cbx_000000000001")?.idleTimeoutSeconds).toBe(900);
|
||||
});
|
||||
|
||||
it("records finished runs and serves logs", async () => {
|
||||
const fleet = testFleet();
|
||||
const ownerHeaders = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user