foobara-aws 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16a858ce4e2944711259dacbf844fd258a1715018aaa7df98364e45739ec33de
4
- data.tar.gz: 995b9e3af507e6cb53e3fd581945d27b9251f9559f0151c9151e5fa5bb277191
3
+ metadata.gz: 3905338908c79d15be8eea5643596893e7604a35d91b78760de209f06822c230
4
+ data.tar.gz: 6a771a30dcc41281ee879b80c2536a6f63bf7a9755846be1685ab1d474034ba5
5
5
  SHA512:
6
- metadata.gz: a328cae1d9959faa1efc89dc399f007005673493704ae20dbe9e02904b85949f7eb76debefeff09830f2a367315efac9bde77981c660690299aced88f5871464
7
- data.tar.gz: a0cb8f25daa37b5ea097d5bf13e145c0a7df3a27b2d6faa801e426dafb3f8f00def81a55093d7c95818cd2caf32b2d7bbe7c6f3864248993b099f749d1218d52
6
+ metadata.gz: cfba5032f0d68511918ed2e2fca46bdf3842bca216c5361fcda23b1360cd6ea8708793ebf7d5063ae68d10d3e738d87337624a7a0c3d11fc8b598a65d3965107
7
+ data.tar.gz: 0fda844ba145042a870a580ef2c445fb8d5e5359866c0134c70a7fea0e67a58326cfbbf77ea6f837af7f2e1dcb64b30861278de84738ed75a64a5c7b85d478b8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-08-05
4
+
5
+ - `Foobara::AWS::SQSConnector` and `Foobara::AWS::EventHandler` — commands invoked by a queue rather than by HTTP.
6
+ Modelled on foobara-resque-connector, and using the same envelope, because a background job is not a special
7
+ kind of object: it is a command reached by a different transport. `connect` generates a `<Command>Async` that
8
+ enqueues, so a malformed job is refused where it is created rather than by a worker later.
9
+ - With no queue URL the connector runs the command inline, so local development and test suites need no SQS and
10
+ no fake. The queue is a deployment concern.
11
+ - `EventHandler` returns `batchItemFailures`, so one poison record does not fail its nine neighbours. `Service`
12
+ creates the event source mapping with `report_batch_item_failures: true`, without which that return value is
13
+ ignored.
14
+ - `Unit` gained a `trigger` (`:http` or `:sqs`), and `plan_from_connectors` plans several connectors at once. An
15
+ SQS unit gets a queue, a dead-letter queue and an event source mapping instead of a route, and its commands
16
+ stay out of the authorizer's anonymous list.
17
+
18
+ - `Packager` takes `provenance:` — a file recording which revision each unit last **changed** at, and `Service`
19
+ takes `provenance:` to stamp it as `FOOBARA_REVISION`. Deliberately not the deploying revision: stamping HEAD
20
+ everywhere would change every function's configuration on every commit, so CloudFormation would update all of
21
+ them and `cdk diff` would show all of them changing whether or not any code did.
22
+ - A unit's revision moves only when its artifact's content hash moves, so an untouched function keeps the honest
23
+ answer to "what is actually running here?" and its configuration does not churn.
24
+ - A revision recorded from a dirty tree is marked `-dirty` and treated as provisional: it is replaced by the first
25
+ clean revision seen to contain the same content, rather than sticking until the unit next changes.
26
+ - `Service` now has specs. It previously had one — that it raises without aws-cdk-lib — which let a real defect
27
+ through: a method defined and never called, so an environment variable silently vanished. There is now a fake
28
+ CDK (`spec/support/fake_cdk.rb`) exercising functions, sizing, routes, tracing and the authorizer, including
29
+ that the authorizer declares no identity source and no caching.
30
+
3
31
  ## [0.3.0] - 2026-08-04
4
32
 
5
33
  - `Foobara::AWS.plan_from_connector(connector)` — build a plan by inspecting a live connector instead of a
data/README.md CHANGED
@@ -15,6 +15,28 @@ service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "bu
15
15
  posts_table.grant_read_write_data(service.function("posts"))
16
16
  ```
17
17
 
18
+ ## Installation
19
+
20
+ ```bash
21
+ bundle add foobara-aws
22
+ ```
23
+
24
+ **No runtime dependencies.** `aws-cdk-lib` is your CDK app's, `foobara` is your
25
+ application's, and `jwt` belongs to the authorizer's own deployment unit — each
26
+ is referenced lazily, only where it is needed. That matters more than usual
27
+ here, because half this gem is loaded *inside a Lambda*, where every dependency
28
+ is paid for on each cold start.
29
+
30
+ Which half you need depends on where you are:
31
+
32
+ | you are | require | you need |
33
+ | --- | --- | --- |
34
+ | planning or packaging, in a build step | `foobara/aws/packager` | `foobara` |
35
+ | synthesising, in a CDK app | `foobara/aws/cdk/service` | `aws-cdk-lib` |
36
+ | serving a command, in a Lambda | `foobara/aws/handler` | neither |
37
+ | verifying a token, in the authorizer | `foobara/aws/authorizer` | `jwt` |
38
+ | checking a deployment | `foobara/aws/check` | neither |
39
+
18
40
  ## Where a plan comes from
19
41
 
20
42
  ```ruby
