diff --git a/builder/ebs/builder.go b/builder/ebs/builder.go index 15ff88d2..44ee22a7 100644 --- a/builder/ebs/builder.go +++ b/builder/ebs/builder.go @@ -360,6 +360,9 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) LaunchMappings: b.config.LaunchMappings, }, instanceStep, + &awscommon.StepAssociateEIP{ + AllocationId: b.config.ElasticIpAllocationId, + }, &awscommon.StepGetPassword{ Debug: b.config.PackerDebug, Comm: &b.config.RunConfig.Comm, diff --git a/builder/ebs/builder.hcl2spec.go b/builder/ebs/builder.hcl2spec.go index bcb315b0..28597848 100644 --- a/builder/ebs/builder.hcl2spec.go +++ b/builder/ebs/builder.hcl2spec.go @@ -65,6 +65,7 @@ type FlatConfig struct { SnapshotGroups []string `mapstructure:"snapshot_groups" required:"false" cty:"snapshot_groups" hcl:"snapshot_groups"` DeregistrationProtection *common.FlatDeregistrationProtectionOptions `mapstructure:"deregistration_protection" required:"false" cty:"deregistration_protection" hcl:"deregistration_protection"` AssociatePublicIpAddress *bool `mapstructure:"associate_public_ip_address" required:"false" cty:"associate_public_ip_address" hcl:"associate_public_ip_address"` + ElasticIpAllocationId *string `mapstructure:"elastic_ip_allocation_id" required:"false" cty:"elastic_ip_allocation_id" hcl:"elastic_ip_allocation_id"` AvailabilityZone *string `mapstructure:"availability_zone" required:"false" cty:"availability_zone" hcl:"availability_zone"` BlockDurationMinutes *int64 `mapstructure:"block_duration_minutes" required:"false" cty:"block_duration_minutes" hcl:"block_duration_minutes"` CapacityReservationPreference *string `mapstructure:"capacity_reservation_preference" required:"false" cty:"capacity_reservation_preference" hcl:"capacity_reservation_preference"` @@ -235,6 +236,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "snapshot_groups": &hcldec.AttrSpec{Name: "snapshot_groups", Type: cty.List(cty.String), Required: false}, "deregistration_protection": &hcldec.BlockSpec{TypeName: "deregistration_protection", Nested: hcldec.ObjectSpec((*common.FlatDeregistrationProtectionOptions)(nil).HCL2Spec())}, "associate_public_ip_address": &hcldec.AttrSpec{Name: "associate_public_ip_address", Type: cty.Bool, Required: false}, + "elastic_ip_allocation_id": &hcldec.AttrSpec{Name: "elastic_ip_allocation_id", Type: cty.String, Required: false}, "availability_zone": &hcldec.AttrSpec{Name: "availability_zone", Type: cty.String, Required: false}, "block_duration_minutes": &hcldec.AttrSpec{Name: "block_duration_minutes", Type: cty.Number, Required: false}, "capacity_reservation_preference": &hcldec.AttrSpec{Name: "capacity_reservation_preference", Type: cty.String, Required: false}, diff --git a/builder/ebssurrogate/builder.go b/builder/ebssurrogate/builder.go index aed268b8..eadb6168 100644 --- a/builder/ebssurrogate/builder.go +++ b/builder/ebssurrogate/builder.go @@ -445,6 +445,9 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) LaunchMappings: b.config.LaunchMappings.Common(), }, instanceStep, + &awscommon.StepAssociateEIP{ + AllocationId: b.config.ElasticIpAllocationId, + }, &awscommon.StepGetPassword{ Debug: b.config.PackerDebug, Comm: &b.config.RunConfig.Comm, diff --git a/builder/ebssurrogate/builder.hcl2spec.go b/builder/ebssurrogate/builder.hcl2spec.go index 86eaafa1..272224e0 100644 --- a/builder/ebssurrogate/builder.hcl2spec.go +++ b/builder/ebssurrogate/builder.hcl2spec.go @@ -82,6 +82,7 @@ type FlatConfig struct { VaultAWSEngine *common.FlatVaultAWSEngineOptions `mapstructure:"vault_aws_engine" required:"false" cty:"vault_aws_engine" hcl:"vault_aws_engine"` PollingConfig *common.FlatAWSPollingConfig `mapstructure:"aws_polling" required:"false" cty:"aws_polling" hcl:"aws_polling"` AssociatePublicIpAddress *bool `mapstructure:"associate_public_ip_address" required:"false" cty:"associate_public_ip_address" hcl:"associate_public_ip_address"` + ElasticIpAllocationId *string `mapstructure:"elastic_ip_allocation_id" required:"false" cty:"elastic_ip_allocation_id" hcl:"elastic_ip_allocation_id"` AvailabilityZone *string `mapstructure:"availability_zone" required:"false" cty:"availability_zone" hcl:"availability_zone"` BlockDurationMinutes *int64 `mapstructure:"block_duration_minutes" required:"false" cty:"block_duration_minutes" hcl:"block_duration_minutes"` CapacityReservationPreference *string `mapstructure:"capacity_reservation_preference" required:"false" cty:"capacity_reservation_preference" hcl:"capacity_reservation_preference"` @@ -256,6 +257,7 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "vault_aws_engine": &hcldec.BlockSpec{TypeName: "vault_aws_engine", Nested: hcldec.ObjectSpec((*common.FlatVaultAWSEngineOptions)(nil).HCL2Spec())}, "aws_polling": &hcldec.BlockSpec{TypeName: "aws_polling", Nested: hcldec.ObjectSpec((*common.FlatAWSPollingConfig)(nil).HCL2Spec())}, "associate_public_ip_address": &hcldec.AttrSpec{Name: "associate_public_ip_address", Type: cty.Bool, Required: false}, + "elastic_ip_allocation_id": &hcldec.AttrSpec{Name: "elastic_ip_allocation_id", Type: cty.String, Required: false}, "availability_zone": &hcldec.AttrSpec{Name: "availability_zone", Type: cty.String, Required: false}, "block_duration_minutes": &hcldec.AttrSpec{Name: "block_duration_minutes", Type: cty.Number, Required: false}, "capacity_reservation_preference": &hcldec.AttrSpec{Name: "capacity_reservation_preference", Type: cty.String, Required: false}, diff --git a/common/clients/ec2_client.go b/common/clients/ec2_client.go index 22706c1f..c990d393 100644 --- a/common/clients/ec2_client.go +++ b/common/clients/ec2_client.go @@ -15,6 +15,7 @@ type Ec2Client interface { ec2.DescribeSnapshotsAPIClient ec2.DescribeImportImageTasksAPIClient + AssociateAddress(ctx context.Context, params *ec2.AssociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.AssociateAddressOutput, error) AuthorizeSecurityGroupIngress(ctx context.Context, params *ec2.AuthorizeSecurityGroupIngressInput, optFns ...func(*ec2.Options)) (*ec2.AuthorizeSecurityGroupIngressOutput, error) AttachVolume(ctx context.Context, params *ec2.AttachVolumeInput, optFns ...func(*ec2.Options)) (*ec2.AttachVolumeOutput, error) @@ -28,6 +29,7 @@ type Ec2Client interface { CreateSecurityGroup(ctx context.Context, params *ec2.CreateSecurityGroupInput, optFns ...func(*ec2.Options)) (*ec2.CreateSecurityGroupOutput, error) DetachVolume(ctx context.Context, params *ec2.DetachVolumeInput, optFns ...func(*ec2.Options)) (*ec2.DetachVolumeOutput, error) + DisassociateAddress(ctx context.Context, params *ec2.DisassociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.DisassociateAddressOutput, error) DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) DescribeInstanceStatus(ctx context.Context, params *ec2.DescribeInstanceStatusInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceStatusOutput, error) DescribeInstanceTypeOfferings(ctx context.Context, params *ec2.DescribeInstanceTypeOfferingsInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstanceTypeOfferingsOutput, error) diff --git a/common/run_config.go b/common/run_config.go index d35eb353..7063a386 100644 --- a/common/run_config.go +++ b/common/run_config.go @@ -132,6 +132,12 @@ type RunConfig struct { // Otherwise, Packer will pick the most available subnet in the VPC selected, // which may not be able to host the instance type you provided. AssociatePublicIpAddress config.Trilean `mapstructure:"associate_public_ip_address" required:"false"` + // The allocation ID of a pre-existing Elastic IP to associate with the build + // instance after launch, e.g. "eipalloc-0123456789abcdef0". Packer will + // disassociate (but NOT release) the EIP when the build completes. + // Requires ec2:AssociateAddress and ec2:DisassociateAddress IAM permissions. + // Cannot be used with associate_public_ip_address. + ElasticIpAllocationId string `mapstructure:"elastic_ip_allocation_id" required:"false"` // Destination availability zone to launch // instance in. Leave this empty to allow Amazon to auto-assign. AvailabilityZone string `mapstructure:"availability_zone" required:"false"` @@ -953,6 +959,19 @@ func (c *RunConfig) Prepare(ctx *interpolate.Context) []error { } } + if c.ElasticIpAllocationId != "" { + reEipAllocId := regexp.MustCompile(`^eipalloc-[0-9a-f]+$`) + if !reEipAllocId.MatchString(c.ElasticIpAllocationId) { + errs = append(errs, fmt.Errorf( + "elastic_ip_allocation_id must be a valid EIP allocation ID (e.g. eipalloc-0123456789abcdef0), got: %q", + c.ElasticIpAllocationId)) + } + if c.AssociatePublicIpAddress != config.TriUnset { + errs = append(errs, fmt.Errorf( + "associate_public_ip_address must not be set when elastic_ip_allocation_id is specified")) + } + } + return errs } diff --git a/common/run_config_test.go b/common/run_config_test.go index c9a74cad..32099b2b 100644 --- a/common/run_config_test.go +++ b/common/run_config_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/hashicorp/packer-plugin-sdk/communicator" + "github.com/hashicorp/packer-plugin-sdk/template/config" ) func init() { @@ -611,3 +612,45 @@ func TestRunConfigPrepare_SSHInterfaceIPv6_DefaultCIDR(t *testing.T) { t.Errorf("expected default CIDR to be '::/0', got: %s", c.TemporarySGSourceCidrs[0]) } } + +func TestRunConfigPrepare_ElasticIpAllocationId_Valid(t *testing.T) { + c := testConfig() + c.ElasticIpAllocationId = "eipalloc-0123456789abcdef0" + errs := c.Prepare(nil) + if len(errs) != 0 { + t.Fatalf("expected no errors, got: %v", errs) + } +} + +func TestRunConfigPrepare_ElasticIpAllocationId_InvalidFormat(t *testing.T) { + cases := []string{ + "eipalloc-UPPER", + "alloc-123", + "eipalloc-", + } + for _, id := range cases { + c := testConfig() + c.ElasticIpAllocationId = id + errs := c.Prepare(nil) + if len(errs) != 1 { + t.Errorf("expected 1 error for %q, got %d: %v", id, len(errs), errs) + } + } +} + +func TestRunConfigPrepare_ElasticIpAllocationId_ConflictsWithAssociatePublicIp(t *testing.T) { + c := testConfig() + c.ElasticIpAllocationId = "eipalloc-0123456789abcdef0" + c.AssociatePublicIpAddress = config.TriTrue + errs := c.Prepare(nil) + found := false + for _, err := range errs { + if err.Error() == "associate_public_ip_address must not be set when elastic_ip_allocation_id is specified" { + found = true + break + } + } + if !found { + t.Fatalf("expected conflict error, got: %v", errs) + } +} diff --git a/common/step_associate_eip.go b/common/step_associate_eip.go new file mode 100644 index 00000000..5d22ad97 --- /dev/null +++ b/common/step_associate_eip.go @@ -0,0 +1,77 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package common + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/hashicorp/packer-plugin-amazon/common/clients" + "github.com/hashicorp/packer-plugin-sdk/multistep" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" +) + +// StepAssociateEIP associates a pre-existing Elastic IP with the build instance +// immediately after launch. Cleanup disassociates (but does not release) the EIP. +type StepAssociateEIP struct { + AllocationId string + associationId string +} + +func (s *StepAssociateEIP) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + if s.AllocationId == "" { + return multistep.ActionContinue + } + ec2Client := state.Get("ec2v2").(clients.Ec2Client) + ui := state.Get("ui").(packersdk.Ui) + instance := state.Get("instance").(ec2types.Instance) + + ui.Say(fmt.Sprintf("Associating Elastic IP %s with instance %s...", s.AllocationId, *instance.InstanceId)) + resp, err := ec2Client.AssociateAddress(ctx, &ec2.AssociateAddressInput{ + AllocationId: aws.String(s.AllocationId), + InstanceId: instance.InstanceId, + }) + if err != nil { + err = fmt.Errorf("error associating EIP %s: %w", s.AllocationId, err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + s.associationId = aws.ToString(resp.AssociationId) + ui.Say(fmt.Sprintf("EIP associated (association ID: %s)", s.associationId)) + + // Refresh instance in state so SSHHost reads the EIP as PublicIpAddress. + descResp, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + InstanceIds: []string{*instance.InstanceId}, + }) + if err != nil || len(descResp.Reservations) == 0 || len(descResp.Reservations[0].Instances) == 0 { + err = fmt.Errorf("error refreshing instance after EIP association: %w", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + state.Put("instance", descResp.Reservations[0].Instances[0]) + return multistep.ActionContinue +} + +func (s *StepAssociateEIP) Cleanup(state multistep.StateBag) { + if s.associationId == "" { + return + } + ec2Client := state.Get("ec2v2").(clients.Ec2Client) + ui := state.Get("ui").(packersdk.Ui) + + ui.Say(fmt.Sprintf("Disassociating Elastic IP (association ID: %s)...", s.associationId)) + _, err := ec2Client.DisassociateAddress(context.Background(), &ec2.DisassociateAddressInput{ + AssociationId: aws.String(s.associationId), + }) + if err != nil { + ui.Error(fmt.Sprintf("Error disassociating EIP %s: %s", s.associationId, err)) + return + } + ui.Say("Elastic IP disassociated.") +} diff --git a/common/step_associate_eip_test.go b/common/step_associate_eip_test.go new file mode 100644 index 00000000..58b99ef9 --- /dev/null +++ b/common/step_associate_eip_test.go @@ -0,0 +1,189 @@ +// Copyright IBM Corp. 2013, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package common + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/hashicorp/packer-plugin-amazon/common/clients" + "github.com/hashicorp/packer-plugin-sdk/multistep" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" +) + +type mockEIPClient struct { + clients.Ec2Client + + associateAddressFunc func(ctx context.Context, params *ec2.AssociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.AssociateAddressOutput, error) + disassociateAddressFunc func(ctx context.Context, params *ec2.DisassociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.DisassociateAddressOutput, error) + describeInstancesFunc func(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) + + associateCalls int + disassociateCalls int + describeCalls int +} + +func (m *mockEIPClient) AssociateAddress(ctx context.Context, params *ec2.AssociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.AssociateAddressOutput, error) { + m.associateCalls++ + return m.associateAddressFunc(ctx, params, optFns...) +} + +func (m *mockEIPClient) DisassociateAddress(ctx context.Context, params *ec2.DisassociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.DisassociateAddressOutput, error) { + m.disassociateCalls++ + return m.disassociateAddressFunc(ctx, params, optFns...) +} + +func (m *mockEIPClient) DescribeInstances(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { + m.describeCalls++ + return m.describeInstancesFunc(ctx, params, optFns...) +} + +func testEIPState(client clients.Ec2Client) multistep.StateBag { + state := new(multistep.BasicStateBag) + state.Put("ec2v2", client) + state.Put("ui", &packersdk.BasicUi{Writer: new(bytes.Buffer)}) + state.Put("instance", ec2types.Instance{ + InstanceId: aws.String("i-1234567890abcdef0"), + }) + return state +} + +func TestStepAssociateEIP_NoOp(t *testing.T) { + mock := &mockEIPClient{} + state := testEIPState(mock) + + step := &StepAssociateEIP{AllocationId: ""} + action := step.Run(context.Background(), state) + if action != multistep.ActionContinue { + t.Fatalf("expected ActionContinue, got %v", action) + } + if mock.associateCalls != 0 { + t.Errorf("expected no AssociateAddress calls, got %d", mock.associateCalls) + } +} + +func TestStepAssociateEIP_Run_Success(t *testing.T) { + refreshedInstance := ec2types.Instance{ + InstanceId: aws.String("i-1234567890abcdef0"), + PublicIpAddress: aws.String("1.2.3.4"), + } + mock := &mockEIPClient{ + associateAddressFunc: func(ctx context.Context, params *ec2.AssociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.AssociateAddressOutput, error) { + return &ec2.AssociateAddressOutput{AssociationId: aws.String("eipassoc-abc123")}, nil + }, + describeInstancesFunc: func(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { + return &ec2.DescribeInstancesOutput{ + Reservations: []ec2types.Reservation{ + {Instances: []ec2types.Instance{refreshedInstance}}, + }, + }, nil + }, + } + state := testEIPState(mock) + + step := &StepAssociateEIP{AllocationId: "eipalloc-0123456789abcdef0"} + action := step.Run(context.Background(), state) + if action != multistep.ActionContinue { + t.Fatalf("expected ActionContinue, got %v", action) + } + if step.associationId != "eipassoc-abc123" { + t.Errorf("unexpected associationId: %s", step.associationId) + } + inst := state.Get("instance").(ec2types.Instance) + if aws.ToString(inst.PublicIpAddress) != "1.2.3.4" { + t.Errorf("expected refreshed instance in state, got PublicIpAddress=%v", inst.PublicIpAddress) + } +} + +func TestStepAssociateEIP_Run_AssociateError(t *testing.T) { + mock := &mockEIPClient{ + associateAddressFunc: func(ctx context.Context, params *ec2.AssociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.AssociateAddressOutput, error) { + return nil, fmt.Errorf("access denied") + }, + } + state := testEIPState(mock) + + step := &StepAssociateEIP{AllocationId: "eipalloc-0123456789abcdef0"} + action := step.Run(context.Background(), state) + if action != multistep.ActionHalt { + t.Fatalf("expected ActionHalt, got %v", action) + } + if state.Get("error") == nil { + t.Error("expected error in state") + } +} + +func TestStepAssociateEIP_Run_DescribeError(t *testing.T) { + mock := &mockEIPClient{ + associateAddressFunc: func(ctx context.Context, params *ec2.AssociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.AssociateAddressOutput, error) { + return &ec2.AssociateAddressOutput{AssociationId: aws.String("eipassoc-abc123")}, nil + }, + describeInstancesFunc: func(ctx context.Context, params *ec2.DescribeInstancesInput, optFns ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { + return nil, fmt.Errorf("describe failed") + }, + } + state := testEIPState(mock) + + step := &StepAssociateEIP{AllocationId: "eipalloc-0123456789abcdef0"} + action := step.Run(context.Background(), state) + if action != multistep.ActionHalt { + t.Fatalf("expected ActionHalt, got %v", action) + } + if state.Get("error") == nil { + t.Error("expected error in state") + } +} + +func TestStepAssociateEIP_Cleanup_Disassociates(t *testing.T) { + mock := &mockEIPClient{ + disassociateAddressFunc: func(ctx context.Context, params *ec2.DisassociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.DisassociateAddressOutput, error) { + return &ec2.DisassociateAddressOutput{}, nil + }, + } + state := testEIPState(mock) + + step := &StepAssociateEIP{ + AllocationId: "eipalloc-0123456789abcdef0", + associationId: "eipassoc-abc123", + } + step.Cleanup(state) + if mock.disassociateCalls != 1 { + t.Errorf("expected 1 DisassociateAddress call, got %d", mock.disassociateCalls) + } +} + +func TestStepAssociateEIP_Cleanup_NoopWhenEmpty(t *testing.T) { + mock := &mockEIPClient{} + state := testEIPState(mock) + + step := &StepAssociateEIP{AllocationId: "eipalloc-0123456789abcdef0", associationId: ""} + step.Cleanup(state) + if mock.disassociateCalls != 0 { + t.Errorf("expected no DisassociateAddress calls, got %d", mock.disassociateCalls) + } +} + +func TestStepAssociateEIP_Cleanup_DisassociateError(t *testing.T) { + mock := &mockEIPClient{ + disassociateAddressFunc: func(ctx context.Context, params *ec2.DisassociateAddressInput, optFns ...func(*ec2.Options)) (*ec2.DisassociateAddressOutput, error) { + return nil, fmt.Errorf("network error") + }, + } + state := testEIPState(mock) + + step := &StepAssociateEIP{ + AllocationId: "eipalloc-0123456789abcdef0", + associationId: "eipassoc-abc123", + } + // Should not panic + step.Cleanup(state) + if mock.disassociateCalls != 1 { + t.Errorf("expected 1 DisassociateAddress call, got %d", mock.disassociateCalls) + } +} diff --git a/docs-partials/common/RunConfig-not-required.mdx b/docs-partials/common/RunConfig-not-required.mdx index 11c029df..d30a64da 100644 --- a/docs-partials/common/RunConfig-not-required.mdx +++ b/docs-partials/common/RunConfig-not-required.mdx @@ -19,6 +19,12 @@ Otherwise, Packer will pick the most available subnet in the VPC selected, which may not be able to host the instance type you provided. +- `elastic_ip_allocation_id` (string) - The allocation ID of a pre-existing Elastic IP to associate with the build + instance after launch, e.g. "eipalloc-0123456789abcdef0". Packer will + disassociate (but NOT release) the EIP when the build completes. + Requires ec2:AssociateAddress and ec2:DisassociateAddress IAM permissions. + Cannot be used with associate_public_ip_address. + - `availability_zone` (string) - Destination availability zone to launch instance in. Leave this empty to allow Amazon to auto-assign.