diff --git a/README.md b/README.md index 40486e0..177b244 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This action uses the AWS SDK default credential provider chain. Configure AWS cr This action requires the following permissions: - cloudformation:DescribeStacks +- cloudformation:GetTemplate - cloudformation:UpdateStack - cloudformation:DescribeStackEvents @@ -62,6 +63,6 @@ The action will monitor stack update progress and fail if update fails. This action doesn't work for stacks that rely on template transformations (stack template has non-empty [“Transform” section][transform]). -On such stacks UpdateStack API call does not recognize parameters-only changes and the action reports there's nothing to update. +On such stacks the UpdateStack API call does not recognize parameters-only changes. The action detects a non-empty Transform section up front and fails with a clear error instead of silently reporting there's nothing to update. [transform]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/transform-section-structure.html diff --git a/main.go b/main.go index dfc6fcf..e5644e8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "flag" "fmt" @@ -66,6 +67,18 @@ func run(ctx context.Context, stackName string, args []string) error { return fmt.Errorf("DescribeStacks returned %d stacks, expected 1", l) } stack := desc.Stacks[0] + + tmpl, err := svc.GetTemplate(ctx, &cloudformation.GetTemplateInput{ + StackName: &stackName, + TemplateStage: types.TemplateStageOriginal, + }) + if err != nil { + return err + } + if hasTransform(unptr(tmpl.TemplateBody)) { + return errors.New("stack relies on template transformations (non-empty Transform section); parameters-only updates are not supported, see README") + } + var params []types.Parameter for _, p := range stack.Parameters { k := unptr(p.ParameterKey) @@ -207,6 +220,35 @@ func parseKvs(list []string) (map[string]string, error) { return out, nil } +// hasTransform reports whether a CloudFormation template body declares a +// non-empty top-level Transform section. Templates may be JSON or YAML; YAML +// short-form intrinsics (!Ref, !GetAtt, …) make full parsing impractical, so the +// YAML path only looks for a top-level Transform key. +func hasTransform(body string) bool { + trimmed := strings.TrimSpace(body) + if strings.HasPrefix(trimmed, "{") { + var doc struct { + Transform json.RawMessage `json:"Transform"` + } + if json.Unmarshal([]byte(trimmed), &doc) != nil { + return false // let UpdateStack surface a malformed template + } + switch strings.TrimSpace(string(doc.Transform)) { + case "", "null", "[]", `""`: + return false + default: + return true + } + } + for _, line := range strings.Split(body, "\n") { + line = strings.TrimRight(line, "\r") + if strings.HasPrefix(line, "Transform:") || strings.HasPrefix(line, "Transform ") { + return true + } + } + return false +} + func unptr[T any](v *T) T { var zero T if v != nil { diff --git a/main_test.go b/main_test.go index bd25d7a..9a0c607 100644 --- a/main_test.go +++ b/main_test.go @@ -27,3 +27,27 @@ func Test_parseKvs(t *testing.T) { } } } + +func Test_hasTransform(t *testing.T) { + for _, tc := range []struct { + name string + body string + want bool + }{ + {name: "yaml with transform", body: "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31\nResources: {}\n", want: true}, + {name: "yaml transform list", body: "Transform:\n - AWS::Serverless-2016-10-31\nResources: {}\n", want: true}, + {name: "yaml no transform", body: "AWSTemplateFormatVersion: '2010-09-09'\nResources:\n Q:\n Type: AWS::SQS::Queue\n", want: false}, + {name: "yaml nested transform key", body: "Resources:\n Transform: something\n", want: false}, + {name: "json with transform", body: `{"Transform": "AWS::Serverless-2016-10-31", "Resources": {}}`, want: true}, + {name: "json transform list", body: `{"Transform": ["AWS::Serverless-2016-10-31"], "Resources": {}}`, want: true}, + {name: "json no transform", body: `{"Resources": {}}`, want: false}, + {name: "json null transform", body: `{"Transform": null, "Resources": {}}`, want: false}, + {name: "empty", body: "", want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := hasTransform(tc.body); got != tc.want { + t.Errorf("hasTransform() = %v, want %v", got, tc.want) + } + }) + } +}