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,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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foobara
4
+ module AWS
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -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: []