simplerubysteps 0.0.7 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,599 @@
1
+ require "simplerubysteps/version"
2
+ require "simplerubysteps/cloudformation"
3
+ require "aws-sdk-cloudformation"
4
+ require "aws-sdk-s3"
5
+ require "aws-sdk-states"
6
+ require "digest"
7
+ require "zip"
8
+ require "tempfile"
9
+ require "json"
10
+ require "optparse"
11
+ require "aws-sdk-cloudwatchlogs"
12
+ require "time"
13
+ require "thread"
14
+
15
+ module Simplerubysteps
16
+ class Tool
17
+ def initialize
18
+ @cloudformation_client = Aws::CloudFormation::Client.new
19
+ @s3_client = Aws::S3::Client.new
20
+ @states_client = Aws::States::Client.new
21
+ @logs_client = Aws::CloudWatchLogs::Client.new
22
+ end
23
+
24
+ def tail_follow_logs(log_group_name, extract_pattern = nil) # FIXME too hacky
25
+ Signal.trap("INT") do
26
+ exit
27
+ end
28
+
29
+ first_event_time = Time.now.to_i * 1000
30
+
31
+ next_tokens = {}
32
+ first_round = true
33
+ loop do
34
+ log_streams = @logs_client.describe_log_streams(
35
+ log_group_name: log_group_name,
36
+ order_by: "LastEventTime",
37
+ descending: true,
38
+ ).log_streams
39
+
40
+ log_streams.each do |log_stream|
41
+ get_log_events_params = {
42
+ log_group_name: log_group_name,
43
+ log_stream_name: log_stream.log_stream_name,
44
+ }
45
+
46
+ if next_tokens.key?(log_stream.log_stream_name)
47
+ get_log_events_params[:next_token] = next_tokens[log_stream.log_stream_name]
48
+ else
49
+ get_log_events_params[:start_time] = first_round ? log_stream.last_event_timestamp : first_event_time
50
+ end
51
+
52
+ response = @logs_client.get_log_events(get_log_events_params)
53
+
54
+ response.events.each do |event|
55
+ if event.timestamp >= first_event_time
56
+ if extract_pattern
57
+ if /#{extract_pattern}/ =~ event.message
58
+ puts $1
59
+ exit
60
+ end
61
+ else
62
+ puts "#{Time.at(event.timestamp / 1000).utc} - #{log_stream.log_stream_name} - #{event.message}"
63
+ end
64
+ end
65
+ end
66
+
67
+ next_tokens[log_stream.log_stream_name] = response.next_forward_token
68
+ end
69
+
70
+ sleep 5
71
+
72
+ first_round = false
73
+ end
74
+ end
75
+
76
+ def stack_outputs(stack_name)
77
+ begin
78
+ response = @cloudformation_client.describe_stacks(stack_name: stack_name)
79
+ outputs = {}
80
+ response.stacks.first.outputs.each do |output|
81
+ outputs[output.output_key] = output.output_value
82
+ end
83
+ outputs
84
+ rescue Aws::CloudFormation::Errors::ServiceError => error
85
+ return nil if error.message =~ /Stack .* does not exist/
86
+ raise error
87
+ end
88
+ end
89
+
90
+ def stack_params(stack_name, template, parameters)
91
+ params = {
92
+ stack_name: stack_name,
93
+ template_body: template,
94
+ capabilities: ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"],
95
+ parameters: [],
96
+ }
97
+ parameters.each do |k, v|
98
+ params[:parameters].push({
99
+ parameter_key: k,
100
+ parameter_value: v,
101
+ })
102
+ end
103
+ params
104
+ end
105
+
106
+ def stack_create(stack_name, template, parameters)
107
+ @cloudformation_client.create_stack(stack_params(stack_name, template, parameters))
108
+ @cloudformation_client.wait_until(:stack_create_complete, stack_name: stack_name)
109
+ stack_outputs(stack_name)
110
+ end
111
+
112
+ def stack_update(stack_name, template, parameters)
113
+ begin
114
+ @cloudformation_client.update_stack(stack_params(stack_name, template, parameters))
115
+ @cloudformation_client.wait_until(:stack_update_complete, stack_name: stack_name)
116
+ stack_outputs(stack_name)
117
+ rescue Aws::CloudFormation::Errors::ServiceError => error
118
+ return stack_outputs(stack_name).merge({ :no_update => true }) if error.message =~ /No updates are to be performed/
119
+ raise unless error.message =~ /No updates are to be performed/
120
+ end
121
+ end
122
+
123
+ def list_stacks_with_prefix(prefix)
124
+ stack_list = []
125
+ next_token = nil
126
+ loop do
127
+ response = @cloudformation_client.list_stacks({
128
+ next_token: next_token,
129
+ stack_status_filter: %w[
130
+ CREATE_COMPLETE
131
+ UPDATE_COMPLETE
132
+ ROLLBACK_COMPLETE
133
+ ],
134
+ })
135
+
136
+ response.stack_summaries.each do |stack|
137
+ if stack.stack_name =~ /^#{prefix}$|^#{prefix}-(.+)/
138
+ stack_list << stack.stack_name
139
+ end
140
+ end
141
+
142
+ next_token = response.next_token
143
+ break if next_token.nil?
144
+ end
145
+
146
+ stack_list
147
+ end
148
+
149
+ def most_recent_stack_with_prefix(prefix)
150
+ stack_list = {}
151
+ next_token = nil
152
+ loop do
153
+ response = @cloudformation_client.list_stacks({
154
+ next_token: next_token,
155
+ stack_status_filter: %w[
156
+ CREATE_COMPLETE
157
+ UPDATE_COMPLETE
158
+ ROLLBACK_COMPLETE
159
+ ],
160
+ })
161
+
162
+ response.stack_summaries.each do |stack|
163
+ if stack.stack_name =~ /^#{prefix}$|^#{prefix}-(.+)/
164
+ stack_list[stack.creation_time] = stack.stack_name
165
+ end
166
+ end
167
+
168
+ next_token = response.next_token
169
+ break if next_token.nil?
170
+ end
171
+
172
+ stack_list.empty? ? nil : stack_list[stack_list.keys.sort.last]
173
+ end
174
+
175
+ def upload_to_s3(bucket, key, body)
176
+ @s3_client.put_object(
177
+ bucket: bucket,
178
+ key: key,
179
+ body: body,
180
+ )
181
+ end
182
+
183
+ def upload_file_to_s3(bucket, key, file_path)
184
+ File.open(file_path, "rb") do |file|
185
+ upload_to_s3(bucket, key, file)
186
+ end
187
+ end
188
+
189
+ def empty_s3_bucket(bucket_name)
190
+ @s3_client.list_objects_v2(bucket: bucket_name).contents.each do |object|
191
+ @s3_client.delete_object(bucket: bucket_name, key: object.key)
192
+ end
193
+ end
194
+
195
+ def create_zip(zip_file, files_by_name)
196
+ Zip::File.open(zip_file, create: true) do |zipfile|
197
+ base_dir = File.expand_path(File.dirname(__FILE__))
198
+ files_by_name.each do |n, f|
199
+ zipfile.add n, f
200
+ end
201
+ end
202
+ end
203
+
204
+ def dir_files(base_dir, glob)
205
+ files_by_name = {}
206
+ base_dir = File.expand_path(base_dir)
207
+ Dir.glob("#{base_dir}/#{glob}").select { |path| File.file?(path) }.each do |f|
208
+ files_by_name[File.expand_path(f)[base_dir.length + 1..-1]] = f
209
+ end
210
+ files_by_name
211
+ end
212
+
213
+ def unversioned_stack_name_from_current_dir
214
+ File.basename(File.expand_path("."))
215
+ end
216
+
217
+ def workflow_files
218
+ dir_files ".", "**/*.rb"
219
+ end
220
+
221
+ def workflow_files_hash
222
+ file_hashes = []
223
+ workflow_files.each do |name, file|
224
+ file_hashes.push Digest::SHA1.file(file)
225
+ end
226
+ Digest::SHA1.hexdigest file_hashes.join(",")
227
+ end
228
+
229
+ def versioned_stack_name_from_current_dir(version)
230
+ if version
231
+ "#{unversioned_stack_name_from_current_dir}-#{version}"
232
+ else
233
+ "#{unversioned_stack_name_from_current_dir}-#{workflow_files_hash()[0...8]}"
234
+ end
235
+ end
236
+
237
+ def my_lib_files
238
+ files = dir_files(File.dirname(__FILE__) + "/..", "**/*.rb").filter { |f| not(f =~ /cloudformation\.rb|tool\.rb/) }
239
+ end
240
+
241
+ def cloudformation_template(lambda_cf_config, deploy_state_machine)
242
+ data = {
243
+ state_machine: deploy_state_machine,
244
+ }
245
+
246
+ if lambda_cf_config
247
+ data[:functions] = lambda_cf_config # see StateMachine.cloudformation_config()
248
+ end
249
+
250
+ Simplerubysteps::cloudformation_yaml(data)
251
+ end
252
+
253
+ def log(extract_pattern, version)
254
+ stack = nil
255
+ if version
256
+ stack = versioned_stack_name_from_current_dir(version)
257
+ else
258
+ stack = most_recent_stack_with_prefix unversioned_stack_name_from_current_dir
259
+ end
260
+ raise "State Machine is not deployed" unless stack
261
+
262
+ current_stack_outputs = stack_outputs(stack)
263
+ raise "State Machine is not deployed" unless current_stack_outputs
264
+
265
+ last_thread = nil
266
+ (0..current_stack_outputs["LambdaCount"].to_i - 1).each do |i|
267
+ function_name = current_stack_outputs["LambdaFunctionName#{i}"]
268
+ last_thread = Thread.new do # FIXME Less brute force approach (?)
269
+ tail_follow_logs "/aws/lambda/#{function_name}", extract_pattern
270
+ end
271
+ end
272
+ last_thread.join if last_thread
273
+ end
274
+
275
+ def destroy_stack(stack)
276
+ current_stack_outputs = stack_outputs(stack)
277
+ raise "No CloudFormation stack to destroy" unless current_stack_outputs
278
+
279
+ deploy_bucket = current_stack_outputs["DeployBucket"]
280
+ raise "No CloudFormation stack to destroy" unless deploy_bucket
281
+
282
+ empty_s3_bucket deploy_bucket
283
+
284
+ puts "Bucket emptied: #{deploy_bucket}"
285
+
286
+ @cloudformation_client.delete_stack(stack_name: stack)
287
+ @cloudformation_client.wait_until(:stack_delete_complete, stack_name: stack)
288
+
289
+ puts "Stack deleted: #{stack}"
290
+ end
291
+
292
+ def destroy(optional_version)
293
+ if optional_version
294
+ destroy_stack versioned_stack_name_from_current_dir(optional_version)
295
+ else
296
+ list_stacks_with_prefix(unversioned_stack_name_from_current_dir).each do |stack|
297
+ destroy_stack stack
298
+ end
299
+ end
300
+ end
301
+
302
+ def deploy(version)
303
+ stack = versioned_stack_name_from_current_dir(version)
304
+
305
+ puts "Stack: #{stack}"
306
+
307
+ current_stack_outputs = stack_outputs(stack)
308
+
309
+ unless current_stack_outputs
310
+ current_stack_outputs = stack_create(stack, cloudformation_template(nil, false), {})
311
+
312
+ puts "Deployment bucket created"
313
+ end
314
+
315
+ deploy_bucket = current_stack_outputs["DeployBucket"]
316
+
317
+ puts "Deployment bucket: #{deploy_bucket}"
318
+
319
+ function_zip_temp = Tempfile.new("function")
320
+ create_zip function_zip_temp.path, my_lib_files.merge(workflow_files)
321
+ lambda_sha = Digest::SHA1.file function_zip_temp.path
322
+ lambda_zip_name = "function-#{lambda_sha}.zip"
323
+ upload_file_to_s3 deploy_bucket, lambda_zip_name, function_zip_temp.path
324
+
325
+ puts "Uploaded: #{lambda_zip_name}"
326
+
327
+ lambda_cf_config = JSON.parse(`ruby -e 'require "./workflow.rb";puts $sm.cloudformation_config.to_json'`)
328
+
329
+ if current_stack_outputs["LambdaCount"].nil? or current_stack_outputs["LambdaCount"].to_i != lambda_cf_config.length # FIXME Do not implicitly delete the state machine when versioning is turned off.
330
+ current_stack_outputs = stack_update(stack, cloudformation_template(lambda_cf_config, false), {
331
+ "LambdaS3" => lambda_zip_name,
332
+ })
333
+
334
+ puts "Lambda function created"
335
+ end
336
+
337
+ lambda_arns = []
338
+ (0..current_stack_outputs["LambdaCount"].to_i - 1).each do |i|
339
+ lambda_arn = current_stack_outputs["LambdaFunctionARN#{i}"]
340
+
341
+ puts "Lambda function: #{lambda_arn}"
342
+
343
+ lambda_arns.push lambda_arn
344
+ end
345
+
346
+ workflow_type = `ruby -e 'require "./workflow.rb";puts $sm.kind'`.strip
347
+
348
+ state_machine_json = JSON.parse(`LAMBDA_FUNCTION_ARNS=#{lambda_arns.join(",")} ruby -e 'require "./workflow.rb";puts $sm.render.to_json'`).to_json
349
+ state_machine_json_sha = Digest::SHA1.hexdigest state_machine_json
350
+ state_machine_json_name = "statemachine-#{state_machine_json_sha}.json"
351
+ upload_to_s3 deploy_bucket, state_machine_json_name, state_machine_json
352
+
353
+ puts "Uploaded: #{state_machine_json_name}"
354
+
355
+ current_stack_outputs = stack_update(stack, cloudformation_template(lambda_cf_config, true), { # FIXME when versioning is turned off: 1) create additional lambdas 2) update State Machine
356
+ "LambdaS3" => lambda_zip_name,
357
+ "StepFunctionsS3" => state_machine_json_name,
358
+ "StateMachineType" => workflow_type,
359
+ })
360
+
361
+ if current_stack_outputs[:no_update]
362
+ puts "Stack not updated"
363
+ else
364
+ puts "Stack updated"
365
+ end
366
+
367
+ puts "State machine: #{current_stack_outputs["StepFunctionsStateMachineARN"]}"
368
+ end
369
+
370
+ def start_sync_execution(state_machine_arn, input)
371
+ @states_client.start_sync_execution(
372
+ state_machine_arn: state_machine_arn,
373
+ input: input,
374
+ )
375
+ end
376
+
377
+ def start_async_execution(state_machine_arn, input)
378
+ @states_client.start_execution(
379
+ state_machine_arn: state_machine_arn,
380
+ input: input,
381
+ )
382
+ end
383
+
384
+ def describe_execution(execution_arn)
385
+ @states_client.describe_execution(
386
+ execution_arn: execution_arn,
387
+ )
388
+ end
389
+
390
+ def wait_for_async_execution_completion(execution_arn)
391
+ response = nil
392
+
393
+ loop do
394
+ response = describe_execution(execution_arn)
395
+ status = response.status
396
+
397
+ break if %w[SUCCEEDED FAILED TIMED_OUT].include?(status)
398
+
399
+ sleep 5
400
+ end
401
+
402
+ response
403
+ end
404
+
405
+ def start(wait, input, version)
406
+ stack = nil
407
+ if version
408
+ stack = versioned_stack_name_from_current_dir(version)
409
+ else
410
+ stack = most_recent_stack_with_prefix unversioned_stack_name_from_current_dir
411
+ end
412
+ raise "State Machine is not deployed" unless stack
413
+
414
+ current_stack_outputs = stack_outputs(stack)
415
+ raise "State Machine is not deployed" unless current_stack_outputs
416
+
417
+ state_machine_arn = current_stack_outputs["StepFunctionsStateMachineARN"]
418
+
419
+ input_json = JSON.parse(input.read).to_json
420
+
421
+ if current_stack_outputs["StateMachineType"] == "STANDARD"
422
+ start_response = start_async_execution(state_machine_arn, input_json)
423
+
424
+ unless wait
425
+ puts start_response.to_json
426
+ else
427
+ execution_arn = start_response.execution_arn
428
+
429
+ puts wait_for_async_execution_completion(execution_arn).to_json
430
+ end
431
+ elsif current_stack_outputs["StateMachineType"] == "EXPRESS"
432
+ puts start_sync_execution(state_machine_arn, input_json).to_json
433
+ else
434
+ raise "Unknown state machine type: #{current_stack_outputs["StateMachineType"]}"
435
+ end
436
+ end
437
+
438
+ def send_task_success(task_token, output = $stdin)
439
+ raise "No token" unless task_token
440
+
441
+ output_json = JSON.parse(output.read).to_json
442
+
443
+ puts @states_client.send_task_success(
444
+ task_token: task_token,
445
+ output: output_json,
446
+ ).to_json
447
+ end
448
+
449
+ def run
450
+ options = {
451
+ :wait => false,
452
+ :input => $stdin,
453
+ :version => "latest",
454
+ :destroy_all => true,
455
+ }
456
+
457
+ subcommands = {
458
+ "deploy" => OptionParser.new do |opts|
459
+ opts.banner = "Usage: #{$0} deploy [options]"
460
+
461
+ opts.on("--version VALUE", "fix version (\"latest\" per default)") do |value|
462
+ options[:version] = value
463
+ end
464
+
465
+ opts.on("--versioned", "enable auto versioning (\"latest\" per default)") do |value|
466
+ options[:version] = nil
467
+ end
468
+
469
+ opts.on("-h", "--help", "Display this help message") do
470
+ puts opts
471
+ exit
472
+ end
473
+ end,
474
+ "destroy" => OptionParser.new do |opts|
475
+ opts.banner = "Usage: #{$0} destroy [options]"
476
+
477
+ opts.on("--version VALUE", "fix version (all versions per default)") do |value|
478
+ options[:version] = value
479
+ options[:destroy_all] = nil
480
+ end
481
+
482
+ opts.on("-h", "--help", "Display this help message") do
483
+ puts opts
484
+ exit
485
+ end
486
+ end,
487
+ "log" => OptionParser.new do |opts|
488
+ opts.banner = "Usage: #{$0} log [options]"
489
+
490
+ opts.on("--extract_pattern VALUE", "Wait for and extract pattern") do |value|
491
+ options[:extract_pattern] = value
492
+ end
493
+
494
+ opts.on("--version VALUE", "fix version (\"latest\" per default)") do |value|
495
+ options[:version] = value
496
+ end
497
+
498
+ opts.on("--most-recent-version", "Use the version of the last stack created") do |value|
499
+ options[:version] = nil
500
+ end
501
+
502
+ opts.on("-h", "--help", "Display this help message") do
503
+ puts opts
504
+ exit
505
+ end
506
+ end,
507
+ "start" => OptionParser.new do |opts|
508
+ opts.banner = "Usage: #{$0} start [options]"
509
+
510
+ opts.on("--wait", "Wait for STANDARD state machine to complete") do
511
+ options[:wait] = true
512
+ end
513
+
514
+ opts.on("--input VALUE", "/path/to/file (STDIN will be used per default)") do |value|
515
+ options[:input] = File.new(value)
516
+ end
517
+
518
+ opts.on("--version VALUE", "fix version (\"latest\" per default)") do |value|
519
+ options[:version] = value
520
+ end
521
+
522
+ opts.on("--most-recent-version", "Use the version of the last stack created") do |value|
523
+ options[:version] = nil
524
+ end
525
+
526
+ opts.on("-h", "--help", "Display this help message") do
527
+ puts opts
528
+ exit
529
+ end
530
+ end,
531
+ "task-success" => OptionParser.new do |opts|
532
+ opts.banner = "Usage: #{$0} task-success [options]"
533
+
534
+ opts.on("--input VALUE", "/path/to/file (STDIN will be used per default)") do |value|
535
+ options[:input] = File.new(value)
536
+ end
537
+
538
+ opts.on("--token VALUE", "The task token") do |value|
539
+ options[:token] = value
540
+ end
541
+
542
+ opts.on("-h", "--help", "Display this help message") do
543
+ puts opts
544
+ exit
545
+ end
546
+ end,
547
+ }
548
+
549
+ global = OptionParser.new do |opts|
550
+ opts.banner = "Usage: #{$0} [command] [options]"
551
+ opts.separator ""
552
+ opts.separator "Commands (#{Simplerubysteps::VERSION}):"
553
+ opts.separator " deploy Create Step Functions State Machine"
554
+ opts.separator " destroy Delete Step Functions State Machine"
555
+ opts.separator " log Continuously prints Lambda function log output"
556
+ opts.separator " start Start State Machine execution"
557
+ opts.separator " task-success Continue Start State Machine execution"
558
+ opts.separator ""
559
+
560
+ opts.on_tail("-h", "--help", "Display this help message") do
561
+ puts opts
562
+ exit
563
+ end
564
+ end
565
+
566
+ begin
567
+ global.order!(ARGV)
568
+ command = ARGV.shift
569
+ options[:command] = command
570
+ subcommands.fetch(command).parse!(ARGV)
571
+ rescue KeyError
572
+ puts "Unknown command: '#{command}'"
573
+ puts
574
+ puts global
575
+ exit 1
576
+ rescue OptionParser::ParseError => error
577
+ puts error.message
578
+ puts subcommands.fetch(command)
579
+ exit 1
580
+ end
581
+
582
+ if options[:command] == "deploy"
583
+ deploy options[:version]
584
+ elsif options[:command] == "start"
585
+ start options[:wait], options[:input], options[:version]
586
+ elsif options[:command] == "log"
587
+ log options[:extract_pattern], options[:version]
588
+ elsif options[:command] == "task-success"
589
+ send_task_success options[:token], options[:input]
590
+ elsif options[:command] == "destroy"
591
+ if options[:destroy_all]
592
+ destroy(nil)
593
+ else
594
+ destroy(options[:version])
595
+ end
596
+ end
597
+ end
598
+ end
599
+ end
@@ -1,3 +1,3 @@
1
1
  module Simplerubysteps
2
- VERSION = "0.0.7"
2
+ VERSION = "0.0.9"
3
3
  end