diff --git a/CHANGELOG.md b/CHANGELOG.md index e045df94..47af4ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -## 0.22.1 - Unreleased +## 0.22.1 - 2026-05-29 + +### Added + +- Added `--arch arm64` / `architecture: arm64` for Linux ARM leases on Azure and AWS, including Azure Dpsv6/Dpdsv6 and AWS Graviton class fallback plus matching Ubuntu ARM64 image resolution. ### Fixed diff --git a/README.md b/README.md index 8ced1499..68b6b293 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ AWS Linux standard c7a/c7i/m7a/m7i.8xlarge family fast …16xlarge family large …24xlarge family beast …48xlarge family, falling back to 32x/24x/16x + arm64 c7g/m7g/r7g families with --arch arm64 AWS Win standard m7i.large, m7a.large, t3.large fast m7i.xlarge, m7a.xlarge, t3.xlarge @@ -255,6 +256,7 @@ Azure standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, th 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 + arm64 Standard_D*ps_v6 / D*pds_v6 Cobalt families with --arch arm64 Azure Win/ WSL2 standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, Standard_D2as_v6 @@ -273,7 +275,9 @@ Cloudflare standard standard-4 beast standard-4 ``` -Override with `--type` or `CRABBOX_SERVER_TYPE` for a specific instance. +Override with `--type` or `CRABBOX_SERVER_TYPE` for a specific instance. Use +`--arch arm64` / `architecture: arm64` for Linux ARM capacity on Azure or AWS; +explicit ARM provider types also select ARM images when no custom image is set. Cloudflare also accepts `lite`, `basic`, `standard-1`, `standard-2`, and `standard-3` as smaller explicit `--type` values; `standard-4` is the default. Providers without a row either use provider-native capacity settings or reject diff --git a/docs/cli.md b/docs/cli.md index 222ba361..cba237c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -426,6 +426,7 @@ CRABBOX_ORG owning org CRABBOX_PROFILE default profile CRABBOX_CONFIG path to an explicit config file CRABBOX_DEFAULT_CLASS default machine class +CRABBOX_ARCH default CPU architecture (amd64|arm64) CRABBOX_SERVER_TYPE provider server/instance type override CRABBOX_IDLE_TIMEOUT idle expiry CRABBOX_TTL max lease lifetime diff --git a/docs/commands/job.md b/docs/commands/job.md index 96e70afa..5af97de9 100644 --- a/docs/commands/job.md +++ b/docs/commands/job.md @@ -85,6 +85,7 @@ jobs: test-wsl2: provider: aws target: windows + architecture: amd64 windows: mode: wsl2 class: beast @@ -110,7 +111,7 @@ jobs: Routing and lease creation: -- `provider`, `target` (or `targetOS`), `windows.mode`, `profile`, `class`. +- `provider`, `target` (or `targetOS`), `windows.mode`, `profile`, `class`, `architecture`. - `type` (or `serverType`), `market` (or `capacity.market`). - `ttl`, `idleTimeout`, `desktop`, `desktopEnv`, `browser`, `code`, `network`. diff --git a/docs/commands/run.md b/docs/commands/run.md index eb135b5a..1d75bae7 100644 --- a/docs/commands/run.md +++ b/docs/commands/run.md @@ -10,6 +10,7 @@ crabbox run --id swift-crab -- pnpm test:changed crabbox run --class beast -- pnpm check crabbox run --provider aws --class beast --market on-demand -- pnpm check crabbox run --provider azure --class beast -- pnpm check +crabbox run --provider azure --arch arm64 --class fast -- go test ./... crabbox run --tailscale -- pnpm check crabbox run --id swift-crab --network tailscale -- pnpm test crabbox run --browser -- google-chrome --headless --version @@ -381,6 +382,7 @@ lease-acting commands): --provider See `crabbox providers` for the full list. --profile --class +--arch amd64|arm64 CPU architecture; arm64 is Linux-only on AWS/Azure. --os Portable Linux OS image, e.g. ubuntu:26.04 --type --market spot|on-demand diff --git a/docs/commands/warmup.md b/docs/commands/warmup.md index bb24da13..b115f9e4 100644 --- a/docs/commands/warmup.md +++ b/docs/commands/warmup.md @@ -10,6 +10,7 @@ crabbox warmup --class beast crabbox warmup --provider aws --class beast --market on-demand crabbox warmup --provider aws --os ubuntu:26.04 --desktop --browser --desktop-env wayland crabbox warmup --provider azure --class beast +crabbox warmup --provider azure --arch arm64 --class fast crabbox warmup --browser crabbox warmup --tailscale crabbox warmup --slug update-flow-smoke @@ -189,6 +190,7 @@ warmup, because it also dispatches the workflow and waits for the ready marker. --provider provider (see crabbox providers); default hetzner --profile configuration profile --class machine class; default beast +--arch amd64|arm64 CPU architecture; arm64 is Linux-only on AWS/Azure --os ubuntu:26.04|ubuntu:24.04 portable Linux OS image selector --type provider server/instance type --market spot|on-demand capacity market (AWS) diff --git a/docs/features/aws.md b/docs/features/aws.md index 0daf4e4e..efac74ee 100644 --- a/docs/features/aws.md +++ b/docs/features/aws.md @@ -28,6 +28,7 @@ Mac. AWS is one of the four brokerable providers, so it can run two ways: ```sh crabbox warmup --provider aws --class beast +crabbox warmup --provider aws --arch arm64 --class fast crabbox run --provider aws --class beast --market on-demand -- pnpm check crabbox warmup --provider aws --target windows --desktop crabbox warmup --provider aws --target windows --windows-mode wsl2 @@ -83,6 +84,12 @@ fast c7a.16xlarge, c7i.16xlarge, m7a.16xlarge, m7i.16xlarge, c7a.12xlarge, large c7a.24xlarge, c7i.24xlarge, m7a.24xlarge, m7i.24xlarge, r7a.24xlarge, c7a.16xlarge, c7a.12xlarge beast c7a.48xlarge, c7i.48xlarge, m7a.48xlarge, m7i.48xlarge, r7a.48xlarge, c7a.32xlarge, c7i.32xlarge, m7a.32xlarge, c7a.24xlarge, c7a.16xlarge +AWS Linux ARM64 (--arch arm64) +standard c7g.8xlarge, m7g.8xlarge, r7g.8xlarge, c7g.4xlarge +fast c7g.16xlarge, m7g.16xlarge, r7g.16xlarge, c7g.12xlarge, c7g.8xlarge +large c7g.16xlarge, m7g.16xlarge, r7g.16xlarge, c7g.12xlarge +beast c7g.16xlarge, m7g.16xlarge, r7g.16xlarge, c7g.12xlarge + AWS Windows standard m7i.large, m7a.large, t3.large fast m7i.xlarge, m7a.xlarge, t3.xlarge @@ -106,9 +113,10 @@ from the C8i/M8i/M8i-flex/R8i families; Crabbox rejects unsupported families ## Images -- **Linux** resolves the latest Ubuntu 26.04 x86_64 AMI from Canonical. Pass - `--os ubuntu:24.04` for the previous LTS. Supported selectors: `ubuntu:26.04` - and `ubuntu:24.04`. +- **Linux** resolves the latest Ubuntu 26.04 AMI from Canonical for the selected + architecture. Pass `--arch arm64` for Graviton/ARM64 capacity and + `--os ubuntu:24.04` for the previous LTS. Supported selectors: + `ubuntu:26.04` and `ubuntu:24.04`. - **Windows** resolves the latest Windows Server 2022 English Full Base AMI. - **macOS** resolves the matching Amazon EC2 macOS AMI for the chosen instance family (arm64 for Apple silicon, x86_64 for `mac1.metal`). diff --git a/docs/features/azure.md b/docs/features/azure.md index b06bcac4..1ac857f3 100644 --- a/docs/features/azure.md +++ b/docs/features/azure.md @@ -34,6 +34,7 @@ dynamic-sessions`; see the [providers overview](providers.md). ```sh crabbox warmup --provider azure --class beast +crabbox warmup --provider azure --arch arm64 --class fast crabbox warmup --provider azure --class beast --azure-os-disk ephemeral crabbox run --provider azure --class standard -- pnpm test crabbox warmup --provider azure --target windows --class standard @@ -58,6 +59,16 @@ large Standard_D96ads_v6, Standard_D96ds_v6, then D/F 64- and 48-vCPU fallba beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96- and 64-vCPU fallbacks ``` +Linux ARM64 classes use Azure Cobalt Dpsv6/Dpdsv6 candidates and ARM64 Ubuntu +Marketplace images: + +```text +standard Standard_D32pds_v6, Standard_D32ps_v6, then 16-vCPU fallbacks +fast Standard_D64pds_v6, Standard_D64ps_v6, then 48/32-vCPU fallbacks +large Standard_D96pds_v6, Standard_D96ps_v6, then 64/48-vCPU fallbacks +beast Standard_D96pds_v6, Standard_D96ps_v6, then 64-vCPU fallbacks +``` + Native Windows and WSL2 use a smaller scale, and the default candidates support the nested virtualization WSL2 needs: @@ -174,6 +185,9 @@ existing vnet and NSG, or pick distinct `azure.vnet`, `azure.subnet`, and The default location is `eastus`. The default Linux image is `Canonical:ubuntu-26_04-lts:server:latest`; native Windows defaults to `MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest`. +With `architecture: arm64` or `--arch arm64`, Linux defaults switch to +`Canonical:ubuntu-26_04-lts:server-arm64:latest` or the matching +`ubuntu-24_04-lts:server-arm64` image when `--os ubuntu:24.04` is set. Set `azure.image` / `CRABBOX_AZURE_IMAGE` as a `Publisher:Offer:SKU:Version` reference to override. diff --git a/docs/features/configuration.md b/docs/features/configuration.md index 3a5bccaf..d48acd81 100644 --- a/docs/features/configuration.md +++ b/docs/features/configuration.md @@ -77,6 +77,7 @@ broker: provider: aws # default provider when --provider is unset target: linux # default target OS: linux | macos | windows +architecture: amd64 # amd64 | arm64; arm64 is Linux-only on AWS/Azure os: ubuntu:26.04 # OS image; resolved to per-provider images for linux windows: mode: normal # normal | wsl2 when target=windows @@ -176,6 +177,7 @@ jobs: windows-wsl2: provider: aws target: windows + architecture: amd64 windows: mode: wsl2 class: beast @@ -679,6 +681,10 @@ Class-to-type mappings live in [Providers](providers.md). When you set ignored. The `serverType:` and `--type` paths intentionally do not fall back; they fail loud if the provider rejects the type. +Set `architecture: arm64` (or `--arch arm64`) for Linux ARM capacity on AWS or +Azure. Explicit ARM provider types also select matching ARM Linux images when +no provider-specific image override is set. + ## Environment variables Most YAML keys have a matching `CRABBOX_*` env override that takes precedence @@ -694,6 +700,7 @@ CRABBOX_ACCESS_CLIENT_ID Cloudflare Access service-token id CRABBOX_ACCESS_CLIENT_SECRET Cloudflare Access service-token secret CRABBOX_PROVIDER default provider CRABBOX_TARGET default target OS +CRABBOX_ARCH default architecture: amd64 or arm64 CRABBOX_OS default OS image CRABBOX_PROFILE default profile CRABBOX_DEFAULT_CLASS default machine class @@ -726,7 +733,7 @@ MODAL_TOKEN_ID / MODAL_TOKEN_SECRET | Setting | User config | Repo config | Profile | Notes | |:--------|:------------|:------------|:--------|:------| | `broker.url` and `broker.token` | yes | no | no | Per-machine identity. | -| `provider`, `class`, `serverType` | optional default | yes | yes | Per-repo defaults; profiles for lanes. | +| `provider`, `class`, `architecture`, `serverType` | optional default | yes | yes | Per-repo defaults; profiles for lanes. | | `sync.exclude`, `sync.fingerprint`, `sync.baseRef` | no | yes | yes | Lives with the repo. | | `env.allow` | no | yes | yes | Repo decides what is safe to forward. | | Per-user SSH key path | yes | no | no | Personal preference. | diff --git a/docs/features/jobs.md b/docs/features/jobs.md index 2c9f618f..437e4ad5 100644 --- a/docs/features/jobs.md +++ b/docs/features/jobs.md @@ -24,7 +24,7 @@ project-specific parallelism. Belongs in a Crabbox job: -- provider, target OS, Windows mode, profile, class/type, market, network; +- provider, target OS, architecture, Windows mode, profile, class/type, market, network; - lease TTL, idle timeout, and stop policy; - whether to run Actions hydration and how long to wait for it; - the remote command and whether it runs through a shell; @@ -142,6 +142,7 @@ windows: mode: wsl2 # normal | wsl2; sets --windows-mode profile: project-check class: beast +architecture: amd64 # amd64 | arm64; arm64 is Linux-only on AWS/Azure type: m8i.4xlarge # alias: serverType market: on-demand # alias: capacity.market network: auto diff --git a/docs/providers/aws.md b/docs/providers/aws.md index 3bf38269..ef47f651 100644 --- a/docs/providers/aws.md +++ b/docs/providers/aws.md @@ -30,6 +30,7 @@ Prefer [Hetzner](./hetzner.md) for cheaper Linux-only capacity, or ```sh crabbox warmup --provider aws --class standard +crabbox warmup --provider aws --arch arm64 --class fast crabbox run --provider aws --class fast -- pnpm test crabbox run --provider aws --market on-demand -- pnpm check crabbox warmup --provider aws --target windows --desktop @@ -63,6 +64,7 @@ class is `beast`. ```yaml provider: aws target: linux +architecture: amd64 class: beast market: spot aws: @@ -76,6 +78,11 @@ aws: macHostId: "" # pin an EC2 Mac Dedicated Host ``` +Set `architecture: arm64` or pass `--arch arm64` for Linux Graviton leases. +Crabbox switches class fallback to C7g/M7g/R7g families and resolves Canonical +Ubuntu ARM64 AMIs unless `aws.ami` is pinned. ARM64 is not supported for managed +Windows or WSL2 targets. + ### Environment variables (direct mode) ```text diff --git a/docs/providers/azure.md b/docs/providers/azure.md index a020215f..fb07f7f0 100644 --- a/docs/providers/azure.md +++ b/docs/providers/azure.md @@ -34,6 +34,7 @@ Azure supports both execution modes: ```sh crabbox warmup --provider azure --class beast +crabbox warmup --provider azure --arch arm64 --class fast crabbox warmup --provider azure --class beast --azure-os-disk ephemeral crabbox run --provider azure --class standard -- pnpm test crabbox run --provider azure --azure-backend dynamic-sessions -- pnpm test @@ -67,6 +68,7 @@ Azure-family backend: ```yaml provider: azure target: linux +architecture: amd64 class: beast azure: backend: vm @@ -88,6 +90,11 @@ azure: environment variables. The client secret is never read from config — it must come from the environment. +Set `architecture: arm64` or pass `--arch arm64` for Linux ARM leases. Crabbox +then switches class fallback to Azure Cobalt Dpsv6/Dpdsv6 sizes and uses the +matching Ubuntu ARM64 Marketplace image unless `azure.image` is explicitly set. +ARM64 is not supported for native Windows, WSL2, or macOS targets. + `azure.network` selects which IP the CLI uses for SSH: `public` (default) uses the VM public IP, `private` uses the NIC private IP from the vnet. Use `private` when connecting through a VPN to the Azure virtual network. diff --git a/internal/cli/aws.go b/internal/cli/aws.go index a11f1ea5..4e1ba613 100644 --- a/internal/cli/aws.go +++ b/internal/cli/aws.go @@ -104,7 +104,7 @@ func (c *AWSClient) SpotPlacementScores(ctx context.Context, cfg Config) ([]type if len(regions) == 0 { return nil, nil } - candidates := awsInstanceTypeCandidatesForClass(cfg.Class) + candidates := awsInstanceTypeCandidatesForConfig(cfg) if cfg.ServerType != "" { candidates = appendUniqueStrings([]string{cfg.ServerType}, candidates...) } @@ -650,14 +650,15 @@ func (c *AWSClient) resolveAMI(ctx context.Context, cfg Config) (string, error) name, architecture := awsMacOSAMIQueryForInstanceType(cfg.ServerType) return c.resolveLatestAmazonAMI(ctx, name, architecture) } - name, label, err := awsLinuxAMIQueryForOS(cfg.OSImage) + architecture := awsLinuxImageArchitecture(effectiveArchitectureForConfig(cfg)) + name, label, err := awsLinuxAMIQueryForOS(cfg.OSImage, effectiveArchitectureForConfig(cfg)) if err != nil { return "", err } out, err := c.ec2.DescribeImages(ctx, &ec2.DescribeImagesInput{ Owners: []string{awsUbuntuOwner}, Filters: []types.Filter{ - {Name: aws.String("architecture"), Values: []string{"x86_64"}}, + {Name: aws.String("architecture"), Values: []string{architecture}}, {Name: aws.String("name"), Values: []string{name}}, {Name: aws.String("root-device-type"), Values: []string{"ebs"}}, {Name: aws.String("virtualization-type"), Values: []string{"hvm"}}, @@ -667,7 +668,7 @@ func (c *AWSClient) resolveAMI(ctx context.Context, cfg Config) (string, error) return "", err } if len(out.Images) == 0 { - return "", exit(3, "no %s x86_64 AMI found in %s; set CRABBOX_AWS_AMI", label, cfg.AWSRegion) + return "", exit(3, "no %s %s AMI found in %s; set CRABBOX_AWS_AMI", label, architecture, cfg.AWSRegion) } sort.Slice(out.Images, func(i, j int) bool { return aws.ToString(out.Images[i].CreationDate) > aws.ToString(out.Images[j].CreationDate) @@ -675,6 +676,13 @@ func (c *AWSClient) resolveAMI(ctx context.Context, cfg Config) (string, error) return aws.ToString(out.Images[0].ImageId), nil } +func awsLinuxImageArchitecture(architecture string) string { + if architecture == ArchitectureARM64 { + return "arm64" + } + return "x86_64" +} + func awsMacOSAMIQueryForInstanceType(instanceType string) (string, string) { if strings.HasPrefix(instanceType, "mac1.") { return "amzn-ec2-macos-14.*", "x86_64_mac" @@ -1024,6 +1032,9 @@ func awsLaunchCandidates(cfg Config) []string { return appendUniqueStrings([]string{cfg.ServerType}, awsInstanceTypeCandidatesForConfig(cfg)...) } fallback := "t3.small" + if cfg.TargetOS == targetLinux && effectiveArchitectureForConfig(cfg) == ArchitectureARM64 { + fallback = "t4g.small" + } if cfg.TargetOS == targetWindows { fallback = "t3.large" if cfg.WindowsMode == windowsModeWSL2 { @@ -1147,9 +1158,10 @@ func awsRecommendedClassForQuota(cfg Config, limitVCPUs int) (string, string) { if limitVCPUs <= 0 { return "", "" } + architecture := effectiveArchitectureForConfig(cfg) classes := []string{"beast", "large", "fast", "standard"} for _, class := range classes { - candidates := awsInstanceTypeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, class) + candidates := awsInstanceTypeCandidatesForTargetModeArchitectureClass(cfg.TargetOS, cfg.WindowsMode, architecture, class) if len(candidates) == 0 { continue } @@ -1157,7 +1169,7 @@ func awsRecommendedClassForQuota(cfg Config, limitVCPUs int) (string, string) { return class, candidates[0] } } - for _, serverType := range awsInstanceTypeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, "standard") { + for _, serverType := range awsInstanceTypeCandidatesForTargetModeArchitectureClass(cfg.TargetOS, cfg.WindowsMode, architecture, "standard") { if awsInstanceTypeVCPUs(serverType) <= limitVCPUs { return "standard", serverType } diff --git a/internal/cli/aws_test.go b/internal/cli/aws_test.go index 26c32a99..7257bb30 100644 --- a/internal/cli/aws_test.go +++ b/internal/cli/aws_test.go @@ -68,6 +68,25 @@ func TestAWSCapacityDoctorCheckWarnsWhenQuotaBelowDefaultClass(t *testing.T) { } } +func TestAWSCapacityDoctorCheckRecommendsARM64Types(t *testing.T) { + cfg := defaultConfig() + cfg.Provider = "aws" + cfg.TargetOS = targetLinux + cfg.Class = "beast" + cfg.Architecture = ArchitectureARM64 + cfg.architectureExplicit = true + cfg.ServerType = serverTypeForConfig(cfg) + + check := awsCapacityDoctorCheckForQuota(cfg, "spot", 32, true, nil) + + if check.Status != "warning" { + t.Fatalf("status=%q, want warning", check.Status) + } + if check.Details["recommended_class"] != "standard" || check.Details["recommended_type"] != "c7g.8xlarge" { + t.Fatalf("recommendation=(%q,%q), want standard/c7g.8xlarge", check.Details["recommended_class"], check.Details["recommended_type"]) + } +} + func TestAWSCapacityDoctorCheckPassesWhenQuotaCoversDefaultClass(t *testing.T) { cfg := defaultConfig() cfg.Provider = "aws" diff --git a/internal/cli/azure.go b/internal/cli/azure.go index 8d20fee3..729e299c 100644 --- a/internal/cli/azure.go +++ b/internal/cli/azure.go @@ -19,19 +19,21 @@ import ( ) const ( - azureAddressSpace = "10.42.0.0/16" - azureSubnetCIDR = "10.42.0.0/24" - azureProviderTag = "crabbox" - AzureOSDiskAuto = "auto" - AzureOSDiskEphemeral = "ephemeral" - AzureOSDiskManaged = "managed" - defaultAzureLinuxImage = "Canonical:ubuntu-26_04-lts:server:latest" - azureNobleLinuxImage = "Canonical:ubuntu-24_04-lts:server:latest" - legacyAzureJammyImage = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest" - legacyAzureNobleGen2Image = "Canonical:0001-com-ubuntu-server-noble:24_04-lts-gen2:latest" - defaultAzureWindowsImage = "MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest" - azureDeleteRetryDelay = 15 * time.Second - azureDeleteRetryAttempts = 13 + azureAddressSpace = "10.42.0.0/16" + azureSubnetCIDR = "10.42.0.0/24" + azureProviderTag = "crabbox" + AzureOSDiskAuto = "auto" + AzureOSDiskEphemeral = "ephemeral" + AzureOSDiskManaged = "managed" + defaultAzureLinuxImage = "Canonical:ubuntu-26_04-lts:server:latest" + defaultAzureLinuxARM64Image = "Canonical:ubuntu-26_04-lts:server-arm64:latest" + azureNobleLinuxImage = "Canonical:ubuntu-24_04-lts:server:latest" + azureNobleLinuxARM64Image = "Canonical:ubuntu-24_04-lts:server-arm64:latest" + legacyAzureJammyImage = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest" + legacyAzureNobleGen2Image = "Canonical:0001-com-ubuntu-server-noble:24_04-lts-gen2:latest" + defaultAzureWindowsImage = "MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest" + azureDeleteRetryDelay = 15 * time.Second + azureDeleteRetryAttempts = 13 ) type AzureClient struct { @@ -146,6 +148,12 @@ func azureImageForConfig(cfg Config) string { if cfg.TargetOS == targetWindows && (cfg.AzureImage == "" || isAzureDefaultLinuxImage(cfg.AzureImage)) { return defaultAzureWindowsImage } + if cfg.TargetOS == targetLinux && effectiveArchitectureForConfig(cfg) == ArchitectureARM64 && (cfg.AzureImage == "" || isAzureDefaultLinuxImage(cfg.AzureImage)) { + if cfg.OSImage == "ubuntu:24.04" { + return azureNobleLinuxARM64Image + } + return defaultAzureLinuxARM64Image + } if cfg.AzureImage == "" { return defaultAzureLinuxImage } @@ -154,21 +162,39 @@ func azureImageForConfig(cfg Config) string { func isAzureDefaultLinuxImage(image string) bool { switch strings.TrimSpace(image) { - case defaultAzureLinuxImage, azureNobleLinuxImage, legacyAzureJammyImage, legacyAzureNobleGen2Image: + case defaultAzureLinuxImage, defaultAzureLinuxARM64Image, azureNobleLinuxImage, azureNobleLinuxARM64Image, legacyAzureJammyImage, legacyAzureNobleGen2Image: return true default: return false } } +func azureVMSizeCandidatesForTargetModeClass(target, windowsMode, class string) []string { + switch target { + case targetLinux: + return azureVMSizeCandidatesForArchitectureClass(ArchitectureAMD64, class) + case targetWindows: + if windowsMode == windowsModeNormal || windowsMode == windowsModeWSL2 { + return azureWindowsVMSizeCandidatesForClass(class) + } + return []string{class} + default: + return []string{class} + } +} + +func azureVMSizeCandidatesForClass(class string) []string { + return azureVMSizeCandidatesForArchitectureClass(ArchitectureAMD64, class) +} + func azureVMSizeCandidatesForConfig(cfg Config) []string { - return azureVMSizeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, cfg.Class) + return azureVMSizeCandidatesForTargetModeArchitectureClass(cfg.TargetOS, cfg.WindowsMode, effectiveArchitectureForConfig(cfg), cfg.Class) } -func azureVMSizeCandidatesForTargetModeClass(target, windowsMode, class string) []string { +func azureVMSizeCandidatesForTargetModeArchitectureClass(target, windowsMode, architecture, class string) []string { switch target { case targetLinux: - return azureVMSizeCandidatesForClass(class) + return azureVMSizeCandidatesForArchitectureClass(architecture, class) case targetWindows: if windowsMode == windowsModeNormal || windowsMode == windowsModeWSL2 { return azureWindowsVMSizeCandidatesForClass(class) @@ -179,7 +205,10 @@ func azureVMSizeCandidatesForTargetModeClass(target, windowsMode, class string) } } -func azureVMSizeCandidatesForClass(class string) []string { +func azureVMSizeCandidatesForArchitectureClass(architecture, class string) []string { + if architecture == ArchitectureARM64 { + return azureARM64VMSizeCandidatesForClass(class) + } 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"} @@ -194,6 +223,26 @@ func azureVMSizeCandidatesForClass(class string) []string { } } +func azureARM64VMSizeCandidatesForClass(class string) []string { + switch class { + case "standard": + return []string{"Standard_D32pds_v6", "Standard_D32ps_v6", "Standard_D16pds_v6", "Standard_D16ps_v6"} + case "fast": + return []string{"Standard_D64pds_v6", "Standard_D64ps_v6", "Standard_D48pds_v6", "Standard_D48ps_v6", "Standard_D32pds_v6", "Standard_D32ps_v6"} + case "large": + return []string{"Standard_D96pds_v6", "Standard_D96ps_v6", "Standard_D64pds_v6", "Standard_D64ps_v6", "Standard_D48pds_v6", "Standard_D48ps_v6"} + case "beast": + return []string{"Standard_D96pds_v6", "Standard_D96ps_v6", "Standard_D64pds_v6", "Standard_D64ps_v6"} + default: + return []string{class} + } +} + +func azureVMSizeIsARM64(vmSize string) bool { + normalized := strings.ToLower(vmSize) + return strings.Contains(normalized, "ps_v6") || strings.Contains(normalized, "pds_v6") || strings.Contains(normalized, "pls_v6") || strings.Contains(normalized, "plds_v6") +} + func azureWindowsVMSizeCandidatesForClass(class string) []string { switch class { case "standard": @@ -214,6 +263,9 @@ func azureSupportsEphemeralOS(vmSize string) bool { if strings.HasPrefix(normalized, "standard_f") && strings.HasSuffix(normalized, "s_v2") { return true } + if strings.Contains(normalized, "pds_v6") || strings.Contains(normalized, "plds_v6") { + 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 diff --git a/internal/cli/azure_test.go b/internal/cli/azure_test.go index 35fd1f4d..db6ed2d3 100644 --- a/internal/cli/azure_test.go +++ b/internal/cli/azure_test.go @@ -73,6 +73,13 @@ func TestAzureImageForConfig(t *testing.T) { if got := azureImageForConfig(linux); got != defaultAzureLinuxImage { t.Fatalf("linux image=%q want %q", got, defaultAzureLinuxImage) } + linuxARM := baseConfig() + linuxARM.TargetOS = targetLinux + linuxARM.Architecture = ArchitectureARM64 + linuxARM.architectureExplicit = true + if got := azureImageForConfig(linuxARM); got != defaultAzureLinuxARM64Image { + t.Fatalf("linux arm64 image=%q want %q", got, defaultAzureLinuxARM64Image) + } windows := baseConfig() windows.TargetOS = targetWindows if got := azureImageForConfig(windows); got != defaultAzureWindowsImage { @@ -119,6 +126,15 @@ func TestAzureVMSizeCandidatesForClass(t *testing.T) { } } +func TestAzureARM64VMSizeCandidatesForClass(t *testing.T) { + t.Parallel() + got := azureARM64VMSizeCandidatesForClass("beast") + want := []string{"Standard_D96pds_v6", "Standard_D96ps_v6", "Standard_D64pds_v6", "Standard_D64ps_v6"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + func TestAzureVMSizeCandidatesForTargetModeClass(t *testing.T) { t.Parallel() linux := azureVMSizeCandidatesForTargetModeClass(targetLinux, windowsModeNormal, "standard") @@ -135,6 +151,18 @@ func TestAzureVMSizeCandidatesForTargetModeClass(t *testing.T) { } } +func TestAzureVMSizeCandidatesForConfigHonorsARM64(t *testing.T) { + t.Parallel() + cfg := baseConfig() + cfg.Provider = "azure" + cfg.TargetOS = targetLinux + cfg.Architecture = ArchitectureARM64 + cfg.architectureExplicit = true + if got := azureVMSizeCandidatesForConfig(cfg)[0]; got != "Standard_D96pds_v6" { + t.Fatalf("first arm64 size=%q", got) + } +} + func TestAzureWindowsVMSizeCandidatesForClass(t *testing.T) { t.Parallel() got := azureWindowsVMSizeCandidatesForClass("beast") diff --git a/internal/cli/config.go b/internal/cli/config.go index c00b9b39..85a2e758 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -17,6 +17,8 @@ type Config struct { Profile string Provider string TargetOS string + Architecture string + architectureExplicit bool OSImage string osImageExplicit bool osImageProviderDefaults string @@ -472,6 +474,7 @@ type JobConfig struct { WindowsMode string Profile string Class string + Architecture string ServerType string Market string TTL time.Duration @@ -559,6 +562,11 @@ func canonicalizeConfigProvider(cfg *Config) { } func applyProviderConfigDefaults(cfg *Config) error { + if normalized, err := normalizeArchitecture(cfg.Architecture); err != nil { + return err + } else { + cfg.Architecture = normalized + } if normalized, err := normalizeOSImage(cfg.OSImage); err != nil { return err } else { @@ -620,7 +628,7 @@ func applyOSImageProviderDefaults(cfg *Config, force bool) { if normalizeTargetOS(cfg.TargetOS) != targetLinux { return } - hetznerImage, azureImage, gcpImage, isloImage, containerImage, err := osImageDefaultProviderImages(cfg.OSImage) + hetznerImage, azureImage, gcpImage, isloImage, containerImage, err := osImageDefaultProviderImagesForArchitecture(cfg.OSImage, effectiveArchitectureForConfig(*cfg)) if err != nil { return } @@ -667,6 +675,7 @@ func baseConfig() Config { Profile: "default", Provider: provider, TargetOS: "linux", + Architecture: ArchitectureAMD64, OSImage: osImage, WindowsMode: "normal", DesktopEnv: desktopEnvXFCE, @@ -831,6 +840,7 @@ type fileConfig struct { Provider string `yaml:"provider,omitempty"` Target string `yaml:"target,omitempty"` TargetOS string `yaml:"targetOS,omitempty"` + Architecture string `yaml:"architecture,omitempty"` OSImage string `yaml:"os,omitempty"` Windows *fileWindowsConfig `yaml:"windows,omitempty"` Desktop *bool `yaml:"desktop,omitempty"` @@ -1377,6 +1387,7 @@ type fileJobConfig struct { Windows *fileWindowsConfig `yaml:"windows,omitempty"` Profile string `yaml:"profile,omitempty"` Class string `yaml:"class,omitempty"` + Architecture string `yaml:"architecture,omitempty"` ServerType string `yaml:"serverType,omitempty"` Type string `yaml:"type,omitempty"` Capacity *fileCapacityConfig `yaml:"capacity,omitempty"` @@ -1525,6 +1536,10 @@ func applyFileConfig(cfg *Config, file fileConfig) { if file.TargetOS != "" { cfg.TargetOS = file.TargetOS } + if file.Architecture != "" { + cfg.Architecture = file.Architecture + cfg.architectureExplicit = true + } if file.OSImage != "" { cfg.OSImage = file.OSImage cfg.osImageExplicit = true @@ -2507,6 +2522,9 @@ func applyFileJobConfig(job JobConfig, file fileJobConfig) JobConfig { if file.Class != "" { job.Class = file.Class } + if file.Architecture != "" { + job.Architecture = file.Architecture + } if file.ServerType != "" { job.ServerType = file.ServerType } @@ -2616,6 +2634,10 @@ func applyEnv(cfg *Config) { cfg.Profile = getenv("CRABBOX_PROFILE", cfg.Profile) cfg.Provider = getenv("CRABBOX_PROVIDER", cfg.Provider) cfg.TargetOS = getenv("CRABBOX_TARGET", getenv("CRABBOX_TARGET_OS", cfg.TargetOS)) + if arch := os.Getenv("CRABBOX_ARCH"); arch != "" { + cfg.Architecture = arch + cfg.architectureExplicit = true + } if osImage := os.Getenv("CRABBOX_OS"); osImage != "" { cfg.OSImage = osImage cfg.osImageExplicit = true @@ -3344,10 +3366,14 @@ func awsInstanceTypeCandidatesForTargetClass(target, class string) []string { } func awsInstanceTypeCandidatesForConfig(cfg Config) []string { - return awsInstanceTypeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, cfg.Class) + return awsInstanceTypeCandidatesForTargetModeArchitectureClass(cfg.TargetOS, cfg.WindowsMode, effectiveArchitectureForConfig(cfg), cfg.Class) } func awsInstanceTypeCandidatesForTargetModeClass(target, windowsMode, class string) []string { + return awsInstanceTypeCandidatesForTargetModeArchitectureClass(target, windowsMode, ArchitectureAMD64, class) +} + +func awsInstanceTypeCandidatesForTargetModeArchitectureClass(target, windowsMode, architecture, class string) []string { switch target { case targetMacOS: return awsMacOSInstanceTypeCandidates() @@ -3379,7 +3405,7 @@ func awsInstanceTypeCandidatesForTargetModeClass(target, windowsMode, class stri return []string{class} } default: - return awsInstanceTypeCandidatesForClass(class) + return awsInstanceTypeCandidatesForArchitectureClass(architecture, class) } } @@ -3398,6 +3424,13 @@ func awsMacOSInstanceTypeCandidates() []string { } func awsInstanceTypeCandidatesForClass(class string) []string { + return awsInstanceTypeCandidatesForArchitectureClass(ArchitectureAMD64, class) +} + +func awsInstanceTypeCandidatesForArchitectureClass(architecture, class string) []string { + if architecture == ArchitectureARM64 { + return awsARM64InstanceTypeCandidatesForClass(class) + } switch class { case "standard": return []string{"c7a.8xlarge", "c7i.8xlarge", "m7a.8xlarge", "m7i.8xlarge", "c7a.4xlarge"} @@ -3412,6 +3445,51 @@ func awsInstanceTypeCandidatesForClass(class string) []string { } } +func awsARM64InstanceTypeCandidatesForClass(class string) []string { + switch class { + case "standard": + return []string{"c7g.8xlarge", "m7g.8xlarge", "r7g.8xlarge", "c7g.4xlarge"} + case "fast": + return []string{"c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge", "c7g.8xlarge"} + case "large": + return []string{"c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge"} + case "beast": + return []string{"c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge"} + default: + return []string{class} + } +} + +func awsInstanceTypeIsARM64(instanceType string) bool { + name := strings.ToLower(strings.SplitN(instanceType, ".", 2)[0]) + switch name { + case "a1", "g5g", "hpc7g", "i4g", "im4gn", "is4gen", "t4g", "x2gd": + return true + } + for _, prefix := range []string{"c", "m", "r"} { + if strings.HasPrefix(name, prefix) && awsGravitonFamilySuffix(strings.TrimPrefix(name, prefix)) { + return true + } + } + return false +} + +func awsGravitonFamilySuffix(value string) bool { + digitEnd := 0 + for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' { + digitEnd++ + } + if digitEnd == 0 { + return false + } + switch value[digitEnd:] { + case "g", "gd", "gn": + return true + default: + return false + } +} + func getenv(name, fallback string) string { if v := os.Getenv(name); v != "" { return v diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index 2078e2e6..10c47231 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -32,6 +32,7 @@ func configShowView(cfg Config) map[string]any { "profile": cfg.Profile, "provider": cfg.Provider, "target": cfg.TargetOS, + "architecture": effectiveArchitectureForConfig(cfg), "os": cfg.OSImage, "windowsMode": cfg.WindowsMode, "class": cfg.Class, @@ -218,7 +219,7 @@ func configShowView(cfg Config) map[string]any { func writeConfigShowText(w io.Writer, cfg Config) { fmt.Fprintf(w, "config=%s\n", userConfigPath()) - fmt.Fprintf(w, "provider=%s target=%s os=%s windows_mode=%s class=%s type=%s profile=%s\n", cfg.Provider, cfg.TargetOS, cfg.OSImage, cfg.WindowsMode, cfg.Class, cfg.ServerType, cfg.Profile) + fmt.Fprintf(w, "provider=%s target=%s arch=%s os=%s windows_mode=%s class=%s type=%s profile=%s\n", cfg.Provider, cfg.TargetOS, effectiveArchitectureForConfig(cfg), cfg.OSImage, cfg.WindowsMode, cfg.Class, cfg.ServerType, cfg.Profile) fmt.Fprintf(w, "broker=%s auth=%s admin_auth=%s\n", blank(cfg.Coordinator, "-"), tokenState(cfg.CoordToken), tokenState(cfg.CoordAdminToken)) fmt.Fprintf(w, "access_auth=%s\n", accessAuthState(cfg.Access)) fmt.Fprintf(w, "ssh=%s@:%s fallback_ports=%s key=%s\n", cfg.SSHUser, cfg.SSHPort, blank(strings.Join(cfg.SSHFallbackPorts, ","), "-"), cfg.SSHKey) @@ -263,6 +264,7 @@ func jobConfigViews(jobs map[string]JobConfig) map[string]any { "windowsMode": job.WindowsMode, "profile": job.Profile, "class": job.Class, + "architecture": job.Architecture, "serverType": job.ServerType, "market": job.Market, "desktop": job.Desktop, diff --git a/internal/cli/config_cmd_test.go b/internal/cli/config_cmd_test.go index 8b88d489..4da98d81 100644 --- a/internal/cli/config_cmd_test.go +++ b/internal/cli/config_cmd_test.go @@ -53,7 +53,7 @@ func TestConfigShowIncludesJobHydrateGitHubRunner(t *testing.T) { t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) t.Setenv("CRABBOX_CONFIG", configPath) - if err := os.WriteFile(configPath, []byte("jobs:\n smoke:\n hydrate:\n actions: true\n githubRunner: true\n"), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte("jobs:\n smoke:\n architecture: arm64\n hydrate:\n actions: true\n githubRunner: true\n"), 0o600); err != nil { t.Fatal(err) } @@ -64,7 +64,8 @@ func TestConfigShowIncludesJobHydrateGitHubRunner(t *testing.T) { } var got struct { Jobs map[string]struct { - Hydrate struct { + Architecture string `json:"architecture"` + Hydrate struct { GitHubRunner bool `json:"githubRunner"` } `json:"hydrate"` } `json:"jobs"` @@ -75,6 +76,9 @@ func TestConfigShowIncludesJobHydrateGitHubRunner(t *testing.T) { if !got.Jobs["smoke"].Hydrate.GitHubRunner { t.Fatalf("json jobs.smoke.hydrate.githubRunner=false in %s", stdout.String()) } + if got.Jobs["smoke"].Architecture != "arm64" { + t.Fatalf("json jobs.smoke.architecture=%q in %s", got.Jobs["smoke"].Architecture, stdout.String()) + } } func TestConfigShowIncludesCloudflareWithoutSecret(t *testing.T) { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 2ab1a5d1..2818fb08 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -1757,20 +1757,21 @@ func TestApplyFileJobConfigCoversJobOptions(t *testing.T) { enabled := true disabled := false job := applyFileJobConfig(JobConfig{}, fileJobConfig{ - Provider: "aws", - TargetOS: targetLinux, - Windows: &fileWindowsConfig{Mode: windowsModeWSL2}, - Profile: "ci", - Class: "large", - Type: "m8i.large", - Capacity: &fileCapacityConfig{Market: "spot"}, - Market: "on-demand", - TTL: "45m", - IdleTimeout: "5m", - Desktop: &enabled, - Browser: &disabled, - Code: &enabled, - Network: "tailscale", + Provider: "aws", + TargetOS: targetLinux, + Windows: &fileWindowsConfig{Mode: windowsModeWSL2}, + Profile: "ci", + Class: "large", + Architecture: "arm64", + Type: "m8i.large", + Capacity: &fileCapacityConfig{Market: "spot"}, + Market: "on-demand", + TTL: "45m", + IdleTimeout: "5m", + Desktop: &enabled, + Browser: &disabled, + Code: &enabled, + Network: "tailscale", Hydrate: &fileJobHydrateConfig{ Actions: &enabled, GitHubRunner: &enabled, @@ -1794,7 +1795,7 @@ func TestApplyFileJobConfigCoversJobOptions(t *testing.T) { Downloads: []string{"out=out", "out=out"}, Stop: "always", }) - if job.Provider != "aws" || job.Target != targetLinux || job.WindowsMode != windowsModeWSL2 || job.Profile != "ci" || job.Class != "large" || job.ServerType != "m8i.large" || job.Market != "on-demand" { + if job.Provider != "aws" || job.Target != targetLinux || job.WindowsMode != windowsModeWSL2 || job.Profile != "ci" || job.Class != "large" || job.Architecture != "arm64" || job.ServerType != "m8i.large" || job.Market != "on-demand" { t.Fatalf("basic job fields not applied: %#v", job) } if job.TTL != 45*time.Minute || job.IdleTimeout != 5*time.Minute { diff --git a/internal/cli/coordinator.go b/internal/cli/coordinator.go index 68ce773c..05f1fdc3 100644 --- a/internal/cli/coordinator.go +++ b/internal/cli/coordinator.go @@ -614,6 +614,7 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK "profile": cfg.Profile, "provider": cfg.Provider, "target": cfg.TargetOS, + "architecture": effectiveArchitectureForConfig(cfg), "windowsMode": cfg.WindowsMode, "desktop": cfg.Desktop, "desktopEnv": normalizedDesktopEnv(cfg.DesktopEnv), diff --git a/internal/cli/job.go b/internal/cli/job.go index 8d7f3c66..b8dd56b9 100644 --- a/internal/cli/job.go +++ b/internal/cli/job.go @@ -198,6 +198,9 @@ func jobLeaseCreateArgs(job JobConfig) []string { if job.Class != "" { args = append(args, "--class", job.Class) } + if job.Architecture != "" { + args = append(args, "--arch", job.Architecture) + } if job.ServerType != "" { args = append(args, "--type", job.ServerType) } diff --git a/internal/cli/job_test.go b/internal/cli/job_test.go index b1825e58..2f7d58ac 100644 --- a/internal/cli/job_test.go +++ b/internal/cli/job_test.go @@ -107,6 +107,39 @@ func TestJobRunDryRunBuildsOrchestrationCommands(t *testing.T) { } } +func TestJobRunDryRunPropagatesArchitecture(t *testing.T) { + clearConfigEnv(t) + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, ".config")) + t.Setenv("CRABBOX_CONFIG", filepath.Join(dir, ".crabbox.yaml")) + if err := os.WriteFile(filepath.Join(dir, ".crabbox.yaml"), []byte(`jobs: + linux-arm: + provider: azure + target: linux + architecture: arm64 + class: fast + command: go test ./... +`), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + app := App{Stdout: &stdout, Stderr: &stderr} + if err := app.Run(context.Background(), []string{"job", "run", "--dry-run", "linux-arm"}); err != nil { + t.Fatalf("job dry-run failed: %v\nstderr=%s", err, stderr.String()) + } + got := stdout.String() + for _, want := range []string{ + "crabbox warmup --provider azure --target linux --class fast --arch arm64 --keep=true", + "crabbox run --provider azure --target linux --class fast --arch arm64 --id '' --no-hydrate -- go test ./...", + } { + if !strings.Contains(got, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, got) + } + } +} + func TestJobRunDryRunNoHydratePropagatesToRun(t *testing.T) { clearConfigEnv(t) dir := t.TempDir() diff --git a/internal/cli/lease_flags.go b/internal/cli/lease_flags.go index e6bc301e..3d694e83 100644 --- a/internal/cli/lease_flags.go +++ b/internal/cli/lease_flags.go @@ -14,6 +14,7 @@ type leaseCreateFlagValues struct { Provider *string Profile *string Class *string + Architecture *string OSImage *string ServerType *string Market *string @@ -38,6 +39,7 @@ func registerLeaseCreateFlags(fs *flag.FlagSet, defaults Config) leaseCreateFlag Provider: fs.String("provider", defaults.Provider, providerHelpAll()), Profile: fs.String("profile", defaults.Profile, "profile"), Class: fs.String("class", defaults.Class, "machine class"), + Architecture: fs.String("arch", defaults.Architecture, "CPU architecture: amd64 or arm64"), OSImage: fs.String("os", defaults.OSImage, "portable Linux OS image selector, for example ubuntu:26.04"), ServerType: fs.String("type", getenv("CRABBOX_SERVER_TYPE", ""), "provider server/instance type"), Market: fs.String("market", defaults.Capacity.Market, "capacity market: spot or on-demand"), @@ -64,6 +66,14 @@ func applyLeaseCreateFlagsForLease(cfg *Config, fs *flag.FlagSet, values leaseCr cfg.Provider = *values.Provider cfg.Profile = *values.Profile cfg.Class = *values.Class + if flagWasSet(fs, "arch") { + arch, err := normalizeArchitecture(*values.Architecture) + if err != nil { + return err + } + cfg.Architecture = arch + cfg.architectureExplicit = true + } if flagWasSet(fs, "pond") { pond, err := requestedPondName(*values.Pond) if err != nil { diff --git a/internal/cli/os_image.go b/internal/cli/os_image.go index 64dc8f14..c1837fcd 100644 --- a/internal/cli/os_image.go +++ b/internal/cli/os_image.go @@ -4,37 +4,48 @@ import "strings" const defaultOSImage = "ubuntu:26.04" +const ( + ArchitectureAMD64 = "amd64" + ArchitectureARM64 = "arm64" +) + type osImageSpec struct { - Selector string - AWSName string - AWSLabel string - AzureImage string - GCPImage string - HetznerImage string - DockerImage string - ContainerName string + Selector string + AWSName string + AWSArm64Name string + AWSLabel string + AzureImage string + AzureArm64Image string + GCPImage string + HetznerImage string + DockerImage string + ContainerName string } var osImageSpecs = map[string]osImageSpec{ "ubuntu:24.04": { - Selector: "ubuntu:24.04", - AWSName: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*", - AWSLabel: "Ubuntu 24.04", - AzureImage: "Canonical:ubuntu-24_04-lts:server:latest", - GCPImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64", - HetznerImage: "ubuntu-24.04", - DockerImage: "docker.io/library/ubuntu:24.04", - ContainerName: "ubuntu:24.04", + Selector: "ubuntu:24.04", + AWSName: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*", + AWSArm64Name: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-arm64-server-*", + AWSLabel: "Ubuntu 24.04", + AzureImage: "Canonical:ubuntu-24_04-lts:server:latest", + AzureArm64Image: "Canonical:ubuntu-24_04-lts:server-arm64:latest", + GCPImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64", + HetznerImage: "ubuntu-24.04", + DockerImage: "docker.io/library/ubuntu:24.04", + ContainerName: "ubuntu:24.04", }, "ubuntu:26.04": { - Selector: "ubuntu:26.04", - AWSName: "ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*", - AWSLabel: "Ubuntu 26.04", - AzureImage: "Canonical:ubuntu-26_04-lts:server:latest", - GCPImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2604-lts-amd64", - HetznerImage: "ubuntu-24.04", - DockerImage: "docker.io/library/ubuntu:26.04", - ContainerName: "ubuntu:26.04", + Selector: "ubuntu:26.04", + AWSName: "ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*", + AWSArm64Name: "ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-arm64-server-*", + AWSLabel: "Ubuntu 26.04", + AzureImage: "Canonical:ubuntu-26_04-lts:server:latest", + AzureArm64Image: "Canonical:ubuntu-26_04-lts:server-arm64:latest", + GCPImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2604-lts-amd64", + HetznerImage: "ubuntu-24.04", + DockerImage: "docker.io/library/ubuntu:26.04", + ContainerName: "ubuntu:26.04", }, } @@ -61,6 +72,32 @@ func normalizeOSImage(value string) (string, error) { return normalized, nil } +func normalizeArchitecture(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", ArchitectureAMD64, "x86_64", "x64": + return ArchitectureAMD64, nil + case ArchitectureARM64, "aarch64": + return ArchitectureARM64, nil + default: + return "", exit(2, "architecture must be amd64 or arm64") + } +} + +func effectiveArchitectureForConfig(cfg Config) string { + if cfg.architectureExplicit { + return cfg.Architecture + } + if cfg.TargetOS == targetLinux { + if cfg.Provider == "azure" && azureVMSizeIsARM64(cfg.ServerType) { + return ArchitectureARM64 + } + if cfg.Provider == "aws" && awsInstanceTypeIsARM64(cfg.ServerType) { + return ArchitectureARM64 + } + } + return cfg.Architecture +} + func osImageSpecFor(value string) (osImageSpec, error) { normalized, err := normalizeOSImage(value) if err != nil { @@ -69,18 +106,28 @@ func osImageSpecFor(value string) (osImageSpec, error) { return osImageSpecs[normalized], nil } -func awsLinuxAMIQueryForOS(value string) (name string, label string, err error) { +func awsLinuxAMIQueryForOS(value string, architecture string) (name string, label string, err error) { spec, err := osImageSpecFor(value) if err != nil { return "", "", err } + if architecture == ArchitectureARM64 { + return spec.AWSArm64Name, spec.AWSLabel, nil + } return spec.AWSName, spec.AWSLabel, nil } func osImageDefaultProviderImages(value string) (hetzner, azure, gcp, docker, container string, err error) { + return osImageDefaultProviderImagesForArchitecture(value, ArchitectureAMD64) +} + +func osImageDefaultProviderImagesForArchitecture(value string, architecture string) (hetzner, azure, gcp, docker, container string, err error) { spec, err := osImageSpecFor(value) if err != nil { return "", "", "", "", "", err } + if architecture == ArchitectureARM64 { + return spec.HetznerImage, spec.AzureArm64Image, spec.GCPImage, spec.DockerImage, spec.ContainerName, nil + } return spec.HetznerImage, spec.AzureImage, spec.GCPImage, spec.DockerImage, spec.ContainerName, nil } diff --git a/internal/cli/os_image_test.go b/internal/cli/os_image_test.go index a6504617..61d915ec 100644 --- a/internal/cli/os_image_test.go +++ b/internal/cli/os_image_test.go @@ -30,11 +30,18 @@ func TestNormalizeOSImage(t *testing.T) { func TestAWSLinuxAMIQueryForOS(t *testing.T) { t.Parallel() - name, label, err := awsLinuxAMIQueryForOS("ubuntu:26.04") + name, label, err := awsLinuxAMIQueryForOS("ubuntu:26.04", ArchitectureAMD64) if err != nil { t.Fatal(err) } if name != "ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*" || label != "Ubuntu 26.04" { t.Fatalf("query name=%q label=%q", name, label) } + name, label, err = awsLinuxAMIQueryForOS("ubuntu:24.04", ArchitectureARM64) + if err != nil { + t.Fatal(err) + } + if name != "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-arm64-server-*" || label != "Ubuntu 24.04" { + t.Fatalf("arm query name=%q label=%q", name, label) + } } diff --git a/internal/cli/run.go b/internal/cli/run.go index 992eb7d5..3099856f 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -32,7 +32,7 @@ func applyServerTypeFlagOverrides(cfg *Config, fs *flag.FlagSet, serverType stri if cfg.ServerTypeExplicit { return } - if cfg.ServerType == "" || flagWasSet(fs, "provider") || flagWasSet(fs, "class") || flagWasSet(fs, "target") || flagWasSet(fs, "windows-mode") { + if cfg.ServerType == "" || flagWasSet(fs, "provider") || flagWasSet(fs, "class") || flagWasSet(fs, "target") || flagWasSet(fs, "windows-mode") || flagWasSet(fs, "arch") { cfg.ServerType = serverTypeForConfig(*cfg) } } diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 0d8b844a..46ba59cb 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -1176,6 +1176,47 @@ func TestAWSServerTypeForClass(t *testing.T) { } } +func TestAWSARM64ServerTypeForConfig(t *testing.T) { + cfg := Config{ + Provider: "aws", + TargetOS: targetLinux, + Architecture: ArchitectureARM64, + architectureExplicit: true, + Class: "beast", + } + if got := serverTypeForConfig(cfg); got != "c7g.16xlarge" { + t.Fatalf("serverTypeForConfig arm64=%q", got) + } +} + +func TestAWSExplicitARM64TypeInference(t *testing.T) { + tests := map[string]string{ + "a1.large": ArchitectureARM64, + "c7g.16xlarge": ArchitectureARM64, + "c7gd.16xlarge": ArchitectureARM64, + "c7gn.16xlarge": ArchitectureARM64, + "g5g.xlarge": ArchitectureARM64, + "hpc7g.16xlarge": ArchitectureARM64, + "im4gn.16xlarge": ArchitectureARM64, + "is4gen.16xlarge": ArchitectureARM64, + "c7a.16xlarge": ArchitectureAMD64, + "g5.xlarge": ArchitectureAMD64, + } + for serverType, want := range tests { + t.Run(serverType, func(t *testing.T) { + cfg := Config{ + Provider: "aws", + TargetOS: targetLinux, + Architecture: ArchitectureAMD64, + ServerType: serverType, + } + if got := effectiveArchitectureForConfig(cfg); got != want { + t.Fatalf("effectiveArchitectureForConfig(%q)=%q want %q", serverType, got, want) + } + }) + } +} + func TestCloudflareContainerInstanceTypeMapping(t *testing.T) { tests := []struct { class string @@ -1289,6 +1330,11 @@ func TestAWSInstanceTypeCandidatesForTargetsAndModes(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("target class candidates=%v want %v", got, want) } + got = awsInstanceTypeCandidatesForTargetModeArchitectureClass(targetLinux, windowsModeNormal, ArchitectureARM64, "fast") + want = []string{"c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge", "c7g.8xlarge"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("arm target class candidates=%v want %v", got, want) + } } func TestAWSLaunchCandidatesAddsPolicyFallbackUnlessExact(t *testing.T) { @@ -1296,6 +1342,10 @@ func TestAWSLaunchCandidatesAddsPolicyFallbackUnlessExact(t *testing.T) { if got[len(got)-1] != "t3.small" { t.Fatalf("last fallback=%q want t3.small in %v", got[len(got)-1], got) } + arm := awsLaunchCandidates(Config{Provider: "aws", TargetOS: targetLinux, Architecture: ArchitectureARM64, architectureExplicit: true, Class: "beast", ServerType: "c7g.16xlarge"}) + if arm[len(arm)-1] != "t4g.small" { + t.Fatalf("last arm fallback=%q want t4g.small in %v", arm[len(arm)-1], arm) + } wsl2 := awsLaunchCandidates(Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Class: "standard", ServerType: "m8i.large"}) for _, candidate := range wsl2 { if strings.HasPrefix(candidate, "t3.") || strings.HasPrefix(candidate, "m7") { diff --git a/internal/cli/target.go b/internal/cli/target.go index b584840e..04293d02 100644 --- a/internal/cli/target.go +++ b/internal/cli/target.go @@ -127,6 +127,26 @@ func validateProviderTarget(cfg Config) error { if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) { return exit(2, "%s", unsupportedManagedTargetMessageForConfig(provider.Name(), cfg)) } + if cfg.Architecture == ArchitectureARM64 { + if cfg.TargetOS != targetLinux { + return exit(2, "architecture=arm64 currently supports target=linux only") + } + if provider.Name() != "azure" && provider.Name() != "aws" { + return exit(2, "architecture=arm64 currently supports provider=azure or provider=aws") + } + } + if cfg.TargetOS == targetLinux && strings.TrimSpace(cfg.ServerType) != "" { + switch provider.Name() { + case "aws": + if err := validateArchitectureServerType("AWS instance type", cfg, awsInstanceTypeIsARM64(cfg.ServerType)); err != nil { + return err + } + case "azure": + if err := validateArchitectureServerType("Azure VM size", cfg, azureVMSizeIsARM64(cfg.ServerType)); err != nil { + return err + } + } + } if provider.Name() == "aws" && cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeWSL2 && @@ -146,6 +166,17 @@ func validateProviderTarget(cfg Config) error { return nil } +func validateArchitectureServerType(kind string, cfg Config, serverTypeARM64 bool) error { + architecture := effectiveArchitectureForConfig(cfg) + if architecture == ArchitectureARM64 && !serverTypeARM64 { + return exit(2, "architecture=arm64 requires an ARM64 %s; %s is not ARM64", kind, cfg.ServerType) + } + if cfg.architectureExplicit && cfg.Architecture == ArchitectureAMD64 && serverTypeARM64 { + return exit(2, "architecture=amd64 requires an amd64 %s; %s is ARM64", kind, cfg.ServerType) + } + return nil +} + func providerSpecSupportsTarget(spec ProviderSpec, targetOS, windowsMode string) bool { for _, target := range spec.Targets { if target.OS != targetOS { diff --git a/internal/cli/target_test.go b/internal/cli/target_test.go index be3ce599..314db6fc 100644 --- a/internal/cli/target_test.go +++ b/internal/cli/target_test.go @@ -77,6 +77,37 @@ func TestValidateProviderTargetRejectsAWSWSL2ExactTypeWithoutNestedVirtualizatio } } +func TestValidateProviderTargetRejectsArchitectureTypeMismatch(t *testing.T) { + tests := []struct { + name string + provider string + architecture string + serverType string + want string + }{ + {name: "aws arm with x86 type", provider: "aws", architecture: ArchitectureARM64, serverType: "c7a.48xlarge", want: "requires an ARM64 AWS instance type"}, + {name: "aws amd64 with arm type", provider: "aws", architecture: ArchitectureAMD64, serverType: "c7g.16xlarge", want: "requires an amd64 AWS instance type"}, + {name: "azure arm with x86 size", provider: "azure", architecture: ArchitectureARM64, serverType: "Standard_D96ds_v6", want: "requires an ARM64 Azure VM size"}, + {name: "azure amd64 with arm size", provider: "azure", architecture: ArchitectureAMD64, serverType: "Standard_D96pds_v6", want: "requires an amd64 Azure VM size"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Provider = tc.provider + cfg.TargetOS = targetLinux + cfg.Architecture = tc.architecture + cfg.architectureExplicit = true + cfg.ServerType = tc.serverType + cfg.ServerTypeExplicit = true + + err := validateProviderTarget(cfg) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err=%v, want %q", err, tc.want) + } + }) + } +} + func TestValidateProviderTargetAllowsAzureWindowsModes(t *testing.T) { for _, mode := range []string{windowsModeNormal, windowsModeWSL2} { t.Run(mode, func(t *testing.T) { diff --git a/package.json b/package.json index 490aab00..d64a411c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/crabbox-plugin", - "version": "0.22.0", + "version": "0.22.1", "description": "OpenClaw plugin for running Crabbox remote testbox workflows", "license": "MIT", "type": "module", diff --git a/worker/package-lock.json b/worker/package-lock.json index 88d46333..e4db0b4f 100644 --- a/worker/package-lock.json +++ b/worker/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openclaw/crabbox-worker", - "version": "0.22.0", + "version": "0.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openclaw/crabbox-worker", - "version": "0.22.0", + "version": "0.22.1", "dependencies": { "@cloudflare/containers": "^0.3.4", "@novnc/novnc": "^1.7.0", diff --git a/worker/package.json b/worker/package.json index 9ddce914..750b1e73 100644 --- a/worker/package.json +++ b/worker/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/crabbox-worker", - "version": "0.22.0", + "version": "0.22.1", "private": true, "type": "module", "scripts": { diff --git a/worker/src/aws.ts b/worker/src/aws.ts index c8ef6f9c..c6588d1a 100644 --- a/worker/src/aws.ts +++ b/worker/src/aws.ts @@ -1135,11 +1135,13 @@ export class EC2SpotClient { return this.resolveLatestAmazonAMI(query.name, query.architecture); } const os = osImageSpec(config.os); + const architecture = config.architecture === "arm64" ? "arm64" : "x86_64"; + const name = config.architecture === "arm64" ? os.awsArm64Name : os.awsName; return this.resolveLatestAMI( awsUbuntuOwner, - os.awsName, - "x86_64", - `no ${os.awsLabel} x86_64 AMI found in ${this.region}`, + name, + architecture, + `no ${os.awsLabel} ${architecture} AMI found in ${this.region}`, ); } @@ -1981,7 +1983,7 @@ function isAWSInsufficientCapacityOnHostError(message: string): boolean { export function awsLaunchCandidates( config: Pick< LeaseConfig, - "serverType" | "serverTypeExplicit" | "class" | "target" | "windowsMode" + "serverType" | "serverTypeExplicit" | "class" | "target" | "windowsMode" | "architecture" >, ): string[] { if (config.serverTypeExplicit) { @@ -1990,7 +1992,12 @@ export function awsLaunchCandidates( if (config.target === "macos") { return uniqueStrings([ config.serverType, - ...awsInstanceTypeCandidatesForTargetClass(config.target, config.class), + ...awsInstanceTypeCandidatesForTargetClass( + config.target, + config.class, + config.windowsMode, + config.architecture, + ), ]); } const policyFallback = @@ -1998,10 +2005,17 @@ export function awsLaunchCandidates( ? config.windowsMode === "wsl2" ? "m8i.large" : "t3.large" - : "t3.small"; + : config.architecture === "arm64" + ? "t4g.small" + : "t3.small"; return uniqueStrings([ config.serverType, - ...awsInstanceTypeCandidatesForTargetClass(config.target, config.class, config.windowsMode), + ...awsInstanceTypeCandidatesForTargetClass( + config.target, + config.class, + config.windowsMode, + config.architecture, + ), policyFallback, ]); } @@ -2135,7 +2149,13 @@ export function awsQuotaPreflightAttempt( type AWSCapacityReadinessConfig = Pick< LeaseConfig, - "target" | "windowsMode" | "class" | "serverType" | "capacityMarket" | "capacityFallback" + | "target" + | "windowsMode" + | "architecture" + | "class" + | "serverType" + | "capacityMarket" + | "capacityFallback" >; export function awsCapacityReadinessCheckForQuota( @@ -2215,7 +2235,7 @@ function awsCapacityReadinessMessage(prefix: string, details: Record, + config: Pick, limitVCPUs: number, ): { machineClass: string; serverType: string } | undefined { if (limitVCPUs <= 0) { @@ -2226,6 +2246,7 @@ function awsRecommendedClassForQuota( config.target, machineClass, config.windowsMode, + config.architecture, ); if (serverType && (awsInstanceTypeVCPUs(serverType) ?? 0) <= limitVCPUs) { return { machineClass, serverType }; @@ -2235,6 +2256,7 @@ function awsRecommendedClassForQuota( config.target, "standard", config.windowsMode, + config.architecture, )) { if ((awsInstanceTypeVCPUs(serverType) ?? 0) <= limitVCPUs) { return { machineClass: "standard", serverType }; diff --git a/worker/src/azure.ts b/worker/src/azure.ts index 44a3806a..fac6dbab 100644 --- a/worker/src/azure.ts +++ b/worker/src/azure.ts @@ -16,7 +16,9 @@ const DELETE_RETRY_ATTEMPTS = 13; const DELETE_RETRY_DELAY_MS = 15_000; const MIN_LRO_POLL_INTERVAL_MS = 15_000; const DEFAULT_AZURE_LINUX_IMAGE = "Canonical:ubuntu-26_04-lts:server:latest"; +const DEFAULT_AZURE_LINUX_ARM64_IMAGE = "Canonical:ubuntu-26_04-lts:server-arm64:latest"; const AZURE_NOBLE_LINUX_IMAGE = "Canonical:ubuntu-24_04-lts:server:latest"; +const AZURE_NOBLE_LINUX_ARM64_IMAGE = "Canonical:ubuntu-24_04-lts:server-arm64:latest"; const LEGACY_AZURE_JAMMY_IMAGE = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"; const LEGACY_AZURE_NOBLE_GEN2_IMAGE = "Canonical:0001-com-ubuntu-server-noble:24_04-lts-gen2:latest"; @@ -218,7 +220,12 @@ export class AzureClient { ? [config.serverType] : prependUnique( config.serverType, - azureVMSizeCandidatesForTargetClass(config.target, config.class, config.windowsMode), + azureVMSizeCandidatesForTargetClass( + config.target, + config.class, + config.windowsMode, + config.architecture, + ), ); const failures: string[] = []; const attempts: ProvisioningAttempt[] = []; @@ -622,6 +629,15 @@ export class AzureClient { private imageForConfig(config: LeaseConfig): string { const image = config.azureImage || this.image; + if ( + config.target === "linux" && + config.architecture === "arm64" && + isAzureDefaultLinuxImage(image) + ) { + return config.os === "ubuntu:24.04" + ? AZURE_NOBLE_LINUX_ARM64_IMAGE + : DEFAULT_AZURE_LINUX_ARM64_IMAGE; + } if (config.target === "windows" && isAzureDefaultLinuxImage(image)) { return DEFAULT_AZURE_WINDOWS_IMAGE; } @@ -1246,6 +1262,9 @@ export function azureSupportsEphemeralOS(vmSize: string): boolean { if (normalized.startsWith("standard_f") && normalized.endsWith("s_v2")) { return true; } + if (normalized.includes("pds_v6") || normalized.includes("plds_v6")) { + return true; + } if ( (normalized.startsWith("standard_d") || normalized.startsWith("standard_e")) && (normalized.includes("ds_v5") || normalized.includes("ds_v6")) @@ -1364,7 +1383,9 @@ function parseAzureStatusMessage(message: string): string { function isAzureDefaultLinuxImage(image: string): boolean { return ( image.trim() === DEFAULT_AZURE_LINUX_IMAGE || + image.trim() === DEFAULT_AZURE_LINUX_ARM64_IMAGE || image.trim() === AZURE_NOBLE_LINUX_IMAGE || + image.trim() === AZURE_NOBLE_LINUX_ARM64_IMAGE || image.trim() === LEGACY_AZURE_JAMMY_IMAGE || image.trim() === LEGACY_AZURE_NOBLE_GEN2_IMAGE ); diff --git a/worker/src/config.ts b/worker/src/config.ts index 6d82c4c2..c091ca9f 100644 --- a/worker/src/config.ts +++ b/worker/src/config.ts @@ -16,6 +16,7 @@ export const awsMacOSInstanceTypeCandidates = [ export interface LeaseConfig { provider: Provider; target: TargetOS; + architecture: Architecture; os: string; windowsMode: WindowsMode; desktop: boolean; @@ -84,6 +85,7 @@ export interface LeaseConfig { } export type AzureOSDiskMode = "managed" | "ephemeral"; +export type Architecture = "amd64" | "arm64"; export interface LeaseConfigDefaults { azureOSDisk?: string; @@ -99,10 +101,15 @@ export function leaseConfig(input: LeaseRequest, defaults: LeaseConfigDefaults = throw new Error(`unsupported provider: ${String(provider)}`); } const target = normalizeTarget(input.target ?? input.targetOS ?? "linux"); + const requestedArchitecture = normalizeArchitecture(input.architecture); + const architectureExplicit = Boolean(input.architecture?.trim()); const os = normalizeOSImage(input.os); const osExplicit = Boolean(input.os?.trim()); const linuxOSImage = target === "linux" ? osImageSpec(os) : undefined; const windowsMode = normalizeWindowsMode(input.windowsMode ?? "normal"); + const architecture = architectureExplicit + ? requestedArchitecture + : inferArchitectureForServerType(provider, target, input.serverType, requestedArchitecture); if ( target !== "linux" && !(provider === "aws" && target === "windows") && @@ -114,6 +121,14 @@ export function leaseConfig(input: LeaseRequest, defaults: LeaseConfigDefaults = } throw new Error(`unsupported target for brokered ${provider}: ${target}`); } + if (architecture === "arm64") { + if (target !== "linux") { + throw new Error("architecture=arm64 currently supports target=linux only"); + } + if (provider !== "azure" && provider !== "aws") { + throw new Error("architecture=arm64 currently supports provider=azure or provider=aws"); + } + } if ( provider === "azure" && target === "windows" && @@ -142,7 +157,17 @@ export function leaseConfig(input: LeaseRequest, defaults: LeaseConfigDefaults = } const machineClass = input.class ?? "beast"; const serverType = - input.serverType ?? serverTypeForConfig(provider, target, windowsMode, machineClass); + input.serverType ?? + serverTypeForConfig(provider, target, windowsMode, machineClass, architecture); + if (input.serverType) { + validateArchitectureServerType( + provider, + target, + architecture, + architectureExplicit, + serverType, + ); + } const ttlSeconds = clampTTL(input.ttlSeconds ?? 5400); const idleTimeoutSeconds = clampIdleTimeout(input.idleTimeoutSeconds ?? 1800); const sshPublicKey = input.sshPublicKey?.trim() ?? ""; @@ -158,6 +183,7 @@ export function leaseConfig(input: LeaseRequest, defaults: LeaseConfigDefaults = return { provider, target, + architecture, os, windowsMode, desktop: input.desktop ?? false, @@ -188,7 +214,13 @@ export function leaseConfig(input: LeaseRequest, defaults: LeaseConfigDefaults = awsSSHCIDRs: validCIDRs(input.awsSSHCIDRs ?? []), awsMacHostID: input.awsMacHostID ?? "", azureLocation: input.azureLocation ?? "", - azureImage: input.azureImage ?? (osExplicit ? (linuxOSImage?.azureImage ?? "") : ""), + azureImage: + input.azureImage ?? + (osExplicit + ? architecture === "arm64" + ? (linuxOSImage?.azureArm64Image ?? "") + : (linuxOSImage?.azureImage ?? "") + : ""), azureSnapshot: input.azureSnapshot ?? "", azureOSDisk: normalizeAzureOSDiskMode(input.azureOSDisk ?? defaults.azureOSDisk), gcpProject: input.gcpProject ?? "", @@ -264,6 +296,75 @@ export function normalizeDesktopEnv(value: string | undefined): "xfce" | "waylan } } +export function normalizeArchitecture(value: string | undefined): Architecture { + const normalized = (value ?? "").trim().toLowerCase(); + switch (normalized) { + case "": + case "amd64": + case "x86_64": + case "x64": + return "amd64"; + case "arm64": + case "aarch64": + return "arm64"; + default: + throw new Error("architecture must be amd64 or arm64"); + } +} + +function inferArchitectureForServerType( + provider: Provider, + target: TargetOS, + serverType: string | undefined, + fallback: Architecture, +): Architecture { + if (target !== "linux" || !serverType) { + return fallback; + } + if (provider === "azure" && azureVMSizeIsARM64(serverType)) { + return "arm64"; + } + if (provider === "aws" && awsInstanceTypeIsARM64(serverType)) { + return "arm64"; + } + return fallback; +} + +function validateArchitectureServerType( + provider: Provider, + target: TargetOS, + architecture: Architecture, + architectureExplicit: boolean, + serverType: string, +): void { + if (target !== "linux") { + return; + } + const serverTypeARM64 = + (provider === "azure" && azureVMSizeIsARM64(serverType)) || + (provider === "aws" && awsInstanceTypeIsARM64(serverType)); + if (architecture === "arm64" && !serverTypeARM64) { + throw new Error( + `architecture=arm64 requires an ARM64 ${providerServerTypeName(provider)}; ${serverType} is not ARM64`, + ); + } + if (architectureExplicit && architecture === "amd64" && serverTypeARM64) { + throw new Error( + `architecture=amd64 requires an amd64 ${providerServerTypeName(provider)}; ${serverType} is ARM64`, + ); + } +} + +function providerServerTypeName(provider: Provider): string { + if (provider === "azure") { + return "Azure VM size"; + } + if (provider === "aws") { + return "AWS instance type"; + } + return "server type"; +} + export function awsPromotedAMIConfigKey(region: string, serverType: string): string { return `${region.trim().toLowerCase()}\0${serverType.trim().toLowerCase()}`; } @@ -452,15 +553,18 @@ export function serverTypeForConfig( target: TargetOS, windowsMode: WindowsMode, machineClass: string, + architecture: Architecture = "amd64", ): string { if (provider === "aws") { return ( - awsInstanceTypeCandidatesForTargetClass(target, machineClass, windowsMode)[0] ?? machineClass + awsInstanceTypeCandidatesForTargetClass(target, machineClass, windowsMode, architecture)[0] ?? + machineClass ); } if (provider === "azure") { return ( - azureVMSizeCandidatesForTargetClass(target, machineClass, windowsMode)[0] ?? machineClass + azureVMSizeCandidatesForTargetClass(target, machineClass, windowsMode, architecture)[0] ?? + machineClass ); } if (provider === "gcp") { @@ -507,9 +611,10 @@ export function azureVMSizeCandidatesForTargetClass( target: TargetOS, machineClass: string, windowsMode: WindowsMode = "normal", + architecture: Architecture = "amd64", ): string[] { if (target === "linux") { - return azureVMSizeCandidatesForClass(machineClass); + return azureVMSizeCandidatesForArchitectureClass(architecture, machineClass); } if (target === "windows" && (windowsMode === "normal" || windowsMode === "wsl2")) { return azureWindowsVMSizeCandidatesForClass(machineClass); @@ -518,6 +623,16 @@ export function azureVMSizeCandidatesForTargetClass( } export function azureVMSizeCandidatesForClass(machineClass: string): string[] { + return azureVMSizeCandidatesForArchitectureClass("amd64", machineClass); +} + +export function azureVMSizeCandidatesForArchitectureClass( + architecture: Architecture, + machineClass: string, +): string[] { + if (architecture === "arm64") { + return azureARM64VMSizeCandidatesForClass(machineClass); + } switch (machineClass) { case "standard": return [ @@ -574,6 +689,45 @@ export function azureVMSizeCandidatesForClass(machineClass: string): string[] { } } +export function azureARM64VMSizeCandidatesForClass(machineClass: string): string[] { + switch (machineClass) { + case "standard": + return ["Standard_D32pds_v6", "Standard_D32ps_v6", "Standard_D16pds_v6", "Standard_D16ps_v6"]; + case "fast": + return [ + "Standard_D64pds_v6", + "Standard_D64ps_v6", + "Standard_D48pds_v6", + "Standard_D48ps_v6", + "Standard_D32pds_v6", + "Standard_D32ps_v6", + ]; + case "large": + return [ + "Standard_D96pds_v6", + "Standard_D96ps_v6", + "Standard_D64pds_v6", + "Standard_D64ps_v6", + "Standard_D48pds_v6", + "Standard_D48ps_v6", + ]; + case "beast": + return ["Standard_D96pds_v6", "Standard_D96ps_v6", "Standard_D64pds_v6", "Standard_D64ps_v6"]; + default: + return [machineClass]; + } +} + +export function azureVMSizeIsARM64(vmSize: string): boolean { + const normalized = vmSize.trim().toLowerCase(); + return ( + normalized.includes("ps_v6") || + normalized.includes("pds_v6") || + normalized.includes("pls_v6") || + normalized.includes("plds_v6") + ); +} + export function azureWindowsVMSizeCandidatesForClass(machineClass: string): string[] { switch (machineClass) { case "standard": @@ -617,6 +771,7 @@ export function awsInstanceTypeCandidatesForTargetClass( target: TargetOS, machineClass: string, windowsMode: WindowsMode = "normal", + architecture: Architecture = "amd64", ): string[] { if (target === "macos") { return awsMacOSInstanceTypeCandidates; @@ -649,7 +804,7 @@ export function awsInstanceTypeCandidatesForTargetClass( return [machineClass]; } } - return awsInstanceTypeCandidatesForClass(machineClass); + return awsInstanceTypeCandidatesForArchitectureClass(architecture, machineClass); } export function serverTypeCandidatesForClass(machineClass: string): string[] { @@ -668,6 +823,16 @@ export function serverTypeCandidatesForClass(machineClass: string): string[] { } export function awsInstanceTypeCandidatesForClass(machineClass: string): string[] { + return awsInstanceTypeCandidatesForArchitectureClass("amd64", machineClass); +} + +export function awsInstanceTypeCandidatesForArchitectureClass( + architecture: Architecture, + machineClass: string, +): string[] { + if (architecture === "arm64") { + return awsARM64InstanceTypeCandidatesForClass(machineClass); + } switch (machineClass) { case "standard": return ["c7a.8xlarge", "c7i.8xlarge", "m7a.8xlarge", "m7i.8xlarge", "c7a.4xlarge"]; @@ -708,6 +873,29 @@ export function awsInstanceTypeCandidatesForClass(machineClass: string): string[ } } +export function awsARM64InstanceTypeCandidatesForClass(machineClass: string): string[] { + switch (machineClass) { + case "standard": + return ["c7g.8xlarge", "m7g.8xlarge", "r7g.8xlarge", "c7g.4xlarge"]; + case "fast": + return ["c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge", "c7g.8xlarge"]; + case "large": + return ["c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge"]; + case "beast": + return ["c7g.16xlarge", "m7g.16xlarge", "r7g.16xlarge", "c7g.12xlarge"]; + default: + return [machineClass]; + } +} + +export function awsInstanceTypeIsARM64(instanceType: string): boolean { + const family = instanceType.trim().toLowerCase().split(".")[0] ?? ""; + if (["a1", "g5g", "hpc7g", "i4g", "im4gn", "is4gen", "t4g", "x2gd"].includes(family)) { + return true; + } + return /^[cmr][0-9]+g[dn]?$/.test(family); +} + function clampTTL(ttlSeconds: number): number { if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { return 5400; diff --git a/worker/src/fleet.ts b/worker/src/fleet.ts index 0c99ad05..71b4c9a3 100644 --- a/worker/src/fleet.ts +++ b/worker/src/fleet.ts @@ -4563,6 +4563,10 @@ function legacyPromotedAWSImageKey(): string { return promotedAWSImagePrefix(); } +function legacyPromotedAWSImageCompatible(image: Pick): boolean { + return !image.architecture || image.architecture === "x86_64"; +} + function promotedAWSLinuxOSImageKey(image: Pick): string { const architecture = image.architecture ?? awsImageArchitectureForTarget("linux", ""); return `image:aws:promoted:linux:${architecture}:${sanitizePromotedAWSImageKeyPart(image.os ?? "")}`; @@ -4684,6 +4688,17 @@ function awsImageArchitectureForTarget(target: TargetOS, serverType: string): st return "x86_64"; } +function awsImageArchitectureForLease( + target: TargetOS, + serverType: string, + architecture?: string, +): string { + if (target === "linux" && architecture === "arm64") { + return "arm64"; + } + return awsImageArchitectureForTarget(target, serverType); +} + function sanitizeAWSRegion(value: string): string { const region = value.trim().toLowerCase(); return /^[a-z]{2}-[a-z-]+-[0-9]$/.test(region) ? region : ""; @@ -7353,7 +7368,11 @@ class AWSProvider implements CloudProvider { if (target === "linux" && promoted.os) { await this.storage.put(promotedAWSLinuxOSImageKey(promoted), promoted); } - if (target === "linux" && (!promoted.os || promoted.os === "ubuntu:24.04")) { + if ( + target === "linux" && + (!promoted.os || promoted.os === "ubuntu:24.04") && + legacyPromotedAWSImageCompatible(promoted) + ) { await this.storage.put(legacyPromotedAWSImageKey(), promoted); } return { image: promoted }; @@ -7374,15 +7393,21 @@ class AWSProvider implements CloudProvider { private async promotedImage(config: { target: TargetOS; + architecture?: string; os?: string; serverType: string; awsRegion: string; }): Promise { + const architecture = awsImageArchitectureForLease( + config.target, + config.serverType, + config.architecture, + ); const scoped = await this.storage.get( promotedAWSImageKey({ target: config.target, ...(config.os ? { os: config.os } : {}), - architecture: awsImageArchitectureForTarget(config.target, config.serverType), + architecture, serverType: config.serverType, region: config.awsRegion, }), @@ -7394,7 +7419,7 @@ class AWSProvider implements CloudProvider { return this.storage.get( legacyScopedPromotedAWSImageKey({ target: config.target, - architecture: awsImageArchitectureForTarget(config.target, config.serverType), + architecture, region: config.awsRegion, }), ); @@ -7406,15 +7431,18 @@ class AWSProvider implements CloudProvider { const osScoped = await this.storage.get( promotedAWSLinuxOSImageKey({ os: config.os, - architecture: awsImageArchitectureForTarget(config.target, config.serverType), + architecture, }), ); if (osScoped) { return osScoped; } } - if (!config.os || config.os === "ubuntu:24.04") { - return this.storage.get(legacyPromotedAWSImageKey()); + if ((!config.os || config.os === "ubuntu:24.04") && architecture === "x86_64") { + const legacy = await this.storage.get(legacyPromotedAWSImageKey()); + if (legacy && legacyPromotedAWSImageCompatible(legacy)) { + return legacy; + } } return undefined; } @@ -7426,6 +7454,7 @@ class AWSProvider implements CloudProvider { // oxlint-disable-next-line eslint/no-await-in-loop -- storage reads preserve deterministic fallback key construction. const promoted = await this.promotedImage({ target: config.target, + architecture: config.architecture, os: config.os, serverType, awsRegion: region, diff --git a/worker/src/os-image.ts b/worker/src/os-image.ts index 1032e894..811bda4d 100644 --- a/worker/src/os-image.ts +++ b/worker/src/os-image.ts @@ -3,8 +3,10 @@ export const defaultOSImage = "ubuntu:26.04"; export interface OSImageSpec { selector: string; awsName: string; + awsArm64Name: string; awsLabel: string; azureImage: string; + azureArm64Image: string; gcpImage: string; hetznerImage: string; } @@ -13,16 +15,20 @@ const specs: Record = { "ubuntu:24.04": { selector: "ubuntu:24.04", awsName: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*", + awsArm64Name: "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-arm64-server-*", awsLabel: "Ubuntu 24.04", azureImage: "Canonical:ubuntu-24_04-lts:server:latest", + azureArm64Image: "Canonical:ubuntu-24_04-lts:server-arm64:latest", gcpImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64", hetznerImage: "ubuntu-24.04", }, "ubuntu:26.04": { selector: "ubuntu:26.04", awsName: "ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*", + awsArm64Name: "ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-arm64-server-*", awsLabel: "Ubuntu 26.04", azureImage: "Canonical:ubuntu-26_04-lts:server:latest", + azureArm64Image: "Canonical:ubuntu-26_04-lts:server-arm64:latest", gcpImage: "projects/ubuntu-os-cloud/global/images/family/ubuntu-2604-lts-amd64", hetznerImage: "ubuntu-24.04", }, diff --git a/worker/src/types.ts b/worker/src/types.ts index d533ed01..c5f84407 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -95,6 +95,7 @@ export interface LeaseRequest { provider?: Provider; target?: TargetOS; targetOS?: TargetOS; + architecture?: string; os?: string; windowsMode?: WindowsMode; desktop?: boolean; diff --git a/worker/test/aws.test.ts b/worker/test/aws.test.ts index 7d74a7e6..e30fed64 100644 --- a/worker/test/aws.test.ts +++ b/worker/test/aws.test.ts @@ -165,6 +165,7 @@ describe("aws provider", () => { const check = awsCapacityReadinessCheckForQuota( { target: "linux", + architecture: "amd64", windowsMode: "normal", class: "beast", serverType: "c7a.48xlarge", @@ -196,6 +197,7 @@ describe("aws provider", () => { const check = awsCapacityReadinessCheckForQuota( { target: "linux", + architecture: "amd64", windowsMode: "normal", class: "beast", serverType: "c7a.48xlarge", @@ -911,11 +913,22 @@ describe("aws provider", () => { awsLaunchCandidates({ class: "standard", target: "windows", + architecture: "amd64", windowsMode: "wsl2", serverType: "m8i.large", serverTypeExplicit: false, }), ).not.toContain("t3.large"); + expect( + awsLaunchCandidates({ + class: "beast", + target: "linux", + architecture: "arm64", + windowsMode: "normal", + serverType: "c7g.16xlarge", + serverTypeExplicit: false, + }), + ).toContain("t4g.small"); }); it("builds ordered AWS region and availability-zone candidates", () => { diff --git a/worker/test/azure.test.ts b/worker/test/azure.test.ts index 9a7b5411..4f96a1ab 100644 --- a/worker/test/azure.test.ts +++ b/worker/test/azure.test.ts @@ -35,6 +35,7 @@ function testLeaseConfig(overrides: Partial = {}): LeaseConfig { return { provider: "azure", target: "linux", + architecture: "amd64", windowsMode: "normal", desktop: false, desktopEnv: "xfce", diff --git a/worker/test/config.test.ts b/worker/test/config.test.ts index c1733c47..f1a7e7bf 100644 --- a/worker/test/config.test.ts +++ b/worker/test/config.test.ts @@ -4,8 +4,10 @@ import { describe, expect, it } from "vitest"; import { awsMacOSInstanceTypeCandidates, + awsARM64InstanceTypeCandidatesForClass, awsInstanceTypeCandidatesForClass, awsInstanceTypeCandidatesForTargetClass, + azureARM64VMSizeCandidatesForClass, azureWindowsVMSizeCandidatesForClass, azureVMSizeCandidatesForClass, azureVMSizeCandidatesForTargetClass, @@ -64,6 +66,9 @@ describe("machine class config", () => { expect(azureVMSizeCandidatesForTargetClass("linux", "standard")).toEqual( azureVMSizeCandidatesForClass("standard"), ); + expect(azureVMSizeCandidatesForTargetClass("linux", "standard", "normal", "arm64")).toEqual( + azureARM64VMSizeCandidatesForClass("standard"), + ); expect(azureVMSizeCandidatesForTargetClass("windows", "standard")).toEqual( azureWindowsVMSizeCandidatesForClass("standard"), ); @@ -88,6 +93,9 @@ describe("machine class config", () => { "m7a.large", "t3.large", ]); + expect(awsInstanceTypeCandidatesForTargetClass("linux", "standard", "normal", "arm64")).toEqual( + awsARM64InstanceTypeCandidatesForClass("standard"), + ); expect(awsInstanceTypeCandidatesForTargetClass("macos", "standard")).toEqual([ ...awsMacOSInstanceTypeCandidates, ]); @@ -100,16 +108,22 @@ describe("machine class config", () => { const classes = ["standard", "fast", "large", "beast"]; const hetzner = parseGoStringArrayCases(goFunctionBody(go, "serverTypeCandidatesForClass")); const awsLinux = parseGoStringArrayCases( - goFunctionBody(go, "awsInstanceTypeCandidatesForClass"), + goFunctionBody(go, "awsInstanceTypeCandidatesForArchitectureClass"), ); const azureLinux = parseGoStringArrayCases( - goFunctionBody(goAzure, "azureVMSizeCandidatesForClass"), + goFunctionBody(goAzure, "azureVMSizeCandidatesForArchitectureClass"), + ); + const azureLinuxARM64 = parseGoStringArrayCases( + goFunctionBody(goAzure, "azureARM64VMSizeCandidatesForClass"), ); const azureWindows = parseGoStringArrayCases( goFunctionBody(goAzure, "azureWindowsVMSizeCandidatesForClass"), ); + const awsLinuxARM64 = parseGoStringArrayCases( + goFunctionBody(go, "awsARM64InstanceTypeCandidatesForClass"), + ); const gcp = parseGoStringArrayCases(goFunctionBody(goGCP, "gcpMachineTypeCandidatesForClass")); - const awsTarget = goFunctionBody(go, "awsInstanceTypeCandidatesForTargetModeClass"); + const awsTarget = goFunctionBody(go, "awsInstanceTypeCandidatesForTargetModeArchitectureClass"); const awsWSL2 = parseGoStringArrayCases( goSwitchAfter(awsTarget, "if windowsMode == windowsModeWSL2"), ); @@ -118,7 +132,9 @@ describe("machine class config", () => { for (const name of classes) { expect(serverTypeCandidatesForClass(name)).toEqual(hetzner[name]); expect(awsInstanceTypeCandidatesForClass(name)).toEqual(awsLinux[name]); + expect(awsARM64InstanceTypeCandidatesForClass(name)).toEqual(awsLinuxARM64[name]); expect(azureVMSizeCandidatesForClass(name)).toEqual(azureLinux[name]); + expect(azureARM64VMSizeCandidatesForClass(name)).toEqual(azureLinuxARM64[name]); expect(azureWindowsVMSizeCandidatesForClass(name)).toEqual(azureWindows[name]); expect(azureVMSizeCandidatesForTargetClass("windows", name)).toEqual(azureWindows[name]); expect(azureVMSizeCandidatesForTargetClass("windows", name, "wsl2")).toEqual( @@ -336,6 +352,113 @@ describe("lease config", () => { expect(config.azureOSDisk).toBe("managed"); }); + it("uses Azure ARM defaults when requested", () => { + const config = leaseConfig({ + provider: "azure", + architecture: "arm64", + os: "ubuntu:26.04", + sshPublicKey: "ssh-ed25519 test", + }); + expect(config.architecture).toBe("arm64"); + expect(config.serverType).toBe("Standard_D96pds_v6"); + expect(config.azureImage).toBe("Canonical:ubuntu-26_04-lts:server-arm64:latest"); + }); + + it("uses AWS ARM defaults when requested", () => { + const config = leaseConfig({ + provider: "aws", + architecture: "arm64", + sshPublicKey: "ssh-ed25519 test", + }); + expect(config.serverType).toBe("c7g.16xlarge"); + }); + + it("infers Azure ARM architecture from explicit ARM VM sizes", () => { + const config = leaseConfig({ + provider: "azure", + serverType: "Standard_D32ps_v6", + os: "ubuntu:24.04", + sshPublicKey: "ssh-ed25519 test", + }); + expect(config.architecture).toBe("arm64"); + expect(config.serverType).toBe("Standard_D32ps_v6"); + expect(config.azureImage).toBe("Canonical:ubuntu-24_04-lts:server-arm64:latest"); + }); + + it("infers AWS ARM architecture from explicit Graviton instance types", () => { + for (const serverType of [ + "a1.large", + "c7g.16xlarge", + "c7gd.16xlarge", + "c7gn.16xlarge", + "g5g.xlarge", + "hpc7g.16xlarge", + "im4gn.16xlarge", + "is4gen.16xlarge", + ]) { + const config = leaseConfig({ + provider: "aws", + serverType, + sshPublicKey: "ssh-ed25519 test", + }); + expect(config.architecture).toBe("arm64"); + expect(config.serverType).toBe(serverType); + } + }); + + it("rejects ARM leases outside supported Linux providers", () => { + expect(() => + leaseConfig({ + provider: "azure", + target: "windows", + architecture: "arm64", + sshPublicKey: "ssh-ed25519 test", + }), + ).toThrow("architecture=arm64 currently supports target=linux only"); + expect(() => + leaseConfig({ + provider: "hetzner", + architecture: "arm64", + sshPublicKey: "ssh-ed25519 test", + }), + ).toThrow("architecture=arm64 currently supports provider=azure or provider=aws"); + }); + + it("rejects explicit server types that do not match the requested architecture", () => { + expect(() => + leaseConfig({ + provider: "aws", + architecture: "arm64", + serverType: "c7a.48xlarge", + sshPublicKey: "ssh-ed25519 test", + }), + ).toThrow("architecture=arm64 requires an ARM64 AWS instance type"); + expect(() => + leaseConfig({ + provider: "aws", + architecture: "amd64", + serverType: "c7g.16xlarge", + sshPublicKey: "ssh-ed25519 test", + }), + ).toThrow("architecture=amd64 requires an amd64 AWS instance type"); + expect(() => + leaseConfig({ + provider: "azure", + architecture: "arm64", + serverType: "Standard_D96ds_v6", + sshPublicKey: "ssh-ed25519 test", + }), + ).toThrow("architecture=arm64 requires an ARM64 Azure VM size"); + expect(() => + leaseConfig({ + provider: "azure", + architecture: "amd64", + serverType: "Standard_D96pds_v6", + sshPublicKey: "ssh-ed25519 test", + }), + ).toThrow("architecture=amd64 requires an amd64 Azure VM size"); + }); + it("normalizes Azure OS disk requests", () => { expect( leaseConfig({ diff --git a/worker/test/fleet.test.ts b/worker/test/fleet.test.ts index 1531dc66..384536c3 100644 --- a/worker/test/fleet.test.ts +++ b/worker/test/fleet.test.ts @@ -4641,6 +4641,27 @@ describe("fleet lease identity and idle", () => { ); }); + it("does not write ARM Linux AWS promotions to the legacy x86 key", async () => { + const storage = new MemoryStorage(); + const fleet = testFleet(storage, { + aws: fakeProvider(), + }); + + const promoted = await fleet.fetch( + request( + "POST", + "/v1/images/ami-arm/promote?target=linux®ion=eu-west-1&os=ubuntu:24.04&architecture=arm64", + { headers: { "x-crabbox-admin": "true" }, body: {} }, + ), + ); + + expect(promoted.status).toBe(200); + expect(storage.value("image:aws:promoted:linux:arm64:ubuntu24.04:eu-west-1")).toEqual( + expect.objectContaining({ id: "ami-arm", architecture: "arm64", os: "ubuntu:24.04" }), + ); + expect(storage.value("image:aws:promoted")).toBeUndefined(); + }); + it("uses promoted AWS image region when creating leases", async () => { const storage = new MemoryStorage(); let createdConfig: LeaseConfig | undefined; @@ -4682,6 +4703,100 @@ describe("fleet lease identity and idle", () => { expect(body.lease.region).toBe("us-east-2"); }); + it("uses ARM64 promoted AWS Linux images for ARM leases", async () => { + const storage = new MemoryStorage(); + let createdConfig: LeaseConfig | undefined; + const fleet = testFleet( + storage, + { + aws: fakeProvider( + (config) => { + createdConfig = config; + }, + { provider: "aws", region: "us-east-2" }, + ), + }, + { CRABBOX_AWS_REGION: "eu-west-1" }, + ); + storage.seed("image:aws:promoted:linux:x86_64:ubuntu26.04", { + id: "ami-x86", + name: "crabbox-x86-image", + state: "available", + region: "us-east-1", + target: "linux", + architecture: "x86_64", + os: "ubuntu:26.04", + promotedAt: "2026-05-01T12:46:00Z", + }); + storage.seed("image:aws:promoted:linux:arm64:ubuntu26.04", { + id: "ami-arm64", + name: "crabbox-arm-image", + state: "available", + region: "us-east-2", + target: "linux", + architecture: "arm64", + os: "ubuntu:26.04", + promotedAt: "2026-05-01T12:47:00Z", + }); + + const response = await fleet.fetch( + request("POST", "/v1/leases", { + body: { + provider: "aws", + architecture: "arm64", + sshPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest test@example.com", + }, + }), + ); + + expect(response.status).toBe(201); + expect(createdConfig?.architecture).toBe("arm64"); + expect(createdConfig?.awsAMI).toBe("ami-arm64"); + expect(createdConfig?.awsRegion).toBe("us-east-2"); + }); + + it("does not use the legacy x86 AWS promoted image for ARM leases", async () => { + const storage = new MemoryStorage(); + let createdConfig: LeaseConfig | undefined; + const fleet = testFleet( + storage, + { + aws: fakeProvider( + (config) => { + createdConfig = config; + }, + { provider: "aws", region: "eu-west-1" }, + ), + }, + { CRABBOX_AWS_REGION: "eu-west-1" }, + ); + storage.seed("image:aws:promoted", { + id: "ami-x86-legacy", + name: "crabbox-legacy-x86", + state: "available", + region: "eu-west-1", + target: "linux", + architecture: "x86_64", + os: "ubuntu:24.04", + promotedAt: "2026-05-01T12:48:00Z", + }); + + const response = await fleet.fetch( + request("POST", "/v1/leases", { + body: { + provider: "aws", + architecture: "arm64", + os: "ubuntu:24.04", + sshPublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest test@example.com", + }, + }), + ); + + expect(response.status).toBe(201); + expect(createdConfig?.architecture).toBe("arm64"); + expect(createdConfig?.awsAMI).toBe(""); + }); + it("passes provider-native checkpoint snapshot fields into new leases", async () => { let awsConfig: LeaseConfig | undefined; let azureConfig: LeaseConfig | undefined; @@ -6614,16 +6729,22 @@ function fakeProvider( fakePromotedAWSImageKey({ target: config.target, os: config.os, - architecture: fakeAWSImageArchitectureForTarget(config.target, config.serverType), + architecture: fakeAWSImageArchitectureForLease( + config.target, + config.serverType, + config.architecture, + ), region: config.awsRegion, }), )) ?? (config.os ? await storage?.get( - `image:aws:promoted:linux:${fakeAWSImageArchitectureForTarget(config.target, config.serverType)}:${config.os.replaceAll(/[^a-z0-9._-]/g, "")}`, + `image:aws:promoted:linux:${fakeAWSImageArchitectureForLease(config.target, config.serverType, config.architecture)}:${config.os.replaceAll(/[^a-z0-9._-]/g, "")}`, ) : undefined) ?? - (!config.os || config.os === "ubuntu:24.04" + ((!config.os || config.os === "ubuntu:24.04") && + fakeAWSImageArchitectureForLease(config.target, config.serverType, config.architecture) === + "x86_64" ? await storage?.get("image:aws:promoted") : undefined); return { @@ -6959,7 +7080,11 @@ function fakeProvider( promoted, ); } - if (target === "linux" && (!promoted.os || promoted.os === "ubuntu:24.04")) { + if ( + target === "linux" && + (!promoted.os || promoted.os === "ubuntu:24.04") && + fakeLegacyPromotedAWSImageCompatible(promoted) + ) { await storage?.put("image:aws:promoted", promoted); } return { image: promoted }; @@ -7073,6 +7198,21 @@ function fakeAWSImageArchitectureForTarget( return "x86_64"; } +function fakeAWSImageArchitectureForLease( + target: "linux" | "macos" | "windows", + serverType: string, + architecture?: string, +): string { + if (target === "linux" && architecture === "arm64") { + return "arm64"; + } + return fakeAWSImageArchitectureForTarget(target, serverType); +} + +function fakeLegacyPromotedAWSImageCompatible(image: Pick): boolean { + return !image.architecture || image.architecture === "x86_64"; +} + function fakeNormalizeOSImage(value: string | undefined | null): string { let normalized = (value ?? "").trim().toLowerCase(); if (!normalized) {