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
8 changes: 8 additions & 0 deletions .changeset/expose-l2-for-grant-attachment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@gradientedge/cdk-utils-aws': minor
'@gradientedge/cdk-utils': minor
---

Expose created Lambda Aliases on the returned Function (new `FunctionWithAliases` type), and accept an `IBucket` for S3 server access logging (new optional `logBucket` on `S3BucketProps`).

Both let downstream constructs work with the real L2 instances instead of re-importing by ARN/name — clearing the `UnclearLambdaEnvironment` warning (and silently-dropped `addPermission()` invokes) when wiring `LambdaIntegration` to an alias, and the `accessLogsPolicyNotAdded` warning when the caller owns the log destination bucket. `StaticSite` is updated to pass its just-created `siteLogBucket` through so consumers benefit without code changes.
8 changes: 7 additions & 1 deletion packages/aws/src/construct/static-site/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,13 @@ export class StaticSite extends CommonConstruct {
* @summary Method to create a site bucket
*/
protected createSiteBucket() {
this.siteBucket = this.s3Manager.createS3Bucket(`${this.id}-site`, this, this.props.siteBucket)
/* Pass the just-created siteLogBucket L2 so server-access logging is wired
via Ref instead of an imported bucket name — lets CDK attach the
log-delivery policy and clears the accessLogsPolicyNotAdded warning. */
this.siteBucket = this.s3Manager.createS3Bucket(`${this.id}-site`, this, {
...this.props.siteBucket,
logBucket: this.siteLogBucket,
})
}

/**
Expand Down
13 changes: 10 additions & 3 deletions packages/aws/src/services/lambda/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { CommonConstruct, CommonStack } from '../../common/index.js'
import { createCfnOutput } from '../../utils/index.js'
import { CloudFrontManager } from '../cloudfront/index.js'

import { LambdaAliasProps, LambdaEdgeProps, LambdaProps } from './types.js'
import { FunctionWithAliases, LambdaAliasProps, LambdaEdgeProps, LambdaProps } from './types.js'

/**
* Provides operations on AWS Lambda
Expand Down Expand Up @@ -128,7 +128,7 @@ export class LambdaManager {
deadLetterQueue = scope.sqsManager.createDeadLetterQueueForLambda(`${id}-dlq`, scope, props, redriveQueue)
}

const lambdaFunction = new Function(scope, `${id}`, {
const lambdaFunction: FunctionWithAliases = new Function(scope, `${id}`, {
...props,
/* When a VPC is provided, allow placement in public subnets (e.g. for NAT access) */
allowPublicSubnet: !!vpc,
Expand Down Expand Up @@ -175,11 +175,17 @@ export class LambdaManager {
}

/* Create aliases and optionally attach provisioned concurrency auto-scaling.
Provisioned concurrency requires an alias — it cannot be set on $LATEST. */
Provisioned concurrency requires an alias — it cannot be set on $LATEST.
The created Alias instances are also stashed on the returned function under
`.lambdaAliases` (keyed by aliasName) so downstream constructs can use them
directly instead of re-importing by ARN — which carries an UnclearLambdaEnvironment
warning and silently drops permissions when CDK can't statically prove same-env. */
if (props.lambdaAliases && !_.isEmpty(props.lambdaAliases)) {
const aliasMap: Record<string, Alias> = {}
props.lambdaAliases.forEach(alias => {
const aliasId = alias.id ?? `${id}-${alias.aliasName}`
const functionAlias = this.createLambdaFunctionAlias(`${aliasId}`, scope, alias, lambdaFunction.currentVersion)
aliasMap[alias.aliasName] = functionAlias
createCfnOutput(`${id}-${alias.aliasName}AliasArn`, scope, functionAlias.functionArn)
createCfnOutput(`${id}-${alias.aliasName}AliasName`, scope, functionAlias.aliasName)

Expand All @@ -190,6 +196,7 @@ export class LambdaManager {
})
}
})
lambdaFunction.lambdaAliases = aliasMap
}

