modulator 0.2.2 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -12,7 +12,7 @@ class Gateway < Roda
12
12
 
13
13
  my_dir = Pathname.new(__FILE__).dirname
14
14
  my_dir.glob('routes/*.rb').each{|file| require_relative file}
15
- DUMMY_AWS_EVENT = Utils.load_json my_dir.parent.join('../../spec/lambda/aws/event.json')
15
+ DUMMY_AWS_EVENT = Utils.load_json my_dir.parent.join('gateway_event.json')
16
16
 
17
17
  before do
18
18
  @time = Time.now
@@ -32,7 +32,7 @@ class Gateway < Roda
32
32
  after do |res|
33
33
  puts "Matched path: #{request.matched_path}"
34
34
  puts "Status: #{response.status}"
35
- puts ("Took: #{Time.now - @time} seconds")
35
+ puts "Took: #{Time.now - @time} seconds"
36
36
  end
37
37
 
38
38
  plugin :error_handler do |e|
@@ -51,11 +51,9 @@ class Gateway < Roda
51
51
 
52
52
  # process lambda configs
53
53
  Modulator::LAMBDAS.each do |lambda_name, lambda_config|
54
- # puts "* Registering #{lambda_name}"
55
- # pp lambda_config
56
54
 
57
- # module and wrapper config
58
- Modulator.set_env lambda_config
55
+ # copy config to env
56
+ Modulator.set_env_values lambda_config
59
57
 
60
58
  # build route
61
59
  @path_params = {}
@@ -1,11 +1,5 @@
1
- require 'aws-sdk-cloudformation'
2
-
3
- # console API
4
1
  Gateway.route('console') do |r|
5
2
 
6
- client = Aws::CloudFormation::Client.new
7
- app_name = (opts[:app_dir] || Pathname.getwd.basename.to_s).camelize
8
-
9
3
  # helpers
10
4
  def capture_output
11
5
  previous_stdout, $stdout = $stdout, StringIO.new
@@ -18,18 +12,29 @@ Gateway.route('console') do |r|
18
12
  $stderr = previous_stderr
19
13
  end
20
14
 
21
- def render_command_result(command_result, command_output)
22
- if command_result
23
- command_result.to_hash
15
+ # cf call return value or its capture from stdout
16
+ def render_cf_call_output(cf_call_result, cf_call_output, cf_call_name = 'aws cf sdk call')
17
+ if cf_call_result
18
+ cf_call_result.to_hash
24
19
  else
25
- {'aws-sdk-cloudformation': command_output.split("\n").first}
20
+ {cf_call_name => cf_call_output.split("\n").first}
26
21
  end
27
22
  end
28
23
 
24
+ # list registered lambdas
25
+ r.on 'lambdas' do
26
+ r.get 'list' do
27
+ Modulator::LAMBDAS
28
+ end
29
+ end
30
+
31
+ # stack operations
29
32
  r.on 'stack' do
30
- bucket_name = ENV['S3BUCKET'] || 'modulator-lambdas'
31
- payload = request.params.symbolize_keys
32
- command_result = nil
33
+ client = Aws::CloudFormation::Client.new
34
+ app_name = (opts[:app_dir] || Pathname.getwd.basename.to_s).camelize
35
+ s3_bucket = opts[:s3_bucket] || ENV['MODULATOR_S3_BUCKET'] || 'modulator-apps'
36
+ payload = request.params.symbolize_keys
37
+ cf_call_result = nil
33
38
 
34
39
  r.get 'events' do
35
40
  resp = client.describe_stack_events(stack_name: app_name, next_token: @headers['X-Next-Token'])
@@ -37,6 +42,7 @@ Gateway.route('console') do |r|
37
42
  resp.stack_events.map(&:to_hash)
38
43
  end
39
44
 
45
+ # initialize stack
40
46
  r.on 'init' do
41
47
  serializer = :yaml
42
48
  content_type = 'text/html'
@@ -47,42 +53,34 @@ Gateway.route('console') do |r|
47
53
  r.pass
48
54
  end
49
55
 
50
- # init stack
51
56
  stack = Modulator.init_stack(
52
- app_name: app_name,
53
- bucket: bucket_name,
57
+ s3_bucket: s3_bucket,
54
58
  timeout: 15
55
59
  )
56
60
 