@@ -31,23 +53,14 @@ alone cannot say which commands are public.
31
53
 
32
54
  All three produce the same `Plan`, and a spec asserts the first two agree.
33
55
 
34
- ## Two halves, on purpose
35
-
36
- ```ruby
37
- Foobara::AWS.plan(manifest) # what to deploy. Plain data. No CDK, no Foobara.
38
- Foobara::AWS::CDK::Service.new # the AWS resources. No application.
39
- ```
40
-
41
- They are separable because they usually run in different places. The plan is
42
- produced where the app is — a running connector serves `/manifest` — and
43
- consumed where the infrastructure is, which is often a CDK app that should not
44
- have to load an application in order to deploy it.
56
+ ## Carrying a plan between processes
45
57
 
46
- A plan survives a JSON round trip, so it travels as a build artifact:
58
+ A plan is plain data and survives a JSON round trip, so it can be produced in one
59
+ process and consumed in another:
47
60
 
48
61
  ```ruby
49
- # in the build, against a running connector
50
- File.write("build/plan.json", JSON.pretty_generate(Foobara::AWS.plan(manifest).to_h))
62
+ # in the build, where the app is loaded
63
+ File.write("build/plan.json", JSON.pretty_generate(Foobara::AWS.plan_from_connector(connector).to_h))
51
64
 
52
65
  # in the CDK app — no Foobara, no ORM, no application gems
53
66
  Foobara::AWS::CDK::Service.new(self, "Api",
@@ -55,9 +68,21 @@ Foobara::AWS::CDK::Service.new(self, "Api",
55
68
  code_root: "build")
56
69
  ```
57
70
 
58
- That also gets you a property worth having: synthesis depends on what was
59
- actually built, so `cdk synth` cannot create a route to a Lambda whose code is
60
- missing. `Service` raises if a unit's artifact directory is not there.
71
+ Worth understanding the trade rather than picking by default, because the two
72
+ options fail in opposite directions:
73
+
74
+ - **Read the connector at synth time** and the topology can never lag the code,
75
+ because it *is* the code. Synthesis then loads the application, so it is
76
+ slower and can fail for application reasons. If you do this, have the CDK
77
+ app's Gemfile inherit the application's rather than restating it — resolving a
78
+ second set of Foobara versions means the plan is computed by a different
79
+ Foobara than the one deployed.
80
+ - **Read a file** and synthesis depends on what was actually *built*, needs none
81
+ of the application's gems, and stays deterministic and offline. But the file is
82
+ only as current as the last build.
83
+
84
+ Either way `Service` raises when a unit has no artifact, so a command added but
85
+ not packaged fails at synth rather than 404ing after deploy.
61
86
 
62
87
  ## What the plan reads
63
88
 
@@ -26,12 +26,13 @@ module Foobara
26
26
  class Service
27
27
  DEFAULT_SIZING = { memory_size: 1024, timeout_seconds: 10 }.freeze
28
28
 
29
- attr_reader :api, :functions, :plan
29
+ attr_reader :api, :functions, :plan, :queues
30
30
 
31
31
  # rubocop:disable Metrics/ParameterLists
32
32
  def initialize(scope, id, plan:, code_root:, sizing: {}, defaults: DEFAULT_SIZING,
33
33
  environment: {}, runtime: nil, architecture: nil, authorizer: nil,
34
- api_props: {}, tracing: nil, log_retention: nil)
34
+ api_props: {}, tracing: nil, log_retention: nil, provenance: {},
35
+ batch_size: 10)
35
36
  # Checked first: everything below needs it, and a bare LoadError from
36
37
  # `constructs` would say nothing about which dependency is missing.
37
38
  cdk
@@ -46,8 +47,16 @@ module Foobara
46
47
  @architecture = architecture
47
48
  @tracing = tracing
48
49
  @log_retention = log_retention
50
+ # unit name => the revision its artifact last changed at, as the
51
+ # packager recorded it. Deliberately NOT the deploying revision: a
52
+ # function that has not changed keeps the answer to "what is actually
53
+ # running here?", and its configuration does not churn on every
54
+ # deploy.
55
+ @provenance = provenance.transform_keys(&:to_s)
56
+ @batch_size = batch_size
49
57
  @functions = {}
50
58
 
59
+ @queues = {}
51
60
  @api = cdk::APIGatewayv2::HttpAPI.new(@scope, "Api", api_props)
52
61
  @authorizer = build_authorizer(authorizer)
53
62
  plan.units.each { |unit| add_unit(unit) }
@@ -58,6 +67,10 @@ module Foobara
58
67
  # table.grant_read_write_data(service.function("posts"))
59
68
  def function(name) = @functions.fetch(name.to_s)
60
69
 
70
+ # The queue an SQS unit consumes, so the units that ENQUEUE onto it can
71
+ # be granted send access and told its URL.
72
+ def queue(name) = @queues.fetch(name.to_s)
73
+
61
74
  def url = @api.url
