crabbox/internal/cli/target.go
Jonathan Moss 00725544c7
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>
2026-05-08 08:23:38 +01:00

230 lines
6.7 KiB
Go

package cli
import (
"path"
"strings"
)
const (
targetLinux = "linux"
targetMacOS = "macos"
targetWindows = "windows"
windowsModeNormal = "normal"
windowsModeWSL2 = "wsl2"
defaultPOSIXWorkRoot = "/work/crabbox"
defaultMacOSWorkRoot = "/Users/ec2-user/crabbox"
defaultWindowsWorkRoot = `C:\crabbox`
)
const (
TargetLinux = targetLinux
TargetMacOS = targetMacOS
TargetWindows = targetWindows
)
func normalizeTargetConfig(cfg *Config) {
cfg.TargetOS = normalizeTargetOS(cfg.TargetOS)
cfg.WindowsMode = normalizeWindowsMode(cfg.WindowsMode)
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS && cfg.SSHUser == baseConfig().SSHUser {
cfg.SSHUser = "ec2-user"
}
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeWSL2 && cfg.SSHUser == baseConfig().SSHUser {
cfg.SSHUser = "Administrator"
}
if cfg.Static.User != "" && cfg.SSHUser == baseConfig().SSHUser {
cfg.SSHUser = cfg.Static.User
}
if isDefaultWorkRoot(cfg.WorkRoot) {
cfg.WorkRoot = defaultWorkRootForTarget(cfg.TargetOS, cfg.WindowsMode)
}
if cfg.Static.Port != "" && cfg.SSHPort == baseConfig().SSHPort {
cfg.SSHPort = cfg.Static.Port
}
if cfg.Static.WorkRoot != "" {
cfg.WorkRoot = cfg.Static.WorkRoot
}
}
func isDefaultWorkRoot(value string) bool {
switch value {
case "", defaultPOSIXWorkRoot, defaultMacOSWorkRoot, defaultWindowsWorkRoot:
return true
default:
return false
}
}
func defaultWorkRootForTarget(targetOS, windowsMode string) string {
if targetOS == targetMacOS {
return defaultMacOSWorkRoot
}
if targetOS == targetWindows && windowsMode == windowsModeNormal {
return defaultWindowsWorkRoot
}
return defaultPOSIXWorkRoot
}
func normalizeTargetOS(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "linux", "ubuntu":
return targetLinux
case "mac", "macos", "darwin", "osx":
return targetMacOS
case "win", "windows":
return targetWindows
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeWindowsMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "normal", "native", "powershell", "ps":
return windowsModeNormal
case "wsl", "wsl2":
return windowsModeWSL2
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func validateTargetConfig(cfg Config) error {
switch cfg.TargetOS {
case targetLinux, targetMacOS, targetWindows:
default:
return exit(2, "target must be linux, macos, or windows")
}
if cfg.TargetOS != targetWindows && cfg.WindowsMode != windowsModeNormal {
return exit(2, "windows.mode is only valid with target=windows")
}
if cfg.TargetOS == targetWindows {
switch cfg.WindowsMode {
case windowsModeNormal, windowsModeWSL2:
default:
return exit(2, "windows.mode must be normal or wsl2")
}
}
return nil
}
func validateProviderTarget(cfg Config) error {
provider, err := ProviderFor(cfg.Provider)
if err != nil {
return err
}
if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) {
return exit(2, "%s", unsupportedManagedTargetMessageForConfig(provider.Name(), cfg))
}
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
if cfg.AWSMacHostID == "" && cfg.Coordinator == "" {
return exit(2, "provider=aws target=macos requires CRABBOX_AWS_MAC_HOST_ID or aws.macHostId for an allocated EC2 Mac Dedicated Host")
}
if cfg.Capacity.Market != "on-demand" {
return exit(2, "provider=aws target=macos requires --market on-demand; EC2 Mac instances are not Spot")
}
return nil
}
return nil
}
func providerSpecSupportsTarget(spec ProviderSpec, targetOS, windowsMode string) bool {
for _, target := range spec.Targets {
if target.OS != targetOS {
continue
}
if targetOS == targetWindows && target.WindowsMode != "" && target.WindowsMode != windowsMode {
continue
}
return true
}
return false
}
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)
case targetMacOS:
return sprintf("provider=%s managed provisioning supports target=linux only; use provider=aws with an EC2 Mac Dedicated Host or provider=ssh for existing macOS hosts", provider)
default:
return sprintf("provider=%s managed provisioning supports target=linux only", provider)
}
}
func newTargetCoordinatorClient(cfg Config) (*CoordinatorClient, bool, error) {
if isStaticProvider(cfg.Provider) {
return nil, false, nil
}
return newCoordinatorClient(cfg)
}
func isStaticProvider(provider string) bool {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "ssh", "static", "static-ssh":
return true
default:
return false
}
}
func isWindowsNativeTarget(target SSHTarget) bool {
return target.TargetOS == targetWindows && target.WindowsMode == windowsModeNormal
}
func isWindowsWSL2Target(target SSHTarget) bool {
return target.TargetOS == targetWindows && target.WindowsMode == windowsModeWSL2
}
func isPOSIXTarget(target SSHTarget) bool {
return !isWindowsNativeTarget(target)
}
func remoteJoin(cfg Config, parts ...string) string {
values := make([]string, 0, len(parts)+1)
if cfg.WorkRoot != "" {
values = append(values, cfg.WorkRoot)
}
values = append(values, parts...)
if cfg.TargetOS == targetWindows && cfg.WindowsMode == windowsModeNormal {
return windowsPathJoin(values...)
}
return path.Join(values...)
}
func RemoteJoin(cfg Config, parts ...string) string {
return remoteJoin(cfg, parts...)
}
func windowsPathJoin(parts ...string) string {
out := ""
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
part = strings.ReplaceAll(part, "/", `\`)
if out == "" {
out = strings.TrimRight(part, `\`)
continue
}
out = strings.TrimRight(out, `\`) + `\` + strings.Trim(part, `\`)
}
return out
}