Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions builder/ebs/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions builder/ebs/builder.hcl2spec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions builder/ebssurrogate/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions builder/ebssurrogate/builder.hcl2spec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions common/clients/ec2_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions common/run_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -953,6 +959,19 @@ func (c *RunConfig) Prepare(ctx *interpolate.Context) []error {
}
}

if c.ElasticIpAllocationId != "" {
reEipAllocId := regexp.MustCompile(`^eipalloc-[0-9a-f]+$`)
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I note that other resource specifiers aren't doing string format validation. Is it preferred to let any value through here, allowing the platform (AWS) to produce an error if required?

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
}

Expand Down
43 changes: 43 additions & 0 deletions common/run_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"github.com/hashicorp/packer-plugin-sdk/communicator"
"github.com/hashicorp/packer-plugin-sdk/template/config"
)

func init() {
Expand Down Expand Up @@ -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)
}
}
77 changes: 77 additions & 0 deletions common/step_associate_eip.go
Original file line number Diff line number Diff line change
@@ -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.")
}
Loading