Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
artyom committed Nov 22, 2024
1 parent cb85a2c commit b359aed
Show file tree
Hide file tree
Showing 6 changed files with 339 additions and 0 deletions.
15 changes: 15 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ISC License

Copyright (c) 2024 Artyom Pervukhin

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Update CloudFormation Stack Parameter Action

This GitHub Action updates a single parameter in an existing CloudFormation stack while preserving all other settings.

## Usage

```yaml
- uses: artyom/update-cloudformation-stack@main
with:
stack: my-stack-name
key: ParameterName
value: NewValue
```
## 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

## AWS Credentials

This action uses the AWS SDK default credential provider chain. Configure AWS credentials using standard GitHub Actions methods:

```yaml
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/my-role
aws-region: us-east-1
```

## AWS Permissions

This action requires the following permissions:

- cloudformation:DescribeStacks
- cloudformation:UpdateStack
- cloudformation:DescribeStackEvents

## Example

```yaml
jobs:
update-stack:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/my-role
aws-region: us-east-1
- uses: artyom/update-cloudformation-stack@main
with:
stack: production-stack
key: ImageTag
value: 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.
25 changes: 25 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Update a single parameter in an existing CloudFormation stack while preserving all other settings
description: >
Updates a single parameter in an existing CloudFormation stack 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
required: true

runs:
using: composite
steps:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
cache-dependency-path: ${{ github.action_path }}/go.sum
- shell: bash
run: |
go run -C ${{ github.action_path }} . -stack=${{ inputs.stack }} -key=${{ inputs.key }} -value=${{ inputs.value }}
24 changes: 24 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module github.com/artyom/update-cloudformation-stack

go 1.23.3

require (
github.com/aws/aws-sdk-go-v2 v1.32.5
github.com/aws/aws-sdk-go-v2/config v1.28.5
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.56.0
github.com/aws/smithy-go v1.22.1
)

require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
)
40 changes: 40 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo=
github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.56.0 h1:zmXJiEm/fQYtFDLIUsZrcPIjTrL3R/noFICGlYBj3Ww=
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.56.0/go.mod h1:9nOjXCDKE+QMK4JaCrLl36PU+VEfJmI7WVehYmojO8s=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
177 changes: 177 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package main

import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"log"
"os"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/cloudformation"
"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
"github.com/aws/smithy-go"
)

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")
flag.Parse()
if err := run(context.Background(), args); err != nil {
if errors.Is(err, errAlreadySet) {
log.Print(githubWarnPrefix, err)
return
}
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")
return
}
log.Fatal(githubErrPrefix, err)
}
}

type runArgs struct {
stack string
key string
value string
}

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

func run(ctx context.Context, args runArgs) error {
if err := args.validate(); err != nil {
return err
}
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
return err
}
svc := cloudformation.NewFromConfig(cfg)

desc, err := svc.DescribeStacks(ctx, &cloudformation.DescribeStacksInput{StackName: &args.stack})
if err != nil {
return err
}
if l := len(desc.Stacks); l != 1 {
return fmt.Errorf("DescribeStacks returned %d stacks, expected 1", l)
}

var params []types.Parameter
var seenKey bool
for _, p := range desc.Stacks[0].Parameters {
k := aws.ToString(p.ParameterKey)
if k == args.key && aws.ToString(p.ParameterValue) == args.value {
return errAlreadySet
}
if k == args.key {
seenKey = true
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")
}
params = append(params, types.Parameter{ParameterKey: &args.key, ParameterValue: &args.value})

token := newToken()
_, err = svc.UpdateStack(ctx, &cloudformation.UpdateStackInput{
StackName: &args.stack,
ClientRequestToken: &token,
UsePreviousTemplate: aws.Bool(true),
Parameters: params,
})
if err != nil {
return err
}
log.Print("polling for stack updates until it's ready, this may take a while")
debugf := func(format string, args ...any) {
if !underGithub {
return
}
log.Printf("::debug::"+format, args...)
}
oldEventsCutoff := time.Now().Add(-time.Hour)
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
case <-ctx.Done():
return ctx.Err()
}
p := cloudformation.NewDescribeStackEventsPaginator(svc, &cloudformation.DescribeStackEventsInput{StackName: &args.stack})
scanEvents:
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return err
}
for _, evt := range page.StackEvents {
if evt.Timestamp != nil && aws.ToTime(evt.Timestamp).Before(oldEventsCutoff) {
break scanEvents
}
if evt.ClientRequestToken == nil || *evt.ClientRequestToken != token {
continue
}
if evt.ResourceStatus == types.ResourceStatusUpdateFailed {
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" {
switch evt.ResourceStatus {
case types.ResourceStatusUpdateComplete:
return nil
}
}
}
}
}
}

func newToken() string {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
panic(err)
}
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.
Usage: update-cloudformation-stack -stack NAME -key PARAM -value VALUE
`
flag.Usage = func() {
fmt.Fprint(flag.CommandLine.Output(), usage)
flag.PrintDefaults()
}
}

var underGithub bool
var githubWarnPrefix string
var githubErrPrefix string

func init() {
underGithub = os.Getenv("GITHUB_ACTIONS") == "true"
if underGithub {
githubWarnPrefix = "::warning::"
githubErrPrefix = "::error::"
}
}

0 comments on commit b359aed

Please sign in to comment.