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,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module Foobara
|
|
6
|
+
module AWS
|
|
7
|
+
# One deployment unit: a Lambda, and the route that reaches it.
|
|
8
|
+
#
|
|
9
|
+
# +name+ is a stable identifier ("posts"), +route+ the API Gateway route key
|
|
10
|
+
# path, and +public_commands+ the ones callable without authentication —
|
|
11
|
+
# derived from the manifest, never handed in.
|
|
12
|
+
# +sizing+ is whatever the unit's commands declared with
|
|
13
|
+
# {Foobara::AWS::Lambda}, reduced to one value per key — empty when none
|
|
14
|
+
# of them said anything.
|
|
15
|
+
Unit = Data.define(:name, :group, :commands, :route, :public_commands, :sizing)
|
|
16
|
+
|
|
17
|
+
# The whole topology, read out of a Foobara manifest.
|
|
18
|
+
#
|
|
19
|
+
# Deliberately plain data. It is built where the manifest is (a running
|
|
20
|
+
# connector, or a file), travels as JSON, and is consumed where the
|
|
21
|
+
# infrastructure is — which may be a different process with none of the
|
|
22
|
+
# application's gems. Same split as the {Service} construct's, and the same
|
|
23
|
+
# reason: synthesis should depend on what was built, not on whether the app
|
|
24
|
+
# happens to load.
|
|
25
|
+
Plan = Data.define(:mount, :granularity, :units) do
|
|
26
|
+
# Every command callable without authentication, across all units. This is
|
|
27
|
+
# what an optional-auth authorizer needs, and it is derived rather than
|
|
28
|
+
# maintained — a command moves in or out of it by declaring
|
|
29
|
+
# `requires_authentication`, nowhere else.
|
|
30
|
+
def anonymous = units.flat_map(&:public_commands).sort
|
|
31
|
+
|
|
32
|
+
def unit(name) = units.find { |u| u.name == name.to_s }
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
{
|
|
36
|
+
"mount" => mount,
|
|
37
|
+
"granularity" => granularity.to_s,
|
|
38
|
+
"units" => units.map do |u|
|
|
39
|
+
{ "name" => u.name, "group" => u.group, "commands" => u.commands,
|
|
40
|
+
"route" => u.route, "public_commands" => u.public_commands,
|
|
41
|
+
"sizing" => u.sizing }
|
|
42
|
+
end
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
47
|
+
|
|
48
|
+
# The inverse of {#to_h}, for a plan that has been through JSON.
|
|
49
|
+
def self.load(data)
|
|
50
|
+
data = data.transform_keys(&:to_s)
|
|
51
|
+
|
|
52
|
+
new(
|
|
53
|
+
mount: data.fetch("mount"),
|
|
54
|
+
granularity: data.fetch("granularity").to_sym,
|
|
55
|
+
units: data.fetch("units").map do |u|
|
|
56
|
+
u = u.transform_keys(&:to_s)
|
|
57
|
+
Unit.new(name: u.fetch("name"), group: u["group"], commands: u.fetch("commands"),
|
|
58
|
+
route: u["route"], public_commands: u.fetch("public_commands", []),
|
|
59
|
+
sizing: (u["sizing"] || {}).to_h { |k, v| [k.to_sym, v] })
|
|
60
|
+
end
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# Build a {Plan} from a Foobara manifest.
|
|
67
|
+
#
|
|
68
|
+
# manifest the parsed manifest, as served at /manifest by a connector
|
|
69
|
+
# mount the connector's path prefix ("/run" for the Rack connector)
|
|
70
|
+
# granularity :domain (default), :organization or :command
|
|
71
|
+
#
|
|
72
|
+
# A connector's manifest, not Foobara.manifest: `requires_authentication`
|
|
73
|
+
# only exists once commands are connected, and it is the field the public
|
|
74
|
+
# list comes from.
|
|
75
|
+
def plan(manifest, mount: "/run", granularity: :domain, exclude: DEFAULT_EXCLUDE)
|
|
76
|
+
commands = app_commands(manifest, exclude)
|
|
77
|
+
units =
|
|
78
|
+
case granularity
|
|
79
|
+
when :domain then group_units(commands, "domain", mount)
|
|
80
|
+
when :organization then group_units(commands, "organization", mount)
|
|
81
|
+
when :command then command_units(commands, mount)
|
|
82
|
+
else raise ArgumentError, "unknown granularity #{granularity.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Plan.new(mount: mount, granularity: granularity, units: units.sort_by(&:name))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Foobara's own commands — its auth domain and anything else it registers —
|
|
89
|
+
# are infrastructure, not the application, and deploying them is never what
|
|
90
|
+
# anyone means. Matched on the command's full name, which is where that
|
|
91
|
+
# namespace actually shows up.
|
|
92
|
+
#
|
|
93
|
+
# NOT matched on the organization: an app that declares none is filed under
|
|
94
|
+
# "global_organization", which is most apps, and excluding it would exclude
|
|
95
|
+
# everything.
|
|
96
|
+
DEFAULT_EXCLUDE = ["Foobara::"].freeze
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def app_commands(manifest, exclude)
|
|
101
|
+
manifest.fetch("command").reject do |name, _command|
|
|
102
|
+
exclude.any? { |prefix| name.to_s.start_with?(prefix) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def group_units(commands, key, mount)
|
|
107
|
+
commands.group_by { |_name, c| c[key].to_s }.map do |group, members|
|
|
108
|
+
names = members.map(&:first)
|
|
109
|
+
Unit.new(
|
|
110
|
+
name: slug(group),
|
|
111
|
+
group: group,
|
|
112
|
+
commands: names.sort,
|
|
113
|
+
# Derived from the commands' own paths, not from the group's name.
|
|
114
|
+
# Those are not the same thing: an app that declares no organization
|
|
115
|
+
# is filed under "global_organization", which appears nowhere in any
|
|
116
|
+
# URL, so grouping by organization and routing by its name would
|
|
117
|
+
# produce a prefix that matches nothing.
|
|
118
|
+
#
|
|
119
|
+
# Greedy: one route per unit rather than per command. That is only
|
|
120
|
+
# safe with a REQUEST authorizer, which sees the path and can decide
|
|
121
|
+
# per command; a JWT authorizer attaches per route, so a unit mixing
|
|
122
|
+
# public and authenticated commands would have to be split.
|
|
123
|
+
route: greedy_route(members, mount),
|
|
124
|
+
public_commands: public_of(members),
|
|
125
|
+
sizing: sizing_of(members)
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def command_units(commands, mount)
|
|
131
|
+
commands.map do |name, command|
|
|
132
|
+
path = Array(command["scoped_full_path"])
|
|
133
|
+
Unit.new(
|
|
134
|
+
name: slug(name),
|
|
135
|
+
group: command["domain"].to_s,
|
|
136
|
+
# Closed over `depends_on`, transitively. A command that invokes
|
|
137
|
+
# another must be able to reach it, so a per-command artifact has to
|
|
138
|
+
# contain the commands it calls even though only one of them is
|
|
139
|
+
# routed to.
|
|
140
|
+
#
|
|
141
|
+
# This is the slicing problem that defeats per-command deployment in
|
|
142
|
+
# most frameworks: you cannot soundly infer it from Ruby's require
|
|
143
|
+
# graph. Foobara does not infer it — commands DECLARE it — which is
|
|
144
|
+
# what makes this granularity usable rather than theoretical.
|
|
145
|
+
commands: closure(name, commands),
|
|
146
|
+
# Exact, not greedy: one command per Lambda needs no wildcard, and an
|
|
147
|
+
# exact route is what lets API Gateway 404 an unknown command itself.
|
|
148
|
+
route: "#{mount}/#{path.join("/")}",
|
|
149
|
+
# Only the ROUTED command decides this. A dependency is invoked in
|
|
150
|
+
# process, never over HTTP, so whether it requires authentication
|
|
151
|
+
# says nothing about whether this route may be called anonymously.
|
|
152
|
+
public_commands: public_of([[name, command]]),
|
|
153
|
+
sizing: sizing_of([[name, command]])
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The longest path prefix every command in the unit shares, with the
|
|
159
|
+
# command segment dropped. For a domain that is the domain's own path; for
|
|
160
|
+
# an organization spanning several domains it is whatever they have in
|
|
161
|
+
# common, which may be nothing — and "nothing" is right: one function
|
|
162
|
+
# serving everything under the mount.
|
|
163
|
+
def greedy_route(members, mount)
|
|
164
|
+
prefixes = members.map { |_name, c| Array(c["scoped_full_path"])[0..-2] }
|
|
165
|
+
common = common_prefix(prefixes)
|
|
166
|
+
|
|
167
|
+
"#{mount}/#{(common + ["{proxy+}"]).join("/")}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def common_prefix(paths)
|
|
171
|
+
first = paths.first || []
|
|
172
|
+
first.each_with_index.take_while { |segment, i| paths.all? { |p| p[i] == segment } }.map(&:first)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Everything +name+ can reach through depends_on, including itself.
|
|
176
|
+
# Iterative rather than recursive because a cycle is legal (two commands
|
|
177
|
+
# that call each other) and would otherwise not terminate.
|
|
178
|
+
def closure(name, commands)
|
|
179
|
+
seen = []
|
|
180
|
+
queue = [name]
|
|
181
|
+
|
|
182
|
+
until queue.empty?
|
|
183
|
+
current = queue.shift
|
|
184
|
+
next if seen.include?(current)
|
|
185
|
+
|
|
186
|
+
seen << current
|
|
187
|
+
queue.concat(Array(commands[current]&.[]("depends_on")))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
seen.sort
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# `requires_authentication`, NOT `authenticator`. The latter says which
|
|
194
|
+
# authenticator applies, which is a different question, and is absent
|
|
195
|
+
# entirely when identity arrives some other way.
|
|
196
|
+
def public_of(members)
|
|
197
|
+
members.reject { |_name, c| c["requires_authentication"] }.map(&:first).sort
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The LARGEST value each command asked for. A unit runs all of its
|
|
201
|
+
# commands in one function, so it must be sized for the hungriest —
|
|
202
|
+
# undersizing is a runtime failure, oversizing is a rounding error on the
|
|
203
|
+
# bill. Declared per command (see {AWSLambda}) because that is where the
|
|
204
|
+
# knowledge is; reduced per unit because that is what a Lambda is.
|
|
205
|
+
def sizing_of(members)
|
|
206
|
+
declared = members.filter_map { |_name, c| c["aws_lambda"] }
|
|
207
|
+
return {} if declared.empty?
|
|
208
|
+
|
|
209
|
+
%w[memory_size timeout_seconds].each_with_object({}) do |key, out|
|
|
210
|
+
values = declared.filter_map { |d| d[key] || d[key.to_sym] }
|
|
211
|
+
out[key.to_sym] = values.max unless values.empty?
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def symbolize(hash) = hash.to_h { |k, v| [k.to_sym, v] }
|
|
216
|
+
|
|
217
|
+
# A CloudFormation-friendly, filesystem-friendly identifier: "Posts" and
|
|
218
|
+
# "Acme::Posts" both become something usable as a directory name and a
|
|
219
|
+
# logical id fragment.
|
|
220
|
+
def slug(name) = name.split("::").map { |part| part.gsub(/([a-z])([A-Z])/, '\1-\2').downcase }.join("-")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/foobara/aws.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "aws/version"
|
|
6
|
+
require_relative "aws/error"
|
|
7
|
+
|
|
8
|
+
module Foobara
|
|
9
|
+
# Run Foobara commands on AWS Lambda, with the topology read out of the
|
|
10
|
+
# manifest rather than restated in infrastructure code.
|
|
11
|
+
#
|
|
12
|
+
# Three concerns, deliberately separable because they run in different places:
|
|
13
|
+
#
|
|
14
|
+
# Foobara::AWS.plan(manifest) WHAT to deploy. Plain data. Needs neither
|
|
15
|
+
# aws-cdk-lib nor Foobara.
|
|
16
|
+
# Foobara::AWS::CDK::Service the AWS resources. Synth time; needs
|
|
17
|
+
# aws-cdk-lib, and no application.
|
|
18
|
+
# Foobara::AWS::Handler serving a request. Runtime, inside the
|
|
19
|
+
# Foobara::AWS::Authorizer Lambda; needs neither of the above.
|
|
20
|
+
#
|
|
21
|
+
# A Lambda bundle requires only "foobara/aws/handler" (or ".../authorizer"),
|
|
22
|
+
# which loads no CDK and no planning code — cold start is on the critical path
|
|
23
|
+
# of every request, and a deployment tool has no business being there.
|
|
24
|
+
module AWS
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
require_relative "aws/lambda"
|
|
29
|
+
require_relative "aws/plan"
|
|
30
|
+
require_relative "aws/cdk/service"
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: foobara-aws
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Omar Qureshi
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Reads a Foobara manifest into a deployment plan — one unit per domain,
|
|
13
|
+
its route, and which of its commands are callable without authentication — and synthesises
|
|
14
|
+
the matching AWS CDK resources. The manifest is the single source of truth, so infrastructure
|
|
15
|
+
never restates what the application already declares.
|
|
16
|
+
email:
|
|
17
|
+
- omar@omarqureshi.net
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- Rakefile
|
|
26
|
+
- lib/foobara/aws.rb
|
|
27
|
+
- lib/foobara/aws/authorizer.rb
|
|
28
|
+
- lib/foobara/aws/cdk/service.rb
|
|
29
|
+
- lib/foobara/aws/error.rb
|
|
30
|
+
- lib/foobara/aws/handler.rb
|
|
31
|
+
- lib/foobara/aws/lambda.rb
|
|
32
|
+
- lib/foobara/aws/packager.rb
|
|
33
|
+
- lib/foobara/aws/plan.rb
|
|
34
|
+
- lib/foobara/aws/version.rb
|
|
35
|
+
homepage: https://github.com/omarqureshi/foobara-aws
|
|
36
|
+
licenses:
|
|
37
|
+
- MIT
|
|
38
|
+
metadata:
|
|
39
|
+
allowed_push_host: https://rubygems.org
|
|
40
|
+
homepage_uri: https://github.com/omarqureshi/foobara-aws
|
|
41
|
+
source_code_uri: https://github.com/omarqureshi/foobara-aws
|
|
42
|
+
changelog_uri: https://github.com/omarqureshi/foobara-aws/blob/main/CHANGELOG.md
|
|
43
|
+
rdoc_options: []
|
|
44
|
+
require_paths:
|
|
45
|
+
- lib
|
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: 3.2.0
|
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
requirements: []
|
|
57
|
+
rubygems_version: 4.0.10
|
|
58
|
+
specification_version: 4
|
|
59
|
+
summary: Run Foobara commands on AWS Lambda, from the manifest.
|
|
60
|
+
test_files: []
|