awx 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ require 'fileutils'
2
+ require 'parseconfig'
3
+
4
+ module App
5
+
6
+ class Config
7
+
8
+ @params = {}
9
+
10
+ extend ConfigUnique
11
+ include ConfigUnique
12
+
13
+ def self.initialize
14
+ if config_file_exists?
15
+ run_load_config
16
+ else
17
+ run_first_journey
18
+ end
19
+ end
20
+
21
+ def self.config_file_exists?
22
+ unless File.exists? ("#{File.expand_path(CONFIG_FILE)}")
23
+ return false
24
+ end
25
+ true
26
+ end
27
+
28
+ def self.run_load_config
29
+ config_params_get
30
+ config_params_validate
31
+ end
32
+
33
+ def self.run_first_journey
34
+ first_journey_message
35
+ config_file_create
36
+ config_file_edit
37
+ config_params_get
38
+ config_params_validate(true)
39
+ end
40
+
41
+ def self.first_journey_message
42
+ puts
43
+ puts "Thank you for installing the #{Blufin::Terminal::format_highlight("#{GEM_NAME}-cli")} ruby gem."
44
+ puts "CLI stands for 'Command Line Interface'."
45
+ puts
46
+ puts "The first thing you'll need to do is setup your configuration file."
47
+ puts "The file is located at: #{Blufin::Terminal::format_directory(ConfigUnique::CONFIG_FILE)}"
48
+ puts
49
+ puts "You probably won't have this file so the program will create it for you."
50
+ puts "\n"
51
+ unless Blufin::Terminal::prompt_yes_no('Create configuration file?')
52
+ Blufin::Terminal::abort
53
+ end
54
+ end
55
+
56
+ def self.config_file_edit
57
+ system("nano #{File.expand_path(CONFIG_FILE)}")
58
+ end
59
+
60
+ def self.config_params_get
61
+ config = ParseConfig.new("#{File.expand_path(CONFIG_FILE)}")
62
+ config.get_params.each do |param|
63
+ @params[param] = config[param]
64
+ end
65
+ end
66
+
67
+ def self.config_params_validate(show_success_message = false)
68
+ error_text = ''
69
+ missing_keys = get_missing_config_keys
70
+ if missing_keys.any?
71
+ missing_keys.each do |key|
72
+ error_text = "#{error_text}\x1B[38;5;196mKey/value must exist and cannot be null:\x1B[0m \x1B[38;5;240m#{key}\x1B[0m\n"
73
+ end
74
+ error_text = error_text[0..-2]
75
+ end
76
+ unless error_text == ''
77
+ show_error_message(error_text)
78
+ end
79
+ if show_success_message
80
+ Blufin::Terminal::success('Configuration parameters are correct.', "You are now ready to start using this utility.\nStart by typing #{Blufin::Terminal::format_command("#{GEM_NAME} --help")} (or #{Blufin::Terminal::format_command("#{GEM_NAME} -h")}).")
81
+ exit
82
+ end
83
+ end
84
+
85
+ def self.show_error_message(error_text)
86
+ Blufin::Terminal::error('Your configuration parameters are invalid.', "#{error_text}\n\nYou can fix this by running #{Blufin::Terminal::format_command("#{GEM_NAME} setup")} (or #{Blufin::Terminal::format_command("#{GEM_NAME} x")}).")
87
+ exit
88
+ end
89
+
90
+ def self.get_missing_config_keys
91
+ missing_keys = required_config_keys.dup
92
+ @params.each do |key, value|
93
+ unless value == ''
94
+ missing_keys.delete(key)
95
+ end
96
+ end
97
+ missing_keys
98
+ end
99
+
100
+ # Checks if a config parameter exists.
101
+ # @return boolean
102
+ def self.param_exists(param_name)
103
+ @params.has_key?(param_name)
104
+ end
105
+
106
+ # Get ALL config parameters.
107
+ # @return Hash
108
+ def self.params
109
+ @params
110
+ end
111
+
112
+ # Get custom keys from the config file (IE: ssh_ec2=user|host|pem)
113
+ # @return String
114
+ def self.get_custom_key(prefix, config_key)
115
+ if config_key.nil?
116
+ Blufin::Terminal::error("The script requires a config parameter from your #{Blufin::Terminal::format_directory(ConfigUnique::CONFIG_FILE)} file", "The key should have a prefix of: #{Blufin::Terminal::format_highlight(prefix)}", true)
117
+ end
118
+ config_param = (config_key =~ /\A#{prefix}\S+\z/i) ? config_key : "#{prefix}#{config_key}"
119
+ unless App::Config::param_exists(config_param)
120
+ Blufin::Terminal::error('Invalid config parameter', "Cannot find #{Blufin::Terminal::format_highlight('key')} #{Blufin::Terminal::format_invalid("\"#{config_param}\"")} in: #{Blufin::Terminal::format_directory(ConfigUnique::CONFIG_FILE)}", true)
121
+ end
122
+ App::Config.param(config_param)
123
+ end
124
+
125
+ end
126
+
127
+ end
@@ -0,0 +1,64 @@
1
+ module ConfigUnique
2
+
3
+ @params = {}
4
+
5
+ # USED BY CONFIG CLASS
6
+ CONFIG_FILE = '~/.awxrc'
7
+ GEM_NAME = 'awx'
8
+
9
+ # STORED PARAMETERS
10
+ PATH_TO_REPO_AWX = 'path_to_repo_awx'
11
+ PATH_TO_REPO_SECRETS = 'path_to_repo_secrets'
12
+ PATH_TO_LAMBDA_EWORLD = 'path_to_lambda_eworld'
13
+
14
+ def param(param_name)
15
+
16
+ raise RuntimeError, "Expected String, instead got:#{param_name.class}" unless param_name.is_a?(String)
17
+
18
+ unless [
19
+ PATH_TO_REPO_AWX,
20
+ PATH_TO_REPO_SECRETS,
21
+ PATH_TO_LAMBDA_EWORLD
22
+ ].include?(param_name)
23
+ unless param_name =~ /^ssh_[a-z_]+$/
24
+ raise RuntimeError, "#{Blufin::Terminal::format_highlight(param_name)} is not a valid config parameter"
25
+ end
26
+ end
27
+
28
+ param_value = @params[param_name]
29
+ return nil if param_value.nil?
30
+
31
+ if [
32
+ PATH_TO_REPO_AWX,
33
+ PATH_TO_REPO_SECRETS,
34
+ PATH_TO_LAMBDA_EWORLD
35
+ ].include?(param_name)
36
+ begin
37
+ return "/#{Blufin::Strings.remove_surrounding_slashes(File.expand_path(param_value))}"
38
+ rescue Exception => e
39
+ Blufin::Terminal::error("Something went wrong trying to get parameter: #{param_name}", e.message)
40
+ end
41
+ end
42
+
43
+ param_value
44
+ end
45
+
46
+ def config_file_create
47
+ File.open("#{File.expand_path(CONFIG_FILE)}", 'w') { |file|
48
+ file.write("# CONFIGURATION FILE -- Make sure that ALL parameters are correct before saving.\n")
49
+ file.write("\n")
50
+ file.write("#{PATH_TO_REPO_AWX}=\n")
51
+ file.write("#{PATH_TO_REPO_SECRETS}=\n")
52
+ file.write("#{PATH_TO_LAMBDA_EWORLD}=\n")
53
+ }
54
+ end
55
+
56
+ def required_config_keys
57
+ [
58
+ PATH_TO_REPO_AWX,
59
+ PATH_TO_REPO_SECRETS,
60
+ PATH_TO_LAMBDA_EWORLD
61
+ ]
62
+ end
63
+
64
+ end
@@ -0,0 +1,17 @@
1
+ module App
2
+
3
+ class Opt
4
+
5
+ OPT_PATH_YML = '/opt/yml'
6
+
7
+ # Get PATH to opt files.
8
+ # @return String
9
+ def self.get_base_path
10
+ base_path = File.dirname(File.expand_path(__FILE__))
11
+ base_path = base_path.gsub(/\/\w+\/\w+\z/i, '')
12
+ base_path
13
+ end
14
+
15
+ end
16
+
17
+ end
@@ -0,0 +1,732 @@
1
+ require 'json'
2
+ require 'securerandom'
3
+ require 'digest'
4
+
5
+ module AppCommand
6
+
7
+ class AWSCloudFormationCreate < ::Convoy::ActionCommand::Base
8
+
9
+ DEFAULT = 'Default'
10
+ DESCRIPTION = 'Description'
11
+ PARAMETERS = 'Parameters'
12
+ SPECIAL_METHODS = [:before_create, :after_create, :before_teardown, :after_teardown]
13
+ OPTION_TAGS = %w(stackname description environment project region resource cache_uuid)
14
+ OPTION_STACK_NAME = 'StackName'
15
+ OPTION_DESCRIPTION = 'Description'
16
+ OPTION_PROJECT = 'Project'
17
+ OPTION_ENVIRONMENT = 'Environment'
18
+ OPTION_REGION = 'Region'
19
+ MATCHERS = %w(CATEGORY TEMPLATE PROJECT ENVIRONMENT REGION UUID)
20
+ OPTIONS = 'Options'
21
+ SPECIAL = 'AWS-specific'
22
+ CACHE_UUID = 'cache_uuid'
23
+ TEST = 'test'
24
+ SPACER = '<<--Spacer-->>'
25
+
26
+ def execute
27
+
28
+ begin
29
+
30
+ @opts = command_options
31
+ @args = arguments
32
+
33
+ @template = nil
34
+ @templates = {}
35
+ @params = {}
36
+ @output = {}
37
+ @regions = App::AWSCli::get_regions
38
+ @auto_fetch_resources = {}
39
+ @auto_fetch_cache = {}
40
+ @data = nil
41
+ @table_widths = {}
42
+ @export_map = {}
43
+ @columns = {}
44
+ @options_default = {} # The options from the cloudformation.rb file (default for all).
45
+ @cache = {}
46
+
47
+ @columns, @data, @export_map, @table_widths = App::AWSReports::parse_metadata(@regions)
48
+
49
+ opts_validate
50
+ opts_routing
51
+
52
+ rescue => e
53
+
54
+ Blufin::Terminal::print_exception(e)
55
+
56
+ end
57
+
58
+ end
59
+
60
+ def opts_validate
61
+
62
+ # If Terminal window is smaller than 230, bomb-out.
63
+ terminal_width_actual = Blufin::Terminal::get_terminal_width
64
+ terminal_required_width = 227
65
+ Blufin::Terminal::error("Output for this command \x1B[38;5;240m(#{Blufin::Terminal::format_action(terminal_required_width)}\x1B[38;5;240m-width)\x1B[0m does not fit in Terminal \x1B[38;5;240m(#{Blufin::Terminal::format_action(terminal_width_actual)}\x1B[38;5;240m-width)\x1B[0m", 'Please make your terminal wider and try again.', true) if terminal_width_actual < terminal_required_width
66
+
67
+ @warnings = []
68
+ @auto_fetch_resources = App::AWSReports::get_auto_fetch_resources(@data)
69
+
70
+ # Validate the cloudformation.rb file that sits in the root of: blufin-secrets/cloudformation.
71
+ cloudformation_defaults_file = "#{App::AWSCloudFormation::get_cloudformation_path}/#{App::AWSCloudFormation::FILE_CLOUDFORMATION_DEFAULTS}"
72
+ if Blufin::Files::file_exists(cloudformation_defaults_file)
73
+ require cloudformation_defaults_file
74
+ expected_constants = %w(STACK_NAME PROJECTS ENVIRONMENTS REGIONS)
75
+ CloudFormation::Defaults::constants.each do |constant|
76
+ constant = constant.to_s
77
+ expected_constants.delete(constant) if expected_constants.include?(constant)
78
+ @options_default[OPTION_STACK_NAME] = CloudFormation::Defaults::STACK_NAME if constant == 'STACK_NAME'
79
+ @options_default[OPTION_PROJECT] = CloudFormation::Defaults::PROJECTS if constant == 'PROJECTS'
80
+ @options_default[OPTION_ENVIRONMENT] = CloudFormation::Defaults::ENVIRONMENTS if constant == 'ENVIRONMENTS'
81
+ @options_default[OPTION_REGION] = CloudFormation::Defaults::REGIONS if constant == 'REGIONS'
82
+ end
83
+ raise RuntimeError, "Missing expected constants: #{expected_constants.join(',')}" if expected_constants.any?
84
+ else
85
+ raise RuntimeError, "File does not exist: #{cloudformation_defaults_file}"
86
+ end
87
+
88
+ # Loop the entire blufin-secrets/cloudformation path(s) and validate all the template(s).
89
+ Blufin::Files::get_dirs_in_dir(App::AWSCloudFormation::get_cloudformation_path).each do |path|
90
+ ps = path.split('/')
91
+ category = ps[ps.length - 1]
92
+ template_folders = Blufin::Files::get_dirs_in_dir(path)
93
+ template_folders.each do |path_inner|
94
+ ps = path_inner.split('/')
95
+ template = ps[ps.length - 1]
96
+ template_name = "#{category}/#{template}"
97
+ file_cloudformation = nil
98
+ file_ruby = nil
99
+ method_before_create = nil
100
+ method_after_create = nil
101
+ method_before_teardown = nil
102
+ method_after_teardown = nil
103
+ intro = nil
104
+ stack_name = nil
105
+ projects = nil
106
+ environments = nil
107
+ regions = nil
108
+ parameters = {}
109
+ warnings_count = @warnings.length
110
+ raise RuntimeError, "Template name must consist of [service]/[service-description] with exactly one slash, instead got: #{template_name}" if template_name.strip.split('/').length != 2
111
+ Blufin::Files::get_files_in_dir(path_inner).each do |file|
112
+ filename = Blufin::Files::extract_file_name(file)
113
+ if filename == 'template.yml'
114
+ begin
115
+ yml_data = YAML.load_file(File.expand_path(file))
116
+ rescue Exception => e
117
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Unable to parse #{Blufin::Terminal::format_highlight('template.yml')}\x1B[38;5;240m: #{e.message}"
118
+ next
119
+ end
120
+ if yml_data.is_a?(Hash)
121
+ if yml_data.has_key?(DESCRIPTION)
122
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Description must be omitted because this will be handled by the script."
123
+ end
124
+ if yml_data.has_key?(PARAMETERS) && yml_data[PARAMETERS].is_a?(Hash) && yml_data[PARAMETERS].length > 0
125
+ yml_data[PARAMETERS].each do |resource_name, data|
126
+ # Validate keys are in specific order.
127
+ expected = {
128
+ 'Type' => true,
129
+ 'Description' => false,
130
+ 'Default' => false,
131
+ 'AllowedPattern' => false,
132
+ 'MinLength' => false,
133
+ 'MinValue' => false,
134
+ 'MaxLength' => false,
135
+ 'MaxValue' => false,
136
+ 'ConstraintDescription' => false,
137
+ }
138
+ Blufin::Validate::assert_valid_keys(expected, data.keys, "#{file} \xe2\x86\x92 #{resource_name}")
139
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Template has reserved parameter: #{Blufin::Terminal::format_invalid(resource_name)}" if [OPTION_STACK_NAME.downcase].concat(OPTION_TAGS).include?(resource_name.downcase)
140
+ parameters[resource_name] = data
141
+ if @auto_fetch_resources.has_key?(resource_name)
142
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Parameter: #{resource_name} cannot have default value: '#{data['Default']}' because it is a live look-up list (from AWS)." if data.keys.include?('Default')
143
+ end
144
+ # Validate parameter type.
145
+ valid_parameter_types = %w(String Number List<Number> CommaDelimitedList)
146
+ valid_parameter_regexes = [/^AWS::[A-Za-z0-9]+::[A-Za-z0-9]+::[A-Za-z0-9]+$/, /^List<AWS::[A-Za-z0-9]+::[A-Za-z0-9]+::[A-Za-z0-9]+>$/]
147
+ unless valid_parameter_types.include?(data['Type'])
148
+ matches_vpr = false
149
+ valid_parameter_regexes.each do |vpr|
150
+ matches_vpr = true if data['Type'] =~ vpr
151
+ end
152
+ if matches_vpr
153
+ constraints = []
154
+ constraints << 'AllowedPattern' if data.has_key?('AllowedPattern')
155
+ constraints << 'MinLength' if data.has_key?('MinLength')
156
+ constraints << 'MaxLength' if data.has_key?('MaxLength')
157
+ constraints << 'MinValue' if data.has_key?('MinValue')
158
+ constraints << 'MaxValue' if data.has_key?('MaxValue')
159
+ if constraints.any?
160
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid parameter constraint(s): #{Blufin::Terminal::format_invalid(constraints.join(','))}. These don't apply to AWS-Specific parameters."
161
+ end
162
+ else
163
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid parameter type: #{Blufin::Terminal::format_invalid(data['Type'])}"
164
+ end
165
+ end
166
+ # Can only have Max/MinLength if this is a String.
167
+ if data.has_key?('MinLength') || data.has_key?('MaxLength') || data.has_key?('AllowedPattern')
168
+ unless data['Type'] == 'String'
169
+ constraints = []
170
+ constraints << 'MinLength' if data.has_key?('MinLength')
171
+ constraints << 'MaxLength' if data.has_key?('MaxLength')
172
+ constraints << 'AllowedPattern' if data.has_key?('AllowedPattern')
173
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid parameter constraint(s): #{Blufin::Terminal::format_invalid(constraints.join(','))}. To use these, type must be String."
174
+ end
175
+ end
176
+ # Can only have Max/MinValue if this is a Number.
177
+ if data.has_key?('MinValue') || data.has_key?('MaxValue')
178
+ unless data['Type'] == 'Number'
179
+ constraints = []
180
+ constraints << 'MinValue' if data.has_key?('MinValue')
181
+ constraints << 'MaxValue' if data.has_key?('MaxValue')
182
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid parameter constraint(s): #{Blufin::Terminal::format_invalid(constraints.join(','))}. To use these, type must be Number."
183
+ end
184
+ end
185
+ end
186
+ end
187
+ file_cloudformation = file
188
+ end
189
+ elsif filename == 'template.rb'
190
+ file_ruby = file
191
+ # Load the template.rb file/class.
192
+ require file
193
+ expected_constants = %w(INTRO)
194
+ Template::constants.each do |constant|
195
+ constant = constant.to_s
196
+ expected_constants.delete(constant) if expected_constants.include?(constant)
197
+ intro = Template::INTRO if constant == 'INTRO'
198
+ # Optional constants.
199
+ stack_name = Template::STACK_NAME if constant == 'STACK_NAME'
200
+ projects = Template::PROJECTS if constant == 'PROJECTS'
201
+ environments = Template::ENVIRONMENTS if constant == 'ENVIRONMENTS'
202
+ regions = Template::REGIONS if constant == 'REGIONS'
203
+ end
204
+ if stack_name.nil? || stack_name.strip == ''
205
+ stack_name = @options_default[OPTION_STACK_NAME]
206
+ else
207
+ # Validate Stack Name (if exists).
208
+ matches = stack_name.scan(/{[A-Za-z0-9]+}/)
209
+ matches.each do |match|
210
+ match = match.gsub(/^{/, '').gsub(/}$/, '')
211
+ if match != match.upcase
212
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 All stack-name matchers must be uppercase, found: #{Blufin::Terminal::format_invalid(match)}"
213
+ next
214
+ end
215
+ unless MATCHERS.include?(match)
216
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid stack-name matcher: #{Blufin::Terminal::format_invalid(match)}"
217
+ end
218
+ stack_name_stripped = stack_name.gsub(/{[A-Za-z0-9]+}/, '')
219
+ if stack_name_stripped !~ /[a-z0-9\-]/
220
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Stack-name has invalid or non-matcher, uppercase characters: #{Blufin::Terminal::format_invalid(stack_name)} \x1B[38;5;240m(#{stack_name_stripped})\x1B[0m"
221
+ end
222
+ end
223
+ end
224
+ # If any constants are missing, this should catch it.
225
+ expected_constants.each { |missing_constant| @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 The #{Blufin::Terminal::format_highlight('template.rb')}\x1B[38;5;240m file is missing a constant: #{Blufin::Terminal::format_invalid(missing_constant)}" }
226
+ Template::methods.each do |method|
227
+ if SPECIAL_METHODS.include?(method)
228
+ if method == :before_create
229
+ method_params = Template::method(:before_create).parameters
230
+ if method_params == [[:req, :params]]
231
+ method_before_create = Template::method(:before_create)
232
+ else
233
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid #{Blufin::Terminal::format_highlight(':before_create')}\x1B[38;5;240m parameters: #{Blufin::Terminal::format_invalid(method_params.inspect)}"
234
+ end
235
+ end
236
+ if method == :after_create
237
+ method_params = Template::method(:after_create).parameters
238
+ if method_params == [[:req, :params], [:req, :output]]
239
+ method_after_create = Template::method(:after_create)
240
+ else
241
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid #{Blufin::Terminal::format_highlight(':after_create')}\x1B[38;5;240m parameters: #{Blufin::Terminal::format_invalid(method_params.inspect)}"
242
+ end
243
+ end
244
+ if method == :before_teardown
245
+ method_params = Template::method(:before_teardown).parameters
246
+ if method_params == [[:req, :output]]
247
+ method_before_teardown = Template::method(:before_teardown)
248
+ else
249
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid #{Blufin::Terminal::format_highlight(':before_teardown')}\x1B[38;5;240m parameters: #{Blufin::Terminal::format_invalid(method_params.inspect)}"
250
+ end
251
+ end
252
+ if method == :after_teardown
253
+ method_params = Template::method(:after_teardown).parameters
254
+ if method_params == [[:req, :output]]
255
+ method_after_teardown = Template::method(:after_teardown)
256
+ else
257
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 Invalid #{Blufin::Terminal::format_highlight(':after_teardown')}\x1B[38;5;240m parameters: #{Blufin::Terminal::format_invalid(method_params.inspect)}"
258
+ end
259
+ end
260
+ end
261
+ end
262
+ intro = nil if !intro.nil? && intro.gsub("\n", '').gsub("\t", '').strip == ''
263
+ stack_name = nil if !stack_name.nil? && stack_name.gsub("\n", '').gsub("\t", '').strip == ''
264
+ # Unload the template.rb file/class.
265
+ Object.send(:remove_const, :Template)
266
+ end
267
+ end
268
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 The #{Blufin::Terminal::format_highlight('template.yml')}\x1B[38;5;240m is missing, empty or invalid." if file_cloudformation.nil?
269
+ @warnings << "\x1B[38;5;196m#{template_name}\x1B[38;5;240m \xe2\x80\x94 The #{Blufin::Terminal::format_highlight('template.rb')}\x1B[38;5;240m is missing, empty or invalid." if file_ruby.nil?
270
+ @templates[category] = {} unless @templates.has_key?(category)
271
+ if @warnings.length > warnings_count
272
+ @templates[category][template] = {
273
+ :name => template,
274
+ :broken => 'Broken'
275
+ }
276
+ else
277
+ @templates[category][template] = {
278
+ :name => template,
279
+ :broken => false,
280
+ :file_cloudformation => file_cloudformation,
281
+ :file_ruby => file_ruby,
282
+ :method_before_create => method_before_create,
283
+ :method_after_create => method_after_create,
284
+ :method_before_teardown => method_before_teardown,
285
+ :method_after_teardown => method_after_teardown,
286
+ :parameters => parameters,
287
+ :intro => intro,
288
+ :stack_name => stack_name,
289
+ :projects => projects,
290
+ :environments => environments,
291
+ :regions => regions,
292
+ }
293
+ end
294
+ end
295
+ end
296
+
297
+ system('clear')
298
+
299
+ if @warnings.any?
300
+ Blufin::Terminal::warning('Some template(s) had issues:', @warnings)
301
+ # During test runs, bomb-out if there are warnings (otherwise continue).
302
+ exit if @opts[:test]
303
+ end
304
+
305
+ end
306
+
307
+ def opts_routing
308
+
309
+ # Show prompt to select template.
310
+ category, template, @template = select_template_prompt
311
+ unless @params.any?
312
+ if !@template[:parameters].nil? && @template[:parameters].any?
313
+ @template[:parameters].each do |(param_name, param_data)|
314
+ @params[param_name] = get_parameter_value(param_data, param_name, category, template)
315
+ puts
316
+ end
317
+ end
318
+ # Cache the inputted value(s).
319
+ cache_params = @params
320
+ cache_params[CACHE_UUID] = get_cache_hexdigest(@template[:parameters])
321
+ Blufin::Files::write_file(get_cache_file(category, template), [cache_params.to_json])
322
+ end
323
+
324
+ # If this is a test-run, abandon ship here.
325
+ if @opts[:test]
326
+ puts
327
+ puts "\x1B[38;5;196m Exiting because this is only a test-run.\x1B[0m"
328
+ puts
329
+ exit
330
+ end
331
+
332
+ # Clear the screen.
333
+ system('clear')
334
+
335
+ capabilities_arr = []
336
+ capabilities_str = nil
337
+
338
+ # Upload the template to S3.
339
+ App::AWSCloudFormation::upload_cloudformation_template(category, template, @params[OPTION_DESCRIPTION])
340
+
341
+ # Validates the template.
342
+ s3_url = App::AWSCloudFormation::get_cloudformation_s3_bucket_url(category, template)
343
+ validation = App::AWSCli::cloudformation_stack_validate(@params[OPTION_REGION], s3_url)
344
+
345
+ # Check if validation output is JSON (and output appropriate format).
346
+ begin
347
+ hash = JSON.parse(validation.to_json)
348
+ raise RuntimeError, 'Not a Hash' unless hash.is_a?(Hash)
349
+ puts
350
+ puts " \x1B[38;5;240mAWS says your template is: \x1B[38;5;40mVALID\x1B[0m"
351
+ puts
352
+
353
+ # TODO - Remove (once dev-done).
354
+ # Blufin::Terminal::code_highlight(hash.to_yaml, 'yml', 4)
355
+
356
+ # Extract required capabilities (if any).
357
+ if hash.has_key?('Capabilities')
358
+ capabilities_arr = hash['Capabilities']
359
+ capabilities_str = "\"#{capabilities_arr.join('" "')}\""
360
+ end
361
+ rescue
362
+ Blufin::Terminal::error("AWS says your template is: \x1B[38;5;196mINVALID", App::AWSCli::format_cli_error(validation), true)
363
+ end
364
+
365
+ # TODO - 2) Must output parameters to have the colon aligned.
366
+ # TODO - 3) Implement Stack Termination protection (CRUCIAL!!).
367
+
368
+ output = {}
369
+ output['StackName'] = @params[OPTION_STACK_NAME]
370
+ output['Description'] = @params[OPTION_DESCRIPTION]
371
+ output['Capabilities'] = capabilities_arr.join(', ') unless capabilities_str.nil?
372
+ output[SPACER] = true
373
+
374
+ @params.each do |key, value|
375
+ next if OPTION_TAGS.include?(key.downcase)
376
+ next if OPTION_STACK_NAME.downcase == key.downcase
377
+ next if OPTION_DESCRIPTION.downcase == key.downcase
378
+ output[key] = value
379
+ end
380
+
381
+ # TODO NOW - FINISH THIS!
382
+ puts output.to_yaml
383
+ exit
384
+
385
+ params_output = []
386
+ params_output << "\x1B[38;5;240mStackName \xe2\x80\x94 \x1B[38;5;40m#{@params[OPTION_STACK_NAME]}"
387
+ params_output << "\x1B[38;5;240mCapabilities \xe2\x80\x94 \x1B[38;5;40m#{capabilities_arr.join(', ')}" unless capabilities_str.nil?
388
+
389
+ @params.each do |key, value|
390
+ next if OPTION_TAGS.include?(key.downcase)
391
+ next if OPTION_STACK_NAME.downcase == key.downcase
392
+ params_output << "\x1B[38;5;240m#{key} \xe2\x80\x94 \x1B[38;5;40m#{value}"
393
+ end
394
+
395
+ if Blufin::Terminal::prompt_yes_no("Deploy: #{render_category_template(category, template)} ?", params_output, 'Create CloudFormation Stack in AWS', false)
396
+
397
+ # Call :before_create() -- if exists.
398
+ @template[:method_before_create].call(@params) unless @template[:method_before_create].nil?
399
+
400
+ # Create the Stack.
401
+ App::AWSCli::cloudformation_stack_create(@params[OPTION_REGION], @params[OPTION_STACK_NAME], s3_url, params: assemble_params(@params), tags: assemble_tags(@params), capabilities: capabilities_arr)
402
+
403
+ # Call :before_create() -- if exists.
404
+ @template[:method_after_create].call(@params) unless @template[:method_after_create].nil?
405
+
406
+ # Success message.
407
+ Blufin::Terminal::success('Stack creation was successful.')
408
+ end
409
+ end
410
+
411
+ private
412
+
413
+ # Show prompt(s) to select template.
414
+ # @return void
415
+ def select_template_prompt
416
+ if @opts[:test]
417
+ category = TEST
418
+ template = TEST
419
+ else
420
+ # Get a list of categories which have at least 1 valid template.
421
+ categories = []
422
+ @templates.keys.each do |template_category|
423
+ next if template_category == TEST
424
+ has_valid = false
425
+ @templates[template_category].each { |template| has_valid = true unless template[1][:broken] }
426
+ categories << template_category if has_valid
427
+ end
428
+ # Bomb-out if all templates are broken. This will happen a lot during dev.
429
+ Blufin::Terminal::error('No valid templates.', 'Please fix the above error(s) and try again.', true) unless categories.any?
430
+ # Show a prompt identical to the 2nd one, except without a template selected.
431
+ Blufin::Terminal::custom('CLOUDFORMATION', 55)
432
+ # Select category prompt.
433
+ category = Blufin::Terminal::prompt_select('Select Category:', categories)
434
+ options = []
435
+ @templates[category].each do |key, data|
436
+ option = {
437
+ :text => "#{data[:name]}\x1B[38;5;246m\x1B[0m",
438
+ :value => data[:name]
439
+ }
440
+ option[:disabled] = data[:broken] if data[:broken]
441
+ options << option
442
+ end
443
+ system('clear')
444
+ # Show a prompt identical to the 2nd one, now with category selected.
445
+ Blufin::Terminal::custom('CLOUDFORMATION', 55, "Selected template: #{Blufin::Terminal::format_action(category)}")
446
+ # Select template prompt.
447
+ template = Blufin::Terminal::prompt_select('Select Template:', options)
448
+ # If template is broken, bomb-out (in theory, a broken template should never be selectable.).
449
+ raise RuntimeError, "Template with category: #{category} and Id: #{template} does not exist." unless @templates.has_key?(category) && @templates[category].has_key?(template)
450
+ Blufin::Terminal::error("Template: #{Blufin::Terminal::format_highlight(@templates[category][template][:name])} is currently broken/incomplete.", 'Please fix error(s) and try again.', true) if @templates[category][template][:broken]
451
+ end
452
+ @params = {}
453
+ @template = @templates[category][template].dup
454
+ system('clear')
455
+ @template[:parameters] = {} if !@template.has_key?(:parameters) || !@template[:parameters].any?
456
+ # Show summary (with intro if exists).
457
+ if @template[:intro].nil?
458
+ template_intro = []
459
+ else
460
+ template_intro = @template[:intro].split("\n")
461
+ template_intro << ''
462
+ end
463
+ params_to_fetch = []
464
+ params_to_fetch_colored = []
465
+ # Output the title + intro.
466
+ Blufin::Terminal::custom('CLOUDFORMATION', 55, "Selected template: #{render_category_template(category, template)}")
467
+ # TODO - This is where you would put more info about the template (either do later or remove this TODO).
468
+ # puts "\x1B[38;5;240m Stack-name: \x1B[38;5;30m#{@template[:stack_name]}\x1B[0m"
469
+ # puts
470
+ template_intro.each { |line| puts "\x1B[38;5;240m #{line}\x1B[0m" }
471
+ # Prepare options. Merge custom with defaults.
472
+ options_current = {}
473
+ @options_default.each do |key, default_options|
474
+ options_current[OPTION_PROJECT] = (@template[:projects].nil? ? default_options : @template[:projects]) if key == OPTION_PROJECT
475
+ options_current[OPTION_ENVIRONMENT] = (@template[:environments].nil? ? default_options : @template[:environments]) if key == OPTION_ENVIRONMENT
476
+ options_current[OPTION_REGION] = (@template[:regions].nil? ? default_options : @template[:regions]) if key == OPTION_REGION
477
+ end
478
+ @options_default.keys.each do |key|
479
+ next if key == OPTION_STACK_NAME
480
+ @template[:parameters][key] = {
481
+ 'Type' => 'String',
482
+ 'Description' => "%w(#{options_current[key].join(' ')})",
483
+ OPTIONS => options_current[key],
484
+ }
485
+ end
486
+ @template[:parameters][OPTION_STACK_NAME] = {
487
+ 'Type' => 'String',
488
+ 'Description' => @template[:stack_name].nil? ? default_options : @template[:stack_name],
489
+ 'MinLength' => 1
490
+ }
491
+ @template[:parameters][OPTION_DESCRIPTION] = {
492
+ 'Type' => 'String',
493
+ 'Description' => 'A quick note to describe this stack (displayed in CloudFormation console).',
494
+ 'MinLength' => 1
495
+ }
496
+ # Get cached values (if exist and parameters haven't changed).
497
+ # Even a one-character change in a description will invalidate the cache.
498
+ cache_file = get_cache_file(category, template)
499
+ @cache = {}
500
+ if Blufin::Files::file_exists(cache_file)
501
+ begin
502
+ @cache = JSON.parse(`cat #{cache_file}`)
503
+ rescue
504
+ @cache = {}
505
+ end
506
+ # Makes sure non of the parameter definitions changed.
507
+ if @cache.has_key?(CACHE_UUID)
508
+ @cache = {} if @cache[CACHE_UUID] != get_cache_hexdigest(@template[:parameters])
509
+ else
510
+ @cache = {}
511
+ end
512
+ end
513
+ # Output the parameters (in table).
514
+ if @template[:parameters].any?
515
+ table_data = []
516
+ # Loop again to build output.
517
+ @template[:parameters].each do |param_name, param_data|
518
+ if @auto_fetch_resources.has_key?(param_name)
519
+ params_to_fetch << param_name
520
+ params_to_fetch_colored << "\x1B[38;5;61m#{param_name}\x1B[38;5;208m"
521
+ end
522
+ ti = (param_data.has_key?(OPTIONS) ? :system : :normal)
523
+ table_data << {
524
+ :type_internal => @auto_fetch_resources.has_key?(param_name) ? :autocomplete : ti,
525
+ :parameter => param_name,
526
+ :type => %w(string number).include?(param_data['Type'].downcase) ? param_data['Type'] : SPECIAL,
527
+ :description => param_data.has_key?('Description') ? param_data['Description'] : "\xe2\x80\x94",
528
+ :allowed_pattern => param_data.has_key?('AllowedPattern') ? param_data['AllowedPattern'] : nil,
529
+ :min_length => param_data.has_key?('MinLength') ? param_data['MinLength'] : nil,
530
+ :min_value => param_data.has_key?('MinValue') ? param_data['MinValue'] : nil,
531
+ :max_length => param_data.has_key?('MaxLength') ? param_data['MaxLength'] : nil,
532
+ :max_value => param_data.has_key?('MaxValue') ? param_data['MaxValue'] : nil,
533
+ :cached_value => @cache.has_key?(param_name) ? @cache[param_name] : "\xe2\x80\x94"
534
+ }
535
+ end
536
+ # This outputs the actual table with all the parameters.
537
+ App::AWSOutputter::display_parameters(table_data)
538
+ puts
539
+ else
540
+ puts
541
+ end
542
+ # If this template has auto-complete params, this shows a spinner to fetch them (whilst you're reading the intro).
543
+ empty_options = []
544
+ if params_to_fetch.any?
545
+ threads = []
546
+ params_to_fetch.each do |ptf|
547
+ sleep(0.01)
548
+ threads << Thread.new {
549
+ options = fetch_autocomplete_options(ptf, silent: true)
550
+ if options.nil? || !options.any?
551
+ empty_options << "\x1B[38;5;196m#{ptf}\x1B[0m \xe2\x80\x94 \x1B[38;5;240mFound 0 result(s)."
552
+ end
553
+ }
554
+ end
555
+ sleep(0.1)
556
+ Blufin::Terminal::execute_proc("AWS - Getting parameter data: [ #{params_to_fetch_colored.join(' | ')}\x1B[38;5;208m ]", Proc.new {
557
+ threads.each { |thread| thread.join }
558
+ })
559
+ puts
560
+ end
561
+ # If we have any auto-complete options that come back empty, we show an error and disable certain options.
562
+ if empty_options.any?
563
+ Blufin::Terminal::error("Cannot currently use this template because some #{Blufin::Terminal::format_highlight('required resources')} don't exist in \x1B[38;5;231m\x1B[48;5;17m AWS \x1B[0m :", empty_options, false)
564
+ puts
565
+ choices = []
566
+ else
567
+ choices = @cache.any? ? [{value: 'Y', text: "Select this template \x1B[38;5;198m(and apply cached values)\x1B[0m"}] : []
568
+ choices << {value: 'y', text: 'Select this template'}
569
+ end
570
+ # The prompt at the end of the intro.
571
+ choices << {value: 'n', text: 'Select another template'}
572
+ choices << {value: 'q', text: 'Quit'}
573
+ choice = Blufin::Terminal::prompt_select('What would you like to do?', choices)
574
+ case choice
575
+ when 'y'
576
+ puts
577
+ return [category, template, @template]
578
+ when 'Y'
579
+ @params = @cache
580
+ return [category, template, @template]
581
+ when 'n'
582
+ system('clear')
583
+ select_template_prompt
584
+ when 'q'
585
+ puts
586
+ exit
587
+ else
588
+ raise RuntimeError, "Unhandled choice: #{choice}"
589
+ end
590
+ end
591
+
592
+ # Shows a parameter and gets input from user.
593
+ # @return void
594
+ def get_parameter_value(param_data, param_name, category, template)
595
+ description = param_data.has_key?(DESCRIPTION) ? param_data[DESCRIPTION] : nil
596
+ options_text = "Select #{param_name}:"
597
+ if param_name == OPTION_STACK_NAME
598
+ constraints = []
599
+ constraints << "\x1B[38;5;240mMinLength: \x1B[38;5;#{App::AWSOutputter::CONSTRAINT_COLOR}m#{param_data['MinLength']}" if param_data.has_key?('MinLength')
600
+ default = @template[:stack_name]
601
+ default = default.gsub(/\{\{CATEGORY\}\}/i, category) if default =~ /\{\{CATEGORY\}\}/i
602
+ default = default.gsub(/\{\{TEMPLATE\}\}/i, template) if default =~ /\{\{TEMPLATE\}\}/i
603
+ default = default.gsub(/\{\{PROJECT\}\}/i, @params[OPTION_PROJECT]) if default =~ /\{\{PROJECT\}\}/i
604
+ default = default.gsub(/\{\{ENVIRONMENT\}\}/i, @params[OPTION_ENVIRONMENT]) if default =~ /\{\{ENVIRONMENT\}\}/i
605
+ default = default.gsub(/\{\{REGION\}\}/i, @params[OPTION_REGION]) if default =~ /\{\{REGION\}\}/i
606
+ default = default.gsub(/\{\{UUID\}\}/i, SecureRandom.uuid.split('-')[0]) if default =~ /\{\{UUID\{\{/i
607
+ default = default.downcase
608
+ return Blufin::Terminal::prompt_ask("Enter #{param_name}#{render_constraints(constraints)}", default: default, help: 'The name of the Stack (as displayed in CloudFormation console).')
609
+ elsif @auto_fetch_resources.has_key?(param_name)
610
+ options = fetch_autocomplete_options(param_name, silent: true)
611
+ return Blufin::Terminal::prompt_select(options_text, options, help: description)
612
+ elsif param_data.has_key?(OPTIONS)
613
+ raise RuntimeError, "Expected Array, instead got: #{param_data[OPTIONS].class}" unless param_data[OPTIONS].is_a?(Array)
614
+ if param_data[OPTIONS].length == 1
615
+ default_option = param_data[OPTIONS][0]
616
+ puts "#{Blufin::Terminal::display_prompt_text(options_text)} \x1B[38;5;46m#{default_option}\x1B[0m"
617
+ return default_option
618
+ else
619
+ return Blufin::Terminal::prompt_select(options_text, param_data[OPTIONS])
620
+ end
621
+ else
622
+ constraints = []
623
+ constraints << "\x1B[38;5;240mRegex: \x1B[38;5;#{App::AWSOutputter::CONSTRAINT_COLOR}m#{param_data['AllowedPattern']}" if param_data.has_key?('AllowedPattern')
624
+ constraints << "\x1B[38;5;240mMinLength: \x1B[38;5;#{App::AWSOutputter::CONSTRAINT_COLOR}m#{param_data['MinLength']}" if param_data.has_key?('MinLength')
625
+ constraints << "\x1B[38;5;240mMinValue: \x1B[38;5;#{App::AWSOutputter::CONSTRAINT_COLOR}m#{param_data['MinValue']}" if param_data.has_key?('MinValue')
626
+ constraints << "\x1B[38;5;240mMaxLength: \x1B[38;5;#{App::AWSOutputter::CONSTRAINT_COLOR}m#{param_data['MaxLength']}" if param_data.has_key?('MaxLength')
627
+ constraints << "\x1B[38;5;240mMaxValue: \x1B[38;5;#{App::AWSOutputter::CONSTRAINT_COLOR}m#{param_data['MaxValue']}" if param_data.has_key?('MaxValue')
628
+ default = param_data.has_key?('Default') ? param_data['Default'] : nil
629
+ loop do
630
+ value = Blufin::Terminal::prompt_ask("Enter #{param_name}#{render_constraints(constraints)}", default: default, help: description)
631
+ value_invalid = false
632
+ value_invalid = true if param_data.has_key?('AllowedPattern') && value !~ /#{param_data['AllowedPattern']}/
633
+ value_invalid = true if param_data['Type'] == 'Number' && value.to_s !~/^\d+(\.\d+)?$/
634
+ value_invalid = true if param_data.has_key?('MinLength') && value.to_s.length < param_data['MinLength']
635
+ value_invalid = true if param_data.has_key?('MaxLength') && value.to_s.length > param_data['MaxLength']
636
+ value_invalid = true if param_data.has_key?('MinValue') && value.to_i < param_data['MinValue']
637
+ value_invalid = true if param_data.has_key?('MaxValue') && value.to_i > param_data['MaxValue']
638
+ if value_invalid
639
+ description = nil
640
+ puts "\x1B[38;5;196mValue does not meet allowed constraint(s).\x1B[0m"
641
+ else
642
+ return value
643
+ end
644
+ end
645
+ end
646
+ puts
647
+ end
648
+
649
+ # Goes off to AWS and gets values for autocomplete supported fields.
650
+ # @return string
651
+ def fetch_autocomplete_options(resource_name, silent: true)
652
+ raise RuntimeError, "Key not found in @auto_fetch_resources: #{resource_name}" unless @auto_fetch_resources.has_key?(resource_name)
653
+ return @auto_fetch_cache[resource_name] if @auto_fetch_cache.has_key?(resource_name)
654
+ resource_title = @auto_fetch_resources[resource_name][:resource_title]
655
+ resource = @auto_fetch_resources[resource_name][:resource]
656
+ results = App::AWSReports::get_aws_data(@regions, resource, resource_title, silent: silent)
657
+ options = App::AWSReports::parse_results_for_prompt(resource, results)
658
+ @auto_fetch_cache[resource_name] = options
659
+ options
660
+ end
661
+
662
+ # Returns short-hand syntax for tags.
663
+ # @return string
664
+ def assemble_tags(params)
665
+ output = []
666
+ params.each do |key, value|
667
+ next unless OPTION_TAGS.include?(key.downcase)
668
+ output << {
669
+ 'Key' => key,
670
+ 'Value' => value
671
+ }
672
+ end
673
+ output.to_json
674
+ end
675
+
676
+ # Returns JSON syntax for params.
677
+ # @return string
678
+ def assemble_params(params)
679
+ output = []
680
+ params.each do |key, value|
681
+ next if OPTION_STACK_NAME.downcase == key.downcase
682
+ unless OPTION_TAGS.include?(key.downcase)
683
+ output << {
684
+ 'ParameterKey' => key,
685
+ 'ParameterValue' => value
686
+ }
687
+ end
688
+ end
689
+ output.to_json
690
+ end
691
+
692
+ # Outputs anything returned from the .before() .after() .teardown() methods in templates.
693
+ # @return void
694
+ def output_res(res)
695
+ if res.is_a?(String) && res.length > 0
696
+ puts "\n\n"
697
+ puts res
698
+ elsif res.is_a?(Array) || res.is_a?(Hash)
699
+ puts "\n\n"
700
+ Blufin::Terminal::code_highlight(res.to_yaml, 'yml', 4)
701
+ end
702
+ end
703
+
704
+ # Standardized way of creating cache filename.
705
+ # @return string
706
+ def get_cache_file(category, template)
707
+ "/tmp/aws-cf-cache-#{category}-#{template}.txt"
708
+ end
709
+
710
+ # Takes the parameter keys and hashes them. Helps detect when to invalidate the cache.
711
+ # @return string
712
+ def get_cache_hexdigest(parameter_hash)
713
+ raise RuntimeError, "Expected Hash, instead got #{parameter_hash.class}" unless parameter_hash.is_a?(Hash)
714
+ Digest::SHA2.hexdigest(parameter_hash.to_s)
715
+ end
716
+
717
+ # Standardized way of rendering category/template output.
718
+ # @return string
719
+ def render_category_template(category, template)
720
+ "#{Blufin::Terminal::format_action(category)} \x1B[38;5;240m[#{Blufin::Terminal::format_highlight(template)}\x1B[38;5;240m]"
721
+ end
722
+
723
+ # Standardized way of rendering constraints output.
724
+ # @return string
725
+ def render_constraints(constraints)
726
+ constraint_bracket_color = 94
727
+ constraints.any? ? " \x1B[38;5;#{constraint_bracket_color}m[ #{constraints.join("\x1B[38;5;240m | ")} \x1B[38;5;#{constraint_bracket_color}m] \x1B[38;5;214m:" : ':'
728
+ end
729
+
730
+ end
731
+
732
+ end