Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
- name: Verify schema
run: make verify-schema

- name: Verify examples
run: make verify-examples

# Integration tests run against real AWS resources (Route53, EC2, etc.)
# These are self-contained: each test creates and cleans up its own resources.
test-integration:
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ formae agent.
configuration on the first update — so the resource is **not discoverable**
in this version. Discovery can be enabled once the full property surface is
modelled.
- `AWS::Lambda::Version` now exposes a `res.functionArn` resolvable carrying
its qualified (versioned) function ARN, so a CloudFront distribution's
`lambdaFunctionAssociations` entry can reference a published version by
reference instead of a hand-edited literal ARN pin. Lambda@Edge rejects
`$LATEST`, so the association has always needed a versioned ARN; previously
that ARN had to be copied in by hand. Note the caveat this doesn't remove:
every field on `Version` is create-only, so a function code change publishes
a *replacement* version rather than updating the existing one. The safe
order is publish the new version, re-point the distribution at it, wait for
CloudFront to propagate the change, and only then delete the old version,
and a replicated Lambda@Edge version can stay undeletable for a while after
the association is removed. Exposing the resolvable removes the hand-edit;
it does not change that ordering.

### Changed

Expand Down
175 changes: 175 additions & 0 deletions examples/cloudfront-lambda-edge/main.pkl
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* © 2025 Platform Engineering Labs Inc.
*
* SPDX-License-Identifier: FSL-1.1-ALv2
*
* Lambda@Edge behind CloudFront: an IAM role trusted by both Lambda and
* Lambda@Edge, a function, a published version, and a distribution whose
* default cache behavior associates that version. CloudFront rejects an
* unqualified function ARN (and $LATEST) on a Lambda@Edge association, so the
* association reads the version's qualified ARN off the published version
* rather than pinning an ARN literal by hand.
*
* Region: Lambda@Edge requires the function and its published version to live
* in us-east-1, and requires the associated ARN to name that same region. The
* schema cannot express or enforce region equality between the distribution
* and the version, so keep this stack's target on us-east-1 and re-check the
* target if you copy this file into a stack of your own.
*
* Replacement and teardown: every field on Version is create-only, so changing
* the function's code publishes a *replacement* version rather than updating
* the existing one. The safe order is publish the new version, re-point the
* distribution at it, wait for CloudFront to propagate the change, and only
* then delete the old version. CloudFront keeps replicas of a Lambda@Edge
* version at its edge locations for some time after the association is
* removed, and the version cannot be deleted while replicas remain — so an old
* version can stay undeletable for a long while and this stack is slow to tear
* down.
*
* Apply: formae apply examples/cloudfront-lambda-edge/main.pkl
* Destroy: formae destroy examples/cloudfront-lambda-edge/main.pkl
*/

amends "@formae/forma.pkl"
import "@formae/formae.pkl"

import "@aws/aws.pkl"

import "@aws/iam/role.pkl"
import "@aws/lambda/func.pkl"
import "@aws/lambda/version.pkl"
import "@aws/cloudfront/cachepolicy.pkl"
import "@aws/cloudfront/originaccesscontrol.pkl"
import "@aws/cloudfront/distribution.pkl"
import "@aws/s3/bucket.pkl"

// Use a fixed label suffix so repeated dev applies don't collide. Override via
// FORMAE_TEST_RUN_ID when running in CI.
local runID = read?("env:FORMAE_TEST_RUN_ID") ?? "dev"

local originBucket = new bucket.Bucket {
label = "cf-edge-lambda-origin"
bucketName = "formae-plugin-sdk-test-cf-edge-origin-\(runID)"
}

local oac = new originaccesscontrol.OriginAccessControl {
label = "cf-edge-lambda-oac"
originAccessControlConfig = new originaccesscontrol.OriginAccessControlConfig {
name = "formae-plugin-sdk-test-cf-edge-oac-\(runID)"
originAccessControlOriginType = "s3"
signingBehavior = "always"
signingProtocol = "sigv4"
}
}

local cp = new cachepolicy.CachePolicy {
label = "cf-edge-lambda-cp"
cachePolicyConfig = new cachepolicy.CachePolicyConfig {
name = "formae-plugin-sdk-test-cf-edge-cp-\(runID)"
defaultTTL = 86400
maxTTL = 31536000
minTTL = 1
parametersInCacheKeyAndForwardedToOrigin = new cachepolicy.ParametersInCacheKeyAndForwardedToOrigin {
enableAcceptEncodingGzip = true
headersConfig = new cachepolicy.CachePolicyHeadersConfig { headerBehavior = "none" }
cookiesConfig = new cachepolicy.CachePolicyCookiesConfig { cookieBehavior = "none" }
queryStringsConfig = new cachepolicy.CachePolicyQueryStringsConfig { queryStringBehavior = "none" }
}
}
}

// Lambda@Edge runs the function under the replication service as well as under
// Lambda itself, so the execution role must trust edgelambda.amazonaws.com in
// addition to lambda.amazonaws.com.
local edgeRole = new role.Role {
label = "cf-edge-lambda-role"
roleName = "formae-plugin-sdk-test-cf-edge-role-\(runID)"
assumeRolePolicyDocument {
["Version"] = "2012-10-17"
["Statement"] {
new {
["Effect"] = "Allow"
["Principal"] {
["Service"] = new Listing {
"lambda.amazonaws.com"
"edgelambda.amazonaws.com"
}
}
["Action"] = "sts:AssumeRole"
}
}
}
}

