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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/Rakefile +12 -0
- data/lib/foobara/aws/authorizer.rb +183 -0
- data/lib/foobara/aws/cdk/service.rb +203 -0
- data/lib/foobara/aws/error.rb +9 -0
- data/lib/foobara/aws/handler.rb +139 -0
- data/lib/foobara/aws/lambda.rb +67 -0
- data/lib/foobara/aws/packager.rb +276 -0
- data/lib/foobara/aws/plan.rb +223 -0
- data/lib/foobara/aws/version.rb +7 -0
- data/lib/foobara/aws.rb +30 -0
- metadata +60 -0
|
@@ -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
|