Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitattributes

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions .projen/deps.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .projen/files.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .projen/tasks.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .projenrc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cdk, javascript, ReleasableCommits, TextFile } from "projen";
import {
AwsProviderStructBuilder,
CfncompatProviderStructBuilder,
LambdaFunctionVpcConfigStructBuilder,
S3BucketWebsiteConfigurationConfigStructBuilder,
S3BucketCorsConfigurationConfigStructBuilder,
Expand Down Expand Up @@ -69,6 +70,7 @@ const project = new cdk.JsiiProject({
"@cdktn/provider-aws@^25.0.0",
"@cdktn/provider-time@^14.0.0",
"@cdktn/provider-archive@^14.0.0",
"@cdktn/provider-cfncompat@^1.0.0",
"@cdktn/provider-tls@^14.0.0",
"@cdktn/provider-cloudinit@^14.0.0",
"@cdktn/provider-docker@^16.0.0",
Expand All @@ -81,6 +83,7 @@ const project = new cdk.JsiiProject({
"@cdktn/provider-aws@25.0.0",
"@cdktn/provider-time@14.0.0",
"@cdktn/provider-archive@14.0.0",
"@cdktn/provider-cfncompat@1.0.0",
"@cdktn/provider-tls@14.0.0",
"@cdktn/provider-cloudinit@14.0.0",
"@cdktn/provider-docker@16.0.0",
Expand Down Expand Up @@ -228,6 +231,7 @@ new TextFile(project, ".nvmrc", {
project.npmrc?.addConfig("node-linker", "hoisted");

new AwsProviderStructBuilder(project);
new CfncompatProviderStructBuilder(project);
new PolicyDocumentStatementStructBuilder(project);
new PolicyDocumentConfigStructBuilder(project);
new LambdaFunctionVpcConfigStructBuilder(project);
Expand Down
18 changes: 18 additions & 0 deletions integ/aws/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,21 @@ func GetS3BucketNotificationE(t testing.TestingT, region string, bucketName stri
Bucket: &bucketName,
})
}

// GetS3BucketNotificationLambdaArns fetches the given bucket's notification
// configuration and returns the set of `LambdaFunctionArn`s currently configured on it -
// e.g. to assert a cross-stack `Custom::S3BucketNotifications` merge (see
// `BucketNotificationsResource`) landed the entries several stacks contributed, and
// only those, without asserting a single stack's exact ordering of the others'.
func GetS3BucketNotificationLambdaArns(t testing.TestingT, region string, bucketName string) map[string]bool {
config, err := GetS3BucketNotificationE(t, region, bucketName)
require.NoError(t, err)

arns := make(map[string]bool, len(config.LambdaFunctionConfigurations))
for _, c := range config.LambdaFunctionConfigurations {
if c.LambdaFunctionArn != nil {
arns[*c.LambdaFunctionArn] = true
}
}
return arns
}
22 changes: 22 additions & 0 deletions integ/aws/storage/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,25 @@ table.policy: ## Test Table with Policy
bucket-notifications: ## Test S3 Bucket with EventBridge Notifications
go test -v -count 1 -timeout 15m ./... -run ^TestBucketNotifications$
.PHONY: bucket-notifications

bucket-notifications-cross-stack: ## Test cross-stack S3 notifications via the cfncompat custom resource
go test -v -count 1 -timeout 45m ./... -run ^TestBucketNotificationsCrossStack$
.PHONY: bucket-notifications-cross-stack

# This test names its own stages (deploy_a, validate_ab, ...) rather than the
# deploy_terraform/validate/cleanup_terraform stages ../../common.mk's %-synth-only and
# friends skip by name, so it spells out its own suffix targets - an explicit target wins
# over a `%` pattern rule with the same stem.
bucket-notifications-cross-stack-synth-only: ## Synth only (no deploy/validate/cleanup) - runs without AWS creds
SKIP_deploy_a=true SKIP_validate_a=true SKIP_deploy_b=true SKIP_validate_ab=true \
SKIP_deploy_c=true SKIP_validate_abc=true SKIP_redeploy_a=true SKIP_validate_abc_again=true \
SKIP_destroy_b=true SKIP_validate_ac=true SKIP_cleanup_terraform=true \
make bucket-notifications-cross-stack
.PHONY: bucket-notifications-cross-stack-synth-only

bucket-notifications-cross-stack-cleanup-only: ## Skip straight to teardown of stacks a/b/c
SKIP_synth_app=true SKIP_deploy_a=true SKIP_validate_a=true SKIP_deploy_b=true SKIP_validate_ab=true \
SKIP_deploy_c=true SKIP_validate_abc=true SKIP_redeploy_a=true SKIP_validate_abc_again=true \
SKIP_destroy_b=true SKIP_validate_ac=true \
make bucket-notifications-cross-stack
.PHONY: bucket-notifications-cross-stack-cleanup-only
19 changes: 19 additions & 0 deletions integ/aws/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ $ make

Test Targets:
bucket-notifications Test S3 Bucket with EventBridge Notifications
bucket-notifications-cross-stack
Test cross-stack S3 notifications via the cfncompat custom resource

Other Targets:
help Print out every target with a description
Expand All @@ -34,3 +36,20 @@ To clean up after running tests
```console
make clean
```

## `bucket-notifications-cross-stack`

Three stacks (`a`, `b`, `c`) add their own prefix-filtered notification entry to one
shared bucket: `a` owns the bucket, `b` and `c` only import it by name. Every entry is
provisioned by the `Custom::S3BucketNotifications` custom resource, so each apply merges
with the other stacks' entries instead of overwriting them.

The `%-no-cleanup` / `%-synth-only` / `%-validate-only` / `%-cleanup-only` patterns do not
apply here: this test names its own stages (`deploy_a`, `validate_ab`, ...). Use the
explicit `bucket-notifications-cross-stack-synth-only` and
`bucket-notifications-cross-stack-cleanup-only` targets instead.

> [!NOTE]
> This target needs the `cdktn-io/cfncompat` provider, served by both
> `registry.terraform.io` and `registry.opentofu.org`; terratest runs `tofu`
> (`integ/aws/util.go`) and resolves it from the OpenTofu registry.
116 changes: 116 additions & 0 deletions integ/aws/storage/apps/bucket-notifications-cross-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// No upstream integ equivalent: three stacks (a/b/c) each add their own prefix-filtered
// notification entry to one shared bucket, owned by stack a and only ever imported
// (`Bucket.fromBucketName`) by b/c, exercising `BucketNotificationsResource` and the
// `@terraconstructs/aws-s3:keepNotificationInImportedBucket` context key end to end.
import { App, LocalBackend, TerraformOutput } from "cdktn";
import { Construct } from "constructs";
import { aws } from "../../../../src";

const environmentName = process.env.ENVIRONMENT_NAME ?? "test";
const region = process.env.AWS_REGION ?? "us-east-1";
const outdir = process.env.OUT_DIR ?? "cdktf.out";
const stackName = process.env.STACK_NAME ?? "bucket-notifications-cross-stack";
const suffix = process.env.SUFFIX;
if (!suffix) {
throw new Error(
"Missing required env var 'SUFFIX'. Set it before synth/deploy (e.g. `SUFFIX=k3m9x1 npx cdktn synth`).",
);
}

// Shared bucket name every stack derives independently from SUFFIX: b/c never read a's
// outputs, so the stacks stay genuinely independent.
const bucketName = `s3n-${suffix}`;

type Owner = "a" | "b" | "c";

const app = new App({
outdir,
// Forces every `addEventNotification` call below through the
// `Custom::S3BucketNotifications` custom resource - the only mechanism that lets b/c
// add notification entries to a bucket they do not own, and lets a share its own
// bucket with them without clobbering their entries on every apply.
context: {
"@terraconstructs/aws-s3:keepNotificationInImportedBucket": true,
},
});

function buildStack(scope: Construct, owner: Owner): void {
const stack = new aws.AwsStack(scope, `${stackName}-${owner}`, {
gridUUID: `g${owner}12345678-1234`,
environmentName,
providerConfig: {
region,
},
});
new LocalBackend(stack, {
path: `${stackName}-${owner}.tfstate`,
});

// Only stack `a` owns the bucket; `b` and `c` only ever import it by name.
const bucket: aws.storage.IBucket =
owner === "a"
? new aws.storage.Bucket(stack, "Bucket", {
bucketName,
forceDestroy: true,
})
: aws.storage.Bucket.fromBucketName(stack, "Bucket", bucketName);

const queue = new aws.notify.Queue(stack, "ResultsQueue", {
queueName: `s3n-${suffix}-${owner}-results`,
});

const fn = new aws.compute.LambdaFunction(stack, "Function", {
functionName: `s3n-${suffix}-${owner}`,
runtime: aws.compute.Runtime.NODEJS_22_X,
handler: "index.handler",
// Forwards every S3 event record to this stack's own "results" queue so the
// terratest harness can observe which stack's target received the event.
// CommonJS, no bundling: nodejs22.x bundles @aws-sdk v3.
code: aws.compute.Code.fromInline(`
const { SQSClient, SendMessageCommand } = require("@aws-sdk/client-sqs");

const sqs = new SQSClient({});
const queueUrl = process.env.RESULTS_QUEUE_URL;
const owner = process.env.STACK_NAME || "unknown";

exports.handler = async (event) => {
const records = event.Records || [];
console.log(JSON.stringify({ owner, records: records.length, event }));
for (const r of records) {
const body = {
owner,
bucket: r.s3 && r.s3.bucket && r.s3.bucket.name,
key: decodeURIComponent(((r.s3 && r.s3.object && r.s3.object.key) || "").replace(/\\+/g, " ")),
eventName: r.eventName,
};
await sqs.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: JSON.stringify(body) }));
}
return { forwarded: records.length };
};
`),
environment: {
RESULTS_QUEUE_URL: queue.queueUrl,
STACK_NAME: owner,
},
});
queue.grantSendMessages(fn);

bucket.addEventNotification(
aws.storage.EventType.OBJECT_CREATED,
new aws.storage.targets.FunctionDestination(fn),
{ prefix: `${owner}/` },
);

// Flat outputs (not `registerOutputs`, which emits one nested object output) so
// terratest can read each id directly with `terraform.Output`.
new TerraformOutput(stack, "bucket_name", { value: bucketName, staticId: true });
new TerraformOutput(stack, "lambda_arn", { value: fn.functionArn, staticId: true });
new TerraformOutput(stack, "queue_url", { value: queue.queueUrl, staticId: true });
new TerraformOutput(stack, "owner", { value: owner, staticId: true });
}

buildStack(app, "a");
buildStack(app, "b");
buildStack(app, "c");

app.synth();
10 changes: 6 additions & 4 deletions integ/aws/storage/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
"name": "integ-aws-storage",
"type": "module",
"scripts": {
"bucket-notifications": "bun run ./bucket-notifications.ts"
"bucket-notifications": "bun run ./bucket-notifications.ts",
"bucket-notifications-cross-stack": "bun run ./bucket-notifications-cross-stack.ts"
},
"dependencies": {
"cdktn": "^0.23.0",
"constructs": "^10.6.0",
"@cdktn/provider-aws": "^24.8.0",
"cdktn": "^0.24.0",
"constructs": ">=10.7.2 <10.8.0",
"@cdktn/provider-aws": "^25.0.0",
"@cdktn/provider-cfncompat": "^1.0.0",
"@types/aws-lambda": "^8.10.76"
},
"devDependencies": {
Expand Down
Loading
Loading