Skip to content

Commit

Permalink
resource: support cloudformation stack type
Browse files Browse the repository at this point in the history
This allows you to define a cloudformation stack like:
```
- Type: "Cloudformation"
  Path: "examples/cloudformation/dynamo/table.yml"
  Name: "painter3"
  CloudformationParameters:
    - "HashKeyElementName=Painter"
```

We use the Name and path to ensure that stacks are unique.

Cloudformation support depends on change sets that we use to update a
deployed stack as the cloudformation changes.

The Path for cloudformation should go directly to the template you want
to use. Unlike terraform and CDK where there is a directory of contents.
  • Loading branch information
dschofie committed May 2, 2024
1 parent 2f3c13e commit 2d4d5cb
Show file tree
Hide file tree
Showing 5 changed files with 325 additions and 0 deletions.
60 changes: 60 additions & 0 deletions examples/cloudformation/dynamo/table.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
AWSTemplateFormatVersion: "2010-09-09"

Description: 'AWS CloudFormation Sample Template DynamoDB_Table: This template demonstrates the creation of a DynamoDB table. **WARNING** This template creates an Amazon DynamoDB table. You will be billed for the AWS resources used if you create a stack from this template.'

Metadata:
License: Apache-2.0

Parameters:
HashKeyElementName:
Description: HashType PrimaryKey Name
Type: String
AllowedPattern: '[a-zA-Z0-9]*'
MinLength: "1"
MaxLength: "2048"
ConstraintDescription: must contain only alphanumberic characters

HashKeyElementType:
Description: HashType PrimaryKey Type
Type: String
Default: S
AllowedPattern: '[S|N]'
MinLength: "1"
MaxLength: "1"
ConstraintDescription: must be either S or N

ReadCapacityUnits:
Description: Provisioned read throughput
Type: Number
Default: "5"
MinValue: "5"
MaxValue: "10000"
ConstraintDescription: must be between 5 and 10000

WriteCapacityUnits:
Description: Provisioned write throughput
Type: Number
Default: "10"
MinValue: "5"
MaxValue: "10000"
ConstraintDescription: must be between 5 and 10000

Resources:
myDynamoDBTable:
Type: AWS::DynamoDB::Table
Properties:
AttributeDefinitions:
- AttributeName: !Ref HashKeyElementName
AttributeType: !Ref HashKeyElementType
KeySchema:
- AttributeName: !Ref HashKeyElementName
KeyType: HASH
ProvisionedThroughput:
ReadCapacityUnits: !Ref ReadCapacityUnits
WriteCapacityUnits: !Ref WriteCapacityUnits

Outputs:
TableName:
Description: Table name of the newly created DynamoDB table
Value: !Ref myDynamoDBTable

14 changes: 14 additions & 0 deletions resource/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ func (a Account) AllBaselineStacks() ([]Stack, error) {
returnStacks = append(returnStacks, currStack)
}

cloudformationStackNames := map[string]struct{}{}
for _, stack := range returnStacks {
if err := stack.Validate(); err != nil {
return nil, err
}

if stack.Type == "Cloudformation" {
if _, ok := cloudformationStackNames[*stack.CloudformationStackName()]; ok {
return nil, oops.Errorf("Multiple Cloudformation stacks have the same Name: (%s) and Path (%s). Please set a distinct Name", stack.Name, stack.Path)
}
cloudformationStackNames[*stack.CloudformationStackName()] = struct{}{}
}
}

return returnStacks, nil
}

Expand Down
47 changes: 47 additions & 0 deletions resource/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package resource

import (
"fmt"
"strings"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/cloudformation"
"github.com/samsarahq/go/oops"
)

Expand All @@ -15,6 +19,8 @@ type Stack struct {
RoleOverrideARNDeprecated string `yaml:"RoleOverrideARN,omitempty"` // Deprecated
AssumeRoleName string `yaml:"AssumeRoleName,omitempty"`
Workspace string `yaml:"Workspace,omitempty"`

CloudformationParameters []string `yaml:"CloudformationParameters,omitempty"`
}

func (s Stack) NewForRegion(region string) Stack {
Expand All @@ -26,6 +32,7 @@ func (s Stack) NewForRegion(region string) Stack {
RoleOverrideARNDeprecated: s.RoleOverrideARNDeprecated,
AssumeRoleName: s.AssumeRoleName,
Workspace: s.Workspace,
CloudformationParameters: s.CloudformationParameters,
}
}

Expand Down Expand Up @@ -62,10 +69,50 @@ func (s Stack) Validate() error {
}
return nil