62
75
 
63
76
  private
@@ -66,6 +79,8 @@ module Foobara
66
79
  fn = build_function(unit)
67
80
  @functions[unit.name] = fn
68
81
 
82
+ return add_queue(unit, fn) unless unit.trigger == :http
83
+
69
84
  props = {
70
85
  path: unit.route,
71
86
  methods: [cdk::APIGatewayv2::HttpMethod::ANY],
@@ -77,12 +92,44 @@ module Foobara
77
92
  @api.add_routes(props)
78
93
  end
79
94
 
95
+ # An SQS unit is reached by an event source mapping rather than a route,
96
+ # so it gets a queue, a dead-letter queue behind it, and no URL at all.
97
+ def add_queue(unit, function)
98
+ dead_letter = cdk::SQS::Queue.new(@scope, "#{logical(unit.name)}Dlq", {})
99
+
100
+ queue = cdk::SQS::Queue.new(@scope, "#{logical(unit.name)}Queue", {
101
+ # Longer than the function's own timeout, or SQS
102
+ # redelivers work that is still running — which looks
103
+ # like duplicate processing rather than a timeout.
104
+ visibility_timeout: cdk::Duration.seconds(
105
+ sizing_for(unit).fetch(:timeout_seconds) * 6
106
+ ),
107
+ dead_letter_queue: { max_receive_count: 5, queue: dead_letter }
108
+ })
109
+
110
+ function.add_event_source(
111
+ cdk::LambdaEventSources::SQSEventSource.new(queue, {
112
+ batch_size: @batch_size,
113
+ # Without this, returning batchItemFailures
114
+ # is ignored and one bad record fails its
115
+ # nine neighbours.
116
+ report_batch_item_failures: true
117
+ })
118
+ )
119
+
120
+ @queues[unit.name] = queue
121
+ end
122
+
123
+ def sizing_for(unit)
124
+ @defaults.merge(unit.sizing).merge(@sizing.fetch(unit.name, {}))
125
+ end
126
+
80
127
  def build_function(unit)
81
128
  # Precedence: an explicit sizing: override, then whatever the unit's
82
129
  # commands declared via aws_lambda, then the defaults. The override
83
130
  # exists for the case the declaration cannot know about — an
84
131
  # environment where everything should be small, say.
85
- sizing = @defaults.merge(unit.sizing).merge(@sizing.fetch(unit.name, {}))
132
+ sizing = sizing_for(unit)
86
133
  asset = File.join(@code_root, unit.name)
87
134
  unless Dir.exist?(asset)
88
135
  raise Error, "no artifact for unit #{unit.name.inspect} at #{asset} — build it before synthesising"
@@ -97,7 +144,7 @@ module Foobara
97
144
  code: cdk::Lambda::Code.from_asset(asset),
98
145
  memory_size: sizing.fetch(:memory_size),
99
146
  timeout: cdk::Duration.seconds(sizing.fetch(:timeout_seconds)),
100
- environment: @environment.merge("FOOBARA_UNIT" => unit.name)
147
+ environment: unit_environment(unit)
101
148
  }.merge(observability))
102
149
  end
103
150
 
@@ -151,6 +198,13 @@ module Foobara
151
198
  }.merge(observability))
152
199
  end
153
200
 
201
+ def unit_environment(unit)
202
+ env = @environment.merge("FOOBARA_UNIT" => unit.name)
203
+ revision = @provenance[unit.name]
204
+ env["FOOBARA_REVISION"] = revision if revision
205
+ env
206
+ end
207
+
154
208
  # Tracing and log retention, applied identically to every function
155
209
  # including the authorizer — a trace that stops at the authorizer would
156
210
  # hide the one invocation that happens on every single request.
@@ -185,7 +239,10 @@ module Foobara
185
239
  # anything at stack level — a "comments" function and a "Comments" table
186
240
  # both want the id "Comments" otherwise, and synthesis fails.
187
241
  def construct(scope, id)
188
- require "constructs"
242
+ # Only if it is not already there: aws-cdk-lib loads it itself, and
243
+ # requiring it again is pointless work in the common case.
244
+ require "constructs" unless defined?(::Constructs)
245
+
189
246
  Constructs::Construct.new(scope, id)
190
247
  end
