Skip to content

Commit

Permalink
Update action to support multiple parameter changes
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
artyom committed Nov 24, 2024
1 parent f07c3f7 commit f77f3a3
Show file tree
Hide file tree
Showing 4 changed files with 97 additions and 60 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# Update CloudFormation Stack Parameter Action
# Update CloudFormation Stack Parameters Action

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

## Usage

```yaml
- uses: artyom/update-cloudformation-stack@main
with:
stack: my-stack-name
key: ParameterName
value: NewValue
parameters: |
Name1=value1
Name2=value2
```
## Inputs
- `stack` - Name of the CloudFormation stack to update
- `key` - Name of the stack parameter to update
- `value` - New value to set for the parameter
- `stack` - name of the CloudFormation stack to update
- `parameters` - pairs of parameters in the Name=Value format, each pair on a separate line

## AWS Credentials

Expand Down Expand Up @@ -51,8 +51,8 @@ jobs:
- uses: artyom/update-cloudformation-stack@main
with:
stack: production-stack
key: ImageTag
value: v123
parameters: |
ImageTag=v123
```

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.
The action will monitor stack update progress and fail if update fails.
15 changes: 6 additions & 9 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
name: Update CloudFormation stack by changing a single parameter
name: Update CloudFormation stack by only changing its parameters
description: >
Updates a single parameter in an existing CloudFormation stack while preserving all other settings.
Updates CloudFormation stack by updating its parameters while preserving all other settings.
inputs:
stack:
description: CloudFormation stack name
required: true
key:
description: Name of stack parameter to update
required: true
value:
description: New value to set for a given parameter
parameters:
description: >
Newline-separated parameters to change in the Name=Value format.
Stack parameters not set here would retain their existing values.
required: true

runs:
using: docker
image: docker://ghcr.io/artyom/update-cloudformation-stack:latest
args:
- '-stack=${{ inputs.stack }}'
- '-key=${{ inputs.key }}'
- '-value=${{ inputs.value }}'
93 changes: 52 additions & 41 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import (
"flag"
"fmt"
"log"
"maps"
"os"
"slices"
"strings"
"time"

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

func main() {
log.SetFlags(0)
var args runArgs
flag.StringVar(&args.stack, "stack", args.stack, "name of the CloudFormation stack to update")
flag.StringVar(&args.key, "key", args.key, "parameter name to update")
flag.StringVar(&args.value, "value", args.value, "parameter value to set")
var stackName string
flag.StringVar(&stackName, "stack", stackName, "name of the CloudFormation stack to update")
flag.Parse()
if err := run(context.Background(), args); err != nil {
if errors.Is(err, errAlreadySet) {
log.Print(githubWarnPrefix, err)
return
}
if err := run(context.Background(), stackName, flag.Args()); err != nil {
var ae smithy.APIError
if errors.As(err, &ae) && ae.ErrorCode() == "ValidationError" && ae.ErrorMessage() == "No updates are to be performed." {
log.Print(githubWarnPrefix, "nothing to update")
Expand All @@ -39,25 +36,27 @@ func main() {
}
}

type runArgs struct {
stack string
key string
value string
}

var errAlreadySet = errors.New("stack already has required parameter value")

func run(ctx context.Context, args runArgs) error {
if err := args.validate(); err != nil {
func run(ctx context.Context, stackName string, args []string) error {
if stackName == "" {
return errors.New("stack name must be set")
}
if underGithub && len(args) == 0 {
args = strings.Split(os.Getenv("INPUT_PARAMETERS"), "\n")
}
toReplace, err := parseKvs(args)
if err != nil {
return err
}
if len(toReplace) == 0 {
return errors.New("empty parameters list")
}
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
return err
}
svc := cloudformation.NewFromConfig(cfg)

desc, err := svc.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &args.stack})
desc, err := svc.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &stackName})
if err != nil {
return err
}
Expand All @@ -66,26 +65,22 @@ func run(ctx context.Context, args runArgs) error {
}
stack := desc.Stacks[0]
var params []types.Parameter
var seenKey bool
for _, p := range stack.Parameters {
k := aws.ToString(p.ParameterKey)
if k == args.key && aws.ToString(p.ParameterValue) == args.value {
return errAlreadySet
}
if k == args.key {
seenKey = true
if v, ok := toReplace[k]; ok {
params = append(params, types.Parameter{ParameterKey: &k, ParameterValue: &v})
delete(toReplace, k)
continue
}
params = append(params, types.Parameter{ParameterKey: &k, UsePreviousValue: aws.Bool(true)})
}
if !seenKey {
return errors.New("stack has no parameter with the given key")
if len(toReplace) != 0 {
return fmt.Errorf("stack has no parameters with these names: %s", strings.Join(slices.Sorted(maps.Keys(toReplace)), ", "))
}
params = append(params, types.Parameter{ParameterKey: &args.key, ParameterValue: &args.value})

token := newToken()
_, err = svc.UpdateStack(ctx, &cloudformation.UpdateStackInput{
StackName: &args.stack,
StackName: &stackName,
ClientRequestToken: &token,
UsePreviousTemplate: aws.Bool(true),
Parameters: params,
Expand All @@ -111,7 +106,7 @@ func run(ctx context.Context, args runArgs) error {
case <-ctx.Done():
return ctx.Err()
}
p := cloudformation.NewDescribeStackEventsPaginator(svc, &cloudformation.DescribeStackEventsInput{StackName: &args.stack})
p := cloudformation.NewDescribeStackEventsPaginator(svc, &cloudformation.DescribeStackEventsInput{StackName: &stackName})
scanEvents:
for p.HasMorePages() {
page, err := p.NextPage(ctx)
Expand All @@ -129,7 +124,7 @@ func run(ctx context.Context, args runArgs) error {
return fmt.Errorf("%v: %s", evt.ResourceStatus, aws.ToString(evt.ResourceStatusReason))
}
debugf("%s\t%s\t%v", aws.ToString(evt.ResourceType), aws.ToString(evt.LogicalResourceId), evt.ResourceStatus)
if aws.ToString(evt.LogicalResourceId) == args.stack && aws.ToString(evt.ResourceType) == "AWS::CloudFormation::Stack" {
if aws.ToString(evt.LogicalResourceId) == stackName && aws.ToString(evt.ResourceType) == "AWS::CloudFormation::Stack" {
switch evt.ResourceStatus {
case types.ResourceStatusUpdateComplete:
return nil
Expand All @@ -148,17 +143,10 @@ func newToken() string {
return "ucs-" + hex.EncodeToString(b)
}

func (a *runArgs) validate() error {
if a.stack == "" || a.key == "" || a.value == "" {
return errors.New("stack, key, and value cannot be empty")
}
return nil
}

func init() {
const usage = `Updates a single parameter in an existing CloudFormation stack while preserving all other settings.
const usage = `Updates CloudFormation stack by updating some of its parameters while preserving all other settings.
Usage: update-cloudformation-stack -stack NAME -key PARAM -value VALUE
Usage: update-cloudformation-stack -stack=NAME Param1=Value1 [Param2=Value2 ...]
`
flag.Usage = func() {
fmt.Fprint(flag.CommandLine.Output(), usage)
Expand All @@ -177,3 +165,26 @@ func init() {
githubErrPrefix = "::error::"
}
}

