foobara-aws
Deploy Foobara commands to AWS Lambda, with the topology read out of the manifest instead of restated in infrastructure code.
connect(Posts) already says what a deployment unit is. The manifest already
says which commands it holds, where they are served, and which need
authentication. This reads that and builds the AWS resources.
plan = Foobara::AWS.plan(JSON.parse(File.read("build/plan.json")))
service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build")
posts_table.grant_read_write_data(service.function("posts"))
Two halves, on purpose
Foobara::AWS.plan(manifest) # what to deploy. Plain data. No CDK, no Foobara.
Foobara::AWS::CDK::Service.new # the AWS resources. No application.
They are separable because they usually run in different places. The plan is
produced where the app is — a running connector serves /manifest — and
consumed where the infrastructure is, which is often a CDK app that should not
have to load an application in order to deploy it.
A plan survives a JSON round trip, so it travels as a build artifact:
# in the build, against a running connector
File.write("build/plan.json", JSON.pretty_generate(Foobara::AWS.plan(manifest).to_h))
# in the CDK app — no Foobara, no ORM, no application gems
Foobara::AWS::CDK::Service.new(self, "Api",
plan: Foobara::AWS::Plan.load(JSON.parse(File.read("build/plan.json"))),
code_root: "build")
That also gets you a property worth having: synthesis depends on what was
actually built, so cdk synth cannot create a route to a Lambda whose code is
missing. Service raises if a unit's artifact directory is not there.
What the plan reads
| plan | manifest field |
|---|---|
| unit identity and grouping | domain or organization |
| the route | mount + scoped_full_path |
| which commands are public | requires_authentication — derived, never handed in |
| how to size the function | aws_lambda (see below) |
The public list is the one to notice. It is not a list you maintain: a command moves in or out of it by how it is connected, and nowhere else.
Granularity
Foobara::AWS.plan(manifest) # one Lambda per domain
Foobara::AWS.plan(manifest, granularity: :organization) # one per organization
Foobara::AWS.plan(manifest, granularity: :command) # one per command
Foobara gives two natural grouping levels where most frameworks give one, so
both are offered, plus the fine-grained case. Per-domain and per-organization
units get a greedy route (/run/Posts/{proxy+}); per-command units get an exact
one.
A greedy route is only safe with a REQUEST authorizer, which sees the path and can decide per command. A JWT authorizer attaches per route, so a unit mixing public and authenticated commands would have to be split into one route each.
Sizing: aws_lambda
The one thing a manifest cannot otherwise supply. Foobara describes what a command is, not how it should be run — but something has to carry it, and the person who knows a command fans out across a whole comment tree is the person writing that command:
class DestroyPost < Foobara::Command
extend Foobara::AWS::Lambda
aws_lambda vcpu: 1, timeout: 60
...
end
No change to Foobara is required: a command's manifest is super.merge(...), so
this adds a key and the connector serves it.
vcpu: resolves to memory. Lambda has no CPU setting — CPU is allocated in
proportion to memory, and 1,769 MB is where a function gets one full vCPU. So
asking for compute is more honest than picking a memory number, but it is not a
second dial. Give both and the larger memory wins.
Where a unit holds several commands, the largest value any of them asked for wins: a unit runs all of its commands in one function, so it must be sized for the hungriest. Undersizing is a runtime failure; oversizing is a rounding error on the bill.
The authorizer
Pass a built authorizer, or a hash and let Service build it:
Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build",
authorizer: { code: "build/authorizer", environment: { "ISSUER" => issuer } })
Building it here is deliberate, because it is the only way to guarantee this:
No identity source, and caching off. Naming an identity source makes it required — when the header is absent, API Gateway answers 401 itself and never invokes the authorizer. Every anonymous caller is refused before any "is this command public?" logic can run, which defeats the only reason to choose a REQUEST authorizer over a JWT one. It is an easy mistake to make and a hard one to see: the symptom is that public commands 401 for signed-out callers, with no log line anywhere, because the function was never called.
The plan's public list and mount are passed to the function as
FOOBARA_ANONYMOUS and FOOBARA_MOUNT, so the authorizer decides per command
from rawPath without being configured separately.
Service does not implement the authorizer itself — verification is
identity-provider-specific, and code: is your own artifact.
Packaging
Foobara::AWS::Packager.new(plan: plan, root: ".", authorizer: {}).build
One artifact per unit: the application's sources, a generated handler.rb, and
a standalone gem bundle holding only that unit's dependencies (units/<name>.gemfile).
Units whose gemfiles resolve identically share one bundle build.
The generated handler is fully generic — everything unit-specific comes from the
plan, and everything app-specific from the boot file, where the application sets
Foobara::AWS.caller_builder. Pass handler_template: for your own.
Two things it handles that are easy to get wrong:
- Standalone, not
bundle exec. Bundler's runtime is a large fraction of a Ruby cold start and buys a deployed artifact nothing. - Gems declared by
path:. Bundler does not copy those into a standalone bundle; it writes their location into the load path, relative to the bundle directory. Copy the bundle into an artifact and that path resolves elsewhere, so the unit boots on the build machine and dies in Lambda. They are copied in and the load path rewritten. (Usemounts:so the build container can see them in the first place.)
Checking a deployment
result = Foobara::AWS::Check.new(url: "https://api.example.com", plan: plan).run
puts result.report
exit 1 unless result.ok?
Driven by the plan, so it knows which commands are public without being told. It asserts the few things true of every Foobara deployment that an application's own tests structurally cannot see, because they live at the edge:
- a public command is reachable without credentials
- a gated command is refused without them, and with an invalid token
- a refusal is JSON, not an HTML page with a 200 on it
Each has a specific failure behind it. A public command that 401s usually means
the authorizer declared an identity source, so API Gateway answered before the
authorizer ran — silent, because the function was never invoked and logged
nothing. A refusal arriving as 200 text/html usually means a SPA history
fallback is rewriting the API's errors, which turns every client-side error check
into a lie. Both are real, both shipped, and both passed a full green test suite.
Every request carries {}, so a command with required inputs answers 422 — which
counts as success, since the question is whether the request reached the command.
Gated commands are therefore only ever called without credentials: they are
refused before executing and nothing is written. Public commands are executed
with empty inputs, which is safe for the usual case of a read; skip: is there
for when it is not.
What it does not do
- Create tables, buckets or queues. Those are the application's, not the connector's. For DynamoDB from Dynamoid models, see dynamoid-cdk-schema, which follows the same describe/build split.
Development
bin/setup
bundle exec rspec
bundle exec rubocop
The specs use a manifest fragment carrying only the fields planning reads — no type declarations, no possible errors — which is a check in itself that nothing else is needed.
License
MIT.