191
248
 
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Running Foobara commands from an AWS event source — SQS, and anything else
4
+ # that delivers records in batches.
5
+ #
6
+ # RUNTIME, not synth. Require this and nothing else from the gem.
7
+ #
8
+ # require "foobara/aws/event_handler"
9
+ # require_relative "config/connector"
10
+ #
11
+ # HANDLER = Foobara::AWS::EventHandler.new(QUEUE_CONNECTOR)
12
+ # def handle(event:, context:) = HANDLER.call(event, context)
13
+ #
14
+ # PARTIAL BATCH FAILURE is the whole reason this is not three lines. SQS hands a
15
+ # Lambda up to ten records at once. Raising on one of them fails the entire
16
+ # batch, so the nine that succeeded are delivered again — work is repeated, and a
17
+ # single poison message can replay its batch until the queue's retry limit.
18
+ #
19
+ # Returning `batchItemFailures` tells SQS which records to redeliver and lets the
20
+ # rest be deleted. It only works if the event source mapping was created with
21
+ # `report_batch_item_failures: true`, which Foobara::AWS::CDK::Service does.
22
+
23
+ require "json"
24
+
25
+ require_relative "error"
26
+ require_relative "version"
27
+
28
+ module Foobara
29
+ module AWS
30
+ class EventHandler
31
+ # What an enqueued message carries. Deliberately the same envelope the
32
+ # Resque connector uses — a command's name and its inputs are all that is
33
+ # needed, and keeping the shape means a queue is just another transport.
34
+ COMMAND_KEY = "command_name"
35
+ INPUTS_KEY = "inputs"
36
+
37
+ # +decode+ turns one record into [command_name, inputs] for event sources
38
+ # that carry no envelope — an S3 notification or a schedule knows nothing
39
+ # about commands, so the application has to say what it means.
40
+ def initialize(connector, decode: nil)
41
+ @connector = connector
42
+ @decode = decode
43
+ end
44
+
45
+ def call(event, _context = nil)
46
+ failures = records(event).filter_map { |record| failure_for(record) }
47
+
48
+ { "batchItemFailures" => failures }
49
+ end
50
+
51
+ private
52
+
53
+ def records(event) = Array(event["Records"] || event[:Records])
54
+
55
+ # Returns nil when the record succeeded, or its identifier when it did not.
56
+ # Nothing is re-raised: one bad record must not fail its nine neighbours,
57
+ # which is the entire point.
58
+ def failure_for(record)
59
+ run(record)
60
+ nil
61
+ rescue StandardError => e
62
+ # The message id, because that is what SQS asks for. Logged as well as
63
+ # reported, or a failure that keeps being redelivered is invisible.
64
+ warn("foobara-aws: record #{identifier(record)} failed: #{e.class}: #{e.message}")
65
+ { "itemIdentifier" => identifier(record) }
66
+ end
67
+
68
+ def identifier(record) = record["messageId"] || record[:messageId]
69
+
70
+ def run(record)
71
+ name, inputs = command_and_inputs(record)
72
+ command_class = @connector.transformed_command_from_name(name)
73
+ raise Error, "no command #{name.inspect} is connected" unless command_class
74
+
75
+ # run!, not run: a command that returns an error outcome has failed, and
76
+ # this needs to raise so the record is reported and redelivered.
77
+ command_class.new(inputs || {}).run!
78
+ end
79
+
80
+ def command_and_inputs(record)
81
+ return @decode.call(record) if @decode
82
+
83
+ body = record["body"] || record[:body]
84
+ envelope = body.is_a?(String) ? JSON.parse(body) : body
85
+ [envelope[COMMAND_KEY], envelope[INPUTS_KEY]]
86
+ end
87
+ end
88
+ end
89
+ end
@@ -37,7 +37,8 @@ module Foobara
37
37
  # rubocop:disable Metrics/ParameterLists
38
38
  def initialize(plan:, root: Dir.pwd, out: "build", sources: %w[app config],
39
39
  gemfiles: "units", image: DEFAULT_IMAGE, env: {}, mounts: [],
40
- handler_template: nil, authorizer: nil, docker: nil)
40
+ handler_template: nil, authorizer: nil, docker: nil,
41
+ provenance: nil, revision: nil)
41
42
  @plan = plan
42
43
  @root = File.expand_path(root)
43
44
  @out = File.expand_path(out, @root)
@@ -51,6 +52,11 @@ module Foobara
51
52
  @mounts = mounts
52
53
  @handler_template = handler_template
53
54
  @authorizer = authorizer
55
+ # Where to keep the record of which revision each unit last CHANGED at.
56
+ # Outside `out` on purpose: it is only useful if it outlives a build and
57
+ # is committed, which a build directory is usually not.
58
+ @provenance_path = provenance
59
+ @revision = revision
54
60
  # Injectable so the layout can be tested without Docker, and so an
55
61
  # environment with a different container runtime can substitute one.
56
62
  @docker = docker || method(:run_docker)
@@ -64,13 +70,92 @@ module Foobara
64
70
  built << build_authorizer if @authorizer
65
71
 
66
72
  File.write(plan_path, JSON.pretty_generate(@plan.to_h))
73
+ record_provenance(built) if @provenance_path
67
74
  built
68
75
  end
69
76
 
77
+ # unit name => the revision at which its artifact last changed. Empty
78
+ # unless `provenance:` was given.
79
+ attr_reader :provenance
80
+
70
81
  def plan_path = File.join(@out, "plan.json")
71
82
 
72
83
  private
73
84
 
