From d77f8529cffb057e0935207ed6ea0209afd8df17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 07:43:49 +0000 Subject: [PATCH] feat(examples): set cross-stack reference strength explicitly to weak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples configured no CDK context at all, so every feature flag was inherited from whatever CDK's unconfigured default happened to be — and synth warned that `@aws-cdk/core:defaultCrossStackReferences` had never been chosen, defaulting to "strong". The examples are the pattern users copy, so a default left implicit here becomes a default inherited downstream. Set the flag to "weak", CDK's recommended value. Cross-stack consumers now read the producer's output with `Fn::GetStackOutput` instead of importing a CloudFormation export. Strong references stop a producer removing an export a consumer still reads — protection worth having for a long-lived system, and the wrong trade for stacks CI deploys and destroys on every run. Contrary to the flag's own description, the choice is not confined to cross-region references: in aws-cdk-lib 2.264.0 a same-account, same-region consumer resolves to `Fn::GetStackOutput` under "weak" too, so `ComposureCDK-MultiStackApiStack`'s template changes and its snapshot moves with it. The producer's output loses its `Export`; nothing else in the suite references across stacks. The context is declared twice by necessity: `cdk.json` for the CLI, and `EXAMPLE_CONTEXT` for the tests, which build their own `App`. CDK applies CLI context after `App`'s `context` prop, so a divergence would not fail on its own — it would leave the tests asserting a template CI never deploys. A new test asserts the two stay in sync, that the multi-stack example resolves weakly, and that an app built without the context still warns. Refs #341 --- packages/examples/README.md | 14 ++++ packages/examples/cdk.json | 5 +- packages/examples/src/agent-volume-app.ts | 5 +- packages/examples/src/app-context.ts | 55 +++++++++++++ packages/examples/src/apps.ts | 3 +- packages/examples/src/crud-api-app.ts | 5 +- packages/examples/src/dns-zone-app.ts | 5 +- packages/examples/src/dual-function-app.ts | 5 +- .../src/dynamo-stream-processor-app.ts | 5 +- packages/examples/src/ec2-app.ts | 5 +- packages/examples/src/mock-api-app.ts | 4 +- packages/examples/src/multi-stack-app.ts | 9 ++- packages/examples/src/neptune-graph-app.ts | 5 +- packages/examples/src/openapi-petstore-app.ts | 5 +- packages/examples/src/order-processor-app.ts | 5 +- packages/examples/src/static-website/app.ts | 5 +- packages/examples/src/tagged-system-app.ts | 5 +- .../multi-stack-app.test.ts.snap | 33 +++++--- packages/examples/test/app-context.test.ts | 78 +++++++++++++++++++ .../examples/test/ascii-templates.test.ts | 4 +- 20 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 packages/examples/src/app-context.ts create mode 100644 packages/examples/test/app-context.test.ts diff --git a/packages/examples/README.md b/packages/examples/README.md index eb8b9c64..f0e123dd 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -42,6 +42,20 @@ npx nx cdk examples -- destroy --all # To skip IAM approval prompts (e.g. in CI): add `--require-approval never` to deploy commands. +## Feature flags + +CDK feature flags are declared, not inherited. [`cdk.json`](cdk.json) carries the context the CLI synthesises with, and [`src/app-context.ts`](src/app-context.ts) carries the same map for the tests — CDK applies CLI context _after_ an `App`'s `context` prop, so a divergence would leave the tests asserting a template CI never deploys. `test/app-context.test.ts` asserts the two stay in sync. + +Examples are the pattern people copy, so a default left implicit here becomes a default inherited downstream — and CDK's unconfigured behaviour shifts between releases. + +| Flag | Value | Why | +| ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@aws-cdk/core:defaultCrossStackReferences` | `"weak"` | Cross-stack consumers read the producer's output with `Fn::GetStackOutput` rather than importing a CloudFormation export. Strong references stop a producer from deleting an export a consumer still reads — worth having for a long-lived system, and the wrong trade for stacks CI deploys and destroys on every run. This is CDK's recommended value; unconfigured, CDK behaves as `"strong"` and warns that the choice was never made. | + +Despite what the flag's own description says, it is not limited to cross-region references: a same-account, same-region consumer resolves to `Fn::GetStackOutput` under `"weak"` too, which is what [`ComposureCDK-MultiStackApiStack`](src/multi-stack-app.ts)'s snapshot shows. + +**Migrating an existing system from strong to weak takes two deploys**, not one: set the flag to `"both"`, deploy everywhere, then set it to `"weak"`. A producer cannot drop an export while a consumer still imports it, so `"both"` keeps the export alive while consumers move across. Setting `"weak"` directly is only safe from a clean slate — which is what these examples are. + ## Costs These examples create minimal resources (Lambda functions, API Gateway endpoints, S3 buckets, CloudFront distributions, t3.micro EC2 instances) and should fall within the [AWS Free Tier](https://aws.amazon.com/free/) for the first 12 months. EC2 instances and VPC flow logs (CloudWatch Logs ingestion + storage) accrue charges once the free-tier window closes, so destroy stacks when done to avoid unexpected charges. diff --git a/packages/examples/cdk.json b/packages/examples/cdk.json index 2e29e391..7b36a521 100644 --- a/packages/examples/cdk.json +++ b/packages/examples/cdk.json @@ -1,3 +1,6 @@ { - "app": "node dist/bin/app.js" + "app": "node dist/bin/app.js", + "context": { + "@aws-cdk/core:defaultCrossStackReferences": "weak" + } } diff --git a/packages/examples/src/agent-volume-app.ts b/packages/examples/src/agent-volume-app.ts index 05689480..d9f5be1e 100644 --- a/packages/examples/src/agent-volume-app.ts +++ b/packages/examples/src/agent-volume-app.ts @@ -1,4 +1,4 @@ -import { App, Size, Stack } from "aws-cdk-lib"; +import { Size, Stack } from "aws-cdk-lib"; import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; import { InstanceClass, @@ -17,6 +17,7 @@ import { type VpcBuilderResult, } from "@composurecdk/ec2"; import { createTopicBuilder } from "@composurecdk/sns"; +import { exampleApp } from "./app-context.js"; /** * A VPC + EC2 instance with a persistent EBS data volume attached at @@ -37,7 +38,7 @@ import { createTopicBuilder } from "@composurecdk/sns"; * * NAT gateways are disabled to keep deploy/destroy fast and cheap. */ -export function createAgentVolumeApp(app = new App()) { +export function createAgentVolumeApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-AgentVolumeStack"); const { alerts } = compose( diff --git a/packages/examples/src/app-context.ts b/packages/examples/src/app-context.ts new file mode 100644 index 00000000..86c537ce --- /dev/null +++ b/packages/examples/src/app-context.ts @@ -0,0 +1,55 @@ +import { App, type AppProps } from "aws-cdk-lib"; + +/** + * The CDK context every example app synthesises with. + * + * `cdk.json` carries the same map so the CLI (`cdk synth`, `cdk deploy`) and + * the tests agree — the CLI passes its context through `CDK_CONTEXT_JSON`, + * which `App` applies *after* `props.context`, so an accidental divergence + * would silently give the tests one template and CI's deploy another. The + * `app-context` test asserts the two stay in sync. + * + * Feature flags are deliberately declared rather than inherited. The examples + * are the pattern users copy, so whatever they leave implicit becomes the + * pattern people inherit — and CDK's unconfigured defaults shift between + * releases (see issue #341). + */ +export const EXAMPLE_CONTEXT: Record = { + /** + * Cross-stack references are weak: the consumer reads the producer's output + * with `Fn::GetStackOutput` instead of importing a CloudFormation export. + * + * `"weak"` is CDK's recommended value; unconfigured, CDK behaves as + * `"strong"` and warns that the choice was never made. Strong references + * block the producer from being deleted or from removing an export while a + * consumer still reads it — real protection for a long-lived system, and + * exactly the wrong trade for stacks CI deploys and tears down on every run. + * + * The flag is read from the **consuming** stack's context and, despite what + * its own description says, is not limited to cross-region references: + * same-account same-region consumers resolve to `Fn::GetStackOutput` under + * `"weak"` too, which is what the multi-stack example's snapshot shows. + * + * An already-deployed system migrating from strong must stage it — + * `"both"`, deploy everywhere, then `"weak"` — because the producer's + * exports cannot disappear while consumers still import them. Setting + * `"weak"` directly is only safe from a clean slate, which is what the + * examples are. + */ + "@aws-cdk/core:defaultCrossStackReferences": "weak", +}; + +/** + * Creates an `App` carrying {@link EXAMPLE_CONTEXT}. + * + * Every example defaults its `app` parameter to this rather than to a bare + * `new App()`, so a stack synthesised by its own test sees the same context + * the CLI supplies when CI deploys it. + * + * @param props - `App` props to merge; a `context` entry here wins over + * {@link EXAMPLE_CONTEXT}. + * @returns A new `App` configured with the examples' context. + */ +export function exampleApp(props: AppProps = {}): App { + return new App({ ...props, context: { ...EXAMPLE_CONTEXT, ...props.context } }); +} diff --git a/packages/examples/src/apps.ts b/packages/examples/src/apps.ts index 07104ba3..0ce70435 100644 --- a/packages/examples/src/apps.ts +++ b/packages/examples/src/apps.ts @@ -1,4 +1,5 @@ import { App } from "aws-cdk-lib"; +import { exampleApp } from "./app-context.js"; import { cleanDeskPolicy } from "./clean-desk-policy.js"; import { createAgentVolumeApp } from "./agent-volume-app.js"; import { createCrudApiApp } from "./crud-api-app.js"; @@ -20,7 +21,7 @@ import { createTaggedSystemApp } from "./tagged-system-app.js"; * tests that assert across all stacks at once. Register a new example here * and it is covered by both. */ -export function buildExampleApp(app = new App()): App { +export function buildExampleApp(app = exampleApp()): App { cleanDeskPolicy(app); createAgentVolumeApp(app); diff --git a/packages/examples/src/crud-api-app.ts b/packages/examples/src/crud-api-app.ts index 4d68160a..d5911251 100644 --- a/packages/examples/src/crud-api-app.ts +++ b/packages/examples/src/crud-api-app.ts @@ -1,4 +1,4 @@ -import { App, Stack } from "aws-cdk-lib"; +import { Stack } from "aws-cdk-lib"; import { AwsIntegration, PassthroughBehavior, @@ -10,6 +10,7 @@ import { createRestApiBuilder } from "@composurecdk/apigateway"; import { createTableBuilder, tableGrants, type TableBuilderResult } from "@composurecdk/dynamodb"; import { createServiceRoleBuilder, type RoleBuilderResult } from "@composurecdk/iam"; import { createKeyBuilder, type KeyBuilderResult } from "@composurecdk/kms"; +import { exampleApp } from "./app-context.js"; /** Every method here returns a bare `200` — no error mapping. A production * API would add `selectionPattern` integration responses (e.g. matching @@ -157,7 +158,7 @@ const DELETE_OPERATION = gadgetIntegration( * cannot infer — a principal decrypting ciphertext it fetched elsewhere, say — * and adding one here would be redundant permission, not extra safety. */ -export function createCrudApiApp(app = new App()) { +export function createCrudApiApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-CrudApiStack"); compose( diff --git a/packages/examples/src/dns-zone-app.ts b/packages/examples/src/dns-zone-app.ts index edd3534d..f56fa4d5 100644 --- a/packages/examples/src/dns-zone-app.ts +++ b/packages/examples/src/dns-zone-app.ts @@ -1,4 +1,4 @@ -import { App, Duration, Fn } from "aws-cdk-lib"; +import { Duration, Fn } from "aws-cdk-lib"; import { HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins"; import { Alpn, HttpsRecordValue } from "aws-cdk-lib/aws-route53"; import { compose, ref } from "@composurecdk/core"; @@ -28,6 +28,7 @@ import { TXT, zoneRecords, } from "@composurecdk/route53/zone"; +import { exampleApp } from "./app-context.js"; /** * A production-like public DNS zone, expressed in the BIND-style zone DSL. @@ -49,7 +50,7 @@ import { * - Composing the record set with a hosted zone and surfacing the delegation * name servers as a CloudFormation output */ -export function createDnsZoneApp(app = new App()): void { +export function createDnsZoneApp(app = exampleApp()): void { const { stack } = createStackBuilder() .description("Public DNS zone (DSL example)") // Route 53 query logging requires us-east-1 — see packages/route53/README.md. diff --git a/packages/examples/src/dual-function-app.ts b/packages/examples/src/dual-function-app.ts index b027e30b..336eac67 100644 --- a/packages/examples/src/dual-function-app.ts +++ b/packages/examples/src/dual-function-app.ts @@ -1,4 +1,4 @@ -import { App, Duration, Stack } from "aws-cdk-lib"; +import { Duration, Stack } from "aws-cdk-lib"; import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; import { Schedule } from "aws-cdk-lib/aws-events"; import { Code, Runtime, Tracing } from "aws-cdk-lib/aws-lambda"; @@ -7,6 +7,7 @@ import { alarmActionsPolicy } from "@composurecdk/cloudwatch"; import { createRuleBuilder, lambdaTarget } from "@composurecdk/events"; import { createFunctionBuilder, type FunctionBuilderResult } from "@composurecdk/lambda"; import { createTopicBuilder } from "@composurecdk/sns"; +import { exampleApp } from "./app-context.js"; /** * Two Lambda functions — an API handler and an async worker — composed @@ -24,7 +25,7 @@ import { createTopicBuilder } from "@composurecdk/sns"; * - Routing every alarm (function + rule) to the alert topic via * `alarmActionsPolicy` */ -export function createDualFunctionApp(app = new App()) { +export function createDualFunctionApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-DualFunctionStack"); const { alerts } = compose( diff --git a/packages/examples/src/dynamo-stream-processor-app.ts b/packages/examples/src/dynamo-stream-processor-app.ts index 294a0b2d..b348aece 100644 --- a/packages/examples/src/dynamo-stream-processor-app.ts +++ b/packages/examples/src/dynamo-stream-processor-app.ts @@ -1,10 +1,11 @@ -import { App, Duration, Stack } from "aws-cdk-lib"; +import { Duration, Stack } from "aws-cdk-lib"; import { AttributeType, StreamViewType } from "aws-cdk-lib/aws-dynamodb"; import { Code, Runtime } from "aws-cdk-lib/aws-lambda"; import { compose, ref } from "@composurecdk/core"; import { createTableV2Builder, type TableV2BuilderResult } from "@composurecdk/dynamodb"; import { createFunctionBuilder, dynamoEventSource } from "@composurecdk/lambda"; import { createQueueBuilder, type QueueBuilderResult } from "@composurecdk/sqs"; +import { exampleApp } from "./app-context.js"; /** * A DynamoDB table streaming change events to a Lambda processor, with a @@ -25,7 +26,7 @@ import { createQueueBuilder, type QueueBuilderResult } from "@composurecdk/sqs"; * alarms (`IteratorAge`, failed-invocation, dropped-event) once the source is * attached, and least-privilege `grantStreamRead` on the table's stream. */ -export function createDynamoStreamProcessorApp(app = new App()) { +export function createDynamoStreamProcessorApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-DynamoStreamProcessorStack"); compose( diff --git a/packages/examples/src/ec2-app.ts b/packages/examples/src/ec2-app.ts index 4f4c4b0d..f81f65e1 100644 --- a/packages/examples/src/ec2-app.ts +++ b/packages/examples/src/ec2-app.ts @@ -1,4 +1,4 @@ -import { App, Stack } from "aws-cdk-lib"; +import { Stack } from "aws-cdk-lib"; import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; import { type ISecurityGroup, @@ -20,6 +20,7 @@ import { type VpcBuilderResult, } from "@composurecdk/ec2"; import { createTopicBuilder } from "@composurecdk/sns"; +import { exampleApp } from "./app-context.js"; /** * A VPC + two explicit security groups + an EC2 bastion host + an SNS @@ -57,7 +58,7 @@ import { createTopicBuilder } from "@composurecdk/sns"; * service endpoints, or `Peer.anyIpv4()` if you only need internet * egress and accept the wider blast radius). */ -export function createEc2App(app = new App()) { +export function createEc2App(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-Ec2Stack"); const { alerts } = compose( diff --git a/packages/examples/src/mock-api-app.ts b/packages/examples/src/mock-api-app.ts index 424ad419..a638ba86 100644 --- a/packages/examples/src/mock-api-app.ts +++ b/packages/examples/src/mock-api-app.ts @@ -1,4 +1,3 @@ -import { App } from "aws-cdk-lib"; import { type Integration, type MethodOptions, @@ -8,6 +7,7 @@ import { import { compose } from "@composurecdk/core"; import { createRestApiBuilder } from "@composurecdk/apigateway"; import { createStackBuilder } from "@composurecdk/cloudformation"; +import { exampleApp } from "./app-context.js"; function jsonMock(statusCode: string, body: Record): [Integration, MethodOptions] { return [ @@ -49,7 +49,7 @@ function jsonMock(statusCode: string, body: Record): [Integrati * └── DELETE → { "id": "123", "deleted": true } * ``` */ -export function createMockApiApp(app = new App()) { +export function createMockApiApp(app = exampleApp()) { const { stack } = createStackBuilder() .description("A mock CRUD API for demonstration") .build(app, "ComposureCDK-MockApiStack"); diff --git a/packages/examples/src/multi-stack-app.ts b/packages/examples/src/multi-stack-app.ts index 4bb2a848..80ffaf4d 100644 --- a/packages/examples/src/multi-stack-app.ts +++ b/packages/examples/src/multi-stack-app.ts @@ -1,10 +1,11 @@ -import { App, Duration } from "aws-cdk-lib"; +import { Duration } from "aws-cdk-lib"; import { LambdaIntegration } from "aws-cdk-lib/aws-apigateway"; import { Code, Runtime } from "aws-cdk-lib/aws-lambda"; import { compose, ref } from "@composurecdk/core"; import { createRestApiBuilder } from "@composurecdk/apigateway"; import { createStackBuilder } from "@composurecdk/cloudformation"; import { createFunctionBuilder, type FunctionBuilderResult } from "@composurecdk/lambda"; +import { exampleApp } from "./app-context.js"; /** * A REST API and its backing Lambda, split across two stacks using the @@ -12,11 +13,13 @@ import { createFunctionBuilder, type FunctionBuilderResult } from "@composurecdk * * Demonstrates: * - Routing components to different stacks via {@link ComposedSystem.withStacks} - * - Cross-stack references resolved automatically by CDK + * - Cross-stack references resolved automatically by CDK — weakly, via + * `Fn::GetStackOutput`, because the app's context sets + * `@aws-cdk/core:defaultCrossStackReferences` (see `app-context.ts`) * - Components without a stack mapping fall back to the default scope * - `.copy()` for deriving stack variants from a shared base configuration */ -export function createMultiStackApp(app = new App()) { +export function createMultiStackApp(app = exampleApp()) { const baseStack = createStackBuilder().tag("project", "multi-stack-example"); const { stack: serviceStack } = baseStack diff --git a/packages/examples/src/neptune-graph-app.ts b/packages/examples/src/neptune-graph-app.ts index b0c90f83..253d9a3e 100644 --- a/packages/examples/src/neptune-graph-app.ts +++ b/packages/examples/src/neptune-graph-app.ts @@ -1,4 +1,4 @@ -import { App, Stack } from "aws-cdk-lib"; +import { Stack } from "aws-cdk-lib"; import { InstanceClass, InstanceSize, @@ -22,6 +22,7 @@ import { } from "@composurecdk/ec2"; import { createClusterBuilder } from "@composurecdk/neptune"; import { InstanceType as NeptuneInstanceType } from "@aws-cdk/aws-neptune-alpha"; +import { exampleApp } from "./app-context.js"; /** * A VPC + a serverless Amazon Neptune cluster + an SSM-managed bastion that @@ -56,7 +57,7 @@ import { InstanceType as NeptuneInstanceType } from "@aws-cdk/aws-neptune-alpha" * this is a real-system exemplar. The CI deploy/destroy cycle flips those to * allow teardown via `cleanDeskPolicy`, applied at the app level. */ -export function createNeptuneGraphApp(app = new App()) { +export function createNeptuneGraphApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-NeptuneGraphStack"); const ssmEndpointBase = createInterfaceEndpointBuilder() diff --git a/packages/examples/src/openapi-petstore-app.ts b/packages/examples/src/openapi-petstore-app.ts index d34c5403..85645c9f 100644 --- a/packages/examples/src/openapi-petstore-app.ts +++ b/packages/examples/src/openapi-petstore-app.ts @@ -1,4 +1,4 @@ -import { App, Aws } from "aws-cdk-lib"; +import { Aws } from "aws-cdk-lib"; import { Code, Runtime } from "aws-cdk-lib/aws-lambda"; import { compose, ref } from "@composurecdk/core"; import { createSpecRestApiBuilder, inlineSpecDefinition } from "@composurecdk/apigateway"; @@ -9,6 +9,7 @@ import { functionGrants, type FunctionBuilderResult, } from "@composurecdk/lambda"; +import { exampleApp } from "./app-context.js"; /** The names the specification's integration refers its backend by — the shape * a model-first export takes, written before the infrastructure exists. */ @@ -177,7 +178,7 @@ const petstoreSpec = { * └── GET → { id, name: "Fido", … } (Lambda, via aws_proxy) * ``` */ -export function createOpenApiPetstoreApp(app = new App()) { +export function createOpenApiPetstoreApp(app = exampleApp()) { const { stack } = createStackBuilder() .description("A PetStore API defined by an OpenAPI specification") .build(app, "ComposureCDK-OpenApiPetstoreStack"); diff --git a/packages/examples/src/order-processor-app.ts b/packages/examples/src/order-processor-app.ts index eb4dfa9a..66e74709 100644 --- a/packages/examples/src/order-processor-app.ts +++ b/packages/examples/src/order-processor-app.ts @@ -1,4 +1,4 @@ -import { App, Duration, Stack } from "aws-cdk-lib"; +import { Duration, Stack } from "aws-cdk-lib"; import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; import { Code, Runtime } from "aws-cdk-lib/aws-lambda"; import { SqsSubscription } from "aws-cdk-lib/aws-sns-subscriptions"; @@ -7,6 +7,7 @@ import { alarmActionsPolicy } from "@composurecdk/cloudwatch"; import { createFunctionBuilder, sqsEventSource } from "@composurecdk/lambda"; import { createTopicBuilder } from "@composurecdk/sns"; import { createQueueBuilder, type QueueBuilderResult } from "@composurecdk/sqs"; +import { exampleApp } from "./app-context.js"; /** * Order intake fanned out through SNS to an SQS work queue, which feeds a @@ -41,7 +42,7 @@ import { createQueueBuilder, type QueueBuilderResult } from "@composurecdk/sqs"; * - Composing the queues alongside `createTopicBuilder` and routing all * alarm actions through `alarmActionsPolicy` */ -export function createOrderProcessorApp(app = new App()) { +export function createOrderProcessorApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-OrderProcessorStack"); const { alerts } = compose( diff --git a/packages/examples/src/static-website/app.ts b/packages/examples/src/static-website/app.ts index cf8f8d73..bda75e74 100644 --- a/packages/examples/src/static-website/app.ts +++ b/packages/examples/src/static-website/app.ts @@ -1,4 +1,4 @@ -import { App, Duration } from "aws-cdk-lib"; +import { Duration } from "aws-cdk-lib"; import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions"; import { Source } from "aws-cdk-lib/aws-s3-deployment"; import { HttpOrigin, S3BucketOrigin } from "aws-cdk-lib/aws-cloudfront-origins"; @@ -16,6 +16,7 @@ import { createDistributionBuilder, type DistributionBuilderResult, } from "@composurecdk/cloudfront"; +import { exampleApp } from "../app-context.js"; /** * A static website hosted on S3 with CloudFront CDN, composed into a single stack. @@ -45,7 +46,7 @@ import { * Control * ``` */ -export function createStaticWebsiteApp(app = new App()) { +export function createStaticWebsiteApp(app = exampleApp()) { const { stack } = createStackBuilder() .description("Static website hosted on S3 with CloudFront CDN") .build(app, "ComposureCDK-StaticWebsiteStack"); diff --git a/packages/examples/src/tagged-system-app.ts b/packages/examples/src/tagged-system-app.ts index 21f7cdae..818f01c2 100644 --- a/packages/examples/src/tagged-system-app.ts +++ b/packages/examples/src/tagged-system-app.ts @@ -1,4 +1,4 @@ -import { App, Stack } from "aws-cdk-lib"; +import { Stack } from "aws-cdk-lib"; import { InstanceClass, InstanceSize, @@ -10,6 +10,7 @@ import { compose, ref } from "@composurecdk/core"; import { tags } from "@composurecdk/cloudformation"; import { createInstanceBuilder, createVpcBuilder, type VpcBuilderResult } from "@composurecdk/ec2"; import { createBucketBuilder } from "@composurecdk/s3"; +import { exampleApp } from "./app-context.js"; /** * A two-component system that demonstrates both layers of the tagging API. @@ -30,7 +31,7 @@ import { createBucketBuilder } from "@composurecdk/s3"; * `.tag("Owner", "...")` and the instance's tag will take precedence over * the system-wide value while siblings still get the system value. */ -export function createTaggedSystemApp(app = new App()) { +export function createTaggedSystemApp(app = exampleApp()) { const stack = new Stack(app, "ComposureCDK-TaggedSystemStack"); compose( diff --git a/packages/examples/test/__snapshots__/multi-stack-app.test.ts.snap b/packages/examples/test/__snapshots__/multi-stack-app.test.ts.snap index 359e7944..a11568a5 100644 --- a/packages/examples/test/__snapshots__/multi-stack-app.test.ts.snap +++ b/packages/examples/test/__snapshots__/multi-stack-app.test.ts.snap @@ -142,7 +142,7 @@ exports[`multi-stack-app > matches the expected api stack template 1`] = ` "Type": "AWS::IAM::Role", "UpdateReplacePolicy": "Retain", }, - "MultiStackAppapiDeploymentA87644C95de9b18a41ec02ef96026c26a931afa1": { + "MultiStackAppapiDeploymentA87644C9403275c3e129857c8559f39b95a8c426": { "DependsOn": [ "MultiStackAppapiGET2EF1C40F", ], @@ -172,7 +172,7 @@ exports[`multi-stack-app > matches the expected api stack template 1`] = ` "Format": "{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","user":"$context.identity.user","caller":"$context.identity.caller","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}", }, "DeploymentId": { - "Ref": "MultiStackAppapiDeploymentA87644C95de9b18a41ec02ef96026c26a931afa1", + "Ref": "MultiStackAppapiDeploymentA87644C9403275c3e129857c8559f39b95a8c426", }, "MethodSettings": [ { @@ -230,7 +230,13 @@ exports[`multi-stack-app > matches the expected api stack template 1`] = ` }, ":lambda:path/2015-03-31/functions/", { - "Fn::ImportValue": "ComposureCDK-MultiStackServiceStack:ExportsOutputFnGetAttMultiStackApphandler260E0508Arn47C6D6F6", + "Fn::GetStackOutput": { + "OutputName": "PublishOutputFnGetAttMultiStackApphandler260E0508Arn8EFCBAFB", + "Region": { + "Ref": "AWS::Region", + }, + "StackName": "ComposureCDK-MultiStackServiceStack", + }, }, "/invocations", ], @@ -253,7 +259,13 @@ exports[`multi-stack-app > matches the expected api stack template 1`] = ` "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { - "Fn::ImportValue": "ComposureCDK-MultiStackServiceStack:ExportsOutputFnGetAttMultiStackApphandler260E0508Arn47C6D6F6", + "Fn::GetStackOutput": { + "OutputName": "PublishOutputFnGetAttMultiStackApphandler260E0508Arn8EFCBAFB", + "Region": { + "Ref": "AWS::Region", + }, + "StackName": "ComposureCDK-MultiStackServiceStack", + }, }, "Principal": "apigateway.amazonaws.com", "SourceArn": { @@ -291,7 +303,13 @@ exports[`multi-stack-app > matches the expected api stack template 1`] = ` "Properties": { "Action": "lambda:InvokeFunction", "FunctionName": { - "Fn::ImportValue": "ComposureCDK-MultiStackServiceStack:ExportsOutputFnGetAttMultiStackApphandler260E0508Arn47C6D6F6", + "Fn::GetStackOutput": { + "OutputName": "PublishOutputFnGetAttMultiStackApphandler260E0508Arn8EFCBAFB", + "Region": { + "Ref": "AWS::Region", + }, + "StackName": "ComposureCDK-MultiStackServiceStack", + }, }, "Principal": "apigateway.amazonaws.com", "SourceArn": { @@ -424,10 +442,7 @@ exports[`multi-stack-app > matches the expected service stack template 1`] = ` { "Description": "Service resources for multi-stack example", "Outputs": { - "ExportsOutputFnGetAttMultiStackApphandler260E0508Arn47C6D6F6": { - "Export": { - "Name": "ComposureCDK-MultiStackServiceStack:ExportsOutputFnGetAttMultiStackApphandler260E0508Arn47C6D6F6", - }, + "PublishOutputFnGetAttMultiStackApphandler260E0508Arn8EFCBAFB": { "Value": { "Fn::GetAtt": [ "MultiStackApphandler260E0508", diff --git a/packages/examples/test/app-context.test.ts b/packages/examples/test/app-context.test.ts new file mode 100644 index 00000000..a7785a4c --- /dev/null +++ b/packages/examples/test/app-context.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { App } from "aws-cdk-lib"; +import { Template } from "aws-cdk-lib/assertions"; +import { EXAMPLE_CONTEXT, exampleApp } from "../src/app-context.js"; +import { createMultiStackApp } from "../src/multi-stack-app.js"; + +/** + * `cdk.json` and {@link EXAMPLE_CONTEXT} are two copies of the same decision: + * the CLI reads the first, the tests synthesise with the second. CDK applies + * CLI context *after* `App`'s `context` prop, so a divergence would not fail + * anywhere on its own — the tests would simply assert a template CI never + * deploys. + */ +describe("example app context", () => { + const cdkJson = JSON.parse(readFileSync(new URL("../cdk.json", import.meta.url), "utf8")) as { + context?: Record; + }; + + it("matches the context declared in cdk.json", () => { + expect(cdkJson.context).toEqual(EXAMPLE_CONTEXT); + }); + + it("applies the context to the apps it creates", () => { + for (const [key, value] of Object.entries(EXAMPLE_CONTEXT)) { + expect(exampleApp().node.tryGetContext(key)).toEqual(value); + } + }); + + it("lets a caller override an entry", () => { + const app = exampleApp({ context: { "@aws-cdk/core:defaultCrossStackReferences": "strong" } }); + + expect(app.node.tryGetContext("@aws-cdk/core:defaultCrossStackReferences")).toBe("strong"); + }); +}); + +/** The annotation id CDK tags its unconfigured-strength warning with (issue #341). */ +const STRENGTH_UNSET_WARNING = "@aws-cdk/core:crossStackReferencesDefaultStrong"; + +function warningsFor(app: App, stackName: string): string { + return app + .synth() + .getStackByName(stackName) + .messages.map(({ entry }) => entry.data) + .filter((data) => typeof data === "string") + .join("\n"); +} + +/** + * The multi-stack example is the only one that references across stacks, so it + * is the one that shows the flag working — and the one that would surface a + * regression if the context ever stopped reaching a synthesising app. + */ +describe("cross-stack reference strength", () => { + it("resolves the multi-stack example's references weakly", () => { + const { apiStack } = createMultiStackApp(); + const template = JSON.stringify(Template.fromStack(apiStack).toJSON()); + + expect(template).toContain("Fn::GetStackOutput"); + expect(template).not.toContain("Fn::ImportValue"); + }); + + it("synthesises without the unconfigured-strength warning", () => { + const { apiStack } = createMultiStackApp(); + + expect(warningsFor(App.of(apiStack) as App, apiStack.stackName)).not.toContain( + STRENGTH_UNSET_WARNING, + ); + }); + + it("still warns when an app is built without the context", () => { + const { apiStack } = createMultiStackApp(new App()); + + expect(warningsFor(App.of(apiStack) as App, apiStack.stackName)).toContain( + STRENGTH_UNSET_WARNING, + ); + }); +}); diff --git a/packages/examples/test/ascii-templates.test.ts b/packages/examples/test/ascii-templates.test.ts index ab354edb..eab74f0b 100644 --- a/packages/examples/test/ascii-templates.test.ts +++ b/packages/examples/test/ascii-templates.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { App } from "aws-cdk-lib"; +import { exampleApp } from "../src/app-context.js"; import { buildExampleApp } from "../src/apps.js"; /** Enough offenders to see the pattern; a UTF-8 blob would otherwise dump thousands. */ @@ -20,7 +20,7 @@ const MAX_REPORTED = 5; */ describe("synthesised example templates", () => { it("contain only ASCII", () => { - const offenders = buildExampleApp(new App({ outdir: "cdk.out/ascii-templates" })) + const offenders = buildExampleApp(exampleApp({ outdir: "cdk.out/ascii-templates" })) .synth() .stacks.flatMap(({ stackName, template }) => { const json = JSON.stringify(template);