From 00f7c5125024bc96f607d2bfffc465ae8ce09164 Mon Sep 17 00:00:00 2001 From: Carlos Eduardo Arango Gutierrez Date: Fri, 13 Feb 2026 11:36:09 +0100 Subject: [PATCH] feat(aws): add AMI architecture detection and cross-validation Add describeImageArch helper to query an AMI's architecture from EC2 and getInstanceTypeArch to query instance type supported architectures. Update resolveImageForNode to return architecture for all code paths including explicit ImageId. Cross-validate AMI architecture against instance type supported architectures in DryRun() to catch mismatches (e.g., arm64 AMI + x86_64 instance type) with a clear error message before instance creation fails. Signed-off-by: Carlos Eduardo Arango Gutierrez --- pkg/provider/aws/aws_ginkgo_test.go | 35 ++- pkg/provider/aws/aws_test.go | 35 ++- pkg/provider/aws/dryrun.go | 25 ++ pkg/provider/aws/image.go | 89 +++++- pkg/provider/aws/image_test.go | 458 ++++++++++++++++++++++++++++ pkg/provider/aws/mock_ec2_test.go | 18 +- 6 files changed, 638 insertions(+), 22 deletions(-) diff --git a/pkg/provider/aws/aws_ginkgo_test.go b/pkg/provider/aws/aws_ginkgo_test.go index a25733422..990213768 100644 --- a/pkg/provider/aws/aws_ginkgo_test.go +++ b/pkg/provider/aws/aws_ginkgo_test.go @@ -340,7 +340,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } @@ -355,8 +360,18 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Large}, - {InstanceType: types.InstanceTypeT3Xlarge}, + {InstanceType: types.InstanceTypeT3Large, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, + {InstanceType: types.InstanceTypeT3Xlarge, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } @@ -518,7 +533,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } @@ -563,7 +583,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } diff --git a/pkg/provider/aws/aws_test.go b/pkg/provider/aws/aws_test.go index 075728ef3..61cc5389f 100644 --- a/pkg/provider/aws/aws_test.go +++ b/pkg/provider/aws/aws_test.go @@ -1226,7 +1226,12 @@ spec: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } @@ -1471,7 +1476,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } @@ -1519,7 +1529,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT4gMedium}, + {InstanceType: types.InstanceTypeT4gMedium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeArm64, + }, + }}, }, }, nil } @@ -1598,7 +1613,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } @@ -1699,7 +1719,12 @@ status: optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, + {InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }}, }, }, nil } diff --git a/pkg/provider/aws/dryrun.go b/pkg/provider/aws/dryrun.go index 9034ea096..b7cf4db96 100644 --- a/pkg/provider/aws/dryrun.go +++ b/pkg/provider/aws/dryrun.go @@ -41,5 +41,30 @@ func (p *Provider) DryRun() error { } cancel(nil) + // Cross-validate architecture compatibility + if p.Spec.Image.Architecture != "" { + cancel = p.log.Loading("Validating architecture compatibility") + archs, err := p.getInstanceTypeArch(p.Spec.Type) + if err != nil { + cancel(logger.ErrLoadingFailed) + return fmt.Errorf("failed to check instance type architecture: %w", err) + } + archMatch := false + for _, a := range archs { + if a == p.Spec.Image.Architecture { + archMatch = true + break + } + } + if !archMatch { + cancel(logger.ErrLoadingFailed) + return fmt.Errorf( + "architecture mismatch: AMI architecture is %s but instance type %s supports %v", + p.Spec.Image.Architecture, p.Spec.Type, archs, + ) + } + cancel(nil) + } + return nil } diff --git a/pkg/provider/aws/image.go b/pkg/provider/aws/image.go index 61de22d3f..4e3388b94 100644 --- a/pkg/provider/aws/image.go +++ b/pkg/provider/aws/image.go @@ -89,6 +89,7 @@ func (p *Provider) resolveOSToAMI() error { } p.Spec.Image.ImageId = &resolved.ImageID + p.Spec.Image.Architecture = normalizeArchToEC2(arch) // Auto-set username if not provided //nolint:staticcheck // Auth is embedded but explicit access is clearer @@ -102,8 +103,9 @@ func (p *Provider) resolveOSToAMI() error { // ResolvedImage contains the resolved AMI information for instance creation. type ResolvedImage struct { - ImageID string - SSHUsername string + ImageID string + SSHUsername string + Architecture string // EC2 architecture: "x86_64" or "arm64" } // resolveImageForNode resolves the AMI for a node based on OS or explicit Image. @@ -112,9 +114,14 @@ type ResolvedImage struct { func (p *Provider) resolveImageForNode(os string, image *v1alpha1.Image, arch string) (*ResolvedImage, error) { // If explicit ImageId is provided, use it if image != nil && image.ImageId != nil && *image.ImageId != "" { + arch, err := p.describeImageArch(*image.ImageId) + if err != nil { + return nil, fmt.Errorf("failed to determine architecture for image %s: %w", *image.ImageId, err) + } return &ResolvedImage{ - ImageID: *image.ImageId, - SSHUsername: "", // Username must be provided in auth config + ImageID: *image.ImageId, + SSHUsername: "", // Username must be provided in auth config + Architecture: arch, }, nil } @@ -126,6 +133,7 @@ func (p *Provider) resolveImageForNode(os string, image *v1alpha1.Image, arch st arch = "x86_64" // Default } } + arch = normalizeArchToEC2(arch) // If OS is specified, resolve via AMI resolver if os != "" { @@ -134,8 +142,9 @@ func (p *Provider) resolveImageForNode(os string, image *v1alpha1.Image, arch st return nil, fmt.Errorf("failed to resolve AMI for OS %s: %w", os, err) } return &ResolvedImage{ - ImageID: resolved.ImageID, - SSHUsername: resolved.SSHUsername, + ImageID: resolved.ImageID, + SSHUsername: resolved.SSHUsername, + Architecture: arch, }, nil } @@ -147,8 +156,9 @@ func (p *Provider) resolveImageForNode(os string, image *v1alpha1.Image, arch st return nil, fmt.Errorf("failed to resolve AMI for OS %s: %w", p.Spec.Instance.OS, err) } return &ResolvedImage{ - ImageID: resolved.ImageID, - SSHUsername: resolved.SSHUsername, + ImageID: resolved.ImageID, + SSHUsername: resolved.SSHUsername, + Architecture: arch, }, nil } @@ -159,8 +169,9 @@ func (p *Provider) resolveImageForNode(os string, image *v1alpha1.Image, arch st return nil, err } return &ResolvedImage{ - ImageID: imageID, - SSHUsername: "ubuntu", + ImageID: imageID, + SSHUsername: "ubuntu", + Architecture: arch, }, nil } @@ -239,6 +250,13 @@ func (p *Provider) setLegacyAMI() error { } p.Spec.Image.ImageId = &imageID + // Store the resolved architecture (normalized to EC2 form) for cross-validation in DryRun + if p.Spec.Image.Architecture == "" { + p.Spec.Image.Architecture = "x86_64" // Legacy default + } else { + p.Spec.Image.Architecture = normalizeArchToEC2(p.Spec.Image.Architecture) + } + // Set default username for Ubuntu if not provided //nolint:staticcheck // Auth is embedded but explicit access is clearer if p.Spec.Auth.Username == "" { @@ -320,3 +338,54 @@ func (p *Provider) checkInstanceTypes() error { return fmt.Errorf("instance type %s is not supported in the current region %s", p.Spec.Type, p.Spec.Region) } + +// normalizeArchToEC2 converts architecture aliases to EC2 canonical form. +// EC2 APIs use "x86_64" and "arm64", but users and other systems may use +// "amd64" (Debian convention) or "aarch64" (kernel convention). +func normalizeArchToEC2(arch string) string { + switch strings.ToLower(arch) { + case "amd64", "x86_64": + return "x86_64" + case "arm64", "aarch64": + return "arm64" + default: + return arch + } +} + +// describeImageArch queries EC2 DescribeImages for a specific AMI ID and +// returns its architecture string (e.g., "x86_64" or "arm64"). +func (p *Provider) describeImageArch(imageID string) (string, error) { + resp, err := p.ec2.DescribeImages(context.TODO(), &ec2.DescribeImagesInput{ + ImageIds: []string{imageID}, + }) + if err != nil { + return "", fmt.Errorf("failed to describe image %s: %w", imageID, err) + } + if len(resp.Images) == 0 { + return "", fmt.Errorf("image %s not found", imageID) + } + return string(resp.Images[0].Architecture), nil +} + +// getInstanceTypeArch queries EC2 DescribeInstanceTypes for a specific instance +// type and returns its list of supported architecture strings. +func (p *Provider) getInstanceTypeArch(instanceType string) ([]string, error) { + resp, err := p.ec2.DescribeInstanceTypes(context.TODO(), &ec2.DescribeInstanceTypesInput{ + InstanceTypes: []types.InstanceType{types.InstanceType(instanceType)}, + }) + if err != nil { + return nil, fmt.Errorf("failed to describe instance type %s: %w", instanceType, err) + } + if len(resp.InstanceTypes) == 0 { + return nil, fmt.Errorf("instance type %s not found", instanceType) + } + if resp.InstanceTypes[0].ProcessorInfo == nil { + return nil, fmt.Errorf("no processor info for instance type %s", instanceType) + } + var archs []string + for _, a := range resp.InstanceTypes[0].ProcessorInfo.SupportedArchitectures { + archs = append(archs, string(a)) + } + return archs, nil +} diff --git a/pkg/provider/aws/image_test.go b/pkg/provider/aws/image_test.go index 5693238f3..dd1ec083c 100644 --- a/pkg/provider/aws/image_test.go +++ b/pkg/provider/aws/image_test.go @@ -33,6 +33,7 @@ import ( "github.com/NVIDIA/holodeck/api/holodeck/v1alpha1" "github.com/NVIDIA/holodeck/internal/ami" + "github.com/NVIDIA/holodeck/internal/logger" ) // mockSSMClient implements ami.SSMParameterGetter for testing. @@ -73,6 +74,20 @@ func TestResolveImageForNode(t *testing.T) { ImageId: aws.String("ami-explicit-123"), Architecture: "x86_64", }, + setupMock: func(ec2Mock *MockEC2Client, ssmMock *mockSSMClient) { + ec2Mock.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{ + Images: []types.Image{ + { + ImageId: aws.String("ami-explicit-123"), + Architecture: types.ArchitectureValuesX8664, + }, + }, + }, nil + } + }, wantImageID: "ami-explicit-123", wantSSHUser: "", // Must be provided in auth config wantErr: false, @@ -331,6 +346,8 @@ func TestResolveImageForNode(t *testing.T) { require.NotNil(t, result) assert.Equal(t, tt.wantImageID, result.ImageID) assert.Equal(t, tt.wantSSHUser, result.SSHUsername) + // Architecture should always be set on successful resolution + assert.NotEmpty(t, result.Architecture) }) } } @@ -546,3 +563,444 @@ func TestFindLegacyAMI(t *testing.T) { }) } } + +func TestDescribeImageArch(t *testing.T) { + tests := []struct { + name string + imageID string + setupMock func(*MockEC2Client) + wantArch string + wantErr bool + wantErrContain string + }{ + { + name: "returns x86_64 architecture", + imageID: "ami-x86-123", + setupMock: func(m *MockEC2Client) { + m.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{ + Images: []types.Image{ + { + ImageId: aws.String("ami-x86-123"), + Architecture: types.ArchitectureValuesX8664, + }, + }, + }, nil + } + }, + wantArch: "x86_64", + wantErr: false, + }, + { + name: "returns arm64 architecture", + imageID: "ami-arm-456", + setupMock: func(m *MockEC2Client) { + m.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{ + Images: []types.Image{ + { + ImageId: aws.String("ami-arm-456"), + Architecture: types.ArchitectureValuesArm64, + }, + }, + }, nil + } + }, + wantArch: "arm64", + wantErr: false, + }, + { + name: "error when image not found", + imageID: "ami-missing", + setupMock: func(m *MockEC2Client) { + m.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{ + Images: []types.Image{}, + }, nil + } + }, + wantErr: true, + wantErrContain: "not found", + }, + { + name: "error on EC2 API failure", + imageID: "ami-fail", + setupMock: func(m *MockEC2Client) { + m.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return nil, fmt.Errorf("EC2 API error") + } + }, + wantErr: true, + wantErrContain: "failed to describe image", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ec2Mock := NewMockEC2Client() + if tt.setupMock != nil { + tt.setupMock(ec2Mock) + } + + env := v1alpha1.Environment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-env"}, + Spec: v1alpha1.EnvironmentSpec{ + Provider: v1alpha1.ProviderAWS, + Instance: v1alpha1.Instance{ + Type: "t3.medium", + Region: "us-east-1", + }, + }, + } + + p := &Provider{ + Environment: &env, + ec2: ec2Mock, + } + + arch, err := p.describeImageArch(tt.imageID) + + if tt.wantErr { + require.Error(t, err) + if tt.wantErrContain != "" { + assert.Contains(t, err.Error(), tt.wantErrContain) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantArch, arch) + }) + } +} + +func TestGetInstanceTypeArch(t *testing.T) { + tests := []struct { + name string + instanceType string + setupMock func(*MockEC2Client) + wantArchs []string + wantErr bool + wantErrContain string + }{ + { + name: "returns x86_64 for t3.medium", + instanceType: "t3.medium", + setupMock: func(m *MockEC2Client) { + m.DescribeInstTypesFunc = func(ctx context.Context, + params *ec2.DescribeInstanceTypesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { + return &ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, + }, + }, nil + } + }, + wantArchs: []string{"x86_64"}, + wantErr: false, + }, + { + name: "returns arm64 for t4g.medium", + instanceType: "t4g.medium", + setupMock: func(m *MockEC2Client) { + m.DescribeInstTypesFunc = func(ctx context.Context, + params *ec2.DescribeInstanceTypesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { + return &ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + InstanceType: types.InstanceTypeT4gMedium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeArm64, + }, + }, + }, + }, + }, nil + } + }, + wantArchs: []string{"arm64"}, + wantErr: false, + }, + { + name: "error when instance type not found", + instanceType: "t99.nonexistent", + setupMock: func(m *MockEC2Client) { + m.DescribeInstTypesFunc = func(ctx context.Context, + params *ec2.DescribeInstanceTypesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { + return &ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{}, + }, nil + } + }, + wantErr: true, + wantErrContain: "not found", + }, + { + name: "error on EC2 API failure", + instanceType: "t3.medium", + setupMock: func(m *MockEC2Client) { + m.DescribeInstTypesFunc = func(ctx context.Context, + params *ec2.DescribeInstanceTypesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { + return nil, fmt.Errorf("EC2 API error") + } + }, + wantErr: true, + wantErrContain: "failed to describe instance type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ec2Mock := NewMockEC2Client() + if tt.setupMock != nil { + tt.setupMock(ec2Mock) + } + + env := v1alpha1.Environment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-env"}, + Spec: v1alpha1.EnvironmentSpec{ + Provider: v1alpha1.ProviderAWS, + Instance: v1alpha1.Instance{ + Type: tt.instanceType, + Region: "us-east-1", + }, + }, + } + + p := &Provider{ + Environment: &env, + ec2: ec2Mock, + } + + archs, err := p.getInstanceTypeArch(tt.instanceType) + + if tt.wantErr { + require.Error(t, err) + if tt.wantErrContain != "" { + assert.Contains(t, err.Error(), tt.wantErrContain) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantArchs, archs) + }) + } +} + +func TestResolveImageForNode_ExplicitImageId_ReturnsArchitecture(t *testing.T) { + // Verify that when an explicit ImageId is provided, the architecture + // is queried from EC2 and returned in the result. + ec2Mock := NewMockEC2Client() + ec2Mock.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + // Verify the correct AMI is being queried + assert.Equal(t, []string{"ami-arm64-custom"}, params.ImageIds) + return &ec2.DescribeImagesOutput{ + Images: []types.Image{ + { + ImageId: aws.String("ami-arm64-custom"), + Architecture: types.ArchitectureValuesArm64, + }, + }, + }, nil + } + + env := v1alpha1.Environment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-env"}, + Spec: v1alpha1.EnvironmentSpec{ + Provider: v1alpha1.ProviderAWS, + Instance: v1alpha1.Instance{ + Type: "t4g.medium", + Region: "us-east-1", + }, + }, + } + + p := &Provider{ + Environment: &env, + ec2: ec2Mock, + } + + image := &v1alpha1.Image{ + ImageId: aws.String("ami-arm64-custom"), + } + result, err := p.resolveImageForNode("", image, "") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "ami-arm64-custom", result.ImageID) + assert.Equal(t, "arm64", result.Architecture) + assert.Equal(t, "", result.SSHUsername) // Must be provided in auth config +} + +func TestDryRun_ArchitectureMismatch(t *testing.T) { + // Test that DryRun detects when an arm64 AMI is used with an x86_64 instance type. + ec2Mock := NewMockEC2Client() + + // checkInstanceTypes needs to find the instance type + ec2Mock.DescribeInstTypesFunc = func(ctx context.Context, + params *ec2.DescribeInstanceTypesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { + // If called with specific InstanceTypes (getInstanceTypeArch), return processor info + if len(params.InstanceTypes) > 0 { + return &ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, + }, + }, nil + } + // Paginated scan for checkInstanceTypes + return &ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, + }, + }, nil + } + + // checkImages -> assertImageIdSupported needs to find the image + ec2Mock.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{ + Images: []types.Image{ + { + ImageId: aws.String("ami-arm64-image"), + CreationDate: aws.String("2026-01-01T00:00:00.000Z"), + Architecture: types.ArchitectureValuesArm64, + }, + }, + }, nil + } + + log := logger.NewLogger() + + env := v1alpha1.Environment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-env"}, + Spec: v1alpha1.EnvironmentSpec{ + Provider: v1alpha1.ProviderAWS, + Instance: v1alpha1.Instance{ + Type: "t3.medium", // x86_64 only + Region: "us-east-1", + Image: v1alpha1.Image{ + ImageId: aws.String("ami-arm64-image"), + Architecture: "arm64", // Mismatched! + }, + }, + Auth: v1alpha1.Auth{ + KeyName: "test-key", + }, + }, + } + + p := &Provider{ + Environment: &env, + ec2: ec2Mock, + log: log, + } + + err := p.DryRun() + require.Error(t, err) + assert.Contains(t, err.Error(), "architecture mismatch") + assert.Contains(t, err.Error(), "arm64") + assert.Contains(t, err.Error(), "t3.medium") +} + +func TestDryRun_ArchitectureMatch(t *testing.T) { + // Test that DryRun succeeds when architecture matches. + ec2Mock := NewMockEC2Client() + + ec2Mock.DescribeInstTypesFunc = func(ctx context.Context, + params *ec2.DescribeInstanceTypesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypesOutput, error) { + return &ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + InstanceType: types.InstanceTypeT4gMedium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeArm64, + }, + }, + }, + }, + }, nil + } + + ec2Mock.DescribeImagesFunc = func(ctx context.Context, + params *ec2.DescribeImagesInput, + optFns ...func(*ec2.Options)) (*ec2.DescribeImagesOutput, error) { + return &ec2.DescribeImagesOutput{ + Images: []types.Image{ + { + ImageId: aws.String("ami-arm64-image"), + CreationDate: aws.String("2026-01-01T00:00:00.000Z"), + Architecture: types.ArchitectureValuesArm64, + }, + }, + }, nil + } + + log := logger.NewLogger() + + env := v1alpha1.Environment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-env"}, + Spec: v1alpha1.EnvironmentSpec{ + Provider: v1alpha1.ProviderAWS, + Instance: v1alpha1.Instance{ + Type: "t4g.medium", // arm64 + Region: "us-east-1", + Image: v1alpha1.Image{ + ImageId: aws.String("ami-arm64-image"), + Architecture: "arm64", // Matches! + }, + }, + Auth: v1alpha1.Auth{ + KeyName: "test-key", + }, + }, + } + + p := &Provider{ + Environment: &env, + ec2: ec2Mock, + log: log, + } + + err := p.DryRun() + require.NoError(t, err) +} diff --git a/pkg/provider/aws/mock_ec2_test.go b/pkg/provider/aws/mock_ec2_test.go index 22742b809..c5f677e4c 100644 --- a/pkg/provider/aws/mock_ec2_test.go +++ b/pkg/provider/aws/mock_ec2_test.go @@ -298,8 +298,22 @@ func (m *MockEC2Client) DescribeInstanceTypes(ctx context.Context, params *ec2.D } return &ec2.DescribeInstanceTypesOutput{ InstanceTypes: []types.InstanceTypeInfo{ - {InstanceType: types.InstanceTypeT3Medium}, - {InstanceType: types.InstanceTypeT3Large}, + { + InstanceType: types.InstanceTypeT3Medium, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, + { + InstanceType: types.InstanceTypeT3Large, + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, }, }, nil }