85
+ # WHY THIS IS NOT SIMPLY THE CURRENT REVISION.
86
+ #
87
+ # Stamping every unit with HEAD would change every function's environment
88
+ # on every commit, so CloudFormation would update all of them and `cdk
89
+ # diff` would show all of them changing — every time, whether or not any
90
+ # of their code did. That is exactly the signal worth keeping.
91
+ #
92
+ # So a unit's revision only moves when its CONTENT moves. An untouched
93
+ # unit keeps the revision it last changed at, its configuration does not
94
+ # change, and CloudFormation leaves it alone. The file is also a readable
95
+ # history: `git log -p` over it says when each Lambda actually changed.
96
+ def record_provenance(built)
97
+ previous = read_provenance
98
+ current = built.to_h { |unit| [unit.fetch(:name), entry_for(unit, previous)] }
99
+
100
+ File.write(@provenance_path, "#{JSON.pretty_generate(current.sort.to_h)}\n")
101
+ @provenance = current.transform_values { |entry| entry["revision"] }
102
+ end
103
+
104
+ # Unchanged content keeps its old revision. Only a real change adopts the
105
+ # current one.
106
+ #
107
+ # Except when the old revision is PROVISIONAL — recorded from a dirty tree,
108
+ # so it names a commit that did not contain this content. Those are
109
+ # replaced by the first clean revision seen to contain it, which is the
110
+ # best available answer and, unlike the alternative, self-healing: the
111
+ # marker would otherwise stick until the unit next changed, which could be
112
+ # never.
113
+ def entry_for(unit, previous)
114
+ content = content_hash(unit.fetch(:dir))
115
+ before = previous[unit.fetch(:name)]
116
+ keep = before && before["content"] == content && !provisional?(before["revision"])
117
+
118
+ { "revision" => keep ? before["revision"] : revision_now, "content" => content }
119
+ end
120
+
121
+ def provisional?(revision) = revision.to_s.end_with?("-dirty")
122
+
123
+ def read_provenance
124
+ return {} unless File.exist?(@provenance_path)
125
+
126
+ JSON.parse(File.read(@provenance_path))
127
+ rescue JSON::ParserError
128
+ {}
129
+ end
130
+
131
+ # The artifact's contents, path-sensitively. Sorted so it does not depend
132
+ # on directory order, and paths are hashed as well as bytes so a rename is
133
+ # a change.
134
+ def content_hash(dir)
135
+ digest = Digest::SHA256.new
136
+
137
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.each do |relative|
138
+ path = File.join(dir, relative)
139
+ next if File.directory?(path)
140
+
141
+ digest << relative << "\0" << Digest::SHA256.file(path).hexdigest
142
+ end
143
+
144
+ digest.hexdigest[0, 16]
145
+ end
146
+
147
+ # `-dirty` because a revision recorded from an unclean tree does not
148
+ # identify the code that was built, and silently pretending otherwise is
149
+ # worse than an ugly string.
150
+ def revision_now
151
+ @revision_now ||= @revision || begin
152
+ sha = `git rev-parse --short HEAD 2>/dev/null`.strip
153
+ raise Error, "no revision available: pass revision: or run inside a git repository" if sha.empty?
154
+
155
+ `git status --porcelain 2>/dev/null`.strip.empty? ? sha : "#{sha}-dirty"
156
+ end
157
+ end
158
+
74
159
  def build_unit(unit)
75
160
  dir = prepare(unit.name)
76
161
  @sources.each { |s| FileUtils.cp_r(File.join(@root, s), dir) }
@@ -101,7 +186,13 @@ module Foobara
101
186
  end
102
187
 
103
188
  def handler_source(unit)
104
- template = @handler_template ? File.read(@handler_template) : DEFAULT_HANDLER
189
+ template =
190
+ if @handler_template
191
+ File.read(@handler_template)
192
+ else
193
+ unit.trigger == :http ? DEFAULT_HANDLER : DEFAULT_EVENT_HANDLER
194
+ end
195
+
105
196
  render(template, unit)
106
197
  end
107
198
 
@@ -252,6 +343,27 @@ module Foobara
252
343
  end
253
344
  ERB
254
345
 
346
+ # For a unit consumed from an event source rather than routed. It connects
347
+ # nothing itself: the application's own connector already knows which
348
+ # commands it consumes, and re-deriving that here could disagree with it.
349
+ DEFAULT_EVENT_HANDLER = <<~ERB
350
+ # Generated by foobara-aws for unit <%= unit.name.inspect %>. Do not edit.
351
+ #
352
+ # Consumes: <%= unit.commands.join(", ") %>
353
+ #
354
+ # Triggered by <%= unit.trigger %>, so there is no route and no
355
+ # authorizer — a message is already inside the trust boundary.
356
+ require_relative "vendor/bundle/bundler/setup"
357
+ require "foobara/aws/event_handler"
358
+ require_relative "config/connector"
359
+
360
+ HANDLER = Foobara::AWS::EventHandler.new(FOOBARA_EVENT_CONNECTOR)
361
+
362
+ def handle(event:, context:)
363
+ HANDLER.call(event, context)
364
+ end
365
+ ERB
366
+
255
367
  DEFAULT_AUTHORIZER = <<~ERB
256
368
  # Generated by foobara-aws. Do not edit.
257
369
  #
@@ -12,7 +12,10 @@ module Foobara
12
12
  # +sizing+ is whatever the unit's commands declared with
13
13
  # {Foobara::AWS::Lambda}, reduced to one value per key — empty when none