57
- # add stack parameters if found in payload
58
- bucket_name[:parameters]&.each do |param|
59
- stack.add_parameter(param[:key],
60
- description: param[:description],
61
- type: param[:type],
62
- value: param[:value]
63
- )
64
- end
65
-
66
- r.post 'valid' do
67
- command_output = capture_output do
68
- command_result = stack.valid?
61
+ # validate stack
62
+ r.post 'validate' do
63
+ cf_call_output = capture_output do
64
+ cf_call_result = stack.valid?
69
65
  end
70
- render_command_result(command_result, command_output)
66
+ render_cf_call_output(cf_call_result, cf_call_output, 'stack validation call')
71
67
  end
72
68
 
69
+ # deploy stack
73
70
  r.post 'deploy' do
74
- command_output = capture_output do
75
- command_result = stack.deploy(
76
- parameters: bucket_name[:parameters]&.map{|param| {parameter_key: param[:key], parameter_value: param[:value]}},
71
+ cf_call_output = capture_output do
72
+ cf_call_result = stack.deploy(
73
+ parameters: s3_bucket[:parameters]&.map{|param| {parameter_key: param[:key], parameter_value: param[:value]}},
77
74
  capabilities: ['CAPABILITY_IAM']
78
75
  )
79
76
  end
80
- render_command_result(command_result, command_output)
77
+ render_cf_call_output(cf_call_result, cf_call_output)
81
78
  end
82
79
 
80
+ # print template
83
81
  r.post do
84
82
  response['Content-Type'] = content_type
85
- template = stack.to_cf(serializer)
83
+ stack.to_cf(serializer)
86
84
  end
87
85
  end
88
86
  end
@@ -0,0 +1,60 @@
1
+ {
2
+ "body": "{\"x\": 123}",
3
+ "isBase64Encoded": false,
4
+
5
+ "resource": "/calc/{proxy+}",
6
+ "path": "/calc/1/add/2",
7
+ "httpMethod": "POST",
8
+
9
+ "multiValueHeaders": {
10
+ "x-custom": [
11
+ "abc"
12
+ ]
13
+ },
14
+ "queryStringParameters": {
15
+ "aa": "11",
16
+ "bb": "22"
17
+ },
18
+ "multiValueQueryStringParameters": {
19
+ "aa": [
20
+ "11"
21
+ ],
22
+ "bb": [
23
+ "22"
24
+ ]
25
+ },
26
+ "pathParameters": {
27
+ "id": "1",
28
+ "other_id": "2"
29
+ },
30
+ "stageVariables": null,
31
+
32
+ "headers": {
33
+ "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
34
+ "User-Agent": "Custom User Agent String",
35
+ "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
36
+ "X-Forwarded-Port": "443",
37
+ "X-Forwarded-Proto": "https"
38
+ },
39
+ "requestContext": {
40
+ "accountId": "123456789012",
41
+ "resourceId": "123456",
42
+ "stage": "prod",
43
+ "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
44
+ "requestTime": "09/Apr/2015:12:34:56 +0000",
45
+ "requestTimeEpoch": 1428582896000,
46
+ "identity": {
47
+ "cognitoIdentityPoolId": null,
48
+ "accountId": null,
49
+ "cognitoIdentityId": null,
50
+ "caller": null,
51
+ "accessKey": null,
52
+ "sourceIp": "127.0.0.1",
53
+ "cognitoAuthenticationType": null,
54
+ "cognitoAuthenticationProvider": null,
55
+ "userArn": null,
56
+ "userAgent": "Custom User Agent String",
57
+ "user": null
58
+ }
59
+ }
60
+ }
@@ -1,5 +1,6 @@
1
1
  require 'pathname'
2
2
 
3
+ # NOTE: aws has LambdaHandler already defined as a class so we need an aws prefix here
3
4
  module AwsLambdaHandler
4
5
  module_function
5
6
 
@@ -8,20 +9,6 @@ module AwsLambdaHandler
8
9
  # TODO: implement handlers for other event types based on some event key, like AwsS3EventHandler
9
10
  AwsApiGatewayEventHandler.call(event: event, context: context)
10
11
  end
11
-
12
- # helpers
13
- def symbolize_keys(obj)
14
- case obj
15
- when Hash
16
- hash = {}
17
- obj.each {|k, v| hash[k.to_sym] = symbolize_keys(v)}
18
- hash
19
- when Array
20
- obj.map {|x| symbolize_keys(x)}
21
- else
22
- obj
23
- end
24
- end
25
12
  end
26
13
 
27
14
  module AwsApiGatewayEventHandler
@@ -96,7 +83,7 @@ module AwsApiGatewayEventHandler
96
83
  mod.send(mod_method, *path_params.values)
97
84
 
98
85
  elsif verb == 'POST'
99
- payload = AwsLambdaHandler.symbolize_keys(JSON.parse(event['body']))
86
+ payload = JSON.parse(event['body'], symbolize_names: true)
100
87
  method_signature.each do |arg_type, arg_name| # [[:req, :id], [:key, :pet]]
101
88
  payload = {arg_name => payload} if arg_type == :key # scope payload to first named argument
102
89
  end
@@ -0,0 +1,256 @@
1
+ require 'forwardable'
2
+ require 'humidifier'
3
+ require_relative 'uploader'
4
+ require_relative 'policies'
5
+
6
+ module StackBuilder
7
+ module_function
8
+
9
+ RUBY_VERSION = 'ruby2.5'
10
+ GEM_PATH_RUBY_VERSION = '2.5.0'
11
+ GEM_PATH = "/opt/ruby/#{GEM_PATH_RUBY_VERSION}"
12
+ LAMBDA_HANDLER_FILE_NAME = 'modulator-lambda-handler'
13
+
14
+ class << self
15
+ attr_accessor :stack, :stack_opts, :app_name, :app_path, :app_dir
16
+ attr_accessor :hidden_dir, :s3_bucket, :lambda_handler_s3_object_version
17
+ attr_accessor :api_gateway_deployment, :api_gateway_id, :lambda_policies
18
+ attr_accessor :lambda_handlers, :lambda_handler_s3_key
19
+ end
20
+
21
+ def init(app_name:, s3_bucket:, **stack_opts)
22
+ puts 'Initializing stack'
23
+ @app_name = app_name.camelize
24
+ @s3_bucket = s3_bucket
25
+ @app_path = Pathname.getwd
26
+ @app_dir = app_path.basename.to_s
27
+ @hidden_dir = '.modulator'
28
+ @stack_opts = stack_opts
29
+ @lambda_handlers = stack_opts[:lambda_handlers] || []
30
+ @lambda_policies = Array(stack_opts[:lambda_policies]) << :cloudwatch
31
+
32
+ # create hidden dir for build artifacts
33
+ app_path.join(hidden_dir).mkpath
34
+
35
+ # init stack instance
36
+ self.stack = Humidifier::Stack.new(name: app_name, aws_template_format_version: '2010-09-09')
37
+
38
+ # app environment - test, development, production ...
39
+ app_envs = stack_opts[:app_envs] || ['development']
40
+ stack.add_parameter('AppEnvironment', description: 'Application environment', type: 'String', allowed_values: app_envs, constraint_description: "Must be one of #{app_envs.join(', ')}")
41
+
42
+ if lambda_handlers.empty?
43
+ # api stage
44
+ stack.add_parameter('ApiGatewayStageName', description: 'Gateway deployment stage', type: 'String', default: 'v1')
45
+
46
+ # add gateway
47
+ stack.add_api_gateway
48
+ stack.add_api_gateway_deployment
49
+ end
50
+
51
+ # add role
52
+ stack.add_lambda_iam_role
53
+
54
+ # add policies to role
55
+ stack.lambda_policies.each do |policy|
56
+ stack.add_policy(policy) if policy.is_a?(Symbol)
57
+ stack.add_policy(policy[:name], **policy) if policy.is_a?(Hash)
58
+ end
59
+
60
+ # simple lambda app
61
+ if lambda_handlers.any?
62
+ stack.upload_lambda_files
63
+ lambda_handlers.each do |handler|
64
+ stack.add_lambda(handler: handler, env: stack_opts[:env] || {}, settings: stack_opts[:settings] || {})
65
+ end
66
+ else
67
+ # upload handlers and layers
68
+ stack.upload_files
69
+ end
70
+
71
+ # return humidifier instance
72
+ stack
73
+ end
74
+
75
+ def upload_files
76
+ if stack_opts[:skip_upload]
77
+ puts 'Skipping upload'
78
+ return
79
+ end
80
+ stack.upload_generic_lambda_handler
81
+ puts 'Generating layers'
82
+ stack.upload_gems_layer
83
+ stack.upload_app_layer
84
+ end
85
+
86
+ def add_lambda_endpoint(**opts) # gateway:, mod:, wrapper: {}, env: {}, settings: {}
87
+ # add api resources and its lambda
88
+ stack.add_api_gateway_resources(gateway: opts[:gateway], lambda: stack.add_generic_lambda(opts))
89
+ end
90
+
91
+ # gateway
92
+ def add_api_gateway
93
+ self.api_gateway_id = 'ApiGateway'
94
+ stack.add(api_gateway_id, Humidifier::ApiGateway::RestApi.new(name: app_name, description: app_name + ' API'))
95
+ end
96
+
97
+ # gateway deployment
98
+ def add_api_gateway_deployment
99
+ self.api_gateway_deployment = Humidifier::ApiGateway::Deployment.new(
100
+ rest_api_id: Humidifier.ref(api_gateway_id),
101
+ stage_name: Humidifier.ref("ApiGatewayStageName")
102
+ )
103
+ stack.add('ApiGatewayDeployment', api_gateway_deployment)
104
+ stack.add_output('ApiGatewayInvokeURL',
105
+ value: Humidifier.fn.sub("https://${#{api_gateway_id}}.execute-api.${AWS::Region}.amazonaws.com/${ApiGatewayStageName}"),
106
+ description: 'API root url',
107
+ export_name: app_name + 'RootUrl'
108
+ )
109
+ api_gateway_deployment.depends_on = []
110
+ end
111
+
112
+ # custom lambda function
113
+ def add_lambda(handler:, env: {}, settings: {})
114
+ lambda_resource = generate_lambda_resource(
115
+ description: "Lambda for #{handler}",
116
+ function_name: ([app_name] << handler.split('.')).flatten.join('-').dasherize,
117
+ handler: handler,
118
+ s3_key: lambda_handler_s3_key,
119
+ env_vars: env.merge('app_env' => Humidifier.ref('AppEnvironment')),
120
+ role: Humidifier.fn.get_att(['LambdaRole', 'Arn']),
121
+ settings: settings
122
+ )
123
+ stack.add(handler.gsub('.', '_').camelize, lambda_resource)
124
+ end
125
+
126
+ # generic lambda function for gateway
127
+ def add_generic_lambda(gateway: {}, mod: {}, wrapper: {}, env: {}, settings: {})
128
+ lambda_config = {}
129
+ name_parts = mod[:name].split('::')
130
+ {gateway: gateway, module: mod, wrapper: wrapper}.each do |env_group_prefix, env_group|
131
+ env_group.each{|env_key, env_value| lambda_config["#{env_group_prefix}_#{env_key}"] = env_value}
132
+ end
133
+ env_vars = env
134
+ .reduce({}){|env_as_string, (k, v)| env_as_string.update(k.to_s => v.to_s)}
135
+ .merge(lambda_config)
136
+ .merge(
137
+ 'GEM_PATH' => GEM_PATH,
138
+ 'app_dir' => app_dir,
139
+ 'app_env' => Humidifier.ref('AppEnvironment')
140
+ )
141
+
142
+ lambda_resource = generate_lambda_resource(
143
+ description: "Lambda for #{mod[:name]}.#{mod[:method]}",
144
+ function_name: [app_name, name_parts, mod[:method]].flatten.join('-').dasherize,
145
+ handler: "#{LAMBDA_HANDLER_FILE_NAME}.AwsLambdaHandler.call",
146
+ s3_key: LAMBDA_HANDLER_FILE_NAME + '.rb.zip',
147
+ env_vars: env_vars,
148
+ role: Humidifier.fn.get_att(['LambdaRole', 'Arn']),
149
+ settings: settings,
150
+ layers: [Humidifier.ref(app_name + 'Layer'), Humidifier.ref(app_name + 'GemsLayer')]
151
+ )
152
+
153
+ # add to stack
154
+ ['Lambda', name_parts, mod[:method].capitalize].join.tap do |id|
155
+ stack.add(id, lambda_resource)
156
+ stack.add_lambda_invoke_permission(id: id, gateway: gateway)
157
+ end
158
+ end
159
+
160
+ def generate_lambda_resource(description:, function_name:, handler:, s3_key:, env_vars:, role:, settings:, layers: [])
161
+ lambda_function = Humidifier::Lambda::Function.new(
162
+ description: description,
163
+ function_name: function_name,
164
+ handler: handler,
165
+ environment: {variables: env_vars},
166
+ role: role,
167
+ timeout: settings[:timeout] || stack_opts[:timeout] || 15,
168
+ memory_size: settings[:memory_size] || stack_opts[:memory_size] || 128,
169
+ runtime: RUBY_VERSION,
170
+ code: {
171
+ s3_bucket: s3_bucket,
172
+ s3_key: s3_key,
173
+ s3_object_version: lambda_handler_s3_object_version
174
+ },
175
+ layers: layers
176
+ )
177
+ end
178
+
179
+ # invoke permission
180
+ def add_lambda_invoke_permission(id:, gateway:)
181
+ arn_path_matcher = gateway[:path].split('/').each_with_object([]) do |fragment, matcher|
182
+ fragment = '*' if fragment.start_with?(':')
183
+ matcher << fragment
184
+ end.join('/')
185
+ stack.add(id + 'InvokePermission' , Humidifier::Lambda::Permission.new(
186
+ action: "lambda:InvokeFunction",
187
+ function_name: Humidifier.fn.get_att([id, 'Arn']),
188
+ principal: "apigateway.amazonaws.com",
189
+ source_arn: Humidifier.fn.sub("arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${#{api_gateway_id}}/*/#{gateway[:verb]}/#{arn_path_matcher}")
190
+ )
191
+ )
192
+ end
193
+
194
+ # gateway method
195
+ def add_api_gateway_resources(gateway:, lambda:)
196
+
197
+ # example: calculator/algebra/:x/:y/sum -> module name, args, method name
198
+ path = gateway[:path].split('/')
199
+
200
+ # root resource
201
+ root_resource = path.shift
202
+ stack.add(root_resource.camelize, Humidifier::ApiGateway::Resource.new(
203
+ rest_api_id: Humidifier.ref(api_gateway_id),
204
+ parent_id: Humidifier.fn.get_att(["ApiGateway", "RootResourceId"]),
205
+ path_part: root_resource
206
+ )
207
+ )
208
+
209
+ # args and method name are nested resources
210
+ parent_resource = root_resource.camelize
211
+ path.each do |fragment|
212
+ if fragment.start_with?(':')
213
+ fragment = fragment[1..-1]
214
+ dynamic_fragment = "{#{fragment}}"
215
+ end
216
+ stack.add(parent_resource + fragment.camelize, Humidifier::ApiGateway::Resource.new(
217
+ rest_api_id: Humidifier.ref(api_gateway_id),
218
+ parent_id: Humidifier.ref(parent_resource),
219
+ path_part: dynamic_fragment || fragment
220
+ )
221
+ )
222
+ parent_resource = parent_resource + fragment.camelize
223
+ end
224
+
225
+ # attach lambda to last resource
226
+ id = 'EndpointFor' + (gateway[:path].gsub(':', '').gsub('/', '_')).camelize
227
+ stack.add(id, Humidifier::ApiGateway::Method.new(
228
+ authorization_type: 'NONE',
229
+ http_method: gateway[:verb].to_s.upcase,
230
+ integration: {
231
+ integration_http_method: 'POST',
232
+ type: "AWS_PROXY",
233
+ uri: Humidifier.fn.sub([
234
+ "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations",
235
+ 'lambdaArn' => Humidifier.fn.get_att([lambda, 'Arn'])
236
+ ])
237
+ },
238
+ rest_api_id: Humidifier.ref(api_gateway_id),
239
+ resource_id: Humidifier.ref(parent_resource) # last evaluated resource
240
+ )
241
+ )
242
+
243
+ # deployment depends on each endpoint
244
+ api_gateway_deployment.depends_on << id
245
+ end
246
+ end
247
+
248
+ # delegate from stack instance to our module
249
+ module Humidifier
250
+ class Stack
251
+ extend Forwardable
252
+ [StackBuilder, StackBuilder::LambdaPolicy].each do |mod|
253
+ def_delegators mod.to_s.to_sym, *mod.singleton_methods
254
+ end
255
+ end
256
+ end