Skip to content

Repository files navigation

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

Installation

bundle add foobara-aws

No runtime dependencies. aws-cdk-lib is your CDK app's, foobara is your application's, and jwt belongs to the authorizer's own deployment unit — each is referenced lazily, only where it is needed. That matters more than usual here, because half this gem is loaded inside a Lambda, where every dependency is paid for on each cold start.

Which half you need depends on where you are:

you are require you need
planning or packaging, in a build step foobara/aws/packager foobara
synthesising, in a CDK app foobara/aws/cdk/service aws-cdk-lib
serving a command, in a Lambda foobara/aws/handler neither
verifying a token, in the authorizer foobara/aws/authorizer jwt
checking a deployment foobara/aws/check neither

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.

Carrying a plan between processes

A plan is plain data and survives a JSON round trip, so it can be produced in one process and consumed in another:

# in the build, where the app is loaded
File.write("build/plan.json", JSON.pretty_generate(Foobara::AWS.plan_from_connector(connector).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")

Worth understanding the trade rather than picking by default, because the two options fail in opposite directions:

  • Read the connector at synth time and the topology can never lag the code, because it is the code. Synthesis then loads the application, so it is slower and can fail for application reasons. If you do this, have the CDK app's Gemfile inherit the application's rather than restating it — resolving a second set of Foobara versions means the plan is computed by a different Foobara than the one deployed.
  • Read a file and synthesis depends on what was actually built, needs none of the application's gems, and stays deterministic and offline. But the file is only as current as the last build.

Either way Service raises when a unit has no artifact, so a command added but not packaged fails at synth rather than 404ing after deploy.

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.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages