modulator 0.2.2 → 0.3.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.
@@ -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