local edgeFunction = new func.Function {
label = "cf-edge-lambda-function"
functionName = "formae-plugin-sdk-test-cf-edge-fn-\(runID)"
role = edgeRole.res.arn
runtime = "python3.12"
handler = "index.handler"
code = new func.Code {
zipFile = """
def handler(event, context):
request = event['Records'][0]['cf']['request']
request['headers']['x-formae-edge'] = [{'key': 'X-Formae-Edge', 'value': 'origin-request'}]
return request
"""
}
memorySize = 128
timeout = 5
}

local edgeVersion = new version.Version {
label = "cf-edge-lambda-version"
functionName = edgeFunction.res.arn
description = "Lambda@Edge origin-request handler"
}

local dist = new distribution.Distribution {
label = "cf-edge-lambda-distribution"
distributionConfig = new distribution.DistributionConfig {
enabled = true
comment = "Lambda@Edge demo Distribution"
origins = new Listing<distribution.Origin> {
new {
id = "s3-origin"
domainName = originBucket.res.regionalDomainName
originAccessControlId = oac.res.id
s3OriginConfig = new distribution.S3OriginConfig {}
}
}
defaultCacheBehavior = new distribution.DefaultCacheBehavior {
targetOriginId = "s3-origin"
viewerProtocolPolicy = "redirect-to-https"
cachePolicyId = cp.res.id
lambdaFunctionAssociations = new Listing<distribution.LambdaFunctionAssociation> {
new {
eventType = "origin-request"
lambdaFunctionARN = edgeVersion.res.functionArn
}
}
}
}
}

forma {
new formae.Stack {
label = "cloudfront-lambda-edge-\(runID)"
description = "CloudFront distribution with a Lambda@Edge origin-request association"
}

new formae.Target {
label = "aws-target"
config = new aws.Config {
region = "us-east-1"
}
}

originBucket
oac
cp
edgeRole
edgeFunction
edgeVersion
dist
}
15 changes: 15 additions & 0 deletions schema/pkl/lambda/version.pkl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ open class RuntimePolicy extends formae.SubResource {
updateRuntimeOn: String
}

open class VersionResolvable extends formae.Resolvable {
hidden type = module.type

hidden functionArn: VersionResolvable = (this) {
property = "FunctionArn"
}
}

@aws.ResourceHint {
type = module.type
identifier = "Version"
Expand Down Expand Up @@ -57,4 +65,11 @@ open class Version extends formae.Resource {
// field that can never be updated in place.
@aws.FieldHint{createOnly = true; hasProviderDefault = true}
runtimePolicy: RuntimePolicy?

hidden parent = this

hidden res: VersionResolvable = new {
label = parent.label
stack = parent.stack?.label
}
}
90 changes: 90 additions & 0 deletions testdata/lambda-version-ref.pkl
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* © 2025 Platform Engineering Labs Inc.
*
* SPDX-License-Identifier: FSL-1.1-ALv2
*/

amends "@formae/forma.pkl"
import "@formae/formae.pkl"

import "@aws/aws.pkl"

import "@aws/iam/role.pkl"
import "@aws/lambda/func.pkl"
import "@aws/lambda/permission.pkl"
import "@aws/lambda/version.pkl"

local testRunID = read("env:FORMAE_TEST_RUN_ID")
local stackName = "plugin-sdk-test-lambda-version-ref-\(testRunID)"

local lambdaRole = new role.Role {
label = "lambda-execution-role"
roleName = "formae-sdk-test-lambda-vref-role-\(testRunID)"
assumeRolePolicyDocument {
["Version"] = "2012-10-17"
["Statement"] {
new {
["Effect"] = "Allow"
["Principal"] {
["Service"] = "lambda.amazonaws.com"
}
["Action"] = "sts:AssumeRole"
}
}
}
}

local testFunction = new func.Function {
label = "test-function-for-version-ref"
functionName = "formae-sdk-test-lambda-vref-\(testRunID)"
role = lambdaRole.res.arn
runtime = "python3.12"
handler = "index.handler"
code = new func.Code {
zipFile = """
import json
def handler(event, context):
return {
'statusCode': 200,
'body': json.dumps({'message': 'hello from formae'})
}
"""
}
memorySize = 128
timeout = 10
}

local testVersion = new version.Version {
label = "plugin-sdk-test-lambda-version-ref"
functionName = testFunction.res.arn
description = "Plugin SDK test version for resolvable reference"
}

forma {
new formae.Stack {
label = stackName
description = "Plugin SDK test for Lambda Version references"
}

new formae.Target {
label = "aws-target"
config = new aws.Config {
region = "us-east-1"
}
}

lambdaRole

testFunction

testVersion

// The permission targets the published version's qualified ARN, so the
// apply only succeeds if the Version's FunctionArn resolves.
new permission.Permission {
label = "plugin-sdk-test-lambda-version-ref-permission"
functionName = testVersion.res.functionArn
action = "lambda:InvokeFunction"
principal = "apigateway.amazonaws.com"
}
}
Loading