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 }