-
Notifications
You must be signed in to change notification settings - Fork 69
/
stackset_deploy.go
438 lines (376 loc) · 15.6 KB
/
stackset_deploy.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
package stackset
import (
"fmt"
"os"
"path/filepath"
"regexp"
"github.com/aws-cloudformation/rain/cft"
"github.com/aws-cloudformation/rain/cft/format"
"github.com/aws-cloudformation/rain/internal/aws/cfn"
"github.com/aws-cloudformation/rain/internal/cmd/deploy"
"github.com/aws-cloudformation/rain/internal/config"
"github.com/aws-cloudformation/rain/internal/console"
"github.com/aws-cloudformation/rain/internal/console/spinner"
"github.com/aws-cloudformation/rain/internal/dc"
"github.com/aws-cloudformation/rain/internal/ui"
"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
"github.com/aws/smithy-go/ptr"
"golang.org/x/exp/constraints"
"gopkg.in/yaml.v3"
"github.com/spf13/cobra"
)
type configFormat struct {
Parameters map[string]string `yaml:"Parameters"`
Tags map[string]string `yaml:"Tags"`
StackSet cfn.StackSetConfig `yaml:"StackSet"`
StackSetInstanses cfn.StackSetInstancesConfig `yaml:"StackSetInstanses"`
}
var accounts []string
var regions []string
var detach bool
var yes bool
var params []string
var tags []string
var configFilePath string
var forceUpdate bool
var ignoreStackInstances bool
// StackSetDeployCmd is the deploy command's entrypoint
var StackSetDeployCmd = &cobra.Command{
Use: "deploy <template> [stackset] [flags]",
Short: "Deploy a CloudFormation stack set from a local template",
Long: `Creates or updates a CloudFormation stack set <stackset> from the template file <template>.
If you don't specify a stack set name, rain will use the template filename minus its extension.
If you do not specify a template file, rain will asume that you want to add a new instance to an existing template,
If a template needs to be packaged before it can be deployed, rain will package the template first.
Rain will attempt to create an S3 bucket to store artifacts that it packages and deploys.
The bucket's name will be of the format rain-artifacts-<AWS account id>-<AWS region>.
The config flags can be used to set accounts, regions to operate and tags with parameters to use.
Configuration file with extended options can be provided along with '--config' flag in YAML or JSON format (see example file for details).
YAML:
Parameters:
Name: Value
Tags:
Name: Value
StackSet:
description: "test description"
...
StackSetInstanses:
accounts:
- "123456789123"
regions:
- us-east-1
- us-east-2
...
Account(s) and region(s) provideed as flags OVERRIDE values from configuration files. Tags and parameters from the configuration file are MERGED with CLI flag values.
`,
Args: cobra.RangeArgs(1, 2),
DisableFlagsInUseLine: false,
Run: func(cmd *cobra.Command, args []string) {
templateFilePath := args[0]
stackSetName := createStackSetName(args)
// Convert cli flags to maps
cliTagFlags := dc.ListToMap("tag", tags)
cliParamFlags := dc.ListToMap("param", params)
// Read configuration data from a file
configData := readConfiguration(configFilePath)
//ovveride config data with CLI flag values
combineConfigDataWithCliFlags(&configData, cliParamFlags, cliTagFlags, accounts, regions)
// Get current stack set if exist
spinner.Push(fmt.Sprintf("Checking current status of stack set '%s'", stackSetName))
existingStackSet, err := cfn.GetStackSet(stackSetName)
spinner.Pop()
isStacksetExists := false
if err == nil && existingStackSet.Status != types.StackSetStatusDeleted {
isStacksetExists = true
}
configData.StackSet.StackSetName = stackSetName
configData.StackSetInstanses.StackSetName = stackSetName
// Package template, if we add new instances templay is not needed
spinner.Push(fmt.Sprintf("Preparing template '%s'", templateFilePath))
configData.StackSet.Template = deploy.PackageTemplate(templateFilePath, yes)
spinner.Pop()
// Build []types.Parameter from configuration data
config.Debugln("Handling parameters")
configData.StackSet.Parameters = buildParameterTypes(configData.StackSet.Template, configData.Parameters, existingStackSet)
// Build []types.Tag from configuration data
config.Debugln("Handling tags")
configData.StackSet.Tags = dc.MakeTags(configData.Tags)
if config.Debug {
for _, param := range configData.StackSet.Parameters {
val := ptr.ToString(param.ParameterValue)
if ptr.ToBool(param.UsePreviousValue) {
val = "<previous value>"
}
config.Debugf(" %s: %s", ptr.ToString(param.ParameterKey), val)
}
}
if isStacksetExists {
if forceUpdate || console.Confirm(true, "Stack set already exists. Do you want to update it?") {
updateStackSet(configData)
if !ignoreStackInstances {
addInstances(configData)
}
} else {
fmt.Println(console.Yellow("operation was cancelled by user"))
}
} else {
createStackSet(configData)
}
},
}
func init() {
dc.FixStackNameRe = regexp.MustCompile(`[^a-zA-Z0-9]+`)
StackSetDeployCmd.Flags().StringSliceVar(&accounts, "accounts", []string{}, "accounts for which to create stack set instances")
StackSetDeployCmd.Flags().StringSliceVar(®ions, "regions", []string{}, "regions where you want to create stack set instances")
StackSetDeployCmd.Flags().BoolVarP(&detach, "detach", "d", false, "once deployment has started, don't wait around for it to finish")
StackSetDeployCmd.Flags().StringSliceVar(&tags, "tags", []string{}, "add tags to the stack; use the format key1=value1,key2=value2")
StackSetDeployCmd.Flags().StringSliceVar(¶ms, "params", []string{}, "set parameter values; use the format key1=value1,key2=value2")
StackSetDeployCmd.Flags().StringVarP(&configFilePath, "config", "c", "", "YAML or JSON file to set additional configuration parameters")
StackSetDeployCmd.Flags().BoolVarP(&forceUpdate, "yes", "y", false, "update the stackset without confirmation")
StackSetDeployCmd.Flags().BoolVarP(&ignoreStackInstances, "ignore-stack-instances", "i", false, "ignores adding or removing stack instances while updating, useful if you are managing the stack instances separately")
}
func readConfiguration(configFilePath string) configFormat {
var configData configFormat
// Read configuration file
if len(configFilePath) != 0 {
configFileContent, err := os.ReadFile(configFilePath)
if err != nil {
panic(ui.Errorf(err, "unable to read config file '%s'", configFilePath))
}
err = yaml.Unmarshal([]byte(configFileContent), &configData)
if err != nil {
panic(ui.Errorf(err, "unable to parse yaml in '%s'", configFilePath))
}
}
return configData
}
func combineConfigDataWithCliFlags(configData *configFormat, cliParams map[string]string, cliTags map[string]string, cliAccounts []string, cliRegions []string) {
// Merge Tags
for k, v := range cliTags {
if _, ok := configData.Tags[k]; ok {
fmt.Println(console.Yellow(fmt.Sprintf("tags flag overrides tag in config file: %s", k)))
}
if configData.Tags == nil {
configData.Tags = make(map[string]string)
}
configData.Tags[k] = v
}
// Merge Params
for k, v := range cliParams {
if _, ok := configData.Parameters[k]; ok {
fmt.Println(console.Yellow(fmt.Sprintf("params flag overrides parameter in config file: %s", k)))
}
if configData.Parameters == nil {
configData.Parameters = make(map[string]string)
}
configData.Parameters[k] = v
}
// Override accounts with CLI values
if len(cliAccounts) > 0 {
configData.StackSetInstanses.Accounts = cliAccounts
}
// Override regions with CLI values
if len(cliRegions) > 0 {
configData.StackSetInstanses.Regions = cliRegions
}
}
// builds stack set name out of the template filename or takes it from the cli args
func createStackSetName(args []string) string {
var stackSetName string
base := filepath.Base(args[0])
if len(args) == 2 {
stackSetName = args[1]
} else {
stackSetName = base[:len(base)-len(filepath.Ext(base))]
// Now ensure it's a valid cfc name
stackSetName = dc.FixStackNameRe.ReplaceAllString(stackSetName, "-")
if len(stackSetName) > dc.MaxStackNameLength {
stackSetName = stackSetName[:dc.MaxStackNameLength]
}
}
return stackSetName
}
// Validate if we have enough configuration data to create/update stack set instances
func isInstanceConfigDataValid(c *cfn.StackSetInstancesConfig) bool {
if c != nil &&
c.Regions != nil && len(c.Regions) > 0 &&
((c.Accounts != nil && len(c.Accounts) > 0) ||
(c.DeploymentTargets != nil && c.DeploymentTargets.OrganizationalUnitIds != nil && len(c.DeploymentTargets.OrganizationalUnitIds) > 0)) {
config.Debugf("ConfigData is valid\n")
return true
} else {
config.Debugf("ConfigData NOT valid\n")
return false
}
}
// removes non existing instances from the StackSetInstancesConfig.
func removeNonExistingInstances(c *cfn.StackSetInstancesConfig) {
// Get current stack set instances
instances, err := cfn.ListStackSetInstances(c.StackSetName)
if err != nil {
panic(ui.Errorf(err, "unable to fetch instances for stack set - '%s'", c.StackSetName))
}
var existingAccounts []string
var existingRegions []string
for _, instance := range instances {
existingAccounts = append(existingAccounts, *instance.Account)
existingRegions = append(existingRegions, *instance.Region)
}
c.Accounts = intersection(c.Accounts, existingAccounts)
c.Regions = intersection(c.Regions, existingRegions)
}
// removes existing instances from the StackSetInstancesConfig.
// We do not remove accounts because we accept list of provided
// accounts and regions as requirement to have instances in all provided
// accounts whether updated or created(added)
func removeExistingInstances(c *cfn.StackSetInstancesConfig) {
// Get current stack set instances
instances, err := cfn.ListStackSetInstances(c.StackSetName)
if err != nil {
panic(ui.Errorf(err, "unable to fetch instances for stack set - '%s'", c.StackSetName))
}
var existingRegions []string
for _, instance := range instances {
existingRegions = append(existingRegions, *instance.Region)
}
c.Regions = difference(c.Regions, existingRegions)
}
// difference returns the elements in `a` that aren't in `b`.
func difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))
for _, x := range b {
mb[x] = struct{}{}
}
var diff []string
for _, x := range a {
if _, found := mb[x]; !found {
diff = append(diff, x)
}
}
return diff
}
// returns two slices intersection
func intersection[T constraints.Ordered](pS ...[]T) []T {
hash := make(map[T]*int) // value, counter
result := make([]T, 0)
for _, slice := range pS {
duplicationHash := make(map[T]bool) // duplication checking for individual slice
for _, value := range slice {
if _, isDup := duplicationHash[value]; !isDup { // is not duplicated in slice
if counter := hash[value]; counter != nil { // is found in hash counter map
if *counter++; *counter >= len(pS) { // is found in every slice
result = append(result, value)
}
} else { // not found in hash counter map
i := 1
hash[value] = &i
}
duplicationHash[value] = true
}
}
}
return result
}
// creates stack set along with stack instances
func createStackSet(configData configFormat) {
stackSetConfig := configData.StackSet
stackSetConfig.StackSetName = configData.StackSet.StackSetName
stackSetConfig.Parameters = configData.StackSet.Parameters
stackSetConfig.Tags = configData.StackSet.Tags
config.Debugf("Stack Set Configuration: \n%s\n", format.PrettyPrint(stackSetConfig))
stackSetConfig.Template = configData.StackSet.Template
// Create Stack Set
spinner.Push("Creating stack set")
stackSetId, err := cfn.CreateStackSet(stackSetConfig)
spinner.Pop()
if err != nil || stackSetId == nil {
panic(ui.Errorf(err, "error while creating stack set '%s' ", configData.StackSet.StackSetName))
} else {
fmt.Printf("Stack set has been created successfuly with ID: %s\n", *stackSetId)
}
// we create instances only if there is enough configuration data was provided in a config file or as cli arguments
if isInstanceConfigDataValid(&configData.StackSetInstanses) {
stackSetInstancesConfig := configData.StackSetInstanses
stackSetInstancesConfig.StackSetName = configData.StackSet.StackSetName
stackSetInstancesConfig.CallAs = configData.StackSet.CallAs
config.Debugf("Stack Set Instances Configuration: \n%s\n", format.PrettyPrint(stackSetInstancesConfig))
// Create Stack Set instances
spinner.Push("Creating stack set instances")
err = cfn.CreateStackSetInstances(stackSetInstancesConfig, !detach)
spinner.Pop()
if err != nil {
panic(ui.Errorf(err, "error while creating stack set instances"))
}
if !detach {
fmt.Println("Stack set instances have been created successfully")
} else {
fmt.Println("Stack set instances creation was initiated successfuly")
}
} else {
fmt.Println("Not enough information provided to create stack set instance(s). Please use configuration file or provide account(s) and region(s) for deployment as command argiments")
}
}
// converts 'string' parameters to typed objects
func buildParameterTypes(template cft.Template, combinedParams map[string]string, stackSet *types.StackSet) []types.Parameter {
defer func() { //catch or finally
if err := recover(); err != nil { //catch
panic(fmt.Errorf("error occured while handling parameters: %v", err))
}
}()
var oldParams []types.Parameter
var stackSetExist = false
if stackSet != nil {
oldParams = stackSet.Parameters
stackSetExist = true
}
return dc.GetParameters(template, combinedParams, oldParams, stackSetExist, true, false)
}
// updates existing stack set and all its instances
func updateStackSet(configData configFormat) {
config.Debugf("Updating Stack Set: %s\nStack Set Configuration: \n%s\nStack Set Instances Configuration: \n%s\n",
configData.StackSet.StackSetName, format.PrettyPrint(configData.StackSet), format.PrettyPrint(configData.StackSetInstanses))
// remove accounts and regions for the instances that do not exist, removed instances supposed to be created but not updated
if !ignoreStackInstances {
removeNonExistingInstances(&configData.StackSetInstanses)
}
// check if we have instances left to update after filtering
if !ignoreStackInstances && !isInstanceConfigDataValid(&configData.StackSetInstanses) {
fmt.Println("There is no instances to update.")
return
}
// Update Stack Set with its instances
spinner.Push("Updating stack set")
// making a copy to avoid mutating the global configuration
stackSetInstances := configData.StackSetInstanses
if ignoreStackInstances {
stackSetInstances.Accounts = nil
stackSetInstances.Regions = nil
}
err := cfn.UpdateStackSet(configData.StackSet, stackSetInstances, !detach)
spinner.Pop()
if err != nil {
panic(ui.Errorf(err, "error occurred while updating stack set '%s' ", configData.StackSetInstanses.StackSetName))
} else {
fmt.Println("Stack set update has been completed.")
}
}
// adds stack set instances to an existing stack set
func addInstances(configData configFormat) {
config.Debugf("Adding Stack Set instance(s): %s\nStack Set Configuration: \n%s\nStack Set Instances Configuration: \n%s\n",
configData.StackSet.StackSetName, format.PrettyPrint(configData.StackSet), format.PrettyPrint(configData.StackSetInstanses))
// remove existing instances from configData
removeExistingInstances(&configData.StackSetInstanses)
// check if we have accounts and regions to update
if !isInstanceConfigDataValid(&configData.StackSetInstanses) {
fmt.Println("There are no new instances to be created.")
os.Exit(0)
}
spinner.Push("Adding stack set instances")
err := cfn.AddStackSetInstances(configData.StackSet, configData.StackSetInstanses, !detach)
spinner.Pop()
if err != nil {
panic(ui.Errorf(err, "error occurred while adding stack set instances for stack set'%s' ", configData.StackSet.StackSetName))
} else {
fmt.Println("Stack set update has been completed.")
}
}