func parseKvs(list []string) (map[string]string, error) {
out := make(map[string]string)
for _, line := range list {
line = strings.TrimSpace(line)
if line == "" {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("wrong parameter format, want key=value pair: %q", line)
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if k == "" || v == "" {
return nil, fmt.Errorf("wrong parameter format, both key and value must be non-empty: %q", line)
}
if _, ok := out[k]; ok {
return nil, fmt.Errorf("duplicate key in parameters list: %q", k)
}
out[k] = v
}
return out, nil
}
29 changes: 29 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import "testing"

func Test_parseKvs(t *testing.T) {
for _, tc := range []struct {
input []string
pairsParsed int
wantErr bool
}{
{input: nil},
{input: []string{"\n"}},
{input: []string{"k=v"}, pairsParsed: 1},
{input: []string{"k=v", "k=v"}, wantErr: true},
{input: []string{"k=v", "k2=v"}, pairsParsed: 2},
{input: []string{"k=v", "", "k2=v", ""}, pairsParsed: 2},
{input: []string{"k=v", "k2=v", "k=v"}, wantErr: true},
{input: []string{"k=v", "junk"}, wantErr: true},
{input: []string{"k= ", "k2=v"}, wantErr: true},
} {
got, err := parseKvs(tc.input)
if tc.wantErr != (err != nil) {
t.Errorf("input: %q, want error: %v, got error: %v", tc.input, tc.wantErr, err)
}
if l := len(got); l != tc.pairsParsed {
t.Errorf("input: %q, got %d kv pairs, want %d", tc.input, l, tc.pairsParsed)
}
}
}

0 comments on commit f77f3a3

Please sign in to comment.