feat!: configurable cache behaviors on the AWS distribution layer - #28
Open
agustincelentano wants to merge 26 commits into
Open
feat!: configurable cache behaviors on the AWS distribution layer#28agustincelentano wants to merge 26 commits into
agustincelentano wants to merge 26 commits into
Conversation
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.
This reverts commit 2d5a50b.
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.
…ame" This reverts commit 172291e.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/OPTIONSallowed andGET/HEADcached, 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-configurationscategory, 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 withcloud_provider is not configured.build_contextnow 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.yamlshipped 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
/api/*and/static/*, verified over HTTP — each path runs only its own function, and neither leaks into another behaviorfinalized, which it could not do before the instance listing existedBreaking change
distribution.lambda_associationsis 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 underbehaviorsandcustom_error_responses.🤖 Generated with Claude Code