simplerubysteps 0.0.7 → 0.0.9

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,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