Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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 .changelog/43955.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:new-action
aws_cloudfront_create_invalidation
```
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/hcl/v2 v2.23.0
github.com/hashicorp/hcl/v2 v2.24.0
github.com/hashicorp/terraform-json v0.27.2
github.com/hashicorp/terraform-plugin-framework v1.16.0
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0
Expand All @@ -300,7 +300,7 @@ require (
github.com/hashicorp/terraform-plugin-log v0.9.0
github.com/hashicorp/terraform-plugin-mux v0.21.0
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0
github.com/hashicorp/terraform-plugin-testing v1.13.3
github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247
github.com/jaswdr/faker/v2 v2.8.0
github.com/jmespath/go-jmespath v0.4.0
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
Expand Down Expand Up @@ -349,7 +349,7 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hc-install v0.9.2 // indirect
github.com/hashicorp/logutils v1.0.0 // indirect
github.com/hashicorp/terraform-exec v0.23.0 // indirect
github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 // indirect
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
Expand All @@ -368,7 +368,7 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/zclconf/go-cty v1.16.4 // indirect
github.com/zclconf/go-cty v1.17.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -661,12 +661,12 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24=
github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I=
github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I=
github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY=
github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 h1:90fcAqw0Qmv4vY7zL4jEKgKarHmOnNN6SjTY68eLKGA=
github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2/go.mod h1:8D3RLLpzAZdhT9jvALYz1KHyGU4OvI73I1o0+01QJxA=
github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU=
github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE=
github.com/hashicorp/terraform-plugin-framework v1.16.0 h1:tP0f+yJg0Z672e7levixDe5EpWwrTrNryPM9kDMYIpE=
Expand All @@ -685,8 +685,8 @@ github.com/hashicorp/terraform-plugin-mux v0.21.0 h1:QsEYnzSD2c3zT8zUrUGqaFGhV/Z
github.com/hashicorp/terraform-plugin-mux v0.21.0/go.mod h1:Qpt8+6AD7NmL0DS7ASkN0EXpDQ2J/FnnIgeUr1tzr5A=
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 h1:NFPMacTrY/IdcIcnUB+7hsore1ZaRWU9cnB6jFoBnIM=
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0/go.mod h1:QYmYnLfsosrxjCnGY1p9c7Zj6n9thnEE+7RObeYs3fA=
github.com/hashicorp/terraform-plugin-testing v1.13.3 h1:QLi/khB8Z0a5L54AfPrHukFpnwsGL8cwwswj4RZduCo=
github.com/hashicorp/terraform-plugin-testing v1.13.3/go.mod h1:WHQ9FDdiLoneey2/QHpGM/6SAYf4A7AZazVg7230pLE=
github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247 h1:lA6ofPwmCXAX7J7kVP9t/WMU5+eA4e9YvJUiRLPdENw=
github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247/go.mod h1:4r/7cxl1mpskfALcq58Iyu5aPiTSco8SVrKkcLyP5g4=
github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk=
github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE=
github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ=
Expand Down Expand Up @@ -790,8 +790,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zclconf/go-cty v1.16.4 h1:QGXaag7/7dCzb+odlGrgr+YmYZFaOCMW6DEpS+UD1eE=
github.com/zclconf/go-cty v1.16.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0=
github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
Expand Down
300 changes: 300 additions & 0 deletions internal/service/cloudfront/create_invalidation_action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package cloudfront

import (
"context"
"fmt"
"slices"
"time"

"github.com/YakDriver/regexache"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/cloudfront"
awstypes "github.com/aws/aws-sdk-go-v2/service/cloudfront/types"
"github.com/hashicorp/aws-sdk-go-base/v2/tfawserr"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/action"
"github.com/hashicorp/terraform-plugin-framework/action/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/id"
"github.com/hashicorp/terraform-provider-aws/internal/framework"
fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types"
"github.com/hashicorp/terraform-provider-aws/names"
)

// @Action(aws_cloudfront_create_invalidation, name="Create Invalidation")
func newCreateInvalidationAction(_ context.Context) (action.ActionWithConfigure, error) {
return &createInvalidationAction{}, nil
}

var (
_ action.Action = (*createInvalidationAction)(nil)
)

type createInvalidationAction struct {
framework.ActionWithModel[createInvalidationModel]
}

type createInvalidationModel struct {
DistributionID types.String `tfsdk:"distribution_id"`
Paths fwtypes.ListOfString `tfsdk:"paths"`
CallerReference types.String `tfsdk:"caller_reference"`
Timeout types.Int64 `tfsdk:"timeout"`
}

func (a *createInvalidationAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Invalidates CloudFront distribution cache for specified paths. This action creates an invalidation request and waits for it to complete.",
Attributes: map[string]schema.Attribute{
"distribution_id": schema.StringAttribute{
Description: "The ID of the CloudFront distribution to invalidate cache for",
Required: true,
Validators: []validator.String{
stringvalidator.RegexMatches(
regexache.MustCompile(`^[A-Z0-9]+$`),
"must be a valid CloudFront distribution ID (e.g., E1GHKQ2EXAMPLE)",
),
},
},
"paths": schema.ListAttribute{
CustomType: fwtypes.ListOfStringType,
Description: "List of file paths or patterns to invalidate. Use /* to invalidate all files",
Required: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.SizeAtLeast(1),
listvalidator.SizeAtMost(3000), // CloudFront limit
},
},
"caller_reference": schema.StringAttribute{
Description: "Unique identifier for the invalidation request. If not provided, one will be generated automatically",
Optional: true,
Validators: []validator.String{
stringvalidator.LengthAtMost(128),
},
},
names.AttrTimeout: schema.Int64Attribute{
Description: "Timeout in seconds to wait for the invalidation to complete (default: 900)",
Optional: true,
Validators: []validator.Int64{
int64validator.AtLeast(60),
int64validator.AtMost(3600),
},
},
},
}
}

func (a *createInvalidationAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
var config createInvalidationModel

// Parse configuration
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}

// Get AWS client
conn := a.Meta().CloudFrontClient(ctx)

distributionID := config.DistributionID.ValueString()

// Convert paths list to string slice
var paths []string
resp.Diagnostics.Append(config.Paths.ElementsAs(ctx, &paths, false)...)
if resp.Diagnostics.HasError() {
return
}

// Validate paths
for _, path := range paths {
if path == "" {
resp.Diagnostics.AddError(
"Invalid Path",
"Path cannot be empty",
)
return
}
if !regexache.MustCompile(`^(/.*|\*)$`).MatchString(path) {
resp.Diagnostics.AddError(
"Invalid Path Format",
fmt.Sprintf("Path '%s' must start with '/' or be '*' for all files", path),
)
return
}
Comment on lines +124 to +130
Copy link
Member

Choose a reason for hiding this comment

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

Not a blocker, but may be nice to write a custom type to handle this validation in the future.

}

// Set caller reference if not provided
callerReference := config.CallerReference.ValueString()
if callerReference == "" {
callerReference = id.UniqueId()
}

// Set default timeout if not provided
timeout := 900 * time.Second
if !config.Timeout.IsNull() {
timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second
}

tflog.Info(ctx, "Starting CloudFront cache invalidation action", map[string]any{
"distribution_id": distributionID,
"paths": paths,
"caller_reference": callerReference,
names.AttrTimeout: timeout.String(),
})

// Send initial progress update
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Starting cache invalidation for CloudFront distribution %s...", distributionID),
})

// Check if distribution exists first
_, err := findDistributionByID(ctx, conn, distributionID)
if err != nil {
if tfawserr.ErrCodeEquals(err, "NoSuchDistribution") {
resp.Diagnostics.AddError(
"Distribution Not Found",
fmt.Sprintf("CloudFront distribution %s was not found", distributionID),
)
return
}
resp.Diagnostics.AddError(
"Failed to Describe Distribution",
fmt.Sprintf("Could not describe CloudFront distribution %s: %s", distributionID, err),
)
return
}

// Create invalidation request
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Creating invalidation request for %d path(s)...", len(paths)),
})

invalidationInput := &cloudfront.CreateInvalidationInput{
DistributionId: aws.String(distributionID),
InvalidationBatch: &awstypes.InvalidationBatch{
CallerReference: aws.String(callerReference),
Paths: &awstypes.Paths{
Quantity: aws.Int32(int32(len(paths))),
Items: paths,
},
},
}

output, err := conn.CreateInvalidation(ctx, invalidationInput)
if err != nil {
if tfawserr.ErrCodeEquals(err, "TooManyInvalidationsInProgress") {
resp.Diagnostics.AddError(
"Too Many Invalidations In Progress",
fmt.Sprintf("CloudFront distribution %s has too many invalidations in progress. Please wait and try again.", distributionID),
)
return
}
if tfawserr.ErrCodeEquals(err, "InvalidArgument") {
resp.Diagnostics.AddError(
"Invalid Invalidation Request",
fmt.Sprintf("Invalid invalidation request for distribution %s: %s", distributionID, err),
)
return
}
resp.Diagnostics.AddError(
"Failed to Create Invalidation",
fmt.Sprintf("Could not create invalidation for CloudFront distribution %s: %s", distributionID, err),
)
return
}

invalidationID := aws.ToString(output.Invalidation.Id)

resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Invalidation %s created, waiting for completion...", invalidationID),
})

// Wait for invalidation to complete with periodic progress updates
err = a.waitForInvalidationComplete(ctx, conn, distributionID, invalidationID, timeout, resp)
if err != nil {
resp.Diagnostics.AddError(
"Timeout Waiting for Invalidation to Complete",
fmt.Sprintf("CloudFront invalidation %s did not complete within %s: %s", invalidationID, timeout, err),
)
return
}

// Final success message
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("CloudFront cache invalidation %s completed successfully for distribution %s", invalidationID, distributionID),
})

tflog.Info(ctx, "CloudFront invalidate cache action completed successfully", map[string]any{
"distribution_id": distributionID,
"invalidation_id": invalidationID,
"paths": paths,
})
}

// waitForInvalidationComplete waits for an invalidation to complete with progress updates
func (a *createInvalidationAction) waitForInvalidationComplete(ctx context.Context, conn *cloudfront.Client, distributionID, invalidationID string, timeout time.Duration, resp *action.InvokeResponse) error {
const (
pollInterval = 30 * time.Second
progressInterval = 60 * time.Second
)

deadline := time.Now().Add(timeout)
lastProgressUpdate := time.Now()

for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}

// Check if we've exceeded the timeout
if time.Now().After(deadline) {
return fmt.Errorf("timeout after %s", timeout)
}

// Get current invalidation status
input := &cloudfront.GetInvalidationInput{
DistributionId: aws.String(distributionID),
Id: aws.String(invalidationID),
}

output, err := conn.GetInvalidation(ctx, input)
if err != nil {
return fmt.Errorf("getting invalidation status: %w", err)
}

currentStatus := aws.ToString(output.Invalidation.Status)

// Send progress update every 60 seconds
if time.Since(lastProgressUpdate) >= progressInterval {
resp.SendProgress(action.InvokeProgressEvent{
Message: fmt.Sprintf("Invalidation %s is currently '%s', continuing to wait for completion...", invalidationID, currentStatus),
})
lastProgressUpdate = time.Now()
}

// Check if we've reached completion
if aws.ToString(output.Invalidation.Status) == "Completed" {
return nil
}

// Check if we're in an unexpected state
validStatuses := []string{
"InProgress",
}
if !slices.Contains(validStatuses, currentStatus) && currentStatus != "Completed" {
return fmt.Errorf("invalidation entered unexpected status: %s", currentStatus)
}

// Wait before next poll
time.Sleep(pollInterval)
}
}
Loading
Loading