14
14
  # of them said anything.
15
- Unit = Data.define(:name, :group, :commands, :route, :public_commands, :sizing)
15
+ # +trigger+ is how the unit is invoked: :http for a routed command, :sqs for
16
+ # one consumed from an SQS queue. An SQS unit has no route — it is reached by an
17
+ # event source mapping, not a URL — so `route` is nil for it.
18
+ Unit = Data.define(:name, :group, :commands, :route, :public_commands, :sizing, :trigger)
16
19
 
17
20
  # The whole topology, read out of a Foobara manifest.
18
21
  #
@@ -38,7 +41,7 @@ module Foobara
38
41
  "units" => units.map do |u|
39
42
  { "name" => u.name, "group" => u.group, "commands" => u.commands,
40
43
  "route" => u.route, "public_commands" => u.public_commands,
41
- "sizing" => u.sizing }
44
+ "sizing" => u.sizing, "trigger" => u.trigger.to_s }
42
45
  end
43
46
  }
44
47
  end
@@ -56,7 +59,8 @@ module Foobara
56
59
  u = u.transform_keys(&:to_s)
57
60
  Unit.new(name: u.fetch("name"), group: u["group"], commands: u.fetch("commands"),
58
61
  route: u["route"], public_commands: u.fetch("public_commands", []),
59
- sizing: (u["sizing"] || {}).to_h { |k, v| [k.to_sym, v] })
62
+ sizing: (u["sizing"] || {}).to_h { |k, v| [k.to_sym, v] },
63
+ trigger: u.fetch("trigger", "http").to_sym)
60
64
  end
61
65
  )
62
66
  end
@@ -72,13 +76,13 @@ module Foobara
72
76
  # A connector's manifest, not Foobara.manifest: `requires_authentication`
73
77
  # only exists once commands are connected, and it is the field the public
74
78
  # list comes from.
75
- def plan(manifest, mount: "/run", granularity: :domain, exclude: DEFAULT_EXCLUDE)
79
+ def plan(manifest, mount: "/run", granularity: :domain, exclude: DEFAULT_EXCLUDE, trigger: :http)
76
80
  commands = app_commands(manifest, exclude)
77
81
  units =
78
82
  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)
83
+ when :domain then group_units(commands, "domain", mount, trigger)
84
+ when :organization then group_units(commands, "organization", mount, trigger)
85
+ when :command then command_units(commands, mount, trigger)
82
86
  else raise ArgumentError, "unknown granularity #{granularity.inspect}"
83
87
  end
84
88
 
@@ -99,9 +103,24 @@ module Foobara
99
103
  #
100
104
  # It adapts each command into the shape {plan} already reads, so there is
101
105
  # one implementation and the two routes cannot drift.
106
+ # Several connectors at once, keyed by how each is triggered:
107
+ #
108
+ # Foobara::AWS.plan_from_connectors(http: rack_connector, sqs: sqs_connector)
109
+ #
110
+ # A domain can appear under both — Profiles serving HTTP commands and
111
+ # consuming a queue — so a non-http unit's name is suffixed. Otherwise two
112
+ # units would claim the same name and the same artifact directory.
113
+ def plan_from_connectors(connectors, **)
114
+ plans = connectors.map { |trigger, connector| plan_from_connector(connector, trigger:, **) }
115
+ first = plans.first or raise ArgumentError, "no connectors given"
116
+
117
+ Plan.new(mount: first.mount, granularity: first.granularity,
118
+ units: plans.flat_map(&:units).sort_by(&:name))
119
+ end
120
+
102
121
  def plan_from_connector(connector, **)
103
122
  commands = connector.command_registry.all_transformed_command_classes.to_h do |transformed|
104
- [transformed.command_class.full_command_name, describe_command(transformed)]
123
+ [command_class_of(transformed).full_command_name, describe_command(transformed)]
105
124
  end
106
125
 
107
126
  plan({ "command" => commands }, **)
@@ -123,19 +142,44 @@ module Foobara
123
142
  # planning reads — a manifest carries much more, and none of the rest
124
143
  # matters here.
125
144
  def describe_command(transformed)
126
- command_class = transformed.command_class
145
+ command_class = command_class_of(transformed)
127
146
 
128
147
  {
129
148
  "domain" => domain_name(command_class),
130
149
  "organization" => organization_name(command_class),
131
150
  "scoped_full_path" => Array(command_class.scoped_full_path),
132
- "requires_authentication" => transformed.requires_authentication,
151
+ # Only a connector that gates commands answers this. One that does
152
+ # not — a queue has no anonymous callers — leaves it nil, which reads
153
+ # as "not gated" and is right: the question does not apply.
154
+ "requires_authentication" => gated?(transformed),
133
155
  "depends_on" => Array(command_class.depends_on).map(&:to_s),
134
156
  # The AWSLambda DSL, when the command extends it.
135
157
  "aws_lambda" => (command_class.aws_lambda_manifest if command_class.respond_to?(:aws_lambda_manifest))
136
158
  }.compact
137
159
  end
138
160
 