case "Cloudformation":
if _, err := s.CloudformationParametersType(); err != nil {
return oops.Wrapf(err, "")
}
return nil

case "":
return oops.Errorf("stack type needs to be set for stack: %+v", s)

default:
return oops.Errorf("only support stack types of `Terraform` and `CDK` not: %s", s.Type)
}
}

func (s Stack) CloudformationParametersType() ([]*cloudformation.Parameter, error) {
var params []*cloudformation.Parameter
for _, param := range s.CloudformationParameters {
parts := strings.Split(param, "=")
if len(parts) != 2 {
return nil, oops.Errorf("cloudformation parameter (%s) should be = delimited and have 2 parts it has %d parts", param, len(parts))
}

params = append(params, &cloudformation.Parameter{
ParameterKey: aws.String(parts[0]),
ParameterValue: aws.String(parts[1]),
})
}

return params, nil
}

func (s Stack) CloudformationStackName() *string {
// Replace:
// - "/" with "-", "/" appears in the path
// - "." with "-", "." shows up with "".yml" or ".json"
name := strings.ReplaceAll(strings.ReplaceAll(s.Path, "/", "-"), ".", "-")
if s.Name != "" {
name = strings.ReplaceAll(s.Name, " ", "-") + "-" + name
}

return &name
}

func (s Stack) ChangeSetName() *string {
changeSetName := fmt.Sprintf("telophase-%s-%d", *s.CloudformationStackName(), time.Now().Unix())
return &changeSetName
}
2 changes: 2 additions & 0 deletions resourceoperation/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ func CollectAccountOps(
ops = append(ops, NewTFOperation(consoleUI, acct, stack, operation))
} else if stack.Type == "CDK" {
ops = append(ops, NewCDKOperation(consoleUI, acct, stack, operation))
} else if stack.Type == "Cloudformation" {
ops = append(ops, NewCloudformationOperation(consoleUI, acct, stack, operation))
}
}

Expand Down
202 changes: 202 additions & 0 deletions resourceoperation/cloudformation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package resourceoperation

import (
"context"
"fmt"
"io/ioutil"
"strings"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudformation"
"github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface"
"github.com/samsarahq/go/oops"
"github.com/santiago-labs/telophasecli/cmd/runner"
"github.com/santiago-labs/telophasecli/lib/awssess"
"github.com/santiago-labs/telophasecli/resource"
)

type cloudformationOp struct {
Account *resource.Account
Operation int
Stack resource.Stack
OutputUI runner.ConsoleUI
DependentOperations []ResourceOperation
CloudformationClient cloudformationiface.CloudFormationAPI
}

func NewCloudformationOperation(consoleUI runner.ConsoleUI, acct *resource.Account, stack resource.Stack, op int) ResourceOperation {
cfg := aws.NewConfig()
if stack.Region != "" {
cfg.WithRegion(stack.Region)
}
sess := session.Must(awssess.DefaultSession(cfg))
creds := stscreds.NewCredentials(sess, *stack.RoleARN(*acct))
cloudformationClient := cloudformation.New(sess,
&aws.Config{
Credentials: creds,
})

return &cloudformationOp{
Account: acct,
Operation: op,
Stack: stack,
OutputUI: consoleUI,
CloudformationClient: cloudformationClient,
}
}

func (co *cloudformationOp) AddDependent(op ResourceOperation) {
co.DependentOperations = append(co.DependentOperations, op)
}

func (co *cloudformationOp) ListDependents() []ResourceOperation {
return co.DependentOperations
}

func (co *cloudformationOp) Call(ctx context.Context) error {
co.OutputUI.Print(fmt.Sprintf("Executing Cloudformation stack in %s", co.Stack.Path), *co.Account)

cs, err := co.createChangeSet(ctx)
if err != nil {
return err
}
if aws.StringValue(cs.Status) == cloudformation.ChangeSetStatusFailed {
if strings.Contains(aws.StringValue(cs.StatusReason), "The submitted information didn't contain changes") {
co.OutputUI.Print(fmt.Sprintf("change set (%s) resulted in no diff, skipping", *co.Stack.ChangeSetName()), *co.Account)
return nil
} else {
return oops.Errorf("change set failed, reason (%s)", aws.StringValue(cs.StatusReason))
}
} else {
co.OutputUI.Print("Created change set with changes:"+cs.String(), *co.Account)
}

// End call if we aren't deploying
if co.Operation != Deploy {
return nil
}

_, err = co.executeChangeSet(ctx, cs.ChangeSetId)
if err != nil {
return oops.Wrapf(err, "executing change set")
}
co.OutputUI.Print("Executed change set", *co.Account)

return nil
}

