crabbox/internal/cli/azure_test.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

390 lines
12 KiB
Go

package cli
import (
"reflect"
"strings"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
)
func TestParseAzureImageRef(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want azureImageRef
wantErr bool
}{
{
name: "ubuntu jammy gen2",
input: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
want: azureImageRef{Publisher: "Canonical", Offer: "0001-com-ubuntu-server-jammy", SKU: "22_04-lts-gen2", Version: "latest"},
},
{
name: "missing version",
input: "Canonical:offer:sku",
wantErr: true,
},
{
name: "empty",
input: "",
wantErr: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := parseAzureImageRef(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %+v, want %+v", got, tc.want)
}
})
}
}
func TestAzureImageForConfig(t *testing.T) {
t.Parallel()
linux := baseConfig()
linux.TargetOS = targetLinux
if got := azureImageForConfig(linux); got != defaultAzureLinuxImage {
t.Fatalf("linux image=%q want %q", got, defaultAzureLinuxImage)
}
windows := baseConfig()
windows.TargetOS = targetWindows
if got := azureImageForConfig(windows); got != defaultAzureWindowsImage {
t.Fatalf("windows image=%q want %q", got, defaultAzureWindowsImage)
}
windows.AzureImage = "Contoso:offer:sku:latest"
if got := azureImageForConfig(windows); got != windows.AzureImage {
t.Fatalf("windows explicit image=%q want %q", got, windows.AzureImage)
}
}
func TestAzureVMSizeCandidatesForClass(t *testing.T) {
t.Parallel()
cases := []struct {
class string
want []string
}{
{class: "standard", want: []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}},
{class: "fast", want: []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}},
{class: "large", want: []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}},
{class: "beast", want: []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}},
{class: "Standard_F2s", want: []string{"Standard_F2s"}},
}
for _, tc := range cases {
got := azureVMSizeCandidatesForClass(tc.class)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("class=%q: got %v, want %v", tc.class, got, tc.want)
}
}
}
func TestAzureVMSizeCandidatesForTargetModeClass(t *testing.T) {
t.Parallel()
linux := azureVMSizeCandidatesForTargetModeClass(targetLinux, windowsModeNormal, "standard")
if !reflect.DeepEqual(linux, azureVMSizeCandidatesForClass("standard")) {
t.Fatalf("linux target got %v want azure linux table", linux)
}
windows := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeNormal, "standard")
if want := azureWindowsVMSizeCandidatesForClass("standard"); !reflect.DeepEqual(windows, want) {
t.Fatalf("windows target got %v want %v", windows, want)
}
wsl2 := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeWSL2, "standard")
if !reflect.DeepEqual(wsl2, []string{"standard"}) {
t.Fatalf("wsl2 target got %v want explicit fallback", wsl2)
}
}
func TestAzureWindowsVMSizeCandidatesForClass(t *testing.T) {
t.Parallel()
got := azureWindowsVMSizeCandidatesForClass("beast")
want := []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestServerTypeForProviderClassAzure(t *testing.T) {
t.Parallel()
got := serverTypeForProviderClass("azure", "beast")
if got != "Standard_D192ds_v6" {
t.Fatalf("got %q, want Standard_D192ds_v6", got)
}
}
func TestAzureSupportsEphemeralOS(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"Standard_D2as_v5": false,
"Standard_D8s_v5": false,
"Standard_D2ads_v5": true,
"Standard_D2ads_v6": true,
"Standard_F2s_v2": true,
"Standard_E4ds_v5": true,
"Standard_D2as_v6": false,
"Standard_D2s_v6": false,
"Standard_B2s": false,
"Standard_A2_v2": false,
"": false,
}
for size, want := range cases {
if got := azureSupportsEphemeralOS(size); got != want {
t.Fatalf("size=%q got %v want %v", size, got, want)
}
}
}
func TestAzureComputerNameWindowsLimit(t *testing.T) {
t.Parallel()
got := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetWindows)
if len(got) > 15 {
t.Fatalf("computer name %q length=%d", got, len(got))
}
if got != "cbxcbx8556d7bc1" {
t.Fatalf("got %q", got)
}
if linux := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetLinux); linux != "crabbox-coral-lobster-c9adbbb9" {
t.Fatalf("linux computer name changed to %q", linux)
}
}
func TestAzureWindowsBootstrapPowerShell(t *testing.T) {
t.Parallel()
cfg := baseConfig()
cfg.Provider = "azure"
cfg.TargetOS = targetWindows
cfg.WorkRoot = defaultWindowsWorkRoot
got := azureWindowsBootstrapPowerShell(cfg, "ssh-rsa test")
for _, want := range []string{
"OpenSSH-Win64.zip",
"Git-2.52.0-64-bit.exe",
"administrators_authorized_keys",
"Match Group administrators",
"$sshPorts = @('2222', '22')",
"PasswordAuthentication no",
"Restart-Service sshd -Force",
"Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath",
} {
if !strings.Contains(got, want) {
t.Fatalf("bootstrap missing %q", want)
}
}
if strings.Contains(got, "Restart-Computer") {
t.Fatalf("azure extension bootstrap must not restart inside Custom Script Extension")
}
}
func TestAzureTagsMapReservedWindowsPrefix(t *testing.T) {
t.Parallel()
labels := map[string]string{
"crabbox": "true",
"windows_mode": "normal",
}
tags := azureTagsFromLabels(labels)
if tags["windows_mode"] != "" {
t.Fatalf("reserved windows tag key was not remapped: %#v", tags)
}
if tags["crabbox_windows_mode"] != "normal" {
t.Fatalf("missing remapped windows mode tag: %#v", tags)
}
server := azureVMToServer(armcompute.VirtualMachine{
Tags: stringMapToPtrMap(tags),
}, "")
if server.Labels["windows_mode"] != "normal" {
t.Fatalf("windows_mode label not restored: %#v", server.Labels)
}
}
func TestAzureSKUCapabilityTrue(t *testing.T) {
t.Parallel()
caps := []*armcompute.ResourceSKUCapabilities{
{Name: to.Ptr("EphemeralOSDiskSupported"), Value: to.Ptr("True")},
}
if !azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
t.Fatal("capability should be true")
}
caps[0].Value = to.Ptr("False")
if azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
t.Fatal("capability should be false")
}
}
func TestStringMapToPtrMap(t *testing.T) {
t.Parallel()
in := map[string]string{"a": "1", "b": "2"}
out := stringMapToPtrMap(in)
if len(out) != 2 {
t.Fatalf("len=%d, want 2", len(out))
}
if *out["a"] != "1" || *out["b"] != "2" {
t.Fatalf("values = %v, %v", *out["a"], *out["b"])
}
}
func TestIsAzureRetryableProvisioningError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": false,
"some other error": false,
"compute.VMs: SkuNotAvailable in this region": true,
"QuotaExceeded for cores": true,
"AllocationFailed: out of capacity": true,
"OverconstrainedAllocationRequest: zone exhausted": true,
}
for msg, want := range cases {
var err error
if msg != "" {
err = errSentinel(msg)
}
if got := isAzureRetryableProvisioningError(err); got != want {
t.Fatalf("msg=%q got %v want %v", msg, got, want)
}
}
}
func TestIsAzureNotFoundError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": false,
"transient": false,
"ResponseError: ResourceNotFound: vm missing": true,
"NotFound: pip already deleted": true,
}
for msg, want := range cases {
var err error
if msg != "" {
err = errSentinel(msg)
}
if got := isAzureNotFoundError(err); got != want {
t.Fatalf("msg=%q got %v want %v", msg, got, want)
}
}
}
func TestIsAzureRetryableDeleteError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": false,
"validation failed": false,
"NicReservedForAnotherVm retry after 180 seconds": true,
"PublicIPAddressCannotBeDeleted because in use": true,
"AnotherOperationInProgress": true,
"OperationNotAllowed retry after 180 seconds": true,
}
for msg, want := range cases {
var err error
if msg != "" {
err = errSentinel(msg)
}
if got := isAzureRetryableDeleteError(err); got != want {
t.Fatalf("msg=%q got %v want %v", msg, got, want)
}
}
}
func TestPreserveNonCrabboxRules(t *testing.T) {
t.Parallel()
in := []*armnetwork.SecurityRule{
{Name: to.Ptr("crabbox-ssh-2222-0")},
{Name: to.Ptr("operator-https")},
nil,
{},
}
got := preserveNonCrabboxRules(in)
if len(got) != 1 || got[0] == nil || got[0].Name == nil || *got[0].Name != "operator-https" {
t.Fatalf("got %+v, want a single operator-https rule", got)
}
}
func TestNextAzureNSGPrioritySkipsPreservedRules(t *testing.T) {
t.Parallel()
used := azureNSGUsedPriorities([]*armnetwork.SecurityRule{{
Name: to.Ptr("operator-ssh"),
Properties: &armnetwork.SecurityRulePropertiesFormat{
Priority: to.Ptr[int32](100),
},
}})
got, err := nextAzureNSGPriority(used)
if err != nil {
t.Fatal(err)
}
if got != 101 {
t.Fatalf("got %d want 101", got)
}
}
type errSentinel string
func (e errSentinel) Error() string { return string(e) }
func TestAzureManagedByCrabbox(t *testing.T) {
t.Parallel()
val := "crabbox"
other := "platform-team"
cases := []struct {
name string
tags map[string]*string
want bool
}{
{name: "nil tags", tags: nil, want: false},
{name: "missing key", tags: map[string]*string{"crabbox": &val}, want: false},
{name: "wrong value", tags: map[string]*string{"managed_by": &other}, want: false},
{name: "match", tags: map[string]*string{"managed_by": &val}, want: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := azureManagedByCrabbox(tc.tags); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
func TestAzureCredentialForConfigPrefersClientSecret(t *testing.T) {
t.Setenv("AZURE_CLIENT_SECRET", "shh")
cfg := Config{
AzureTenant: "00000000-0000-0000-0000-000000000001",
AzureClientID: "00000000-0000-0000-0000-000000000002",
}
cred, err := azureCredentialForConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := cred.(*azidentity.ClientSecretCredential); !ok {
t.Fatalf("got %T, want *azidentity.ClientSecretCredential", cred)
}
}
func TestAzureCredentialForConfigFallsBackToDefault(t *testing.T) {
// Make sure env vars don't accidentally yield ClientSecretCredential.
t.Setenv("AZURE_CLIENT_SECRET", "")
cfg := Config{AzureTenant: "tenant", AzureClientID: "client"}
cred, err := azureCredentialForConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := cred.(*azidentity.ClientSecretCredential); ok {
t.Fatalf("got ClientSecretCredential, want DefaultAzureCredential")
}
if _, ok := cred.(*azidentity.DefaultAzureCredential); !ok {
t.Fatalf("got %T, want *azidentity.DefaultAzureCredential", cred)
}
}