Skip to content

Commit 050af97

Browse files
committed
resource: support cloudformation stack type
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.
1 parent 2f3c13e commit 050af97

File tree

5 files changed

+325
-0
lines changed

5 files changed

+325
-0
lines changed
+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
AWSTemplateFormatVersion: "2010-09-09"
2+
3+
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.'
4+
5+
Metadata:
6+
License: Apache-2.0
7+
8+
Parameters:
9+
HashKeyElementName:
10+
Description: HashType PrimaryKey Name
11+
Type: String
12+
AllowedPattern: '[a-zA-Z0-9]*'
13+
MinLength: "1"
14+
MaxLength: "2048"
15+
ConstraintDescription: must contain only alphanumberic characters
16+
17+
HashKeyElementType:
18+
Description: HashType PrimaryKey Type
19+
Type: String
20+
Default: S
21+
AllowedPattern: '[S|N]'
22+
MinLength: "1"
23+
MaxLength: "1"
24+
ConstraintDescription: must be either S or N
25+
26+
ReadCapacityUnits:
27+
Description: Provisioned read throughput
28+
Type: Number
29+
Default: "5"
30+
MinValue: "5"
31+
MaxValue: "10000"
32+
ConstraintDescription: must be between 5 and 10000
33+
34+
WriteCapacityUnits:
35+
Description: Provisioned write throughput
36+
Type: Number
37+
Default: "10"
38+
MinValue: "5"
39+
MaxValue: "10000"
40+
ConstraintDescription: must be between 5 and 10000
41+
42+
Resources:
43+
myDynamoDBTable:
44+
Type: AWS::DynamoDB::Table
45+
Properties:
46+
AttributeDefinitions:
47+
- AttributeName: !Ref HashKeyElementName
48+
AttributeType: !Ref HashKeyElementType
49+
KeySchema:
50+
- AttributeName: !Ref HashKeyElementName
51+
KeyType: HASH
52+
ProvisionedThroughput:
53+
ReadCapacityUnits: !Ref ReadCapacityUnits
54+
WriteCapacityUnits: !Ref WriteCapacityUnits
55+
56+
Outputs:
57+
TableName:
58+
Description: Table name of the newly created DynamoDB table
59+
Value: !Ref myDynamoDBTable
60+

resource/account.go

+14
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,20 @@ func (a Account) AllBaselineStacks() ([]Stack, error) {
139139
returnStacks = append(returnStacks, currStack)
140140
}
141141

142+
cloudformationStackNames := map[string]struct{}{}
143+
for _, stack := range returnStacks {
144+
if err := stack.Validate(); err != nil {
145+
return nil, err
146+
}
147+
148+
if stack.Type == "Cloudformation" {
149+
if _, ok := cloudformationStackNames[*stack.CloudformationStackName()]; ok {
150+
return nil, oops.Errorf("Multiple Cloudformation stacks have the same Name: (%s) and Path (%s). Please set a distinct Name", stack.Name, stack.Path)
151+
}
152+
cloudformationStackNames[*stack.CloudformationStackName()] = struct{}{}
153+
}
154+
}
155+
142156
return returnStacks, nil
143157
}
144158

resource/stack.go

+47
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package resource
22

33
import (
44
"fmt"
5+
"strings"
6+
"time"
57

8+
"github.com/aws/aws-sdk-go/aws"
9+
"github.com/aws/aws-sdk-go/service/cloudformation"
610
"github.com/samsarahq/go/oops"
711
)
812

@@ -15,6 +19,8 @@ type Stack struct {
1519
RoleOverrideARNDeprecated string `yaml:"RoleOverrideARN,omitempty"` // Deprecated
1620
AssumeRoleName string `yaml:"AssumeRoleName,omitempty"`
1721
Workspace string `yaml:"Workspace,omitempty"`
22+
23+
CloudformationParameters []string `yaml:"CloudformationParameters,omitempty"`
1824
}
1925

