feat(azure): support linux and native windows leases
Add Azure as a managed provider for direct and brokered Crabbox leases. - provision Azure Linux VMs with cloud-init, spot fallback, shared network adoption, and per-lease cleanup - provision native Azure Windows VMs with VM Agent bootstrap and SSH/sync/run support - add Azure broker support in the Cloudflare Worker, provider config, docs, and tests - fix async Azure delete handling so successful 202 delete LROs do not refetch deleted resources - keep Go core coverage above the CI threshold Verified with CI plus live Azure Linux and native Windows leases. Co-authored-by: Jonathan Moss <2729151+jwmoss@users.noreply.github.com>
This commit is contained in:
parent
2e1194f6c0
commit
00725544c7
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 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@ -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
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
|
||||
| lease + cost state |
|
||||
| |
|
||||
+------------ SSH + rsync to leased runner <--------------+
|
||||
@ -61,9 +61,9 @@ crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
|
||||
|
||||
- **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** — 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.
|
||||
- **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.
|
||||
|
||||
A direct-provider mode (`--provider hetzner|aws` with local credentials) exists for debugging the broker itself; the brokered path is the default.
|
||||
A direct-provider mode (`--provider hetzner|aws|azure` 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,14 +73,15 @@ 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 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.
|
||||
- **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.
|
||||
- **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. Hetzner Windows is not a managed target; use AWS for managed Windows 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. 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.
|
||||
- **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.
|
||||
@ -112,6 +113,16 @@ 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
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
|
||||
| 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 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.
|
||||
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.
|
||||
|
||||
## A run, end to end
|
||||
|
||||
|
||||
@ -104,6 +104,7 @@ 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:
|
||||
@ -111,7 +112,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` and `aws`, and leaves interfaces ready for `hetzner-static`.
|
||||
The current broker implements `hetzner-ephemeral`, `aws`, and `azure`, and leaves interfaces ready for `hetzner-static`.
|
||||
|
||||
## Machine Bootstrap
|
||||
|
||||
|
||||
10
docs/cli.md
10
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] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider hetzner|aws]
|
||||
crabbox login [--url <url>] [--provider hetzner|aws|azure] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider hetzner|aws|azure]
|
||||
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]
|
||||
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 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 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]
|
||||
|
||||
@ -47,7 +47,7 @@ error and continue with the next candidate.
|
||||
## Flags
|
||||
|
||||
```text
|
||||
--provider hetzner|aws provider to sweep (delegated providers do not need cleanup)
|
||||
--provider hetzner|aws|azure 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
|
||||
--provider hetzner|aws|azure
|
||||
--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]
|
||||
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws|azure]
|
||||
```
|
||||
|
||||
`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|ssh
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -71,7 +71,7 @@ the exit code.
|
||||
## Flags
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|ssh provider to validate
|
||||
--provider hetzner|aws|azure|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|ssh override the configured provider
|
||||
--provider hetzner|aws|azure|ssh|daytona 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|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|azure|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 default provider to store with the broker
|
||||
--provider hetzner|aws|azure 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|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|azure|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|ssh
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--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|ssh|daytona
|
||||
--provider hetzner|aws|azure|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|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|azure|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|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|azure|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|ssh
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -9,6 +9,7 @@ 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
|
||||
@ -41,8 +42,10 @@ 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, or
|
||||
`--provider ssh --target windows` for an existing Hetzner Windows host.
|
||||
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.
|
||||
|
||||
With `--provider aws --target windows --windows-mode normal --desktop`, Crabbox
|
||||
creates a real AWS Windows Server lease. EC2Launch user data installs OpenSSH
|
||||
@ -57,6 +60,11 @@ 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
|
||||
@ -69,7 +77,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -155,7 +155,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws
|
||||
--provider hetzner|aws|azure
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
@ -177,7 +177,7 @@ daemon stop
|
||||
|
||||
Limitations:
|
||||
|
||||
- Coordinator-backed Hetzner and AWS desktop leases are supported.
|
||||
- Coordinator-backed Hetzner, AWS, and Azure Linux 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 +189,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 desktop leases`
|
||||
`webvnc currently supports coordinator-backed hetzner/aws/azure 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
|
||||
|
||||
@ -35,6 +35,7 @@ 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.
|
||||
|
||||
@ -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 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.
|
||||
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.
|
||||
|
||||
The flow:
|
||||
|
||||
|
||||
133
docs/features/azure.md
Normal file
133
docs/features/azure.md
Normal file
@ -0,0 +1,133 @@
|
||||
# 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)
|
||||
@ -69,8 +69,10 @@ 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) |
|
||||
|
||||
@ -2,20 +2,22 @@
|
||||
|
||||
Read when:
|
||||
|
||||
- changing Hetzner, AWS, or Blacksmith Testbox provisioning;
|
||||
- changing Hetzner, AWS, Azure, or Blacksmith Testbox provisioning;
|
||||
- adding a backend;
|
||||
- adjusting machine classes, fallback order, regions, or images.
|
||||
|
||||
Crabbox currently supports two brokered providers:
|
||||
Crabbox currently supports three 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, and EC2 Mac when a Dedicated Host is configured. Static SSH
|
||||
still exists for reusing existing macOS and Windows machines:
|
||||
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:
|
||||
|
||||
```text
|
||||
ssh Existing SSH host selected by static.host
|
||||
@ -32,6 +34,7 @@ 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, static SSH hosts, and Blacksmith
|
||||
Providers still own machines: Hetzner, AWS, Azure, static SSH hosts, and Blacksmith
|
||||
Testbox. Tailscale only changes which host Crabbox dials for SSH-backed work.
|
||||
|
||||
V1 support:
|
||||
|
||||
@ -12,6 +12,7 @@ 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 | Windows clien |
|
||||
| [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 |
|
||||
@ -38,7 +39,8 @@ crabbox run --provider blacksmith-testbox --id tbx_123 -- pnpm test
|
||||
|
||||
## Brokered Versus Direct
|
||||
|
||||
AWS and Hetzner can run through the Crabbox coordinator or directly from the CLI.
|
||||
AWS, Azure, 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.
|
||||
|
||||
@ -56,6 +58,7 @@ 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 |
|
||||
|
||||
215
docs/providers/azure.md
Normal file
215
docs/providers/azure.md
Normal file
@ -0,0 +1,215 @@
|
||||
# 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)
|
||||
@ -35,6 +35,7 @@ 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`
|
||||
@ -43,19 +44,21 @@ 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/hetzner`,
|
||||
`internal/providers/aws`, `internal/providers/azure`,
|
||||
`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/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 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/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,6 +17,13 @@ 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
|
||||
@ -38,9 +45,12 @@ 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
|
||||
@ -51,9 +61,10 @@ 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/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.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
|
||||
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,3 +1,17 @@
|
||||
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=
|
||||
@ -60,6 +74,8 @@ 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=
|
||||
@ -79,6 +95,10 @@ 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=
|
||||
@ -110,12 +130,21 @@ 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=
|
||||
|
||||
@ -206,11 +206,11 @@ Global:
|
||||
--version Print version
|
||||
|
||||
Config:
|
||||
crabbox login [--url <url>] [--provider aws|hetzner] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider aws|hetzner]
|
||||
crabbox login [--url <url>] [--provider aws|azure|hetzner] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider aws|azure|hetzner]
|
||||
crabbox config path
|
||||
crabbox config show [--json]
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider aws|hetzner]
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider aws|azure|hetzner]
|
||||
|
||||
Environment:
|
||||
CRABBOX_COORDINATOR Broker URL
|
||||
@ -220,7 +220,7 @@ Environment:
|
||||
CRABBOX_ACCESS_CLIENT_ID Cloudflare Access service token client ID
|
||||
CRABBOX_ACCESS_CLIENT_SECRET Cloudflare Access service token client secret
|
||||
CRABBOX_ACCESS_TOKEN Cloudflare Access JWT for protected routes
|
||||
CRABBOX_PROVIDER hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
|
||||
CRABBOX_PROVIDER hetzner, aws, azure, 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
|
||||
|
||||
@ -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 or aws")
|
||||
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
|
||||
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")
|
||||
|
||||
976
internal/cli/azure.go
Normal file
976
internal/cli/azure.go
Normal file
@ -0,0 +1,976 @@
|
||||
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)
|
||||
}
|
||||
389
internal/cli/azure_test.go
Normal file
389
internal/cli/azure_test.go
Normal file
@ -0,0 +1,389 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -102,12 +102,7 @@ tasks:
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = `C:\crabbox`
|
||||
}
|
||||
wslMode := cfg.WindowsMode == windowsModeWSL2
|
||||
func windowsBootstrapHeaderPowerShell(cfg Config, publicKey, workRoot string) string {
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
@ -130,6 +125,108 @@ 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"
|
||||
@ -139,17 +236,16 @@ $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
|
||||
@ -244,74 +340,7 @@ 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" }
|
||||
}
|
||||
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")
|
||||
` + windowsBootstrapCorePowerShell() + `
|
||||
Initialize-CrabboxWSL2
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
|
||||
@ -380,6 +409,24 @@ 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))
|
||||
|
||||
@ -187,6 +187,7 @@ 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,6 +47,9 @@ 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")
|
||||
}
|
||||
|
||||
@ -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 or aws")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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,6 +38,16 @@ 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
|
||||
@ -195,25 +205,31 @@ 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,
|
||||
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,
|
||||
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,
|
||||
Sync: SyncConfig{
|
||||
Delete: true,
|
||||
Checksum: false,
|
||||
@ -283,6 +299,7 @@ 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"`
|
||||
@ -336,6 +353,19 @@ 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"`
|
||||
@ -648,6 +678,38 @@ 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
|
||||
@ -943,6 +1005,18 @@ 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)
|
||||
@ -1109,6 +1183,9 @@ 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)
|
||||
}
|
||||
|
||||
@ -1122,6 +1199,9 @@ 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 or aws")
|
||||
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
|
||||
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 {
|
||||
|
||||
@ -492,6 +492,8 @@ 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,
|
||||
|
||||
@ -379,6 +379,8 @@ 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
|
||||
@ -401,6 +403,8 @@ 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",
|
||||
@ -415,6 +419,12 @@ 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, "provider: hetzner, aws, or ssh")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
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 desktop leases")
|
||||
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
}
|
||||
if *id == "" && !isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
|
||||
|
||||
@ -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, or ssh")
|
||||
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, azure, or ssh")
|
||||
id := fs.String("id", "", "remote lease id to inspect")
|
||||
targetFlags := registerTargetFlags(fs, defaultConfig())
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -138,6 +138,20 @@ 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,7 +38,13 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, "provider: hetzner, aws, or ssh")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
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.TargetOS == targetWindows {
|
||||
if (cfg.Provider == "aws" || cfg.Provider == "azure") && cfg.TargetOS == targetWindows {
|
||||
return ensureTestboxKeyWithType(leaseID, "rsa")
|
||||
}
|
||||
return ensureTestboxKey(leaseID)
|
||||
|
||||
@ -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 or aws")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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, ssh, blacksmith-testbox, daytona, or islo"
|
||||
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo"
|
||||
}
|
||||
|
||||
func providerHelpSSH() string {
|
||||
return "provider: hetzner, aws, ssh, or daytona"
|
||||
return "provider: hetzner, aws, azure, ssh, or daytona"
|
||||
}
|
||||
|
||||
func isBlacksmithProvider(provider string) bool {
|
||||
|
||||
@ -8,12 +8,37 @@ 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,6 +31,21 @@ 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)
|
||||
|
||||
@ -1107,6 +1107,9 @@ 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
|
||||
|
||||
@ -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, "provider: hetzner, aws, or ssh")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
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")
|
||||
|
||||
@ -115,7 +115,7 @@ func validateProviderTarget(cfg Config) error {
|
||||
return err
|
||||
}
|
||||
if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) {
|
||||
return exit(2, "%s", unsupportedManagedTargetMessage(provider.Name(), cfg.TargetOS))
|
||||
return exit(2, "%s", unsupportedManagedTargetMessageForConfig(provider.Name(), cfg))
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
|
||||
if cfg.AWSMacHostID == "" && cfg.Coordinator == "" {
|
||||
@ -143,6 +143,20 @@ 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,6 +51,43 @@ 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, "provider: hetzner, aws, or ssh")
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
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")
|
||||
fmt.Fprintln(fs.Output(), " --provider hetzner|aws|azure")
|
||||
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 or aws")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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 desktop leases")
|
||||
return exit(2, "webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
@ -303,7 +303,7 @@ func (a App) webVNCDaemonCommand(ctx context.Context, args []string) error {
|
||||
func (a App) webVNCDaemonStart(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("webvnc daemon start", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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")
|
||||
@ -388,7 +388,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 or aws")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
localPort := fs.String("local-port", "", "local VNC tunnel port")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
@ -405,7 +405,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 desktop leases")
|
||||
return exit(2, "webvnc status currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
@ -499,7 +499,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 or aws")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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 +516,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 desktop leases")
|
||||
return exit(2, "webvnc reset currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
|
||||
@ -2,6 +2,7 @@ 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"
|
||||
|
||||
204
internal/providers/azure/backend.go
Normal file
204
internal/providers/azure/backend.go
Normal file
@ -0,0 +1,204 @@
|
||||
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) }
|
||||
35
internal/providers/azure/provider.go
Normal file
35
internal/providers/azure/provider.go
Normal file
@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
84
internal/providers/azure/provider_test.go
Normal file
84
internal/providers/azure/provider_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
825
worker/src/azure.ts
Normal file
825
worker/src/azure.ts
Normal file
@ -0,0 +1,825 @@
|
||||
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));
|
||||
}
|
||||
@ -94,9 +94,10 @@ tasks:
|
||||
`;
|
||||
}
|
||||
|
||||
export function windowsBootstrapPowerShell(config: LeaseConfig): string {
|
||||
function windowsBootstrapHeaderPowerShell(config: LeaseConfig): string {
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
function Retry($ScriptBlock) {
|
||||
for ($i = 1; $i -le 8; $i++) {
|
||||
@ -117,23 +118,23 @@ $user = ${psQuote(config.sshUser)}
|
||||
$publicKey = ${psQuote(config.sshPublicKey)}
|
||||
$workRoot = ${psQuote(config.workRoot)}
|
||||
$sshPorts = ${windowsSSHPortsPowerShell(config)}
|
||||
$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"
|
||||
$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"
|
||||
$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
|
||||
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
|
||||
`;
|
||||
}
|
||||
$userPassword = Get-Content -Raw -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()
|
||||
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
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
|
||||
}
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
@ -142,13 +143,17 @@ 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 $windowsUsernamePath -Value $user
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsPasswordPath -Value $userPassword
|
||||
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 }
|
||||
@ -173,9 +178,18 @@ $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"
|
||||
@ -195,6 +209,26 @@ 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
|
||||
@ -258,7 +292,27 @@ 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,6 +27,8 @@ export interface LeaseConfig {
|
||||
awsRootGB: number;
|
||||
awsSSHCIDRs: string[];
|
||||
awsMacHostID: string;
|
||||
azureLocation: string;
|
||||
azureImage: string;
|
||||
capacityMarket: "spot" | "on-demand";
|
||||
capacityStrategy:
|
||||
| "most-available"
|
||||
@ -50,7 +52,7 @@ export interface LeaseConfig {
|
||||
|
||||
export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
const provider = input.provider ?? "hetzner";
|
||||
if (provider !== "hetzner" && provider !== "aws") {
|
||||
if (provider !== "hetzner" && provider !== "aws" && provider !== "azure") {
|
||||
throw new Error(`unsupported provider: ${String(provider)}`);
|
||||
}
|
||||
const target = normalizeTarget(input.target ?? input.targetOS ?? "linux");
|
||||
@ -58,13 +60,23 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
|
||||
if (
|
||||
target !== "linux" &&
|
||||
!(provider === "aws" && target === "windows") &&
|
||||
!(provider === "aws" && target === "macos")
|
||||
!(provider === "aws" && target === "macos") &&
|
||||
!(provider === "azure" && target === "windows" && windowsMode === "normal")
|
||||
) {
|
||||
if (provider === "hetzner") {
|
||||
throw new Error(unsupportedManagedTargetMessage(provider, target));
|
||||
if (provider === "hetzner" || provider === "azure") {
|
||||
throw new Error(unsupportedManagedTargetMessage(provider, target, windowsMode));
|
||||
}
|
||||
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}`);
|
||||
@ -115,6 +127,8 @@ 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",
|
||||
@ -153,7 +167,20 @@ function defaultSSHUser(provider: Provider, target: TargetOS, windowsMode: Windo
|
||||
return "crabbox";
|
||||
}
|
||||
|
||||
function unsupportedManagedTargetMessage(provider: Provider, target: TargetOS): string {
|
||||
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";
|
||||
}
|
||||
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`;
|
||||
}
|
||||
@ -163,6 +190,13 @@ function unsupportedManagedTargetMessage(provider: Provider, target: TargetOS):
|
||||
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
|
||||
@ -239,6 +273,9 @@ 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);
|
||||
}
|
||||
|
||||
@ -253,9 +290,124 @@ 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,
|
||||
|
||||
@ -6,7 +6,8 @@ import {
|
||||
awsRegionCandidates,
|
||||
isRetryableAWSProvisioningError,
|
||||
} from "./aws";
|
||||
import { leaseConfig, validCIDRs } from "./config";
|
||||
import { AzureClient } from "./azure";
|
||||
import { azureLocationFor, leaseConfig, validCIDRs } from "./config";
|
||||
import { HetznerClient } from "./hetzner";
|
||||
import { errorMessage, json, pathParts, readJson, requestOwner } from "./http";
|
||||
import { githubAuthRoute, githubPortalLogin, githubPortalLogout } from "./oauth";
|
||||
@ -638,6 +639,9 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (config.provider === "aws" && !config.awsAMI) {
|
||||
config.awsAMI = (await this.promotedAWSImage())?.id ?? "";
|
||||
}
|
||||
if (config.provider === "azure" && !config.azureLocation) {
|
||||
config.azureLocation = azureLocationFor(this.env, "");
|
||||
}
|
||||
const leaseID = validLeaseID(input.leaseID) ? input.leaseID : newLeaseID();
|
||||
const leases = await this.leaseRecords();
|
||||
const slug = allocateLeaseSlug(
|
||||
@ -760,6 +764,9 @@ export class FleetDurableObject implements DurableObject {
|
||||
record.capacityHints = hints;
|
||||
}
|
||||
}
|
||||
if (config.provider === "azure") {
|
||||
record.region = config.azureLocation;
|
||||
}
|
||||
await this.putLease(record);
|
||||
await this.scheduleAlarm();
|
||||
return json({ lease: record }, { status: 201 });
|
||||
@ -2206,12 +2213,13 @@ export class FleetDurableObject implements DurableObject {
|
||||
? await this.provider("aws").listCrabboxServers()
|
||||
: provider === "hetzner"
|
||||
? await this.provider("hetzner").listCrabboxServers()
|
||||
: [
|
||||
...(await this.provider("hetzner").listCrabboxServers()),
|
||||
...(await this.provider("aws")
|
||||
.listCrabboxServers()
|
||||
.catch(() => [])),
|
||||
];
|
||||
: provider === "azure"
|
||||
? await this.provider("azure").listCrabboxServers()
|
||||
: [
|
||||
...(await this.provider("hetzner").listCrabboxServers()),
|
||||
...(await this.listProviderMachinesSafe("aws")),
|
||||
...(await this.listProviderMachinesSafe("azure")),
|
||||
];
|
||||
return json({ machines });
|
||||
}
|
||||
|
||||
@ -2856,6 +2864,14 @@ export class FleetDurableObject implements DurableObject {
|
||||
return event;
|
||||
}
|
||||
|
||||
private async listProviderMachinesSafe(provider: Provider): Promise<ProviderMachine[]> {
|
||||
try {
|
||||
return await this.provider(provider).listCrabboxServers();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private provider(provider: Provider, region = "eu-west-1"): CloudProvider {
|
||||
const testProvider = this.testProviders[provider];
|
||||
if (testProvider) {
|
||||
@ -2864,6 +2880,9 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (provider === "aws") {
|
||||
return new AWSProvider(this.env, region || this.env.CRABBOX_AWS_REGION || "eu-west-1");
|
||||
}
|
||||
if (provider === "azure") {
|
||||
return new AzureProvider(this.env);
|
||||
}
|
||||
return new HetznerProvider(this.env);
|
||||
}
|
||||
|
||||
@ -2875,6 +2894,10 @@ export class FleetDurableObject implements DurableObject {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (lease.provider === "azure") {
|
||||
await this.provider("azure").deleteServer(lease.cloudID);
|
||||
return;
|
||||
}
|
||||
await this.provider("hetzner").deleteServer(String(lease.serverID));
|
||||
if (validCrabboxProviderKey(lease.providerKey)) {
|
||||
await this.provider("hetzner").deleteSSHKey(lease.providerKey);
|
||||
@ -3664,7 +3687,7 @@ function boundedRunEvent(
|
||||
if (input.slug) {
|
||||
event.slug = truncateString(input.slug, 128);
|
||||
}
|
||||
if (input.provider === "aws" || input.provider === "hetzner") {
|
||||
if (input.provider === "aws" || input.provider === "hetzner" || input.provider === "azure") {
|
||||
event.provider = input.provider;
|
||||
}
|
||||
if (input.target === "linux" || input.target === "macos" || input.target === "windows") {
|
||||
@ -4258,6 +4281,52 @@ class HetznerProvider implements CloudProvider {
|
||||
}
|
||||
}
|
||||
|
||||
class AzureProvider implements CloudProvider {
|
||||
private readonly client: AzureClient;
|
||||
|
||||
constructor(env: Env) {
|
||||
this.client = new AzureClient(env);
|
||||
}
|
||||
|
||||
listCrabboxServers(): Promise<ProviderMachine[]> {
|
||||
return this.client.listCrabboxServers();
|
||||
}
|
||||
|
||||
createServerWithFallback(
|
||||
config: ReturnType<typeof leaseConfig>,
|
||||
leaseID: string,
|
||||
slug: string,
|
||||
owner: string,
|
||||
): Promise<{
|
||||
server: ProviderMachine;
|
||||
serverType: string;
|
||||
market?: string;
|
||||
attempts?: ProvisioningAttempt[];
|
||||
}> {
|
||||
return this.client.createServerWithFallback(config, leaseID, slug, owner);
|
||||
}
|
||||
|
||||
deleteServer(id: string): Promise<void> {
|
||||
return this.client.deleteServer(id);
|
||||
}
|
||||
|
||||
createImage(): Promise<ProviderImage> {
|
||||
throw new Error("azure images are not supported");
|
||||
}
|
||||
|
||||
getImage(): Promise<ProviderImage> {
|
||||
throw new Error("azure images are not supported");
|
||||
}
|
||||
|
||||
async deleteSSHKey(): Promise<void> {
|
||||
// Azure stores the SSH public key inline on the VM; nothing to clean up.
|
||||
}
|
||||
|
||||
hourlyPriceUSD(): Promise<number | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class AWSProvider implements CloudProvider {
|
||||
private readonly client: EC2SpotClient;
|
||||
private readonly region: string;
|
||||
|
||||
@ -16,6 +16,17 @@ 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;
|
||||
@ -85,6 +96,8 @@ 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";
|
||||
@ -104,7 +117,7 @@ export interface LeaseRequest {
|
||||
sshPublicKey?: string;
|
||||
}
|
||||
|
||||
export type Provider = "hetzner" | "aws";
|
||||
export type Provider = "hetzner" | "aws" | "azure";
|
||||
export type TargetOS = "linux" | "macos" | "windows";
|
||||
export type WindowsMode = "normal" | "wsl2";
|
||||
|
||||
|
||||
375
worker/test/azure.test.ts
Normal file
375
worker/test/azure.test.ts
Normal file
@ -0,0 +1,375 @@
|
||||
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,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { awsUserData, cloudInit, windowsBootstrapPowerShell } from "../src/bootstrap";
|
||||
import {
|
||||
awsUserData,
|
||||
azureWindowsBootstrapPowerShell,
|
||||
cloudInit,
|
||||
windowsBootstrapPowerShell,
|
||||
} from "../src/bootstrap";
|
||||
import type { LeaseConfig } from "../src/config";
|
||||
|
||||
const config: LeaseConfig = {
|
||||
@ -196,6 +201,7 @@ 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");
|
||||
@ -216,6 +222,27 @@ 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,6 +5,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
awsInstanceTypeCandidatesForClass,
|
||||
awsInstanceTypeCandidatesForTargetClass,
|
||||
azureWindowsVMSizeCandidatesForClass,
|
||||
azureVMSizeCandidatesForClass,
|
||||
azureVMSizeCandidatesForTargetClass,
|
||||
leaseConfig,
|
||||
serverTypeCandidatesForClass,
|
||||
serverTypeForClass,
|
||||
@ -44,6 +47,29 @@ 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",
|
||||
@ -55,11 +81,18 @@ 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"),
|
||||
@ -69,6 +102,9 @@ 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],
|
||||
@ -203,6 +239,48 @@ 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");
|
||||
|
||||
@ -3,6 +3,21 @@ import { describe, expect, it } from "vitest";
|
||||
import type { LeaseRecord } from "../src/types";
|
||||
import { costLimits, enforceCostLimits, leaseCost, usageSummary } from "../src/usage";
|
||||
|
||||
describe("azure cost overrides", () => {
|
||||
it("honors CRABBOX_COST_RATES_JSON for Azure SKUs", () => {
|
||||
const cost = leaseCost(
|
||||
{
|
||||
CRABBOX_COST_RATES_JSON: JSON.stringify({ "azure:Standard_D16as_v5": 0.77 }),
|
||||
},
|
||||
"azure",
|
||||
"Standard_D16as_v5",
|
||||
3600,
|
||||
undefined,
|
||||
);
|
||||
expect(cost.hourlyUSD).toBe(0.77);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage accounting", () => {
|
||||
it("estimates cost and aggregates by owner and org", () => {
|
||||
const now = new Date("2026-05-01T02:00:00Z");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user