Skip to content

Commit f77f3a3

Browse files
committed
Update action to support multiple parameter changes
The code now allows updating multiple CloudFormation stack parameters at once instead of being limited to a single parameter change. Parameters are specified in a Name=Value format, with each pair on a separate line. This is a breaking change, but since the code is barely two days old, it's unlikely somebody depends on it already.
1 parent f07c3f7 commit f77f3a3

File tree

4 files changed

+97
-60
lines changed

4 files changed

+97
-60
lines changed

README.md

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
1-
# Update CloudFormation Stack Parameter Action
1+
# Update CloudFormation Stack Parameters Action
22

3-
This GitHub Action updates a single parameter in an existing CloudFormation stack while preserving all other settings.
3+
This GitHub Action updates existing CloudFormation stack by changing some of its parameters while preserving all other settings.
44

55
## Usage
66

77
```yaml
88
- uses: artyom/update-cloudformation-stack@main
99
with:
1010
stack: my-stack-name
11-
key: ParameterName
12-
value: NewValue
11+
parameters: |
12+
Name1=value1
13+
Name2=value2
1314
```
1415
1516
## Inputs
1617
17-
- `stack` - Name of the CloudFormation stack to update
18-
- `key` - Name of the stack parameter to update
19-
- `value` - New value to set for the parameter
18+
- `stack` - name of the CloudFormation stack to update
19+
- `parameters` - pairs of parameters in the Name=Value format, each pair on a separate line
2020

2121
## AWS Credentials
2222

