Skip to content

feat!: configurable cache behaviors on the AWS distribution layer - #28

Open
agustincelentano wants to merge 26 commits into
mainfrom
feat/cloudfront-multi-behavior
Open

feat!: configurable cache behaviors on the AWS distribution layer#28
agustincelentano wants to merge 26 commits into
mainfrom
feat/cloudfront-multi-behavior

Conversation

@agustincelentano

Copy link
Copy Markdown
Contributor

What this does

The CloudFront layer hardcoded its cache behaviors: a default one — the only place a function could be attached — plus a fixed /static/* behavior with nothing attachable to it. There was no way to serve /api/* differently, or to run a function anywhere other than on the default behavior.

Behaviors are data now: a default plus an ordered list, each with its own viewer protocol, compression and invocations. The list order is the CloudFront precedence, so the module iterates a list, never a map.

An invocation names the function kind and the event in one field, the way the form offers it — Lambda@Edge - viewer request, CloudFront Function - viewer response — and the module splits it into the association block CloudFront expects. Only the pairs CloudFront accepts are offered, so a Function on an origin event is not something the form can express.

Caching is deliberately left alone: no query strings or cookies forwarded, TTL 0/3600/86400, GET/HEAD/OPTIONS allowed and GET/HEAD cached, exactly as before and not configurable. Upgrading changes no distribution's caching.

Two problems found along the way

Both were pre-existing and surfaced while testing this against a real account.

A scope could read another scope type's configuration. Every scope type shares the scope-configurations category, and the context carries a single configuration for it: the one closest in the NRN hierarchy. Two scope types configured at the same level tie, and the winner is arbitrary — a static scope in an account that also runs Lambda scopes would read the Lambda configuration and fail with cloud_provider is not configured. build_context now asks for its own by specification and puts it back in the context, so every setup script keeps reading it from the same place.

The scope reported no instances. instance/workflows/list.yaml shipped empty, so the scope answered nothing when the platform asked what it was running: its Instances view stayed blank and a blue/green deployment waited forever for instances to turn healthy. A static scope runs no pods — what serves traffic is the distribution, so that is the instance it reports, the way the Lambda scope reports its function.

Testing

  • 86 OpenTofu tests and 189 BATS tests, all green
  • End-to-end against a real CloudFront distribution: a Lambda@Edge on the default behavior and a CloudFront Function on each of /api/* and /static/*, verified over HTTP — each path runs only its own function, and neither leaks into another behavior
  • The blue/green deployment reaches finalized, which it could not do before the instance listing existed

Breaking change

distribution.lambda_associations is gone; attach functions through the default behavior's invocations or a behavior's. The /static/* behavior and the SPA error responses are no longer implicit: declare them under behaviors and custom_error_responses.

🤖 Generated with Claude Code

agustin.celentano added 26 commits September 11, 2026 12:06
The CloudFront layer hardcoded its cache behaviors: a default one, the only
place Lambda@Edge functions could be attached, plus a fixed /static/* behavior
with no way to attach anything to it. Caching used the legacy forwarded_values
and TTL model, and the distribution options (price class, root object, geo
restriction, custom error responses) were fixed too — the 403/404 to
/index.html mapping in particular breaks any app that is not a SPA.

Behaviors are now data: a default_behavior plus an ordered list, each one with
its own cache policy, invocations and viewer settings. The list order is the
CloudFront precedence, so the module iterates a list rather than a map.

Caching moves to cache policies, resolved by name (managed policies included)
or referenced by id. Invocations are one field per event type instead of an
array: CloudFront accepts a single Lambda@Edge per event type and a single
CloudFront Function per viewer event, so the array was a record in disguise —
and it kept the UI from nesting one "Add" button inside another.

BREAKING CHANGE: distribution.lambda_associations is gone; attach functions
through default_behavior or a behavior instead. The /static/* behavior and the
SPA error responses are no longer created implicitly: declare them under
behaviors and custom_error_responses.
Looking a cache, origin request or response headers policy up by name calls the
List* and Get* CloudFront APIs. Without them the deploy fails with AccessDenied
the moment a behavior references a policy by name instead of by id.
moto does not implement ListCachePolicies, so the integration context references
the managed policy ids — constants across every AWS account — while resolution
by name stays covered by the module unit tests.
…dvanced tab

The Distribution tab now shows the CDN choice, one fixed block for the default
behavior, and an Add button that appends another block with a path pattern.
Every block carries the same three sections: Delivery, Invocations and Caching.

Caching opens with a single switch, off by default. Off keeps the caching this
scope has always done — no query strings or cookies forwarded, TTL 0/3600/86400
— so upgrading changes nothing for an existing distribution. On reveals the
cache, origin request and response headers policy pickers.

The distribution-wide settings move to an Advanced tab, custom error responses
last: they are the ones that turn a 404 into a 200, which only a SPA wants.

cache_policy loses its schema default. The platform materializes JSON Schema
defaults when it stores a scope configuration, so a defaulted policy name would
always be present and would override an explicitly chosen policy id.
Each behavior block now carries an Invocations list with an Add button: pick
when the function runs from a single dropdown — CloudFront Function or
Lambda@Edge, on the viewer or origin event — and paste the ARN beside it.
…together

A $ref ignores its sibling keywords in draft-07, so the path_pattern declared
next to the $ref in the array items was dropped and the form rendered the raw
scope. It moves into the definition itself and the item becomes a plain $ref;
the default behavior block just leaves it out of its layout, since CloudFront
gives it no path pattern.

Both blocks now live under one Behaviors section: the default first — CloudFront
requires it and it serves whatever no pattern matches — then the Add list.
…stribution

Nothing in the distribution form could be edited: default_behavior was a $ref
carrying sibling keywords, which draft-07 discards, so the renderer resolved no
properties and every control pointed at a field that did not exist. The k8s
scope uses no $defs at all — the behavior properties are now written out in
both places, which is longer but is what the form actually supports.

Advanced also becomes a section inside the Distribution tab instead of a tab of
its own, and each behavior block lists plain controls, following how the k8s
scope lays out additional_ports.
Follows how the k8s scope lays its form out: the main controls first, then a
Categorization with options.collapsable ADVANCED holding the distribution-wide
settings, instead of a tab of its own.

The nested objects are declared required and given defaults, the way k8s does
with autoscaling and health_check — without that the form renders their fields
disabled, since the parent object does not exist in the data yet. Invocations
also drops uniqueItems, which greys out the Add button while the last item is
still empty.
…ent pairs

The invocation picker names both halves of the choice at once: which function
runs — a CloudFront Function or a Lambda@Edge — and on which event. Only the
pairs CloudFront accepts are offered, so a Function on an origin event is not
something the form can express. The ARN sits beside it.

The Advanced section's first category is now called General, since it holds the
distribution-wide settings rather than anything specific to the CDN choice.
…down

oneOf with const and title renders as a select at the root of the schema but
not inside an array's detail layout, where the field fell back to free text.
The original lambda_associations used enum, so the invocation picker does too —
its values carry the reading order of the choice: which function, then when.
Everything under default_behavior rendered disabled: the fields sat three
levels deep (distribution.default_behavior.field) and the form only resolves
two, which is as deep as the k8s scope ever goes. They become default_* fields
on the distribution itself, alongside price_class and the rest.

Compress also gains a description and renders as a toggle beside its label.
The lambda_associations control that worked declared no detail layout — the
renderer builds each item from the schema itself. Every array here did declare
one, and the fields inside came out disabled.

The extra behaviors also take the name AWS gives them: cache behaviors, ordered
by the precedence of their path patterns.
The scope configuration form renders five element types, which is all the
original uiSchema ever used: VerticalLayout, a single Categorization, Category,
Control and Label. The Group and the nested Categorization came from the k8s
service spec, which is a different form in a different screen — here they left
the whole tab read-only.

Sections are Labels now, and the required list on distribution goes away: the
original declares none, and an unsatisfied one leaves the form invalid.
Each behavior repeated four policy pickers whether or not anyone wanted to
touch caching, which made a list of behaviors long to read for nothing. The
array gets its detail layout back — the Group was what broke the form, not the
detail — and with it the rules that keep those four fields out of sight until
the behavior asks to configure caching.
Behaviors go back to the caching the scope has always done: no query strings or
cookies forwarded, TTL 0/3600/86400, GET/HEAD/OPTIONS allowed and GET/HEAD
cached. None of it is configurable, so none of it takes up room in the form.

A behavior is now a path pattern, a viewer protocol policy, compression and its
invocations. Compression stays because it is a property of the behavior itself,
unrelated to how caching is expressed.
…Group

The cache behaviors heading said the same thing twice — the control already
renders its own title and description — so the Label goes.

The distribution-wide settings move back into a collapsable Categorization,
the way the k8s scope does it. The Group is what left the form read-only last
time; this keeps the Categorization and drops the Group, which tells us which
of the two the renderer actually refuses.
…lt one

Cache behaviors announced itself as a field label while Default behavior had a
heading, which read as if one belonged to the other. Both are headings now, so
the two halves of the section look like what they are: peers.

The invocations array also drops its description — the Invocation field below
it already names which function runs and when.
A level-3 heading followed by its own paragraph stacked two vertical margins
before the control below it. One level down and a single line of text each,
which is as much of the spacing as the schema can influence — the gap between
an array's title and its Add button belongs to the form component.
Compression is a one-switch property of the behavior; invocations is a list
that grows. The short field reads better before the one that expands.
The CloudFront layer hardcoded its behaviors: a default one, the only place
functions could be attached, plus a fixed /static/* behavior with nothing
attachable to it. Behaviors are data now — a default plus an ordered list, each
with its own viewer protocol, compression and invocations. The list order is
the CloudFront precedence, so the module iterates a list, never a map.

Caching stays exactly as it was and stays out of the interface: nothing
forwarded to the origin, TTL 0/3600/86400, GET/HEAD/OPTIONS allowed and
GET/HEAD cached. An upgrade changes no distribution's caching.

An invocation names the function kind and the event in one string, the way the
form offers it, and the module splits it into the association block CloudFront
expects: lambda_function_association or function_association.

BREAKING CHANGE: distribution.lambda_associations is gone; attach functions
through the default behavior's invocations or a behavior's. The /static/*
behavior and the SPA error responses are no longer implicit: declare them under
behaviors and custom_error_responses.
…ives

Every scope type in an account shares the scope-configurations category, and
the context carries a single configuration for it: the one that resolves
closest in the NRN hierarchy. Two scope types configured at the same level tie,
and the winner is arbitrary — a static scope would read the Lambda scope's
configuration and fail with "cloud_provider is not configured", which is what
happens today in an account that runs both.

build_context now asks for the configuration by its specification and puts it
back in the context, so every setup script keeps reading it from the same
place. The deepest NRN still wins, which is how the platform resolves providers
when several configurations of the same specification exist.
The specification listing returns nothing without an --nrn, so the lookup found
no specification, the configuration was never replaced, and the scope kept
reading whichever one the context carried — the bug this was meant to fix.

The np mock now answers an nrn-less listing the way the real CLI does, with an
empty result, so a test covers the flag instead of passing regardless of it.
The instance listing workflow shipped empty, so the scope answered nothing when
the platform asked what it was running. Its Instances view stayed blank, and a
blue/green deployment waited forever for instances to turn healthy before
switching traffic — which is what a second deployment onto a static scope hits
today.

A static scope runs no pods: what serves traffic is the CloudFront
distribution, so that is the instance it reports, the way the Lambda scope
reports its function. It is Running once CloudFront finishes propagating it,
Pending while it does, and carries the domain, aliases and origin as details.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant