Skip to content

Commit 2604401

Browse files
authored
Merge pull request #43955 from hashicorp/f-cloudfront-invalidate-cache-action
New action: `aws_cloudfront_create_invalidation`
2 parents 7f567de + 2374a35 commit 2604401

File tree

8 files changed

+669
-12
lines changed

8 files changed

+669
-12
lines changed

.changelog/43955.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:new-action
2+
aws_cloudfront_create_invalidation
3+
```

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ require (
289289
github.com/hashicorp/go-multierror v1.1.1
290290
github.com/hashicorp/go-uuid v1.0.3
291291
github.com/hashicorp/go-version v1.7.0
292-
github.com/hashicorp/hcl/v2 v2.23.0
292+
github.com/hashicorp/hcl/v2 v2.24.0
293293
github.com/hashicorp/terraform-json v0.27.2
294294
github.com/hashicorp/terraform-plugin-framework v1.16.0
295295
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0
@@ -300,7 +300,7 @@ require (
300300
github.com/hashicorp/terraform-plugin-log v0.9.0
301301
github.com/hashicorp/terraform-plugin-mux v0.21.0
302302
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0
303-
github.com/hashicorp/terraform-plugin-testing v1.13.3
303+
github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247
304304
github.com/jaswdr/faker/v2 v2.8.0
305305
github.com/jmespath/go-jmespath v0.4.0
306306
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38
@@ -349,7 +349,7 @@ require (
349349
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
350350
github.com/hashicorp/hc-install v0.9.2 // indirect
351351
github.com/hashicorp/logutils v1.0.0 // indirect
352-
github.com/hashicorp/terraform-exec v0.23.0 // indirect
352+
github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 // indirect
353353
github.com/hashicorp/terraform-registry-address v0.4.0 // indirect
354354
github.com/hashicorp/terraform-svchost v0.1.1 // indirect
355355
github.com/hashicorp/yamux v0.1.2 // indirect
@@ -368,7 +368,7 @@ require (
368368
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
369369
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
370370
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
371-
github.com/zclconf/go-cty v1.16.4 // indirect
371+
github.com/zclconf/go-cty v1.17.0 // indirect
372372
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
373373
go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.63.0 // indirect
374374
go.opentelemetry.io/otel v1.38.0 // indirect

go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -661,12 +661,12 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe
661661
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
662662
github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24=
663663
github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I=
664-
github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
665-
github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
664+
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
665+
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
666666
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
667667
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
668-
github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I=
669-
github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY=
668+
github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 h1:90fcAqw0Qmv4vY7zL4jEKgKarHmOnNN6SjTY68eLKGA=
669+
github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2/go.mod h1:8D3RLLpzAZdhT9jvALYz1KHyGU4OvI73I1o0+01QJxA=
670670
github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU=
671671
github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE=
672672
github.com/hashicorp/terraform-plugin-framework v1.16.0 h1:tP0f+yJg0Z672e7levixDe5EpWwrTrNryPM9kDMYIpE=
@@ -685,8 +685,8 @@ github.com/hashicorp/terraform-plugin-mux v0.21.0 h1:QsEYnzSD2c3zT8zUrUGqaFGhV/Z
685685
github.com/hashicorp/terraform-plugin-mux v0.21.0/go.mod h1:Qpt8+6AD7NmL0DS7ASkN0EXpDQ2J/FnnIgeUr1tzr5A=
686686
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 h1:NFPMacTrY/IdcIcnUB+7hsore1ZaRWU9cnB6jFoBnIM=
687687
github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0/go.mod h1:QYmYnLfsosrxjCnGY1p9c7Zj6n9thnEE+7RObeYs3fA=
688-
github.com/hashicorp/terraform-plugin-testing v1.13.3 h1:QLi/khB8Z0a5L54AfPrHukFpnwsGL8cwwswj4RZduCo=
689-
github.com/hashicorp/terraform-plugin-testing v1.13.3/go.mod h1:WHQ9FDdiLoneey2/QHpGM/6SAYf4A7AZazVg7230pLE=
688+
github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247 h1:lA6ofPwmCXAX7J7kVP9t/WMU5+eA4e9YvJUiRLPdENw=
689+
github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247/go.mod h1:4r/7cxl1mpskfALcq58Iyu5aPiTSco8SVrKkcLyP5g4=
690690
github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk=
691691
github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE=
692692
github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ=
@@ -790,8 +790,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
790790
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
791791
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
792792
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
793-
github.com/zclconf/go-cty v1.16.4 h1:QGXaag7/7dCzb+odlGrgr+YmYZFaOCMW6DEpS+UD1eE=
794-
github.com/zclconf/go-cty v1.16.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
793+
github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0=
794+
github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U=
795795
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
796796
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
797797
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package cloudfront
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"slices"
10+
"time"
11+
12+
"github.com/YakDriver/regexache"
13+
"github.com/aws/aws-sdk-go-v2/aws"
14+
"github.com/aws/aws-sdk-go-v2/service/cloudfront"
15+
awstypes "github.com/aws/aws-sdk-go-v2/service/cloudfront/types"
16+
"github.com/hashicorp/aws-sdk-go-base/v2/tfawserr"
17+
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
18+
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
19+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
20+
"github.com/hashicorp/terraform-plugin-framework/action"
21+
"github.com/hashicorp/terraform-plugin-framework/action/schema"
22+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
23+
"github.com/hashicorp/terraform-plugin-framework/types"
24+
"github.com/hashicorp/terraform-plugin-log/tflog"
25+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/id"
26+
"github.com/hashicorp/terraform-provider-aws/internal/framework"
27+
fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types"
28+
"github.com/hashicorp/terraform-provider-aws/names"
29+
)
30+
31+
// @Action(aws_cloudfront_create_invalidation, name="Create Invalidation")
32+
func newCreateInvalidationAction(_ context.Context) (action.ActionWithConfigure, error) {
33+
return &createInvalidationAction{}, nil
34+
}
35+
36+
var (
37+
_ action.Action = (*createInvalidationAction)(nil)
38+
)
39+
40+
type createInvalidationAction struct {
41+
framework.ActionWithModel[createInvalidationModel]
42+
}
43+
44+
type createInvalidationModel struct {
45+
DistributionID types.String `tfsdk:"distribution_id"`
46+
Paths fwtypes.ListOfString `tfsdk:"paths"`
47+
CallerReference types.String `tfsdk:"caller_reference"`
48+
Timeout types.Int64 `tfsdk:"timeout"`
49+
}
50+
51+
func (a *createInvalidationAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) {
52+
resp.Schema = schema.Schema{
53+
Description: "Invalidates CloudFront distribution cache for specified paths. This action creates an invalidation request and waits for it to complete.",
54+
Attributes: map[string]schema.Attribute{
55+
"distribution_id": schema.StringAttribute{
56+
Description: "The ID of the CloudFront distribution to invalidate cache for",
57+
Required: true,
58+
Validators: []validator.String{
59+
stringvalidator.RegexMatches(
60+
regexache.MustCompile(`^[A-Z0-9]+$`),
61+
"must be a valid CloudFront distribution ID (e.g., E1GHKQ2EXAMPLE)",
62+
),
63+
},
64+
},
65+
"paths": schema.ListAttribute{
66+
CustomType: fwtypes.ListOfStringType,
67+
Description: "List of file paths or patterns to invalidate. Use /* to invalidate all files",
68+
Required: true,
69+
ElementType: types.StringType,
70+
Validators: []validator.List{
71+
listvalidator.SizeAtLeast(1),
72+
listvalidator.SizeAtMost(3000), // CloudFront limit
73+
},
74+
},
75+
"caller_reference": schema.StringAttribute{
76+
Description: "Unique identifier for the invalidation request. If not provided, one will be generated automatically",
77+
Optional: true,
78+
Validators: []validator.String{
79+
stringvalidator.LengthAtMost(128),
80+
},
81+
},
82+
names.AttrTimeout: schema.Int64Attribute{
83+
Description: "Timeout in seconds to wait for the invalidation to complete (default: 900)",
84+
Optional: true,
85+
Validators: []validator.Int64{
86+
int64validator.AtLeast(60),
87+
int64validator.AtMost(3600),
88+
},
89+
},
90+
},
91+
}
92+
}
93+
94+
func (a *createInvalidationAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
95+
var config createInvalidationModel
96+
97+
// Parse configuration
98+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
99+
if resp.Diagnostics.HasError() {
100+
return
101+
}
102+
103+
// Get AWS client
104+
conn := a.Meta().CloudFrontClient(ctx)
105+
106+
distributionID := config.DistributionID.ValueString()
107+
108+
// Convert paths list to string slice
109+
var paths []string
110+
resp.Diagnostics.Append(config.Paths.ElementsAs(ctx, &paths, false)...)
111+
if resp.Diagnostics.HasError() {
112+
return
113+
}
114+
115+
// Validate paths
116+
for _, path := range paths {
117+
if path == "" {
118+
resp.Diagnostics.AddError(
119+
"Invalid Path",
120+
"Path cannot be empty",
121+
)
122+
return
123+
}
124+
if !regexache.MustCompile(`^(/.*|\*)$`).MatchString(path) {
125+
resp.Diagnostics.AddError(
126+
"Invalid Path Format",
127+
fmt.Sprintf("Path '%s' must start with '/' or be '*' for all files", path),
128+
)
129+
return
130+
}
131+
}
132+
133+
// Set caller reference if not provided
134+
callerReference := config.CallerReference.ValueString()
135+
if callerReference == "" {
136+
callerReference = id.UniqueId()
137+
}
138+
139+
// Set default timeout if not provided
140+
timeout := 900 * time.Second
141+
if !config.Timeout.IsNull() {
142+
timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second
143+
}
144+
145+
tflog.Info(ctx, "Starting CloudFront cache invalidation action", map[string]any{
146+
"distribution_id": distributionID,
147+
"paths": paths,
148+
"caller_reference": callerReference,
149+
names.AttrTimeout: timeout.String(),
150+
})
151+
152+
// Send initial progress update
153+
resp.SendProgress(action.InvokeProgressEvent{
154+
Message: fmt.Sprintf("Starting cache invalidation for CloudFront distribution %s...", distributionID),
155+
})
156+
157+
// Check if distribution exists first
158+
_, err := findDistributionByID(ctx, conn, distributionID)
159+
if err != nil {
160+
if tfawserr.ErrCodeEquals(err, "NoSuchDistribution") {
161+
resp.Diagnostics.AddError(
162+
"Distribution Not Found",
163+
fmt.Sprintf("CloudFront distribution %s was not found", distributionID),
164+
)
165+
return
166+
}
167+
resp.Diagnostics.AddError(
168+
"Failed to Describe Distribution",
169+
fmt.Sprintf("Could not describe CloudFront distribution %s: %s", distributionID, err),
170+
)
171+
return
172+
}
173+
174+
// Create invalidation request
175+
resp.SendProgress(action.InvokeProgressEvent{
176+
Message: fmt.Sprintf("Creating invalidation request for %d path(s)...", len(paths)),
177+
})
178+
179+
invalidationInput := &cloudfront.CreateInvalidationInput{
180+
DistributionId: aws.String(distributionID),
181+
InvalidationBatch: &awstypes.InvalidationBatch{
182+
CallerReference: aws.String(callerReference),
183+
Paths: &awstypes.Paths{
184+
Quantity: aws.Int32(int32(len(paths))),
185+
Items: paths,
186+
},
187+
},
188+
}
189+
190+
output, err := conn.CreateInvalidation(ctx, invalidationInput)
191+
if err != nil {
192+
if tfawserr.ErrCodeEquals(err, "TooManyInvalidationsInProgress") {
193+
resp.Diagnostics.AddError(
194+
"Too Many Invalidations In Progress",
195+
fmt.Sprintf("CloudFront distribution %s has too many invalidations in progress. Please wait and try again.", distributionID),
196+
)
197+
return
198+
}
199+
if tfawserr.ErrCodeEquals(err, "InvalidArgument") {
200+
resp.Diagnostics.AddError(
201+
"Invalid Invalidation Request",
202+
fmt.Sprintf("Invalid invalidation request for distribution %s: %s", distributionID, err),
203+
)
204+
return
205+
}
206+
resp.Diagnostics.AddError(
207+
"Failed to Create Invalidation",
208+
fmt.Sprintf("Could not create invalidation for CloudFront distribution %s: %s", distributionID, err),
209+
)
210+
return
211+
}
212+
213+
invalidationID := aws.ToString(output.Invalidation.Id)
214+
215+
resp.SendProgress(action.InvokeProgressEvent{
216+
Message: fmt.Sprintf("Invalidation %s created, waiting for completion...", invalidationID),
217+
})
218+
219+
// Wait for invalidation to complete with periodic progress updates
220+
err = a.waitForInvalidationComplete(ctx, conn, distributionID, invalidationID, timeout, resp)
221+
if err != nil {
222+
resp.Diagnostics.AddError(
223+
"Timeout Waiting for Invalidation to Complete",
224+
fmt.Sprintf("CloudFront invalidation %s did not complete within %s: %s", invalidationID, timeout, err),
225+
)
226+
return
227+
}
228+
229+
// Final success message
230+
resp.SendProgress(action.InvokeProgressEvent{
231+
Message: fmt.Sprintf("CloudFront cache invalidation %s completed successfully for distribution %s", invalidationID, distributionID),
232+
})
233+
234+
tflog.Info(ctx, "CloudFront invalidate cache action completed successfully", map[string]any{
235+
"distribution_id": distributionID,
236+
"invalidation_id": invalidationID,
237+
"paths": paths,
238+
})
239+
}
240+
241+
// waitForInvalidationComplete waits for an invalidation to complete with progress updates
242+
func (a *createInvalidationAction) waitForInvalidationComplete(ctx context.Context, conn *cloudfront.Client, distributionID, invalidationID string, timeout time.Duration, resp *action.InvokeResponse) error {
243+
const (
244+
pollInterval = 30 * time.Second
245+
progressInterval = 60 * time.Second
246+
)
247+
248+
deadline := time.Now().Add(timeout)
249+
lastProgressUpdate := time.Now()
250+
251+
for {
252+
select {
253+
case <-ctx.Done():
254+
return ctx.Err()
255+
default:
256+
}
257+
258+
// Check if we've exceeded the timeout
259+
if time.Now().After(deadline) {
260+
return fmt.Errorf("timeout after %s", timeout)
261+
}
262+
263+
// Get current invalidation status
264+
input := &cloudfront.GetInvalidationInput{
265+
DistributionId: aws.String(distributionID),
266+
Id: aws.String(invalidationID),
267+
}
268+
269+
output, err := conn.GetInvalidation(ctx, input)
270+
if err != nil {
271+
return fmt.Errorf("getting invalidation status: %w", err)
272+
}
273+
274+
currentStatus := aws.ToString(output.Invalidation.Status)
275+
276+
// Send progress update every 60 seconds
277+
if time.Since(lastProgressUpdate) >= progressInterval {
278+
resp.SendProgress(action.InvokeProgressEvent{
279+
Message: fmt.Sprintf("Invalidation %s is currently '%s', continuing to wait for completion...", invalidationID, currentStatus),
280+
})
281+
lastProgressUpdate = time.Now()
282+
}
283+
284+
// Check if we've reached completion
285+
if aws.ToString(output.Invalidation.Status) == "Completed" {
286+
return nil
287+
}
288+
289+
// Check if we're in an unexpected state
290+
validStatuses := []string{
291+
"InProgress",
292+
}
293+
if !slices.Contains(validStatuses, currentStatus) && currentStatus != "Completed" {
294+
return fmt.Errorf("invalidation entered unexpected status: %s", currentStatus)
295+
}
296+
297+
// Wait before next poll
298+
time.Sleep(pollInterval)
299+
}
300+
}

0 commit comments

Comments
 (0)