161
+ # A connector wraps commands in a TransformedCommand only when it has
162
+ # something to transform. One with no transformers — a queue connector,
163
+ # say — registers the command class itself.
164
+ #
165
+ # Rescuing rather than asking respond_to?: Foobara namespaces answer
166
+ # respond_to? through method_missing, so the question comes back true and
167
+ # the call still raises.
168
+ def command_class_of(transformed)
169
+ transformed.command_class
170
+ rescue NoMethodError
171
+ transformed
172
+ end
173
+
174
+ # Only a connector that gates commands answers this. One that does not —
175
+ # a queue has no anonymous callers — leaves it nil, which reads as "not
176
+ # gated", and for a unit with no route the question does not arise.
177
+ def gated?(transformed)
178
+ transformed.requires_authentication
179
+ rescue NoMethodError
180
+ nil
181
+ end
182
+
139
183
  def domain_name(command_class)
140
184
  command_class.domain.scoped_full_name
141
185
  rescue StandardError
@@ -154,11 +198,11 @@ module Foobara
154
198
  end
155
199
  end
156
200
 
157
- def group_units(commands, key, mount)
201
+ def group_units(commands, key, mount, trigger)
158
202
  commands.group_by { |_name, c| c[key].to_s }.map do |group, members|
159
203
  names = members.map(&:first)
160
204
  Unit.new(
161
- name: slug(group),
205
+ name: unit_name(slug(group), trigger),
162
206
  group: group,
163
207
  commands: names.sort,
164
208
  # Derived from the commands' own paths, not from the group's name.
@@ -171,18 +215,24 @@ module Foobara
171
215
  # safe with a REQUEST authorizer, which sees the path and can decide
172
216
  # per command; a JWT authorizer attaches per route, so a unit mixing
173
217
  # public and authenticated commands would have to be split.
174
- route: greedy_route(members, mount),
175
- public_commands: public_of(members),
176
- sizing: sizing_of(members)
218
+ # No route for anything but HTTP: an SQS unit is reached by an
219
+ # event source mapping.
220
+ route: (greedy_route(members, mount) if trigger == :http),
221
+ # Only meaningful for something with a route. A queue has no
222
+ # anonymous callers, and letting these through would put the command
223
+ # in the authorizer's anonymous list, where it does not belong.
224
+ public_commands: trigger == :http ? public_of(members) : [],
225
+ sizing: sizing_of(members),
226
+ trigger: trigger
177
227
  )
178
228
  end
179
229
  end
180
230
 
181
- def command_units(commands, mount)
231
+ def command_units(commands, mount, trigger)
182
232
  commands.map do |name, command|
183
233
  path = Array(command["scoped_full_path"])
184
234
  Unit.new(
185
- name: slug(name),
235
+ name: unit_name(slug(name), trigger),
186
236
  group: command["domain"].to_s,
187
237
  # Closed over `depends_on`, transitively. A command that invokes
188
238
  # another must be able to reach it, so a per-command artifact has to
@@ -196,12 +246,13 @@ module Foobara
196
246
  commands: closure(name, commands),
197
247
  # Exact, not greedy: one command per Lambda needs no wildcard, and an
198
248
  # exact route is what lets API Gateway 404 an unknown command itself.
199
- route: "#{mount}/#{path.join("/")}",
249
+ route: ("#{mount}/#{path.join("/")}" if trigger == :http),
200
250
  # Only the ROUTED command decides this. A dependency is invoked in
201
251
  # process, never over HTTP, so whether it requires authentication
202
252
  # says nothing about whether this route may be called anonymously.
203
- public_commands: public_of([[name, command]]),
204
- sizing: sizing_of([[name, command]])
253
+ public_commands: trigger == :http ? public_of([[name, command]]) : [],
254
+ sizing: sizing_of([[name, command]]),
255
+ trigger: trigger
205
256
  )
206
257
  end
207
258
  end
@@ -263,6 +314,11 @@ module Foobara
263
314
  end
264
315
  end
265
316
 
317
+ # HTTP units keep their bare name, so nothing about existing deployments
318
+ # changes; anything else is suffixed to stay distinct from a same-named
319
+ # domain served over HTTP.
320
+ def unit_name(name, trigger) = trigger == :http ? name : "#{name}-#{trigger}"
321
+
266
322
  def symbolize(hash) = hash.to_h { |k, v| [k.to_sym, v] }
267
323
 