if (props.tags && !_.isEmpty(props.tags)) {
Expand Down
16 changes: 15 additions & 1 deletion packages/aws/src/services/lambda/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { AliasProps, FunctionProps } from 'aws-cdk-lib/aws-lambda'
import { Alias, AliasProps, Function, FunctionProps } from 'aws-cdk-lib/aws-lambda'
import { SqsEventSourceProps } from 'aws-cdk-lib/aws-lambda-event-sources'

import { TagProps } from '../../types/index.js'
import { QueueProps } from '../simple-queue-service/index.js'

/**
* A Function returned by {@link LambdaManager.createLambdaFunction} when the
* caller supplied {@link LambdaProps.lambdaAliases}. The aliases that were
* created are exposed under `lambdaAliases`, keyed by aliasName, so downstream
* constructs can pass them directly to integrations (e.g. ApiGateway's
* LambdaIntegration) instead of re-importing by ARN — which carries an
* UnclearLambdaEnvironment warning and silently drops the invoke permission
* when CDK can't statically prove same-env.
*/
/** @category Interface */
export type FunctionWithAliases = Function & {
lambdaAliases?: Record<string, Alias>
}

/**
* Props for Lambda@Edge function, matching aws-cdk-lib experimental EdgeFunctionProps.
* Inlined because aws-cdk-lib does not export this subpath via its package exports map.
Expand Down
11 changes: 8 additions & 3 deletions packages/aws/src/services/simple-storage-service/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,14 @@ export class S3Manager {
if (props.existingBucket && props.bucketName) {
bucket = Bucket.fromBucketName(scope, `${id}`, S3Manager.determineBucketName(scope, props, props.bucketName))
} else {
/* Optionally look up a separate bucket for server access logging */
let logBucket
if (props.logBucketName) {
/* Resolve the server-access-logging destination bucket. When the caller
already owns the log bucket (e.g. created earlier in the same stack)
they should pass the IBucket instance via `logBucket` — CDK then attaches
the log-delivery policy directly and no `accessLogsPolicyNotAdded`
warning is raised. The `logBucketName` path remains for true imports
where only the name is known. */
let logBucket = props.logBucket
if (!logBucket && props.logBucketName) {
logBucket = Bucket.fromBucketName(
scope,
`${id}-logs`,
Expand Down
14 changes: 13 additions & 1 deletion packages/aws/src/services/simple-storage-service/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@ export interface S3BucketProps extends s3.BucketProps {
existingBucket?: boolean
/** Lifecycle rules for objects in the bucket */
lifecycleRules?: LifecycleRule[]
/** Name of an existing bucket to use for server access logging */
/**
* Bucket instance to use for server access logging. Pass this when the caller
* already owns the log destination bucket (e.g. created earlier in the same
* stack) — CDK can then add the log-delivery policy to it directly. Falls back
* to {@link logBucketName} when not provided.
*/
logBucket?: s3.IBucket
/**
* Name of an existing bucket to use for server access logging. When set, the
* bucket is imported by name — CDK cannot mutate its policy in that case,
* which surfaces an `accessLogsPolicyNotAdded` warning at synth. Prefer
* {@link logBucket} when the destination bucket is owned by the caller.
*/
logBucketName?: string
/** Tags to apply to the bucket */
tags?: TagProps[]
Expand Down
30 changes: 28 additions & 2 deletions packages/aws/test/services/lambda-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Template } from 'aws-cdk-lib/assertions'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as lambda from 'aws-cdk-lib/aws-lambda'
import { Construct } from 'constructs'
import { CommonConstruct, CommonStack, CommonStackProps } from '../../src/index.js'
import _ from 'lodash'
import { CommonConstruct, CommonStack, CommonStackProps, FunctionWithAliases } from '../../src/index.js'
import { STUB_SECRET_ARN } from '../common/stubs.js'

interface TestStackProps extends CommonStackProps {
Expand Down Expand Up @@ -33,6 +34,7 @@ const testStackProps = {

class TestCommonStack extends CommonStack {
declare props: TestStackProps
declare construct: TestCommonConstruct

constructor(parent: cdk.App, name: string, props: cdk.StackProps) {
super(parent, name, props)
Expand Down Expand Up @@ -69,6 +71,7 @@ class TestInvalidCommonStack extends CommonStack {

class TestCommonConstruct extends CommonConstruct {
declare props: TestStackProps
public testLambdaWithConcurrency!: FunctionWithAliases

constructor(parent: Construct, name: string, props: TestStackProps) {
super(parent, name, props)
Expand Down Expand Up @@ -98,7 +101,7 @@ class TestCommonConstruct extends CommonConstruct {
[testLayer],
new lambda.AssetCode('packages/aws/test/common/nodejs/lib')
)
this.lambdaManager.createLambdaFunction(
this.testLambdaWithConcurrency = this.lambdaManager.createLambdaFunction(
'test-lambda-with-concurrency',
this,
this.props.testLambdaWithConcurrency,
Expand Down Expand Up @@ -254,6 +257,29 @@ describe('TestLambdaConstruct', () => {
})
})

describe('TestLambdaConstructAliasesExposed', () => {
test('exposes lambdaAliases keyed by aliasName as expected', () => {
const fn = commonStack.construct.testLambdaWithConcurrency
expect(_.keys(fn.lambdaAliases)).toEqual(['test-concurrent-alias'])
})

test('exposes the real Alias L2 attached to the consuming stack as expected', () => {
const alias = commonStack.construct.testLambdaWithConcurrency.lambdaAliases?.['test-concurrent-alias']
expect(alias?.aliasName).toEqual('test-concurrent-alias')
/* Same stack — what addPermission() needs to skip the UnclearLambdaEnvironment warning */
expect(cdk.Stack.of(alias!)).toBe(commonStack)
})

test('exposed alias count matches the number of AWS::Lambda::Alias CFN resources synthesised', () => {
const aliasResources = _.pickBy(
template.toJSON().Resources as Record<string, { Type: string }>,
resource => resource.Type === 'AWS::Lambda::Alias'
)
const exposedAliasCount = _.size(commonStack.construct.testLambdaWithConcurrency.lambdaAliases)
expect(_.size(aliasResources)).toBeGreaterThanOrEqual(exposedAliasCount)
})
})

describe('TestLambdaConstruct', () => {
test('provisions new lambda with concurrency settings as expected', () => {
template.hasResourceProperties('AWS::Lambda::Function', {
Expand Down
58 changes: 58 additions & 0 deletions packages/aws/test/services/s3-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as cdk from 'aws-cdk-lib'
import { Template } from 'aws-cdk-lib/assertions'
import { Construct } from 'constructs'
import _ from 'lodash'
import { CommonConstruct, CommonStack, CommonStackProps } from '../../src/index.js'

interface TestStackProps extends CommonStackProps {
Expand Down Expand Up @@ -195,6 +196,63 @@ describe('TestS3ManagerLifecycle', () => {
})
})

/* Test S3 access logging wired through the new `logBucket` (L2) prop */
class TestS3OwnedLogBucketStack extends CommonStack {
declare props: TestStackProps
declare construct: TestS3OwnedLogBucketConstruct

constructor(parent: cdk.App, name: string, props: cdk.StackProps) {
super(parent, name, props)
this.construct = new TestS3OwnedLogBucketConstruct(this, 'test-s3-owned-log-bucket', this.props)
}

protected determineConstructProps(props: cdk.StackProps) {
return {
...super.determineConstructProps(props),
...{
testBucket: this.node.tryGetContext('siteBucket'),
testLogBucket: this.node.tryGetContext('siteLogBucket'),
},
}
}
}

class TestS3OwnedLogBucketConstruct extends CommonConstruct {
declare props: TestStackProps

constructor(parent: Construct, name: string, props: TestStackProps) {
super(parent, name, props)
const logBucket = this.s3Manager.createS3Bucket('test-owned-log-bucket', this, this.props.testLogBucket)
this.s3Manager.createS3Bucket('test-owned-bucket', this, { ...this.props.testBucket, logBucket })
}
}

const appS3Owned = new cdk.App({ context: testStackProps })
const stackS3Owned = new TestS3OwnedLogBucketStack(appS3Owned, 'test-s3-owned-log-bucket-stack', testStackProps)
const templateS3Owned = Template.fromStack(stackS3Owned)

describe('TestS3ManagerOwnedLogBucket', () => {
test('wires LoggingConfiguration via Ref to the owned log bucket as expected', () => {
/* When the caller passes the L2 IBucket, CDK can resolve the log destination
to a same-stack Ref instead of a literal bucket name string — which is what
allows it to attach the log-delivery policy and avoid the
@aws-cdk/aws-s3:accessLogsPolicyNotAdded warning. */
const buckets = _.pickBy(
templateS3Owned.toJSON().Resources as Record<string, { Type: string; Properties: any }>,
resource => resource.Type === 'AWS::S3::Bucket'
)
const sourceBucket = _.find(buckets, bucket => bucket.Properties.BucketName === 'site-123456789-eu-west-1-test')
const logBucketLogicalId = _.findKey(
buckets,
bucket => bucket.Properties.BucketName === 'site-logs-123456789-eu-west-1-test'
)
expect(sourceBucket?.Properties.LoggingConfiguration).toEqual({
DestinationBucketName: { Ref: logBucketLogicalId },
LogFilePrefix: 'logs/',
})
})
})

describe('TestS3Manager - Error Handling', () => {
test('throws error when folders are empty', () => {
const testStack = new TestCommonStack(app, 'test-error-folders', testStackProps)
Expand Down