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"))

Where a plan comes from

Foobara::AWS.plan(manifest)                  # a manifest, however you obtained it
Foobara::AWS.plan_from_connector(connector)  # a live connector, no server needed
Foobara::AWS::Plan.load(JSON.parse(json))    # one that has been through JSON

plan_from_connector is usually what a build step wants: it reads the objects directly, so there is no server to start and no snapshot to go stale. It takes a connector, not a set of command classes — requires_authentication is decided at connect time and lives on the transformed command, so the classes alone cannot say which commands are public.

All three produce the same Plan, and a spec asserts the first two agree.

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_authenticationderived, 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. (Use mounts: 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.