2026
func (s Stack) NewForRegion(region string) Stack {
@@ -26,6 +32,7 @@ func (s Stack) NewForRegion(region string) Stack {
2632
RoleOverrideARNDeprecated: s.RoleOverrideARNDeprecated,
2733
AssumeRoleName: s.AssumeRoleName,
2834
Workspace: s.Workspace,
35+
CloudformationParameters: s.CloudformationParameters,
2936
}
3037
}
3138

@@ -62,10 +69,50 @@ func (s Stack) Validate() error {
6269
}
6370
return nil
6471

72+
case "Cloudformation":
73+
if _, err := s.CloudformationParametersType(); err != nil {
74+
return oops.Wrapf(err, "")
75+
}
76+
return nil
77+
6578
case "":
6679
return oops.Errorf("stack type needs to be set for stack: %+v", s)
6780

6881
default:
6982
return oops.Errorf("only support stack types of `Terraform` and `CDK` not: %s", s.Type)
7083
}
7184
}
85+
86+
func (s Stack) CloudformationParametersType() ([]*cloudformation.Parameter, error) {
87+
var params []*cloudformation.Parameter
88+
for _, param := range s.CloudformationParameters {
89+
parts := strings.Split(param, "=")
90+
if len(parts) != 2 {
91+
return nil, oops.Errorf("cloudformation parameter (%s) should be = delimited and have 2 parts it has %d parts", param, len(parts))
92+
}
93+
94+
params = append(params, &cloudformation.Parameter{
95+
ParameterKey: aws.String(parts[0]),
96+
ParameterValue: aws.String(parts[1]),
97+
})
98+
}
99+
100+
return params, nil
101+
}
102+
103+
func (s Stack) CloudformationStackName() *string {
104+
// Replace:
105+
// - "/" with "-", "/" appears in the path
106+
// - "." with "-", "." shows up with "".yml" or ".json"
107+
name := strings.ReplaceAll(strings.ReplaceAll(s.Path, "/", "-"), ".", "-")
108+
if s.Name != "" {
109+
name = strings.ReplaceAll(s.Name, " ", "-") + "-" + name
110+
}
111+
112+
return &name
113+
}
114+
115+
func (s Stack) ChangeSetName() *string {
116+
changeSetName := fmt.Sprintf("telophase-%s-%d", *s.CloudformationStackName(), time.Now().Unix())
117+
return &changeSetName
118+
}

resourceoperation/account.go

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ func CollectAccountOps(
7777
ops = append(ops, NewTFOperation(consoleUI, acct, stack, operation))
7878
} else if stack.Type == "CDK" {
7979
ops = append(ops, NewCDKOperation(consoleUI, acct, stack, operation))
80+
} else if stack.Type == "Cloudformation" {
81+
ops = append(ops, NewCloudformationOperation(consoleUI, acct, stack, operation))
8082
}
8183
}
8284

resourceoperation/cloudformation.go