@@ -51,8 +51,8 @@ jobs:
5151
- uses: artyom/update-cloudformation-stack@main
5252
with:
5353
stack: production-stack
54-
key: ImageTag
55-
value: v123
54+
parameters: |
55+
ImageTag=v123
5656
```
5757

58-
The action will monitor stack update progress and fail if update fails. If parameter already has the requested value, action will exit with a warning message.
58+
The action will monitor stack update progress and fail if update fails.

action.yml

+6-9
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
1-
name: Update CloudFormation stack by changing a single parameter
1+
name: Update CloudFormation stack by only changing its parameters
22
description: >
3-
Updates a single parameter in an existing CloudFormation stack while preserving all other settings.
3+
Updates CloudFormation stack by updating its parameters while preserving all other settings.
44
55
inputs:
66
stack:
77
description: CloudFormation stack name
88
required: true
9-
key:
10-
description: Name of stack parameter to update
11-
required: true
12-
value:
13-
description: New value to set for a given parameter
9+
parameters:
10+
description: >
11+
Newline-separated parameters to change in the Name=Value format.
12+
Stack parameters not set here would retain their existing values.
1413
required: true
1514

1615
runs:
1716
using: docker
1817
image: docker://ghcr.io/artyom/update-cloudformation-stack:latest
1918
args:
2019
- '-stack=${{ inputs.stack }}'
21-
- '-key=${{ inputs.key }}'
22-
- '-value=${{ inputs.value }}'

main.go

+52-41
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import (
88
"flag"
99
"fmt"
1010
"log"
11+
"maps"
1112
"os"
13+
"slices"
14+
"strings"
1215
"time"
1316

1417
"github.com/aws/aws-sdk-go-v2/aws"
@@ -20,16 +23,10 @@ import (
2023

2124
func main() {
2225
log.SetFlags(0)
23-
var args runArgs
24-
flag.StringVar(&args.stack, "stack", args.stack, "name of the CloudFormation stack to update")
25-
flag.StringVar(&args.key, "key", args.key, "parameter name to update")
26-
flag.StringVar(&args.value, "value", args.value, "parameter value to set")
26+
var stackName string
27+
flag.StringVar(&stackName, "stack", stackName, "name of the CloudFormation stack to update")
2728
flag.Parse()
28-
if err := run(context.Background(), args); err != nil {
29-
if errors.Is(err, errAlreadySet) {
30-
log.Print(githubWarnPrefix, err)
31-
return
32-
}
29+
if err := run(context.Background(), stackName, flag.Args()); err != nil {
3330
var ae smithy.APIError
3431
if errors.As(err, &ae) && ae.ErrorCode() == "ValidationError" && ae.ErrorMessage() == "No updates are to be performed." {
3532
log.Print(githubWarnPrefix, "nothing to update")
@@ -39,25 +36,27 @@ func main() {
3936
}
4037
}
4138

42-
type runArgs struct {
43-
stack string
44-
key string
45-
value string
46-
}
47-
48-
var errAlreadySet = errors.New("stack already has required parameter value")
49-
50-
func run(ctx context.Context, args runArgs) error {
51-
if err := args.validate(); err != nil {
39+
func run(ctx context.Context, stackName string, args []string) error {
40+
if stackName == "" {
41+
return errors.New("stack name must be set")
42+
}
43+
if underGithub && len(args) == 0 {
44+
args = strings.Split(os.Getenv("INPUT_PARAMETERS"), "\n")
45+
}
46+
toReplace, err := parseKvs(args)
47+
if err != nil {
5248
return err
5349
}
50+
if len(toReplace) == 0 {
51+
return errors.New("empty parameters list")
52+
}
5453
cfg, err := config.LoadDefaultConfig(ctx)
5554
if err != nil {
5655
return err
5756
}
5857
svc := cloudformation.NewFromConfig(cfg)
5958

60-
desc, err := svc.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &args.stack})
59+
desc, err := svc.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &stackName})
6160
if err != nil {
6261
return err
6362
}
@@ -66,26 +65,22 @@ func run(ctx context.Context, args runArgs) error {
6665
}
6766
stack := desc.Stacks[0]
6867
var params []types.Parameter
69-
var seenKey bool
7068
for _, p := range stack.Parameters {
7169
k := aws.ToString(p.ParameterKey)
72-
if k == args.key && aws.ToString(p.ParameterValue) == args.value {
73-
return errAlreadySet
74-
}
75-
if k == args.key {
76-
seenKey = true
70+
if v, ok := toReplace[k]; ok {
71+
params = append(params, types.Parameter{ParameterKey: &k, ParameterValue: &v})
72+
delete(toReplace, k)
7773
continue
7874
}
7975
params = append(params, types.Parameter{ParameterKey: &k, UsePreviousValue: aws.Bool(true)})
8076
}
81-
if !seenKey {
82-
return errors.New("stack has no parameter with the given key")
77+
if len(toReplace) != 0 {
78+
return fmt.Errorf("stack has no parameters with these names: %s", strings.Join(slices.Sorted(maps.Keys(toReplace)), ", "))
8379
}
84-
params = append(params, types.Parameter{ParameterKey: &args.key, ParameterValue: &args.value})
8580

8681
token := newToken()
8782
_, err = svc.UpdateStack(ctx, &cloudformation.UpdateStackInput{
88-
StackName: &args.stack,
83+
StackName: &stackName,
8984
ClientRequestToken: &token,
9085
UsePreviousTemplate: aws.Bool(true),
9186
Parameters: params,
@@ -111,7 +106,7 @@ func run(ctx context.Context, args runArgs) error {
111106
case <-ctx.Done():
112107
return ctx.Err()
113108
}
114-
p := cloudformation.NewDescribeStackEventsPaginator(svc, &cloudformation.DescribeStackEventsInput{StackName: &args.stack})
109+
p := cloudformation.NewDescribeStackEventsPaginator(svc, &cloudformation.DescribeStackEventsInput{StackName: &stackName})
115110
scanEvents:
116111
for p.HasMorePages() {
117112
page, err := p.NextPage(ctx)
@@ -129,7 +124,7 @@ func run(ctx context.Context, args runArgs) error {
129124
return fmt.Errorf("%v: %s", evt.ResourceStatus, aws.ToString(evt.ResourceStatusReason))
130125
}
131126
debugf("%s\t%s\t%v", aws.ToString(evt.ResourceType), aws.ToString(evt.LogicalResourceId), evt.ResourceStatus)
132-
if aws.ToString(evt.LogicalResourceId) == args.stack && aws.ToString(evt.ResourceType) == "AWS::CloudFormation::Stack" {
127+
if aws.ToString(evt.LogicalResourceId) == stackName && aws.ToString(evt.ResourceType) == "AWS::CloudFormation::Stack" {
133128
switch evt.ResourceStatus {
134129
case types.ResourceStatusUpdateComplete:
135130
return nil
@@ -148,17 +143,10 @@ func newToken() string {
148143
return "ucs-" + hex.EncodeToString(b)
149144
}
150145

151-
func (a *runArgs) validate() error {
152-
if a.stack == "" || a.key == "" || a.value == "" {
153-
return errors.New("stack, key, and value cannot be empty")
154-
}
155-
return nil
156-
}
157-
158146
func init() {
159-
const usage = `Updates a single parameter in an existing CloudFormation stack while preserving all other settings.
147+
const usage = `Updates CloudFormation stack by updating some of its parameters while preserving all other settings.
160148
161-
Usage: update-cloudformation-stack -stack NAME -key PARAM -value VALUE
149+
Usage: update-cloudformation-stack -stack=NAME Param1=Value1 [Param2=Value2 ...]
162150
`
163151
flag.Usage = func() {
164152
fmt.Fprint(flag.CommandLine.Output(), usage)
@@ -177,3 +165,26 @@ func init() {
177165
githubErrPrefix = "::error::"
178166
}
179167
}
168+
169+
func parseKvs(list []string) (map[string]string, error) {
170+
out := make(map[string]string)
171+
for _, line := range list {
172+
line = strings.TrimSpace(line)
173+
if line == "" {
174+
continue
175+
}
176+
k, v, ok := strings.Cut(line, "=")
177+
if !ok {
178+
return nil, fmt.Errorf("wrong parameter format, want key=value pair: %q", line)
179+
}
180+
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
181+
if k == "" || v == "" {
182+
return nil, fmt.Errorf("wrong parameter format, both key and value must be non-empty: %q", line)
183+
}
184+
if _, ok := out[k]; ok {
185+
return nil, fmt.Errorf("duplicate key in parameters list: %q", k)
186+
}
187+
out[k] = v
188+
}
189+
return out, nil
190+
}

main_test.go

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import "testing"
4+
5+
func Test_parseKvs(t *testing.T) {
6+
for _, tc := range []struct {
7+
input []string
8+
pairsParsed int
9+
wantErr bool
10+
}{
11+
{input: nil},
12+
{input: []string{"\n"}},
13+
{input: []string{"k=v"}, pairsParsed: 1},
14+
{input: []string{"k=v", "k=v"}, wantErr: true},
15+
{input: []string{"k=v", "k2=v"}, pairsParsed: 2},
16+
{input: []string{"k=v", "", "k2=v", ""}, pairsParsed: 2},
17+
{input: []string{"k=v", "k2=v", "k=v"}, wantErr: true},
18+
{input: []string{"k=v", "junk"}, wantErr: true},
19+
{input: []string{"k= ", "k2=v"}, wantErr: true},
20+
} {
21+
got, err := parseKvs(tc.input)
22+
if tc.wantErr != (err != nil) {
23+
t.Errorf("input: %q, want error: %v, got error: %v", tc.input, tc.wantErr, err)
24+
}
25+
if l := len(got); l != tc.pairsParsed {
26+
t.Errorf("input: %q, got %d kv pairs, want %d", tc.input, l, tc.pairsParsed)
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)