268
324
  # A CloudFormation-friendly, filesystem-friendly identifier: "Posts" and
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A connector that runs commands from an SQS queue instead of over HTTP.
4
+ #
5
+ # Named for the service rather than for the idea: it speaks SQS specifically —
6
+ # its envelope, its client, its at-least-once delivery — and calling it a queue
7
+ # connector would suggest it abstracts over queues, which it does not.
8
+ #
9
+ # Modelled on foobara-resque-connector, and on purpose: the envelope is the same
10
+ # (`command_name` and `inputs`), so a background job is not a special kind of
11
+ # object — it is a command reached by a different transport.
12
+ #
13
+ # QUEUE = Foobara::AWS::SQSConnector.new(queue_url: ENV["RECONCILE_QUEUE_URL"])
14
+ # QUEUE.connect(Profiles::ReconcileAuthorSnapshot)
15
+ #
16
+ # Profiles::ReconcileAuthorSnapshotAsync.run!(user_sub: sub) # enqueues
17
+ #
18
+ # `connect` generates that `…Async` command, so enqueuing is itself a command:
19
+ # it validates its inputs against the real command's input type, so a malformed
20
+ # job is refused where it is enqueued rather than discovered by a worker later.
21
+ #
22
+ # WITH NO QUEUE URL IT RUNS INLINE. That is what makes local development and a
23
+ # test suite work without SQS or a fake, and it means the queue is a deployment
24
+ # concern rather than something the application has to know about.
25
+ #
26
+ # Requires Foobara. The consuming half — Foobara::AWS::EventHandler — does not.
27
+
28
+ require "json"
29
+
30
+ # Not merely "foobara": Foobara::CommandConnector exists after foobara/all but
31
+ # WITHOUT its methods, so subclassing it succeeds and fails later at `connect`
32
+ # with "no superclass method". Requiring the project explicitly is the
33
+ # difference between an error at load and a confusing one at runtime.
34
+ require "foobara/command_connectors"
35
+
36
+ require_relative "error"
37
+
38
+ module Foobara
39
+ module AWS
40
+ class SQSConnector < Foobara::CommandConnector
41
+ # Enqueues rather than executes. Generated per connected command by
42
+ # `connect`, the way ResqueConnector generates its own.
43
+ class EnqueueCommand < Foobara::Command
44
+ class << self
45
+ attr_accessor :target_command_class, :sqs_connector
46
+ end
47
+
48
+ def execute
49
+ self.class.sqs_connector.enqueue(
50
+ self.class.target_command_class.full_command_name, raw_inputs
51
+ )
52
+ end
53
+ end
54
+
55
+ attr_reader :queue_url
56
+
57
+ def initialize(*, queue_url: nil, sqs: nil, **, &)
58
+ @queue_url = queue_url
59
+ @sqs = sqs
60
+
61
+ super(*, **, &)
62
+ end
63
+
64
+ def connect(connectable, *, **, &)
65
+ exposed = Util.array(super)
66
+
67
+ exposed.each { |exposed_command| build_async_command(exposed_command) }
68
+
69
+ exposed
70
+ end
71
+
72
+ # Puts the envelope on the queue, or runs the command inline when there is
73
+ # no queue configured.
74
+ def enqueue(command_name, inputs)
75
+ # Checked in both paths: a message naming a command nothing consumes is
76
+ # a job that can only fail, and it should fail here rather than in a
77
+ # worker after a delivery delay.
78
+ command_class = transformed_command_from_name(command_name)
79
+ raise Error, "command #{command_name.inspect} is not connected" unless command_class
80
+
81
+ envelope = { EventHandler::COMMAND_KEY => command_name }
82
+ envelope[EventHandler::INPUTS_KEY] = inputs unless inputs.nil? || inputs.empty?
83
+
84
+ return command_class.new(inputs || {}).run! unless queue_url
85
+
86
+ sqs.send_message(queue_url: queue_url, message_body: JSON.dump(envelope))
87
+ envelope
88
+ end
89
+
90
+ private
91
+
92
+ def sqs
93
+ @sqs ||= begin
94
+ require "aws-sdk-sqs"
95
+ Aws::SQS::Client.new
96
+ rescue LoadError
97
+ raise Error,
98
+ "SQSConnector needs the `aws-sdk-sqs` gem to enqueue, which foobara-aws does not depend on. " \
99
+ "Add it to the unit that enqueues, or pass sqs: your own client."
100
+ end
101
+ end
102
+
103
+ def build_async_command(exposed_command)
104
+ command_class = exposed_command.command_class
105
+ async = Util.make_class("#{command_class.name}Async", EnqueueCommand)
106
+
107
+ # The real command's input type, so an invalid job is refused at the
108
+ # point of enqueuing rather than by a worker minutes later.
109
+ inputs_type = exposed_command.transformed_command_class.inputs_type
110
+ async.inputs(inputs_type) if inputs_type
111
+
112
+ async.sqs_connector = self
113
+ async.target_command_class = exposed_command
114
+ async
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ require_relative "event_handler"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Foobara
4
4
  module AWS
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara-aws
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Omar Qureshi
@@ -28,10 +28,12 @@ files:
28
28
  - lib/foobara/aws/cdk/service.rb
29
29
  - lib/foobara/aws/check.rb
30
30
  - lib/foobara/aws/error.rb
31
+ - lib/foobara/aws/event_handler.rb
31
32
  - lib/foobara/aws/handler.rb
32
33
  - lib/foobara/aws/lambda.rb
33
34
  - lib/foobara/aws/packager.rb
34
35
  - lib/foobara/aws/plan.rb
36
+ - lib/foobara/aws/sqs_connector.rb
35
37
  - lib/foobara/aws/version.rb
36
38
  homepage: https://github.com/omarqureshi/foobara-aws
37
39
  licenses: