simplerubysteps 0.0.7 → 0.0.8

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,454 @@
1
+ require "simplerubysteps/version"
2
+ require "aws-sdk-cloudformation"
3
+ require "aws-sdk-s3"
4
+ require "aws-sdk-states"
5
+ require "digest"
6
+ require "zip"
7
+ require "tempfile"
8
+ require "json"
9
+ require "optparse"
10
+ require "aws-sdk-cloudwatchlogs"
11
+ require "time"
12
+
13
+ module Simplerubysteps
14
+ class Tool
15
+ def initialize
16
+ @cloudformation_client = Aws::CloudFormation::Client.new
17
+ @s3_client = Aws::S3::Client.new
18
+ @states_client = Aws::States::Client.new
19
+ @logs_client = Aws::CloudWatchLogs::Client.new
20
+ end
21
+
22
+ def tail_follow_logs(log_group_name, extract_pattern = nil) # FIXME too hacky and not really working
23
+ Signal.trap("INT") do
24
+ exit
25
+ end
26
+
27
+ first_event_time = Time.now.to_i * 1000
28
+
29
+ next_tokens = {}
30
+ first_round = true
31
+ loop do
32
+ log_streams = @logs_client.describe_log_streams(
33
+ log_group_name: log_group_name,
34
+ order_by: "LastEventTime",
35
+ descending: true,
36
+ ).log_streams
37
+
38
+ log_streams.each do |log_stream|
39
+ get_log_events_params = {
40
+ log_group_name: log_group_name,
41
+ log_stream_name: log_stream.log_stream_name,
42
+ }
43
+
44
+ if next_tokens.key?(log_stream.log_stream_name)
45
+ get_log_events_params[:next_token] = next_tokens[log_stream.log_stream_name]
46
+ else
47
+ get_log_events_params[:start_time] = first_round ? log_stream.last_event_timestamp : first_event_time
48
+ end
49
+
50
+ response = @logs_client.get_log_events(get_log_events_params)
51
+
52
+ response.events.each do |event|
53
+ if event.timestamp >= first_event_time
54
+ if extract_pattern
55
+ if /#{extract_pattern}/ =~ event.message
56
+ puts $1
57
+ exit
58
+ end
59
+ else
60
+ puts "#{Time.at(event.timestamp / 1000).utc} - #{log_stream.log_stream_name} - #{event.message}"
61
+ end
62
+ end
63
+ end
64
+
65
+ next_tokens[log_stream.log_stream_name] = response.next_forward_token
66
+ end
67
+
68
+ sleep 5
69
+
70
+ first_round = false
71
+ end
72
+ end
73
+
74
+ def stack_outputs(stack_name)
75
+ begin
76
+ response = @cloudformation_client.describe_stacks(stack_name: stack_name)
77
+ outputs = {}
78
+ response.stacks.first.outputs.each do |output|
79
+ outputs[output.output_key] = output.output_value
80
+ end
81
+ outputs
82
+ rescue Aws::CloudFormation::Errors::ServiceError => error
83
+ return nil if error.message =~ /Stack .* does not exist/
84
+ raise error
85
+ end
86
+ end
87
+
88
+ def stack_params(stack_name, template, parameters)
89
+ params = {
90
+ stack_name: stack_name,
91
+ template_body: template,
92
+ capabilities: ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"],
93
+ parameters: [],
94
+ }
95
+ parameters.each do |k, v|
96
+ params[:parameters].push({
97
+ parameter_key: k,
98
+ parameter_value: v,
99
+ })
100
+ end
101
+ params
102
+ end
103
+
104
+ def stack_create(stack_name, template, parameters)
105
+ @cloudformation_client.create_stack(stack_params(stack_name, template, parameters))
106
+ @cloudformation_client.wait_until(:stack_create_complete, stack_name: stack_name)
107
+ stack_outputs(stack_name)
108
+ end
109
+
110
+ def stack_update(stack_name, template, parameters)
111
+ begin
112
+ @cloudformation_client.update_stack(stack_params(stack_name, template, parameters))
113
+ @cloudformation_client.wait_until(:stack_update_complete, stack_name: stack_name)
114
+ stack_outputs(stack_name)
115
+ rescue Aws::CloudFormation::Errors::ServiceError => error
116
+ return stack_outputs(stack_name).merge({ :no_update => true }) if error.message =~ /No updates are to be performed/
117
+ raise unless error.message =~ /No updates are to be performed/
118
+ end
119
+ end
120
+
121
+ def upload_to_s3(bucket, key, body)
122
+ @s3_client.put_object(
123
+ bucket: bucket,
124
+ key: key,
125
+ body: body,
126
+ )
127
+ end
128
+
129
+ def upload_file_to_s3(bucket, key, file_path)
130
+ File.open(file_path, "rb") do |file|
131
+ upload_to_s3(bucket, key, file)
132
+ end
133
+ end
134
+
135
+ def empty_s3_bucket(bucket_name)
136
+ @s3_client.list_objects_v2(bucket: bucket_name).contents.each do |object|
137
+ @s3_client.delete_object(bucket: bucket_name, key: object.key)
138
+ end
139
+ end
140
+
141
+ def create_zip(zip_file, files_by_name)
142
+ Zip::File.open(zip_file, create: true) do |zipfile|
143
+ base_dir = File.expand_path(File.dirname(__FILE__))
144
+ files_by_name.each do |n, f|
145
+ zipfile.add n, f
146
+ end
147
+ end
148
+ end
149
+
150
+ def dir_files(base_dir, glob)
151
+ files_by_name = {}
152
+ base_dir = File.expand_path(base_dir)
153
+ Dir.glob("#{base_dir}/#{glob}").select { |path| File.file?(path) }.each do |f|
154
+ files_by_name[File.expand_path(f)[base_dir.length + 1..-1]] = f
155
+ end
156
+ files_by_name
157
+ end
158
+
159
+ def stack_name_from_current_dir
160
+ File.basename(File.expand_path("."))
161
+ end
162
+
163
+ def workflow_files
164
+ dir_files ".", "**/*.rb"
165
+ end
166
+
167
+ def my_lib_files
168
+ files = dir_files(File.dirname(__FILE__) + "/..", "**/*.rb").filter { |f| not(f =~ /tool.rb/) }
169
+ end
170
+
171
+ def cloudformation_template
172
+ File.open("#{File.dirname(__FILE__)}/statemachine.yaml", "r") do |file|
173
+ return file.read
174
+ end
175
+ end
176
+
177
+ def log(extract_pattern = nil)
178
+ current_stack_outputs = stack_outputs(stack_name_from_current_dir)
179
+ raise "State Machine is not deployed" unless current_stack_outputs
180
+ function_name = current_stack_outputs["LambdaFunctionName"]
181
+
182
+ tail_follow_logs "/aws/lambda/#{function_name}", extract_pattern
183
+ end
184
+
185
+ def destroy
186
+ current_stack_outputs = stack_outputs(stack_name_from_current_dir)
187
+ deploy_bucket = current_stack_outputs["DeployBucket"]
188
+ rause "No CloudFormation stack to destroy" unless deploy_bucket
189
+
190
+ empty_s3_bucket deploy_bucket
191
+
192
+ puts "Bucket emptied: #{deploy_bucket}"
193
+
194
+ @cloudformation_client.delete_stack(stack_name: stack_name_from_current_dir)
195
+ @cloudformation_client.wait_until(:stack_delete_complete, stack_name: stack_name_from_current_dir)
196
+
197
+ puts "Stack deleted"
198
+ end
199
+
200
+ def deploy
201
+ current_stack_outputs = stack_outputs(stack_name_from_current_dir)
202
+
203
+ unless current_stack_outputs
204
+ current_stack_outputs = stack_create(stack_name_from_current_dir, cloudformation_template, {
205
+ "DeployLambda" => "no",
206
+ "DeployStepfunctions" => "no",
207
+ "LambdaS3" => "",
208
+ "StepFunctionsS3" => "",
209
+ "StateMachineType" => "",
210
+ })
211
+
212
+ puts "Deployment bucket created"
213
+ end
214
+
215
+ deploy_bucket = current_stack_outputs["DeployBucket"]
216
+
217
+ puts "Deployment bucket: #{deploy_bucket}"
218
+
219
+ function_zip_temp = Tempfile.new("function")
220
+ create_zip function_zip_temp.path, my_lib_files.merge(workflow_files)
221
+ lambda_sha = Digest::SHA1.file function_zip_temp.path
222
+ lambda_zip_name = "function-#{lambda_sha}.zip"
223
+ upload_file_to_s3 deploy_bucket, lambda_zip_name, function_zip_temp.path
224
+
225
+ puts "Uploaded: #{lambda_zip_name}"
226
+
227
+ unless current_stack_outputs["LambdaFunctionARN"]
228
+ current_stack_outputs = stack_update(stack_name_from_current_dir, cloudformation_template, {
229
+ "DeployLambda" => "yes",
230
+ "DeployStepfunctions" => "no",
231
+ "LambdaS3" => lambda_zip_name,
232
+ "StepFunctionsS3" => "",
233
+ "StateMachineType" => "",
234
+ })
235
+
236
+ puts "Lambda function created"
237
+ end
238
+
239
+ lambda_arn = current_stack_outputs["LambdaFunctionARN"]
240
+
241
+ puts "Lambda function: #{lambda_arn}"
242
+
243
+ workflow_type = `ruby -e 'require "./workflow.rb";puts $sm.kind'`.strip
244
+
245
+ state_machine_json = JSON.parse(`LAMBDA_FUNCTION_ARN=#{lambda_arn} ruby -e 'require "./workflow.rb";puts $sm.render.to_json'`).to_json
246
+ state_machine_json_sha = Digest::SHA1.hexdigest state_machine_json
247
+ state_machine_json_name = "statemachine-#{state_machine_json_sha}.json"
248
+ upload_to_s3 deploy_bucket, state_machine_json_name, state_machine_json
249
+
250
+ puts "Uploaded: #{state_machine_json_name}"
251
+
252
+ current_stack_outputs = stack_update(stack_name_from_current_dir, cloudformation_template, {
253
+ "DeployLambda" => "yes",
254
+ "DeployStepfunctions" => "yes",
255
+ "LambdaS3" => lambda_zip_name,
256
+ "StepFunctionsS3" => state_machine_json_name,
257
+ "StateMachineType" => workflow_type,
258
+ })
259
+
260
+ if current_stack_outputs[:no_update]
261
+ puts "Stack not updated"
262
+ else
263
+ puts "Stack updated"
264
+ end
265
+
266
+ puts "State machine: #{current_stack_outputs["StepFunctionsStateMachineARN"]}"
267
+ end
268
+
269
+ def start_sync_execution(state_machine_arn, input)
270
+ @states_client.start_sync_execution(
271
+ state_machine_arn: state_machine_arn,
272
+ input: input,
273
+ )
274
+ end
275
+
276
+ def start_async_execution(state_machine_arn, input)
277
+ @states_client.start_execution(
278
+ state_machine_arn: state_machine_arn,
279
+ input: input,
280
+ )
281
+ end
282
+
283
+ def describe_execution(execution_arn)
284
+ @states_client.describe_execution(
285
+ execution_arn: execution_arn,
286
+ )
287
+ end
288
+
289
+ def wait_for_async_execution_completion(execution_arn)
290
+ response = nil
291
+
292
+ loop do
293
+ response = describe_execution(execution_arn)
294
+ status = response.status
295
+
296
+ break if %w[SUCCEEDED FAILED TIMED_OUT].include?(status)
297
+
298
+ sleep 5
299
+ end
300
+
301
+ response
302
+ end
303
+
304
+ def start(wait = true, input = $stdin)
305
+ current_stack_outputs = stack_outputs(stack_name_from_current_dir)
306
+ raise "State Machine is not deployed" unless current_stack_outputs
307
+ state_machine_arn = current_stack_outputs["StepFunctionsStateMachineARN"]
308
+
309
+ input_json = JSON.parse(input.read).to_json
310
+
311
+ if current_stack_outputs["StateMachineType"] == "STANDARD"
312
+ start_response = start_async_execution(state_machine_arn, input_json)
313
+
314
+ unless wait
315
+ puts start_response.to_json
316
+ else
317
+ execution_arn = start_response.execution_arn
318
+
319
+ puts wait_for_async_execution_completion(execution_arn).to_json
320
+ end
321
+ elsif current_stack_outputs["StateMachineType"] == "EXPRESS"
322
+ puts start_sync_execution(state_machine_arn, input_json).to_json
323
+ else
324
+ raise "Unknown state machine type: #{current_stack_outputs["StateMachineType"]}"
325
+ end
326
+ end
327
+
328
+ def send_task_success(task_token, output = $stdin)
329
+ raise "No token" unless task_token
330
+
331
+ output_json = JSON.parse(output.read).to_json
332
+
333
+ puts @states_client.send_task_success(
334
+ task_token: task_token,
335
+ output: output_json,
336
+ ).to_json
337
+ end
338
+
339
+ def run
340
+ options = {
341
+ :wait => false,
342
+ :input => $stdin,
343
+ }
344
+
345
+ subcommands = {
346
+ "deploy" => OptionParser.new do |opts|
347
+ opts.banner = "Usage: #{$0} deploy [options]"
348
+
349
+ opts.on("-h", "--help", "Display this help message") do
350
+ puts opts
351
+ exit
352
+ end
353
+ end,
354
+ "destroy" => OptionParser.new do |opts|
355
+ opts.banner = "Usage: #{$0} destroy [options]"
356
+
357
+ opts.on("-h", "--help", "Display this help message") do
358
+ puts opts
359
+ exit
360
+ end
361
+ end,
362
+ "log" => OptionParser.new do |opts|
363
+ opts.banner = "Usage: #{$0} log [options]"
364
+
365
+ opts.on("--extract_pattern VALUE", "Wait for and extract pattern") do |value|
366
+ options[:extract_pattern] = value
367
+ end
368
+
369
+ opts.on("-h", "--help", "Display this help message") do
370
+ puts opts
371
+ exit
372
+ end
373
+ end,
374
+ "start" => OptionParser.new do |opts|
375
+ opts.banner = "Usage: #{$0} start [options]"
376
+
377
+ opts.on("--wait", "Wait for STANDARD state machine to complete") do
378
+ options[:wait] = true
379
+ end
380
+
381
+ opts.on("--input VALUE", "/path/to/file (STDIN will be used per default)") do |value|
382
+ options[:input] = File.new(value)
383
+ end
384
+
385
+ opts.on("-h", "--help", "Display this help message") do
386
+ puts opts
387
+ exit
388
+ end
389
+ end,
390
+ "task-success" => OptionParser.new do |opts|
391
+ opts.banner = "Usage: #{$0} task-success [options]"
392
+
393
+ opts.on("--input VALUE", "/path/to/file (STDIN will be used per default)") do |value|
394
+ options[:input] = File.new(value)
395
+ end
396
+
397
+ opts.on("--token VALUE", "The task token") do |value|
398
+ options[:token] = value
399
+ end
400
+
401
+ opts.on("-h", "--help", "Display this help message") do
402
+ puts opts
403
+ exit
404
+ end
405
+ end,
406
+ }
407
+
408
+ global = OptionParser.new do |opts|
409
+ opts.banner = "Usage: #{$0} [command] [options]"
410
+ opts.separator ""
411
+ opts.separator "Commands (#{Simplerubysteps::VERSION}):"
412
+ opts.separator " deploy Create Step Functions State Machine"
413
+ opts.separator " destroy Delete Step Functions State Machine"
414
+ opts.separator " log Continuously prints Lambda function log output"
415
+ opts.separator " start Start State Machine execution"
416
+ opts.separator " task-success Continue Start State Machine execution"
417
+ opts.separator ""
418
+
419
+ opts.on_tail("-h", "--help", "Display this help message") do
420
+ puts opts
421
+ exit
422
+ end
423
+ end
424
+
425
+ begin
426
+ global.order!(ARGV)
427
+ command = ARGV.shift
428
+ options[:command] = command
429
+ subcommands.fetch(command).parse!(ARGV)
430
+ rescue KeyError
431
+ puts "Unknown command: '#{command}'"
432
+ puts
433
+ puts global
434
+ exit 1
435
+ rescue OptionParser::ParseError => error
436
+ puts error.message
437
+ puts subcommands.fetch(command)
438
+ exit 1
439
+ end
440
+
441
+ if options[:command] == "deploy"
442
+ deploy
443
+ elsif options[:command] == "start"
444
+ start options[:wait], options[:input]
445
+ elsif options[:command] == "log"
446
+ log options[:extract_pattern]
447
+ elsif options[:command] == "task-success"
448
+ send_task_success options[:token], options[:input]
449
+ elsif options[:command] == "destroy"
450
+ destroy
451
+ end
452
+ end
453
+ end
454
+ end
@@ -1,3 +1,3 @@
1
1
  module Simplerubysteps
2
- VERSION = "0.0.7"
2
+ VERSION = "0.0.8"
3
3
  end