func (co *cloudformationOp) createChangeSet(ctx context.Context) (*cloudformation.DescribeChangeSetOutput, error) {
params, err := co.Stack.CloudformationParametersType()
if err != nil {
return nil, oops.Wrapf(err, "CloudformationParameters")
}

// If we can find the stack then we just update. If not then we continue on
changeSetType := cloudformation.ChangeSetTypeUpdate

stack, err := co.CloudformationClient.DescribeStacksWithContext(ctx,
&cloudformation.DescribeStacksInput{
StackName: co.Stack.CloudformationStackName(),
})
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
changeSetType = cloudformation.ChangeSetTypeCreate
// Reset err in case it is re-referenced somewhere else
err = nil
} else {
return nil, oops.Wrapf(err, "describe stack with name: (%s)", *co.Stack.CloudformationStackName())
}
} else {
if len(stack.Stacks) == 1 && aws.StringValue(stack.Stacks[0].StackStatus) == cloudformation.StackStatusReviewInProgress {
// If we reset to Create the change set the same change set will be reused.
changeSetType = cloudformation.ChangeSetTypeCreate
}
}
template, err := ioutil.ReadFile(co.Stack.Path)
if err != nil {
return nil, fmt.Errorf("failed to read stack template at path: (%s) should be a path to one file", co.Stack.Path)
}

strTemplate := string(template)
changeSet, err := co.CloudformationClient.CreateChangeSetWithContext(ctx,
&cloudformation.CreateChangeSetInput{
Parameters: params,
StackName: co.Stack.CloudformationStackName(),
ChangeSetName: co.Stack.ChangeSetName(),
TemplateBody: &strTemplate,
ChangeSetType: &changeSetType,
},
)
if err != nil {
return nil, oops.Wrapf(err, "createChangeSet for stack: %s", co.Stack.Name)
}

for {
cs, err := co.CloudformationClient.DescribeChangeSetWithContext(ctx,
&cloudformation.DescribeChangeSetInput{
StackName: changeSet.StackId,
ChangeSetName: changeSet.Id,
})
if err != nil {
return nil, oops.Wrapf(err, "DescribeChangeSet")
}

state := aws.StringValue(cs.Status)
switch state {
case cloudformation.ChangeSetStatusCreateInProgress:
co.OutputUI.Print(fmt.Sprintf("Still creating change set for stack: %s", *co.Stack.CloudformationStackName()), *co.Account)

case cloudformation.ChangeSetStatusCreateComplete:
co.OutputUI.Print(fmt.Sprintf("Successfully created change set for stack: %s", *co.Stack.CloudformationStackName()), *co.Account)
return cs, nil

case cloudformation.ChangeSetStatusFailed:
return cs, nil
}

time.Sleep(5 * time.Second)
}
}

func (co *cloudformationOp) executeChangeSet(ctx context.Context, changeSetID *string) (*cloudformation.DescribeChangeSetOutput, error) {
_, err := co.CloudformationClient.ExecuteChangeSetWithContext(ctx,
&cloudformation.ExecuteChangeSetInput{
ChangeSetName: changeSetID,
})
if err != nil {
return nil, oops.Wrapf(err, "executing change set")
}

for {
cs, err := co.CloudformationClient.DescribeChangeSetWithContext(ctx,
&cloudformation.DescribeChangeSetInput{
ChangeSetName: changeSetID,
})
if err != nil {
return nil, oops.Wrapf(err, "DescribeChangeSet")
}

state := aws.StringValue(cs.ExecutionStatus)
switch state {
case cloudformation.ExecutionStatusExecuteInProgress:
co.OutputUI.Print(fmt.Sprintf("Still executing change set for stack: (%s) for path: %s", *co.Stack.CloudformationStackName(), co.Stack.Path), *co.Account)

case cloudformation.ExecutionStatusExecuteComplete:
co.OutputUI.Print(fmt.Sprintf("Successfully executed change set for stack: (%s) for path: %s", *co.Stack.CloudformationStackName(), co.Stack.Path), *co.Account)
return cs, nil

case cloudformation.ExecutionStatusExecuteFailed:
co.OutputUI.Print(fmt.Sprintf("Failed to execute change set: (%s) for path: %s", *co.Stack.CloudformationStackName(), co.Stack.Path), *co.Account)
return cs, oops.Errorf("ExecuteChangeSet failed")
}

time.Sleep(5 * time.Second)
}
}

func (co *cloudformationOp) ToString() string {
return ""
}

0 comments on commit 2d4d5cb

Please sign in to comment.