+202
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package resourceoperation
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io/ioutil"
7+
"strings"
8+
"time"
9+
10+
"github.com/aws/aws-sdk-go/aws"
11+
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
12+
"github.com/aws/aws-sdk-go/aws/session"
13+
"github.com/aws/aws-sdk-go/service/cloudformation"
14+
"github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface"
15+
"github.com/samsarahq/go/oops"
16+
"github.com/santiago-labs/telophasecli/cmd/runner"
17+
"github.com/santiago-labs/telophasecli/lib/awssess"
18+
"github.com/santiago-labs/telophasecli/resource"
19+
)
20+
21+
type cloudformationOp struct {
22+
Account *resource.Account
23+
Operation int
24+
Stack resource.Stack
25+
OutputUI runner.ConsoleUI
26+
DependentOperations []ResourceOperation
27+
CloudformationClient cloudformationiface.CloudFormationAPI
28+
}
29+
30+
func NewCloudformationOperation(consoleUI runner.ConsoleUI, acct *resource.Account, stack resource.Stack, op int) ResourceOperation {
31+
cfg := aws.NewConfig()
32+
if stack.Region != "" {
33+
cfg.WithRegion(stack.Region)
34+
}
35+
sess := session.Must(awssess.DefaultSession(cfg))
36+
creds := stscreds.NewCredentials(sess, *stack.RoleARN(*acct))
37+
cloudformationClient := cloudformation.New(sess,
38+
&aws.Config{
39+
Credentials: creds,
40+
})
41+
42+
return &cloudformationOp{
43+
Account: acct,
44+
Operation: op,
45+
Stack: stack,
46+
OutputUI: consoleUI,
47+
CloudformationClient: cloudformationClient,
48+
}
49+
}
50+
51+
func (co *cloudformationOp) AddDependent(op ResourceOperation) {
52+
co.DependentOperations = append(co.DependentOperations, op)
53+
}
54+
55+
func (co *cloudformationOp) ListDependents() []ResourceOperation {
56+
return co.DependentOperations
57+
}
58+
59+
func (co *cloudformationOp) Call(ctx context.Context) error {
60+
co.OutputUI.Print(fmt.Sprintf("Executing Cloudformation stack in %s", co.Stack.Path), *co.Account)
61+
62+
cs, err := co.createChangeSet(ctx)
63+
if err != nil {
64+
return err
65+
}
66+
if aws.StringValue(cs.Status) == cloudformation.ChangeSetStatusFailed {
67+
if strings.Contains(aws.StringValue(cs.StatusReason), "The submitted information didn't contain changes") {
68+
co.OutputUI.Print(fmt.Sprintf("change set (%s) resulted in no diff, skipping", *co.Stack.ChangeSetName()), *co.Account)
69+
return nil
70+
} else {
71+
return oops.Errorf("change set failed, reason (%s)", aws.StringValue(cs.StatusReason))
72+
}
73+
} else {
74+
co.OutputUI.Print("Created change set with changes:"+cs.String(), *co.Account)
75+
}
76+
77+
// End call if we aren't deploying
78+
if co.Operation != Deploy {
79+
return nil
80+
}
81+
82+
_, err = co.executeChangeSet(ctx, cs.ChangeSetId)
83+
if err != nil {
84+
return oops.Wrapf(err, "executing change set")
85+
}
86+
co.OutputUI.Print("Executed change set", *co.Account)
87+
88+
return nil
89+
}
90+
91+
func (co *cloudformationOp) createChangeSet(ctx context.Context) (*cloudformation.DescribeChangeSetOutput, error) {
92+
params, err := co.Stack.CloudformationParametersType()
93+
if err != nil {
94+
return nil, oops.Wrapf(err, "CloudformationParameters")
95+
}
96+
97+
// If we can find the stack then we just update. If not then we continue on
98+
changeSetType := cloudformation.ChangeSetTypeUpdate
99+
100+
stack, err := co.CloudformationClient.DescribeStacksWithContext(ctx,
101+
&cloudformation.DescribeStacksInput{
102+
StackName: co.Stack.CloudformationStackName(),
103+
})
104+
if err != nil {
105+
if strings.Contains(err.Error(), "does not exist") {
106+
changeSetType = cloudformation.ChangeSetTypeCreate
107+
// Reset err in case it is re-referenced somewhere else
108+
err = nil
109+
} else {
110+
return nil, oops.Wrapf(err, "describe stack with name: (%s)", *co.Stack.CloudformationStackName())
111+
}
112+
} else {
113+
if len(stack.Stacks) == 1 && aws.StringValue(stack.Stacks[0].StackStatus) == cloudformation.StackStatusReviewInProgress {
114+
// If we reset to Create the change set the same change set will be reused.
115+
changeSetType = cloudformation.ChangeSetTypeCreate
116+
}
117+
}
118+
template, err := ioutil.ReadFile(co.Stack.Path)
119+
if err != nil {
120+
return nil, fmt.Errorf("failed to read stack template at path: (%s) should be a path to one file", co.Stack.Path)
121+
}
122+
123+
strTemplate := string(template)
124+
changeSet, err := co.CloudformationClient.CreateChangeSetWithContext(ctx,
125+
&cloudformation.CreateChangeSetInput{
126+
Parameters: params,
127+
StackName: co.Stack.CloudformationStackName(),
128+
ChangeSetName: co.Stack.ChangeSetName(),
129+
TemplateBody: &strTemplate,
130+
ChangeSetType: &changeSetType,
131+
},
132+
)
133+
if err != nil {
134+
return nil, oops.Wrapf(err, "createChangeSet for stack: %s", co.Stack.Name)
135+
}
136+
137+
for {
138+
cs, err := co.CloudformationClient.DescribeChangeSetWithContext(ctx,
139+
&cloudformation.DescribeChangeSetInput{
140+
StackName: changeSet.StackId,
141+
ChangeSetName: changeSet.Id,
142+
})
143+
if err != nil {
144+
return nil, oops.Wrapf(err, "DescribeChangeSet")
145+
}
146+
147+
state := aws.StringValue(cs.Status)
148+
switch state {
149+
case cloudformation.ChangeSetStatusCreateInProgress:
150+
co.OutputUI.Print(fmt.Sprintf("Still creating change set for stack: %s", *co.Stack.CloudformationStackName()), *co.Account)
151+
152+
case cloudformation.ChangeSetStatusCreateComplete:
153+
co.OutputUI.Print(fmt.Sprintf("Successfully created change set for stack: %s", *co.Stack.CloudformationStackName()), *co.Account)
154+
return cs, nil
155+
156+
case cloudformation.ChangeSetStatusFailed:
157+
return cs, nil
158+
}
159+
160+
time.Sleep(5 * time.Second)
161+
}
162+
}
163+
164+
func (co *cloudformationOp) executeChangeSet(ctx context.Context, changeSetID *string) (*cloudformation.DescribeChangeSetOutput, error) {
165+
_, err := co.CloudformationClient.ExecuteChangeSetWithContext(ctx,
166+
&cloudformation.ExecuteChangeSetInput{
167+
ChangeSetName: changeSetID,
168+
})
169+
if err != nil {
170+
return nil, oops.Wrapf(err, "executing change set")
171+
}
172+
173+
for {
174+
cs, err := co.CloudformationClient.DescribeChangeSetWithContext(ctx,
175+
&cloudformation.DescribeChangeSetInput{
176+
ChangeSetName: changeSetID,
177+
})
178+
if err != nil {
179+
return nil, oops.Wrapf(err, "DescribeChangeSet")
180+
}
181+
182+
state := aws.StringValue(cs.ExecutionStatus)
183+
switch state {
184+
case cloudformation.ExecutionStatusExecuteInProgress:
185+
co.OutputUI.Print(fmt.Sprintf("Still executing change set for stack: (%s) for path: %s", *co.Stack.CloudformationStackName(), co.Stack.Path), *co.Account)
186+
187+
case cloudformation.ExecutionStatusExecuteComplete:
188+
co.OutputUI.Print(fmt.Sprintf("Successfully executed change set for stack: (%s) for path: %s", *co.Stack.CloudformationStackName(), co.Stack.Path), *co.Account)
189+
return cs, nil
190+
191+
case cloudformation.ExecutionStatusExecuteFailed:
192+
co.OutputUI.Print(fmt.Sprintf("Failed to execute change set: (%s) for path: %s", *co.Stack.CloudformationStackName(), co.Stack.Path), *co.Account)
193+
return cs, oops.Errorf("ExecuteChangeSet failed")
194+
}
195+
196+
time.Sleep(5 * time.Second)
197+
}
198+
}
199+
200+
func (co *cloudformationOp) ToString() string {
201+
return ""
202+
}

0 commit comments

Comments
 (0)