foobara-aws 0.1.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.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Serving a Foobara command from AWS Lambda.
4
+ #
5
+ # RUNTIME, not synth. Require this file and nothing else from the gem: it loads
6
+ # no planning code and no CDK, because cold start is on the critical path of
7
+ # every request and a deployment tool has no business being there.
8
+ #
9
+ # require "foobara/aws/handler"
10
+ # require_relative "config/boot"
11
+ #
12
+ # HANDLER = Foobara::AWS::Handler.new { |connector| Posts.foobara_all_command... }
13
+ #
14
+ # def handle(event:, context:) = HANDLER.call(event, context)
15
+ #
16
+ # The connector is built by the caller, because which commands a unit registers
17
+ # — and how it authenticates them — is the application's business. What this
18
+ # owns is the translation either side of it.
19
+
20
+ require_relative "error"
21
+ require_relative "version"
22
+
23
+ module Foobara
24
+ module AWS
25
+ class << self
26
+ # WHO IS CALLING, for the duration of one invocation.
27
+ #
28
+ # Foobara authenticates only commands declaring `requires_authentication`,
29
+ # which answers "may this caller in?" but not "who is this?". A command
30
+ # that is public AND viewer-aware — one that marks your own posts editable
31
+ # — needs the second without the first, and Foobara has nowhere to put it.
32
+ #
33
+ # So the handler puts it here, and an application reads it from its
34
+ # connector's authenticator and from any command that needs the viewer as
35
+ # data. A thread-local is not elegant; it is the smallest thing that works
36
+ # until Foobara grows a notion of optional identity.
37
+ def current_caller = Thread.current[:foobara_aws_caller]
38
+
39
+ # Not an endless def: Ruby does not allow a setter to be one.
40
+ def current_caller=(value)
41
+ Thread.current[:foobara_aws_caller] = value
42
+ end
43
+
44
+ # Turns the authorizer's verified claims into whatever the application
45
+ # wants as its caller. Set once, in the app's boot file, so the generated
46
+ # handler needs no application-specific code in it.
47
+ #
48
+ # Foobara::AWS.caller_builder = ->(claims) { Viewer.new(claims["sub"], claims["name"]) }
49
+ attr_accessor :caller_builder
50
+ end
51
+
52
+ # Adapts an API Gateway v2 payload to what a Rack connector wants, and the
53
+ # authorizer's verified claims to the caller.
54
+ class Handler
55
+ # The claims a REQUEST authorizer returned, in SIMPLE format. API Gateway
56
+ # forwards only strings, so everything here is a string.
57
+ CLAIMS_PATH = %w[requestContext authorizer lambda].freeze
58
+
59
+ # +viewer+ receives the claims hash (empty for an anonymous caller on a
60
+ # public command) and returns whatever the application wants as its
61
+ # caller — a Struct, a model, anything. Return nil for "nobody".
62
+ #
63
+ # +dev_identity+ enables the X-Dev-Sub / X-Dev-Name escape hatch for local
64
+ # work. It defaults to OFF and must stay off in a deployed unit: those
65
+ # headers are unauthenticated, so honouring them would let any caller name
66
+ # themselves — including on the commands the authorizer just gated, and on
67
+ # the public-but-viewer-aware ones it lets through anonymously.
68
+ def initialize(connector, viewer: nil, dev_identity: false)
69
+ @connector = connector
70
+ @viewer = viewer
71
+ @dev_identity = dev_identity
72
+ end
73
+
74
+ # Identity is established BEFORE the connector runs and cleared after,
75
+ # whatever happens — a leaked caller would be served to whoever reuses this
76
+ # execution environment next, which is the worst possible bug to have.
77
+ def call(event, _context = nil)
78
+ env = rack_env(event)
79
+ AWS.current_caller = identify(event, env)
80
+
81
+ respond(@connector.call(env))
82
+ ensure
83
+ AWS.current_caller = nil
84
+ end
85
+
86
+ # What the authorizer verified, or the dev headers when explicitly allowed.
87
+ # Public so an application can reuse it for its own middleware.
88
+ def identify(event, env = nil)
89
+ claims = event.dig(*CLAIMS_PATH) || {}
90
+ claims = dev_identity(env || rack_env(event)) || {} if claims.empty? && @dev_identity
91
+ return nil if claims.empty?
92
+
93
+ builder = @viewer || AWS.caller_builder
94
+ builder ? builder.call(claims) : claims
95
+ end
96
+
97
+ private
98
+
99
+ def respond(rack_response)
100
+ status, headers, body = rack_response
101
+ { "statusCode" => status, "headers" => headers, "body" => Array(body).join }
102
+ end
103
+
104
+ def rack_env(event)
105
+ request = event["requestContext"] || {}
106
+ http = request["http"] || {}
107
+
108
+ env = {
109
+ "REQUEST_METHOD" => http["method"] || "POST",
110
+ # Passed through unchanged. The route IS the connector's own path, so
111
+ # stripping a prefix here would break dispatch.
112
+ "PATH_INFO" => event["rawPath"] || http["path"] || "",
113
+ "QUERY_STRING" => event["rawQueryString"].to_s,
114
+ "rack.input" => StringIO.new(decoded_body(event)),
115
+ "rack.errors" => $stderr
116
+ }
117
+ (event["headers"] || {}).each { |k, v| env["HTTP_#{k.upcase.tr("-", "_")}"] = v }
118
+ env
119
+ end
120
+
121
+ # API Gateway base64-encodes a binary body and says so. Ignoring the flag
122
+ # turns an uploaded byte into mojibake far from where it happened.
123
+ def decoded_body(event)
124
+ body = event["body"].to_s
125
+ event["isBase64Encoded"] ? body.unpack1("m") : body
126
+ end
127
+
128
+ def dev_identity(env)
129
+ sub = env["HTTP_X_DEV_SUB"]
130
+ name = env["HTTP_X_DEV_NAME"]
131
+ return nil unless sub
132
+
133
+ { "sub" => sub, "name" => name || sub }
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ require "stringio"
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foobara
4
+ module AWS
5
+ # Declares how a command should be RUN, next to the command that says what it
6
+ # does:
7
+ #
8
+ # class DestroyPost < Foobara::Command
9
+ # extend Foobara::AWS::Lambda
10
+ # aws_lambda vcpu: 1, timeout: 60
11
+ # ...
12
+ # end
13
+ #
14
+ # This is the one thing a manifest cannot otherwise supply. Foobara describes
15
+ # what a command IS, not how it should be run — a defensible line, but
16
+ # something has to carry it, and the person who knows a command fans out
17
+ # across a whole comment tree is the person writing the command.
18
+ #
19
+ # No change to Foobara is needed: a command's manifest is `super.merge(...)`,
20
+ # so this adds a key and the connector serves it. {Foobara::AWS.plan} reads it
21
+ # back, and {Service} sizes each function from it.
22
+ #
23
+ # ON vcpu. Lambda has no CPU setting — CPU is allocated in proportion to
24
+ # memory, and 1769 MB is the point at which a function gets one full vCPU.
25
+ # So `vcpu:` is a more honest way to ask for compute than picking a memory
26
+ # number, but it RESOLVES to memory; it is not a second dial. Give both and
27
+ # the larger memory wins, because undersizing is a runtime failure while
28
+ # oversizing is a rounding error on the bill.
29
+ module Lambda
30
+ # AWS's own figure: one vCPU is allocated at 1,769 MB.
31
+ MEMORY_PER_VCPU = 1769
32
+
33
+ # Lambda's floor and ceiling. Asking outside them fails at deploy time,
34
+ # which is a slow way to learn about a typo.
35
+ MEMORY_RANGE = (128..10_240)
36
+ TIMEOUT_RANGE = (1..900)
37
+
38
+ def aws_lambda(memory: nil, vcpu: nil, timeout: nil)
39
+ memory = resolve_memory(memory, vcpu)
40
+
41
+ if memory && !MEMORY_RANGE.cover?(memory)
42
+ raise ArgumentError, "aws_lambda memory #{memory} outside Lambda's #{MEMORY_RANGE}"
43
+ end
44
+ if timeout && !TIMEOUT_RANGE.cover?(timeout)
45
+ raise ArgumentError, "aws_lambda timeout #{timeout} outside Lambda's #{TIMEOUT_RANGE} seconds"
46
+ end
47
+
48
+ @aws_lambda = { "memory_size" => memory, "timeout_seconds" => timeout }.compact
49
+ end
50
+
51
+ def aws_lambda_manifest = @aws_lambda
52
+
53
+ def foobara_manifest
54
+ manifest = super
55
+ @aws_lambda ? manifest.merge(aws_lambda: @aws_lambda) : manifest
56
+ end
57
+
58
+ private
59
+
60
+ def resolve_memory(memory, vcpu)
61
+ return memory unless vcpu
62
+
63
+ [memory, (vcpu * MEMORY_PER_VCPU).ceil].compact.max
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "erb"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ require_relative "error"
9
+ require_relative "plan"
10
+
11
+ module Foobara
12
+ module AWS
13
+ # Builds one deployable artifact per unit in a {Plan}.
14
+ #
15
+ # BUILD time — neither runtime nor synth. It shells out to Docker, so it is
16
+ # required explicitly and never from a Lambda or a CDK app.
17
+ #
18
+ # Foobara::AWS::Packager.new(plan:, root: ".", out: "build").build
19
+ #
20
+ # Each artifact contains the application's sources, a generated `handler.rb`,
21
+ # and a standalone gem bundle holding only that unit's dependencies. Units
22
+ # whose gemfiles resolve identically share one bundle build.
23
+ #
24
+ # It also writes the plan next to them. That is the contract with synthesis:
25
+ # the CDK app reads what was BUILT, so it cannot route to an artifact that
26
+ # does not exist, and it needs neither a running connector nor the app.
27
+ class Packager
28
+ # A Lambda-like container, so native extensions are built against the
29
+ # runtime's own libraries rather than the host's.
30
+ DEFAULT_IMAGE = "public.ecr.aws/sam/build-ruby4.0"
31
+
32
+ # Bundler's standalone mode, NOT `bundle exec`: Bundler's runtime costs
33
+ # several hundred milliseconds at Lambda's smaller memory sizes, on every
34
+ # cold start, for nothing a deployed artifact needs.
35
+ BUNDLE_COMMAND = "bundle install --standalone"
36
+
37
+ # rubocop:disable Metrics/ParameterLists
38
+ def initialize(plan:, root: Dir.pwd, out: "build", sources: %w[app config],
39
+ gemfiles: "units", image: DEFAULT_IMAGE, env: {}, mounts: [],
40
+ handler_template: nil, authorizer: nil, docker: nil)
41
+ @plan = plan
42
+ @root = File.expand_path(root)
43
+ @out = File.expand_path(out, @root)
44
+ @sources = sources
45
+ @gemfiles = gemfiles
46
+ @image = image
47
+ @env = env
48
+ # Extra host paths to mount. A gemfile referencing a gem by path outside
49
+ # the application root would otherwise fail inside the container, which
50
+ # sees only the root.
51
+ @mounts = mounts
52
+ @handler_template = handler_template
53
+ @authorizer = authorizer
54
+ # Injectable so the layout can be tested without Docker, and so an
55
+ # environment with a different container runtime can substitute one.
56
+ @docker = docker || method(:run_docker)
57
+ end
58
+ # rubocop:enable Metrics/ParameterLists
59
+
60
+ def build
61
+ FileUtils.mkdir_p(@out)
62
+
63
+ built = @plan.units.map { |unit| build_unit(unit) }
64
+ built << build_authorizer if @authorizer
65
+
66
+ File.write(plan_path, JSON.pretty_generate(@plan.to_h))
67
+ built
68
+ end
69
+
70
+ def plan_path = File.join(@out, "plan.json")
71
+
72
+ private
73
+
74
+ def build_unit(unit)
75
+ dir = prepare(unit.name)
76
+ @sources.each { |s| FileUtils.cp_r(File.join(@root, s), dir) }
77
+ File.write(File.join(dir, "handler.rb"), handler_source(unit))
78
+ vendor(unit.name, dir)
79
+
80
+ { name: unit.name, dir: dir, commands: unit.commands.length }
81
+ end
82
+
83
+ # The authorizer is a deployment unit but not a plan unit: it serves no
84
+ # commands and needs none of the application — only this gem and jwt.
85
+ # Giving it the app's sources would put the whole domain model on the cold
86
+ # start of every authenticated request.
87
+ def build_authorizer
88
+ name = @authorizer.fetch(:name, "authorizer")
89
+ dir = prepare(name)
90
+ File.write(File.join(dir, "handler.rb"), authorizer_source)
91
+ vendor(name, dir)
92
+
93
+ { name: name, dir: dir, commands: 0 }
94
+ end
95
+
96
+ def prepare(name)
97
+ dir = File.join(@out, name)
98
+ FileUtils.rm_rf(dir)
99
+ FileUtils.mkdir_p(dir)
100
+ dir
101
+ end
102
+
103
+ def handler_source(unit)
104
+ template = @handler_template ? File.read(@handler_template) : DEFAULT_HANDLER
105
+ render(template, unit)
106
+ end
107
+
108
+ def authorizer_source
109
+ template = @authorizer[:template] ? File.read(@authorizer[:template]) : DEFAULT_AUTHORIZER
110
+ render(template, nil)
111
+ end
112
+
113
+ def render(template, unit)
114
+ ERB.new(template, trim_mode: "-").result(binding)
115
+ end
116
+
117
+ # One bundle per distinct gemfile, keyed by its contents. Units that
118
+ # resolve identically then share a build rather than repeating it — the
119
+ # difference between one docker run and one per unit.
120
+ def vendor(name, dir)
121
+ gemfile = File.join(@root, @gemfiles, "#{name}.gemfile")
122
+ raise Error, "no gemfile for unit #{name.inspect} at #{gemfile}" unless File.exist?(gemfile)
123
+
124
+ cache = File.join(@out, ".bundles", Digest::SHA256.hexdigest(File.read(gemfile))[0, 16])
125
+ unless File.directory?(File.join(cache, "bundler"))
126
+ build_bundle(gemfile, cache)
127
+ vendor_path_gems!(cache, gemfile)
128
+ end
129
+
130
+ FileUtils.mkdir_p(File.join(dir, "vendor"))
131
+ FileUtils.cp_r(cache, File.join(dir, "vendor/bundle"))
132
+ end
133
+
134
+ def build_bundle(gemfile, cache)
135
+ FileUtils.mkdir_p(cache)
136
+ ok = @docker.call(gemfile, cache)
137
+ raise Error, "bundle install failed for #{gemfile}" unless ok
138
+ end
139
+
140
+ # A gem declared by `path:` is NOT copied into a standalone bundle —
141
+ # bundler adds its location to the load path instead. That location is
142
+ # written RELATIVE to the bundle directory, so once the bundle is copied
143
+ # into an artifact it resolves somewhere else entirely, and the unit boots
144
+ # on the build machine and dies in Lambda with a LoadError.
145
+ #
146
+ # Copy each such gem in and rewrite the load path to point at the copy.
147
+ # Only lib and exe: an artifact needs neither specs nor .git.
148
+ #
149
+ # The lockfile is the source of truth for WHICH gems those are. Detecting
150
+ # them by inspecting the generated paths is guesswork that has already
151
+ # changed once between bundler versions.
152
+ def vendor_path_gems!(cache, gemfile)
153
+ setup = File.join(cache, "bundler", "setup.rb")
154
+ return unless File.exist?(setup)
155
+
156
+ roots = path_gem_roots(gemfile)
157
+ return if roots.empty?
158
+
159
+ source = File.read(setup)
160
+ roots.each { |root| source = vendor_one(cache, root, source) }
161
+ File.write(setup, source)
162
+ end
163
+
164
+ # PATH sections of the unit's lockfile, resolved against the gemfile that
165
+ # declared them.
166
+ def path_gem_roots(gemfile)
167
+ lock = "#{gemfile}.lock"
168
+ return [] unless File.exist?(lock)
169
+
170
+ File.read(lock).scan(/^PATH\n remote: (.+)$/).flatten.map do |remote|
171
+ File.expand_path(remote.strip, File.dirname(gemfile))
172
+ end
173
+ end
174
+
175
+ def vendor_one(cache, root, source)
176
+ name = File.basename(root)
177
+ dest = File.join(cache, "path-gems", name)
178
+
179
+ FileUtils.rm_rf(dest)
180
+ FileUtils.mkdir_p(dest)
181
+ %w[lib exe].each do |sub|
182
+ from = File.join(root, sub)
183
+ FileUtils.cp_r(from, dest) if File.exist?(from)
184
+ end
185
+
186
+ source.gsub(%r{^\$:\.unshift File\.expand_path\("[^"]*/#{Regexp.escape(name)}/([^"]+)"\)$}) do
187
+ %($:.unshift File.expand_path("\#{__dir__}/../path-gems/#{name}/#{Regexp.last_match(1)}"))
188
+ end
189
+ end
190
+
191
+ def run_docker(gemfile, cache)
192
+ env_args = @env.flat_map { |k, v| ["-e", "#{k}=#{v}"] }
193
+ # Mounted at the same path they have on the host, so the absolute paths
194
+ # bundler writes resolve identically in both places.
195
+ mount_args = @mounts.flat_map { |path| ["-v", "#{File.expand_path(path)}:#{File.expand_path(path)}"] }
196
+
197
+ system(
198
+ "docker", "run", "--rm", "--platform", "linux/amd64",
199
+ # As the invoking user, or the build output is root-owned and the next
200
+ # step cannot read it. HOME because bundler writes there and the
201
+ # container has none for this uid.
202
+ "--user", "#{Process.uid}:#{Process.gid}", "-e", "HOME=/tmp",
203
+ *env_args, *mount_args,
204
+ "-v", "#{@root}:#{@root}", "-v", "#{cache}:/vendor", "-w", @root,
205
+ "--entrypoint", "bash", @image, "-c",
206
+ "set -e; export BUNDLE_GEMFILE=#{gemfile} BUNDLE_PATH=/vendor; #{BUNDLE_COMMAND}",
207
+ out: File::NULL
208
+ )
209
+ end
210
+
211
+ # Generic because everything unit-specific comes from the plan and
212
+ # everything app-specific comes from the boot file: an application sets
213
+ # Foobara::AWS.caller_builder there, and nothing else is needed here.
214
+ DEFAULT_HANDLER = <<~ERB
215
+ # Generated by foobara-aws for unit <%= unit.name.inspect %>. Do not edit.
216
+ #
217
+ # Serves: <%= unit.commands.join(", ") %>
218
+ #
219
+ # NOTE the standalone require rather than `bundle exec`: Bundler's runtime
220
+ # is a large fraction of a Ruby cold start and buys a deployed artifact
221
+ # nothing.
222
+ require_relative "vendor/bundle/bundler/setup"
223
+ require "foobara/aws/handler"
224
+ require_relative "config/boot"
225
+ require "foobara/rack_connector"
226
+
227
+ # This unit registers exactly its own commands. It does not have to
228
+ # REFUSE the others — they were never connected, so there is nothing to
229
+ # refuse, and an unknown path 404s by construction.
230
+ PUBLIC_COMMANDS = <%= unit.public_commands.inspect %>.freeze
231
+
232
+ CONNECTOR = Foobara::CommandConnectors::Http::Rack.new(
233
+ # instance_exec'd against the request, so it takes no argument.
234
+ # It reads what the handler already resolved from verified claims.
235
+ authenticator: -> { Foobara::AWS.current_caller }
236
+ )
237
+
238
+ <%= unit.commands.inspect %>.each do |name|
239
+ CONNECTOR.connect(
240
+ Object.const_get(name),
241
+ requires_authentication: !PUBLIC_COMMANDS.include?(name)
242
+ )
243
+ end
244
+
245
+ HANDLER = Foobara::AWS::Handler.new(
246
+ CONNECTOR,
247
+ dev_identity: ENV["FOOBARA_DEV_IDENTITY"] == "1"
248
+ )
249
+
250
+ def handle(event:, context:)
251
+ HANDLER.call(event, context)
252
+ end
253
+ ERB
254
+
255
+ DEFAULT_AUTHORIZER = <<~ERB
256
+ # Generated by foobara-aws. Do not edit.
257
+ #
258
+ # The optional-auth authorizer. Its public list and mount come from the
259
+ # environment, which the CDK Service sets from the plan — so which
260
+ # commands are reachable anonymously is derived from
261
+ # requires_authentication and never maintained by hand.
262
+ require_relative "vendor/bundle/bundler/setup"
263
+ require "foobara/aws/authorizer"
264
+
265
+ HANDLER = Foobara::AWS::Authorizer.from_env(
266
+ issuer: ENV.fetch("FOOBARA_ISSUER"),
267
+ audience: ENV.fetch("FOOBARA_AUDIENCE", "").split(",")
268
+ )
269
+
270
+ def handle(event:, context:)
271
+ HANDLER.call(event, context)
272
+ end
273
+ ERB
274
+ end
275
+ end
276
+ end