@fjall/components-infrastructure 7.3.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/lib/app.d.ts +12 -0
  2. package/dist/lib/app.js +25 -5
  3. package/dist/lib/config/aws/ipam.js +4 -3
  4. package/dist/lib/patterns/aws/cdn.d.ts +7 -1
  5. package/dist/lib/patterns/aws/cdn.js +9 -2
  6. package/dist/lib/patterns/aws/interfaces/domain.d.ts +6 -0
  7. package/dist/lib/patterns/aws/storage.js +3 -0
  8. package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +9 -4
  9. package/dist/lib/patterns/aws/targets/fjallTargets.js +11 -5
  10. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +33 -40
  11. package/dist/lib/patterns/aws/targets/targetResolution.js +42 -52
  12. package/dist/lib/resources/aws/backup/backupVault.js +2 -0
  13. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +9 -0
  14. package/dist/lib/resources/aws/cdn/cloudFront.js +2 -1
  15. package/dist/lib/resources/aws/compute/ecs.js +4 -3
  16. package/dist/lib/resources/aws/compute/ecsNetworking.js +27 -9
  17. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +2 -1
  18. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +6 -2
  19. package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +2 -1
  20. package/dist/lib/resources/aws/database/rdsHelpers.js +12 -2
  21. package/dist/lib/resources/aws/database/rdsInstance.js +2 -1
  22. package/dist/lib/resources/aws/database/rdsProxyOutput.js +2 -1
  23. package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
  24. package/dist/lib/resources/aws/networking/ipamPool.js +2 -1
  25. package/dist/lib/resources/aws/networking/vpc.js +6 -1
  26. package/dist/lib/resources/aws/secrets/kms.d.ts +24 -7
  27. package/dist/lib/resources/aws/secrets/kms.js +27 -5
  28. package/dist/lib/resources/aws/secrets/parameter.js +3 -1
  29. package/dist/lib/resources/aws/secrets/secret.js +4 -1
  30. package/dist/lib/resources/aws/storage/ecr.d.ts +6 -0
  31. package/dist/lib/resources/aws/storage/ecr.js +8 -4
  32. package/dist/lib/resources/aws/storage/s3.d.ts +10 -0
  33. package/dist/lib/resources/aws/storage/s3.js +9 -4
  34. package/dist/lib/utils/albAliasTargetRegistry.d.ts +44 -0
  35. package/dist/lib/utils/albAliasTargetRegistry.js +69 -0
  36. package/dist/lib/utils/env.d.ts +9 -0
  37. package/dist/lib/utils/env.js +12 -0
  38. package/dist/lib/utils/exportNaming.d.ts +50 -0
  39. package/dist/lib/utils/exportNaming.js +52 -0
  40. package/dist/lib/utils/orgConfigParser.d.ts +3 -8
  41. package/dist/lib/utils/orgConfigParser.js +4 -14
  42. package/dist/lib/utils/removalPolicy.d.ts +11 -3
  43. package/dist/lib/utils/removalPolicy.js +20 -5
  44. package/package.json +5 -4
@@ -11,6 +11,9 @@ import { SecurityGroup } from "../networking/securityGroup.js";
11
11
  import { DNS_APEX, isManagedDomainBinding, isWithinZone } from "../../../utils/domainTypes.js";
12
12
  import { readInjectedManagedDomainBinding } from "../../../utils/managedDomainContext.js";
13
13
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
14
+ import { stackScopedExportName } from "../../../utils/exportNaming.js";
15
+ import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
16
+ import { albDnsExportName, albHostedZoneIdExportName } from "@fjall/util";
14
17
  import { getSafeZoneName, toPascalCase } from "../../../utils/capitaliseString.js";
15
18
  import { FjallLogger } from "../../../utils/validationLogger.js";
16
19
  import { isServiceEc2 } from "./ecsTaskDefinition.js";
@@ -44,27 +47,42 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
44
47
  });
45
48
  new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerDnsName`, {
46
49
  key: `${ctx.outputName}LoadBalancerDnsName`,
47
- exportName: `${props.clusterName}LoadBalancerDnsName`,
50
+ exportName: stackScopedExportName(ctx.scope, `${props.clusterName}LoadBalancerDnsName`),
48
51
  value: loadBalancer.loadBalancerDnsName
49
52
  });
50
- // Cross-stack exports consumed by the Domain construct's `fjallApp(<name>)`
51
- // alias target. Names mirror the consumer contract in `cross-system-parity`.
53
+ // Cross-stack exports consumed by the Domain construct's
54
+ // `fjallApp(<app>, <compute>)` alias target. Keyed on the APP name not
55
+ // `ctx.outputName` (the compute construct id) — because `fjall add compute`
56
+ // names the compute from the user's --name, so an app "my-app" with a
57
+ // compute "api" exported `ApiAlbDnsName` while the Domain asked for
58
+ // `MyAppAlbDnsName`. Qualified by the compute too, because an app may front
59
+ // several ALBs and the app-keyed name alone collides; the unqualified
60
+ // app-level pair is emitted once by App.synth, and only when there is
61
+ // exactly one (albAliasTargetRegistry.ts).
62
+ const targetAppName = props.appName ?? props.clusterName;
63
+ registerAlbAliasTarget({
64
+ appName: targetAppName,
65
+ computeName: props.clusterName,
66
+ scope: ctx.scope,
67
+ dnsName: loadBalancer.loadBalancerDnsName,
68
+ hostedZoneId: loadBalancer.loadBalancerCanonicalHostedZoneId
69
+ });
52
70
  new CfnOutput(ctx.scope, `${ctx.outputName}AlbDnsName`, {
53
71
  key: `${ctx.outputName}AlbDnsName`,
54
- exportName: `${ctx.outputName}AlbDnsName`,
72
+ exportName: albDnsExportName(targetAppName, props.clusterName),
55
73
  value: loadBalancer.loadBalancerDnsName,
56
74
  description: `ALB DNS name for ${props.clusterName} (consumed by Domain alias targets)`
57
75
  });
58
76
  new CfnOutput(ctx.scope, `${ctx.outputName}AlbHostedZoneId`, {
59
77
  key: `${ctx.outputName}AlbHostedZoneId`,
60
- exportName: `${ctx.outputName}AlbHostedZoneId`,
78
+ exportName: albHostedZoneIdExportName(targetAppName, props.clusterName),
61
79
  value: loadBalancer.loadBalancerCanonicalHostedZoneId,
62
80
  description: `ALB canonical hosted zone ID for ${props.clusterName}`
63
81
  });
64
82
  const customDomain = props.cluster?.domain || props.cluster?.domainConfig?.domainName;
65
83
  new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerUrl`, {
66
84
  key: `${ctx.outputName}LoadBalancerUrl`,
67
- exportName: `${props.clusterName}LoadBalancerUrl`,
85
+ exportName: stackScopedExportName(ctx.scope, `${props.clusterName}LoadBalancerUrl`),
68
86
  value: customDomain
69
87
  ? `https://${customDomain}`
70
88
  : `http://${loadBalancer.loadBalancerDnsName}`,
@@ -73,7 +91,7 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
73
91
  // Export load balancer ARN for monitoring
74
92
  new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerArn`, {
75
93
  key: `${ctx.outputName}LoadBalancerArn`,
76
- exportName: `${props.clusterName}LoadBalancerArn`,
94
+ exportName: stackScopedExportName(ctx.scope, `${props.clusterName}LoadBalancerArn`),
77
95
  value: loadBalancer.loadBalancerArn,
78
96
  description: `Load Balancer ARN for ${props.clusterName}`
79
97
  });
@@ -469,13 +487,13 @@ export function addDirectAccessOutputs(ctx, autoScalingGroup) {
469
487
  3000;
470
488
  new CfnOutput(ctx.scope, `${ctx.outputName}AutoScalingGroupName`, {
471
489
  key: `${ctx.outputName}AutoScalingGroupName`,
472
- exportName: `${props.clusterName}AutoScalingGroupName`,
490
+ exportName: stackScopedExportName(ctx.scope, `${props.clusterName}AutoScalingGroupName`),
473
491
  value: autoScalingGroup.autoScalingGroupName,
474
492
  description: `Run: aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names <name> to find instance IP`
475
493
  });
476
494
  new CfnOutput(ctx.scope, `${ctx.outputName}DirectAccessPort`, {
477
495
  key: `${ctx.outputName}DirectAccessPort`,
478
- exportName: `${props.clusterName}DirectAccessPort`,
496
+ exportName: stackScopedExportName(ctx.scope, `${props.clusterName}DirectAccessPort`),
479
497
  value: String(containerPort),
480
498
  description: `Access your app at http://<EC2-PUBLIC-IP>:${containerPort}`
481
499
  });
@@ -10,6 +10,7 @@ import { SecurityGroup } from "../networking/securityGroup.js";
10
10
  import { Ec2Instance } from "./ec2.js";
11
11
  import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
12
12
  import { toPascalCase } from "../../../utils/capitaliseString.js";
13
+ import { stackScopedExportName } from "../../../utils/exportNaming.js";
13
14
  import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CAPACITY, DEFAULT_EC2_INSTANCE_MONITORING, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, DEFAULT_HEALTH_CHECK_GRACE_SECONDS, DEFAULT_MIN_HEALTHY_PERCENT, DEFAULT_MAX_HEALTHY_PERCENT, DEFAULT_DESIRED_COUNT, inferAmiHardwareType } from "./ecsConstants.js";
14
15
  import { assertAnchorUnique, assertSharedAsgConfigMatches, assertValidCapacitySlot, getEc2ConfigKey, legacyAnchorFromKey, LEGACY_KEY_FIELDS, resolveCapacityAnchor, resolveCapacitySlot } from "./ecsCapacityConfig.js";
15
16
  import { lookupCapacityIdentityPin } from "../../../utils/capacityIdentityContext.js";
@@ -308,7 +309,7 @@ export function createService(ctx, serviceName, serviceProps, taskDefinition, as
308
309
  }
309
310
  new CfnOutput(ctx.scope, `${ctx.outputName}${toPascalCase(serviceName)}ServiceArn`, {
310
311
  key: `${ctx.outputName}${toPascalCase(serviceName)}ServiceArn`,
311
- exportName: `${ctx.props.clusterName}${serviceName}ServiceArn`,
312
+ exportName: stackScopedExportName(ctx.scope, `${ctx.props.clusterName}${serviceName}ServiceArn`),
312
313
  value: service.serviceArn,
313
314
  description: `ECS Service ARN for ${serviceName}`
314
315
  });
@@ -159,9 +159,13 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
159
159
  }
160
160
  }
161
161
  if (containerConfig.secrets && containerConfig.secrets.length > 0) {
162
+ // `secrets` is a set of names: a repeated entry (hand-merged arrays,
163
+ // shared-const spread overlap) must not construct a second SSM import —
164
+ // the construct id embeds the name, and a duplicate id fails synth.
165
+ const uniqueSecretNames = [...new Set(containerConfig.secrets)];
162
166
  if (containerConfig.secretsImport) {
163
167
  const secretsImportKeys = Object.keys(containerConfig.secretsImport);
164
- const duplicateKeys = containerConfig.secrets.filter((key) => secretsImportKeys.includes(key));
168
+ const duplicateKeys = uniqueSecretNames.filter((key) => secretsImportKeys.includes(key));
165
169
  if (duplicateKeys.length > 0) {
166
170
  throw new Error(`Container '${containerConfig.name}' in service '${serviceName}' has duplicate secret keys ` +
167
171
  `defined in both secrets and secretsImport: ${duplicateKeys.join(", ")}. ` +
@@ -169,7 +173,7 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
169
173
  }
170
174
  }
171
175
  const ssmSecretsPath = deriveSsmSecretsPath(ctx.props, serviceName, serviceProps.ssmSecretsPath);
172
- for (const secretName of containerConfig.secrets) {
176
+ for (const secretName of uniqueSecretNames) {
173
177
  validateSecretName(secretName, `container '${containerConfig.name}' of service '${serviceName}'`);
174
178
  const paramPath = `${ssmSecretsPath}/${secretName}`;
175
179
  const param = StringParameter.fromSecureStringParameterAttributes(ctx.scope, `${ctx.props.clusterName}${serviceName}${containerConfig.name}${secretName}SsmParam`, { parameterName: paramPath });
@@ -3,6 +3,7 @@ import { CfnGlobalCluster } from "aws-cdk-lib/aws-rds";
3
3
  import { Construct } from "constructs";
4
4
  import { RdsAurora } from "./rdsAurora.js";
5
5
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
6
+ import { stackScopedExportName } from "../../../utils/exportNaming.js";
6
7
  import { toPascalCase } from "../../../utils/capitaliseString.js";
7
8
  export class RdsAuroraGlobal extends Construct {
8
9
  connections;
@@ -133,7 +134,7 @@ export class RdsAuroraGlobal extends Construct {
133
134
  const outputName = toPascalCase(this.props.databaseName);
134
135
  new CfnOutput(this, `${outputName}GlobalClusterIdentifierOutput`, {
135
136
  value: this.globalId,
136
- exportName: `${outputName}GlobalClusterIdentifier`
137
+ exportName: stackScopedExportName(this, `${outputName}GlobalClusterIdentifier`)
137
138
  });
138
139
  }
139
140
  getCredentials() {
@@ -32,7 +32,13 @@ const MULTI_USER_ROTATION_APPLICATIONS = {
32
32
  */
33
33
  export function resolveStorageEncryptionKey(scope, databaseName, storageKey) {
34
34
  if (isCMKRequested(storageKey)) {
35
- return new CustomerManagedKey(scope, `${databaseName}ClusterEncryptionKey`, { aliasName: ResourceNaming.storageEncryptionKeyAlias(databaseName) }).key;
35
+ return new CustomerManagedKey(scope, `${databaseName}ClusterEncryptionKey`, {
36
+ aliasName: ResourceNaming.storageEncryptionKeyAlias(databaseName),
37
+ // RDS is RemovalPolicy.SNAPSHOT, so deleting the stack leaves a final
38
+ // snapshot behind. Destroying its storage key would make that snapshot
39
+ // permanently unrestorable.
40
+ protects: "outlives-stack"
41
+ }).key;
36
42
  }
37
43
  if (isAwsManagedKey(storageKey) || storageKey === undefined) {
38
44
  return undefined;
@@ -45,7 +51,11 @@ export function resolveStorageEncryptionKey(scope, databaseName, storageKey) {
45
51
  */
46
52
  export function resolvePerformanceInsightsKey(scope, databaseName, piEnabled, encryptionKey) {
47
53
  if (piEnabled && isCMKRequested(encryptionKey)) {
48
- return new CustomerManagedKey(scope, `${databaseName}PerformanceInsightsKey`, { aliasName: ResourceNaming.insightsKeyAlias(databaseName) }).key;
54
+ return new CustomerManagedKey(scope, `${databaseName}PerformanceInsightsKey`, {
55
+ aliasName: ResourceNaming.insightsKeyAlias(databaseName),
56
+ // Performance-insights data dies with the instance.
57
+ protects: "stack-scoped"
58
+ }).key;
49
59
  }
50
60
  return undefined;
51
61
  }
@@ -237,7 +237,8 @@ export class RdsInstance extends Construct {
237
237
  const { piEnabled, piConfig } = resolveDatabaseInsights(props.databaseInsights);
238
238
  const readReplicaPerformanceInsightsKey = piEnabled && isCMKRequested(piConfig?.encryptionKey)
239
239
  ? new CustomerManagedKey(this, `${this.databaseNameValue}ReadReplicaReaderInsightsKey`, {
240
- aliasName: ResourceNaming.readReplicaInsightsKeyAlias(this.databaseNameValue)
240
+ aliasName: ResourceNaming.readReplicaInsightsKeyAlias(this.databaseNameValue),
241
+ protects: "stack-scoped"
241
242
  }).key
242
243
  : undefined;
243
244
  this.readReplicaSecurityGroup = new SecurityGroup(this, `${this.databaseNameValue}ReadReplicaSecurityGroup`, {
@@ -1,5 +1,6 @@
1
1
  import { CfnOutput } from "aws-cdk-lib";
2
2
  import { toPascalCase } from "../../../utils/capitaliseString.js";
3
+ import { stackScopedExportName } from "../../../utils/exportNaming.js";
3
4
  /**
4
5
  * Add a CfnOutput for the RDS proxy endpoint.
5
6
  * Shared by RdsAurora and RdsInstance constructs.
@@ -8,7 +9,7 @@ export function addProxyCfnOutput(scope, constructId, databaseName, proxy) {
8
9
  const outputName = toPascalCase(databaseName);
9
10
  new CfnOutput(scope, `${outputName}ProxyEndpointOutput`, {
10
11
  key: `${outputName}ProxyEndpoint`,
11
- exportName: `${outputName}ProxyEndpoint`,
12
+ exportName: stackScopedExportName(scope, `${outputName}ProxyEndpoint`),
12
13
  value: proxy.endpoint
13
14
  });
14
15
  }
@@ -29,6 +29,9 @@ export class Trail extends Construct {
29
29
  ? RemovalPolicy.RETAIN
30
30
  : RemovalPolicy.DESTROY;
31
31
  this.encryptionKey = new CustomerManagedKey(this, `${id}CloudTrailEncryptionKey`, {
32
+ // Bound to the bucket's own policy below — the trail's log objects
33
+ // outlive the stack exactly when the bucket does.
34
+ protects: "outlives-stack",
32
35
  aliasName: `cmk/cloudtrail/${id}/encryptionKey`,
33
36
  removalPolicy: storagePolicy
34
37
  });
@@ -6,6 +6,7 @@ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import { ResourceShare } from "../utilities/resourceShare.js";
7
7
  import { CustomResource } from "../utilities/customResource.js";
8
8
  import getAccountId from "../../../utils/getAccountId.js";
9
+ import { IPAM_PRIVATE_DEFAULT_SCOPE_EXPORT_NAME } from "../../../utils/exportNaming.js";
9
10
  import { accountConstructKey, findAccountNameCollision } from "../../../utils/capitaliseString.js";
10
11
  import { FjallLogger } from "../../../utils/validationLogger.js";
11
12
  import { formatIpamPairTagValue, IPAM_OPERATIONS_POOL_TAG_KEY } from "@fjall/util/aws";
@@ -20,7 +21,7 @@ export class IpamPool extends Construct {
20
21
  constructor(scope, id, props) {
21
22
  super(scope, id);
22
23
  // Get the default IPAM scope ID from props, or CFN import
23
- const ipamScopeId = props.ipamScope || Fn.importValue("ipamPrivateDefaultScopeId");
24
+ const ipamScopeId = props.ipamScope || Fn.importValue(IPAM_PRIVATE_DEFAULT_SCOPE_EXPORT_NAME);
24
25
  const regions = props.regions;
25
26
  if (!regions || regions.length === 0) {
26
27
  throw new Error("At least one region must be specified for IPAM pools");
@@ -27,9 +27,14 @@ export class Vpc extends ec2.Vpc {
27
27
  });
28
28
  this.hasNatGateways = natGateways !== 0;
29
29
  this.addInterfaceEndpoints(id, props, this.hasNatGateways);
30
+ // Export names are unique per account+region, so the id is load-bearing:
31
+ // without it a stack holding two VPCs emits the name twice and
32
+ // CloudFormation rejects the template. The `-vpc-cidr` suffix must stay
33
+ // last — the webapp finds a linked app's CIDR by ListExports + suffix
34
+ // filter (webapp app/.server/services/infrastructure/captureVpcCidr.ts).
30
35
  new CfnOutput(this, "VpcCidrBlock", {
31
36
  value: this.vpcCidrBlock,
32
- exportName: `${Stack.of(this).stackName}-vpc-cidr`
37
+ exportName: `${Stack.of(this).stackName}${id}-vpc-cidr`
33
38
  });
34
39
  }
35
40
  static gatewayEndpoints(props) {
@@ -1,20 +1,37 @@
1
1
  import { RemovalPolicy } from "aws-cdk-lib";
2
2
  import { Alias, Key } from "aws-cdk-lib/aws-kms";
3
3
  import { Construct } from "constructs";
4
+ export declare const KEY_PROTECTION_SCOPES: readonly ["stack-scoped", "outlives-stack"];
5
+ export type KeyProtectionScope = (typeof KEY_PROTECTION_SCOPES)[number];
4
6
  interface CustomerManagedKeyProps {
5
7
  description?: string;
6
8
  aliasName?: string;
7
9
  /**
8
- * Removal policy for the KMS key.
10
+ * Whether anything this key encrypts survives deletion of the stack that
11
+ * created it. Required, and deliberately not defaulted: a key destroyed
12
+ * while a resource it encrypted still exists makes that resource
13
+ * permanently unreadable, and nothing downstream can detect it.
9
14
  *
10
- * IMPORTANT: KMS keys cannot be immediately deleted. When RemovalPolicy.DESTROY
11
- * is used, the key will be scheduled for deletion with a 7-day waiting period
12
- * (AWS minimum). During this period, the key is disabled but can be recovered.
15
+ * - `"stack-scoped"` everything the key encrypts goes with the stack (an
16
+ * SSM parameter, a Secrets Manager secret, performance-insights data).
17
+ * The key follows the env-aware default (D17): production RETAINs, every
18
+ * other recognised stage DESTROYs. This is what stops a torn-down dev
19
+ * stack leaving a chargeable key behind forever.
20
+ * - `"outlives-stack"` — at least one encrypted resource survives (an RDS
21
+ * final snapshot under `RemovalPolicy.SNAPSHOT`, a retained audit bucket,
22
+ * a backup vault). The key RETAINs in every environment: ~$1/month is not
23
+ * a reason to render a surviving snapshot unrestorable.
13
24
  *
14
- * For production workloads, always use RETAIN (default) to prevent accidental
15
- * data loss - deleting a KMS key makes all encrypted data permanently unrecoverable.
25
+ * A caller that knows better may still override with `removalPolicy` — the
26
+ * teardown paths that delete the survivor first legitimately do.
27
+ */
28
+ readonly protects: KeyProtectionScope;
29
+ /**
30
+ * Explicit removal policy, overriding what `protects` would resolve to.
16
31
  *
17
- * @default RemovalPolicy.RETAIN
32
+ * Prefer binding it to the policy of the thing being encrypted rather than
33
+ * choosing independently — `CloudTrail` passes the same `storagePolicy` to
34
+ * its bucket and its key precisely so the two cannot drift apart.
18
35
  */
19
36
  removalPolicy?: RemovalPolicy;
20
37
  }
@@ -3,6 +3,22 @@ import { Alias, Key } from "aws-cdk-lib/aws-kms";
3
3
  import { Construct } from "constructs";
4
4
  import { toPascalCase } from "../../../utils/capitaliseString.js";
5
5
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
6
+ import { stackScopedExportName } from "../../../utils/exportNaming.js";
7
+ import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
8
+ export const KEY_PROTECTION_SCOPES = [
9
+ "stack-scoped",
10
+ "outlives-stack"
11
+ ];
12
+ /**
13
+ * How long a scheduled-for-deletion key stays recoverable.
14
+ *
15
+ * The window must cover the longest recovery affordance of anything the key
16
+ * protects, or the survivor becomes unreadable before its own recovery period
17
+ * expires. The longest in this codebase is Secrets Manager's 30-day recovery
18
+ * window, so the key matches it. (30 days is also AWS's own default and its
19
+ * maximum; the minimum is 7.)
20
+ */
21
+ const PENDING_DELETION_WINDOW = Duration.days(30);
6
22
  export class CustomerManagedKey extends Construct {
7
23
  key;
8
24
  alias;
@@ -10,17 +26,23 @@ export class CustomerManagedKey extends Construct {
10
26
  super(scope, id);
11
27
  // Sanitise id for CloudFormation output keys (must be alphanumeric)
12
28
  const outputName = toPascalCase(id);
29
+ const removalPolicy = props.removalPolicy ??
30
+ (props.protects === "outlives-stack"
31
+ ? RemovalPolicy.RETAIN
32
+ : toRemovalPolicy(envAwareRemovalPolicyDefault()));
13
33
  this.key = new Key(this, `${id}Key`, {
14
34
  description: props.description || `${id} KMS Key`,
15
- removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN,
16
- pendingWindow: props.removalPolicy === RemovalPolicy.DESTROY
17
- ? Duration.days(14)
35
+ removalPolicy,
36
+ // Keyed on the RESOLVED policy: reading props.removalPolicy here meant
37
+ // a key that resolved to DESTROY any other way got no window at all.
38
+ pendingWindow: removalPolicy === RemovalPolicy.DESTROY
39
+ ? PENDING_DELETION_WINDOW
18
40
  : undefined
19
41
  });
20
42
  new CfnOutput(this, `${outputName}KeyArn`, {
21
43
  key: `${outputName}Arn`,
22
44
  value: this.key.keyArn,
23
- exportName: `${outputName}KeyArn`
45
+ exportName: stackScopedExportName(this, `${outputName}KeyArn`)
24
46
  });
25
47
  this.alias = new Alias(this, `${id}KeyAlias`, {
26
48
  aliasName: props.aliasName || ResourceNaming.defaultKeyAlias(id),
@@ -29,7 +51,7 @@ export class CustomerManagedKey extends Construct {
29
51
  new CfnOutput(this, `${outputName}KeyAliasArn`, {
30
52
  key: `${outputName}AliasArn`,
31
53
  value: this.alias.aliasArn,
32
- exportName: `${outputName}KeyAliasArn`
54
+ exportName: stackScopedExportName(this, `${outputName}KeyAliasArn`)
33
55
  });
34
56
  }
35
57
  }
@@ -26,7 +26,9 @@ export class SecureStringParameter extends Construct {
26
26
  else {
27
27
  this.cmk = new CustomerManagedKey(this, `${id}Key`, {
28
28
  description: props.description || `${id} KMS Key`,
29
- aliasName: props.aliasName
29
+ aliasName: props.aliasName,
30
+ // The parameter is deleted with the stack; nothing it encrypts survives.
31
+ protects: "stack-scoped"
30
32
  });
31
33
  }
32
34
  this.parameter = new AwsCustomResource(this, `${id}SecureStringParameter`, {
@@ -72,7 +72,10 @@ export class Secret extends Construct {
72
72
  }
73
73
  // Create KMS key for new secrets only
74
74
  const customerManagedKey = new CustomerManagedKey(this, `${id}CustomerManagedKey`, {
75
- aliasName: props.aliasName ?? ResourceNaming.defaultKeyAlias(id)
75
+ aliasName: props.aliasName ?? ResourceNaming.defaultKeyAlias(id),
76
+ // The secret goes with the stack. Its 30-day recovery window is
77
+ // covered by the key's own pending-deletion window, not by retention.
78
+ protects: "stack-scoped"
76
79
  });
77
80
  this.secretsCustomerManagedKey = customerManagedKey;
78
81
  /**
@@ -7,6 +7,12 @@ interface EcrProps {
7
7
  /** Default IMMUTABLE (supply-chain guard). Dev-slot repos MUST pass MUTABLE —
8
8
  * every `fjall dev up` repushes the branch tag (BUG-22). */
9
9
  tagMutability?: TagMutability;
10
+ /**
11
+ * Overrides the env-aware default (D17: production → RETAIN, every other
12
+ * recognised environment → DESTROY). RETAIN outlives the stack; DESTROY
13
+ * takes the repository AND every image in it.
14
+ */
15
+ removalPolicy?: "DESTROY" | "RETAIN";
10
16
  }
11
17
  export declare class EcrFactory {
12
18
  static build(id: string, props?: EcrProps): (app: App, scope: Construct) => Ecr;
@@ -1,5 +1,6 @@
1
1
  import { Repository, TagMutability } from "aws-cdk-lib/aws-ecr";
2
2
  import { CfnOutput, RemovalPolicy } from "aws-cdk-lib";
3
+ import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
3
4
  export class EcrFactory {
4
5
  static build(id, props) {
5
6
  return (app, scope) => {
@@ -24,14 +25,17 @@ export class Ecr extends Repository {
24
25
  });
25
26
  }
26
27
  static getRepositoryProps(props) {
27
- // Gotcha: DESTROY + emptyOnDelete:true silently wipes every pushed image
28
- // on stack teardown — next deploy pulls "not found" for any referenced tag.
28
+ const destroy = (props?.removalPolicy ?? envAwareRemovalPolicyDefault()) === "DESTROY";
29
29
  return {
30
30
  ...(props?.repositoryName && { repositoryName: props.repositoryName }),
31
31
  imageScanOnPush: true,
32
32
  imageTagMutability: props?.tagMutability ?? TagMutability.IMMUTABLE,
33
- emptyOnDelete: false,
34
- removalPolicy: RemovalPolicy.RETAIN
33
+ // CloudFormation refuses to delete a repository still holding images, so
34
+ // DESTROY with `false` is not a gentler DESTROY — it fails the stack
35
+ // delete outright. Gotcha: DESTROY wipes every pushed image, so a
36
+ // redeploy pulls "not found" for any tag the old repository held.
37
+ emptyOnDelete: destroy,
38
+ removalPolicy: destroy ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN
35
39
  };
36
40
  }
37
41
  static build(id, props) {
@@ -36,6 +36,16 @@ export interface S3BucketProps extends BucketProps {
36
36
  backupVaultTier?: BackupTier;
37
37
  publicReadAccess?: boolean;
38
38
  websiteHosting?: WebsiteHostingConfig;
39
+ /**
40
+ * Name a `fjallBucket(<name>)` alias target uses to import this bucket's
41
+ * website exports. Defaults to `bucketName ?? id`, which is right for a
42
+ * direct `new S3Bucket(scope, "Assets", …)` — `id` is then the author's own
43
+ * handle. A wrapper that nests the bucket under a derived id MUST pass this:
44
+ * `Storage` builds its bucket at `${id}Bucket`, so without it a
45
+ * `fjallBucket("assets")` record would import `AssetsWebsiteEndpoint`
46
+ * against an exported `AssetsBucketWebsiteEndpoint`.
47
+ */
48
+ aliasTargetName?: string;
39
49
  /**
40
50
  * Declarative bucket-policy statements, each appended via
41
51
  * `addToResourcePolicy`. The TLS-only `enforceSSL` deny is always applied
@@ -4,6 +4,7 @@ import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
4
4
  import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
5
5
  import { RegionInfo } from "aws-cdk-lib/region-info";
6
6
  import { toPascalCase } from "../../../utils/capitaliseString.js";
7
+ import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
7
8
  import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
8
9
  export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
9
10
  function shouldAutoVersion(tier) {
@@ -17,7 +18,7 @@ function toResourcePolicyPrincipal(identifier) {
17
18
  export class S3Bucket extends Bucket {
18
19
  backupVaultTier;
19
20
  constructor(scope, id, props = {}) {
20
- const { websiteHosting, backupVaultTier, resourcePolicyStatements, ...cdkProps } = props;
21
+ const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, ...cdkProps } = props;
21
22
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
22
23
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
23
24
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
@@ -73,10 +74,14 @@ export class S3Bucket extends Bucket {
73
74
  Tags.of(this).add(SDK_PRE_EMPTY_TAG_KEY, "true");
74
75
  }
75
76
  if (websiteHosting) {
76
- const safeBucket = toPascalCase((props.bucketName ?? id).replace(/[^A-Za-z0-9-]/g, ""));
77
+ const targetBucketName = aliasTargetName ?? props.bucketName ?? id;
78
+ // Only the export name routes through the contract helper. `safeBucket`
79
+ // stays local so the CfnOutput logical ids are unchanged — unifying the
80
+ // two would silently rename them.
81
+ const safeBucket = toPascalCase(targetBucketName.replace(/[^A-Za-z0-9-]/g, ""));
77
82
  new CfnOutput(this, `${safeBucket}WebsiteEndpoint`, {
78
83
  key: `${safeBucket}WebsiteEndpoint`,
79
- exportName: `${safeBucket}WebsiteEndpoint`,
84
+ exportName: bucketWebsiteEndpointExportName(targetBucketName),
80
85
  value: this.bucketWebsiteDomainName,
81
86
  description: `S3 website endpoint for ${id} (consumed by Domain alias targets)`
82
87
  });
@@ -88,7 +93,7 @@ export class S3Bucket extends Bucket {
88
93
  }
89
94
  new CfnOutput(this, `${safeBucket}WebsiteHostedZoneId`, {
90
95
  key: `${safeBucket}WebsiteHostedZoneId`,
91
- exportName: `${safeBucket}WebsiteHostedZoneId`,
96
+ exportName: bucketWebsiteHostedZoneIdExportName(targetBucketName),
92
97
  value: websiteHostedZoneId,
93
98
  description: `Route 53 hosted zone id for the S3 website endpoint of ${id}`
94
99
  });
@@ -0,0 +1,44 @@
1
+ import type { Construct } from "constructs";
2
+ /**
3
+ * The app-level half of the `Domain` ALB alias-target contract.
4
+ *
5
+ * `fjallApp(appName)` resolves `<App>AlbDnsName` / `<App>AlbHostedZoneId` from
6
+ * a *different* stack, so the name has to be derivable from the app name
7
+ * alone — the Domain stack cannot see how many ALBs the app has. But an app
8
+ * may legitimately front several: one per ALB-bearing compute. Emitting the
9
+ * app-keyed pair from each of them produced the identical `Outputs` entry N
10
+ * times and CloudFormation rejected the entire template ("The Outputs section
11
+ * contains duplicate Export names"), which is how the compute deploy-test
12
+ * suite — seven ALB-bearing clusters in one app — failed on 2026-08-09.
13
+ *
14
+ * So the two halves are split by who can answer the question:
15
+ *
16
+ * - Each compute emits its own `<App>-<Compute>-Alb…` pair unconditionally.
17
+ * Always unique, always present, addressable as `fjallApp(app, compute)`.
18
+ * - The App emits the unqualified `<App>Alb…` pair at synth time, and ONLY
19
+ * when the app registered exactly one ALB — the sole case where "the
20
+ * app's ALB" names one thing.
21
+ *
22
+ * Two or more and no app-level export is written. A `Domain` using the bare
23
+ * `fjallApp(app)` then fails at CloudFormation with "No export named
24
+ * <App>AlbDnsName found", which names the missing export and is fixed by
25
+ * naming the compute. Picking one of N silently would deploy a domain
26
+ * pointing at an arbitrary load balancer, and would keep working right up
27
+ * until the registration order changed.
28
+ */
29
+ export interface AlbAliasTargetRegistration {
30
+ readonly appName: string;
31
+ readonly computeName: string;
32
+ /** Stack-attached scope the app-level `CfnOutput` is emitted into. */
33
+ readonly scope: Construct;
34
+ readonly dnsName: string;
35
+ readonly hostedZoneId: string;
36
+ }
37
+ export declare function registerAlbAliasTarget(registration: AlbAliasTargetRegistration): void;
38
+ /**
39
+ * Emit the unqualified app-level alias exports for every app in this tree
40
+ * that registered exactly one ALB. Call once, from `App.synth()`, before
41
+ * `super.synth()` — the construct tree is still mutable there, and that is
42
+ * the same window `applyTagsAspect()` uses.
43
+ */
44
+ export declare function emitAppLevelAlbAliasExports(root: Construct): void;
@@ -0,0 +1,69 @@
1
+ import { CfnOutput } from "aws-cdk-lib";
2
+ import { albDnsExportName, albHostedZoneIdExportName } from "@fjall/util";
3
+ import { toPascalCase } from "./capitaliseString.js";
4
+ import { FjallLogger } from "./validationLogger.js";
5
+ /**
6
+ * Keyed on the construct-tree root (the `App`) so two apps synthesised in one
7
+ * process — the platform and organisation stacks do this — never see each
8
+ * other's registrations. Weak so a discarded tree does not leak.
9
+ */
10
+ const registryByRoot = new WeakMap();
11
+ export function registerAlbAliasTarget(registration) {
12
+ const root = registration.scope.node.root;
13
+ const existing = registryByRoot.get(root);
14
+ if (existing === undefined) {
15
+ registryByRoot.set(root, [registration]);
16
+ return;
17
+ }
18
+ existing.push(registration);
19
+ }
20
+ /**
21
+ * Emit the unqualified app-level alias exports for every app in this tree
22
+ * that registered exactly one ALB. Call once, from `App.synth()`, before
23
+ * `super.synth()` — the construct tree is still mutable there, and that is
24
+ * the same window `applyTagsAspect()` uses.
25
+ */
26
+ export function emitAppLevelAlbAliasExports(root) {
27
+ const registrations = registryByRoot.get(root);
28
+ if (registrations === undefined)
29
+ return;
30
+ // `App.synth()` is re-entrant (CDK caches the assembly and returns it), and
31
+ // a second emission would collide on the output's construct id. Draining
32
+ // makes the call idempotent.
33
+ registryByRoot.delete(root);
34
+ const byApp = new Map();
35
+ for (const registration of registrations) {
36
+ const forApp = byApp.get(registration.appName);
37
+ if (forApp === undefined) {
38
+ byApp.set(registration.appName, [registration]);
39
+ }
40
+ else {
41
+ forApp.push(registration);
42
+ }
43
+ }
44
+ for (const [appName, forApp] of byApp) {
45
+ const only = forApp.length === 1 ? forApp[0] : undefined;
46
+ if (only === undefined) {
47
+ const computeNames = forApp.map((r) => r.computeName).join(", ");
48
+ FjallLogger.warn(`App '${appName}' fronts ${String(forApp.length)} load balancers (${computeNames}), ` +
49
+ `so no app-level '${albDnsExportName(appName)}' export was emitted — ` +
50
+ `'the app's ALB' would be ambiguous. Target a specific one with ` +
51
+ `fjallApp('${appName}', '<compute>') in your Domain records.`);
52
+ continue;
53
+ }
54
+ // Output logical ids are alphanumeric-only; the app name is not.
55
+ const logicalId = `${toPascalCase(appName)}AppAlb`;
56
+ new CfnOutput(only.scope, `${logicalId}DnsName`, {
57
+ key: `${logicalId}DnsName`,
58
+ exportName: albDnsExportName(appName),
59
+ value: only.dnsName,
60
+ description: `ALB DNS name for ${appName} (sole ALB; consumed by fjallApp('${appName}'))`
61
+ });
62
+ new CfnOutput(only.scope, `${logicalId}HostedZoneId`, {
63
+ key: `${logicalId}HostedZoneId`,
64
+ exportName: albHostedZoneIdExportName(appName),
65
+ value: only.hostedZoneId,
66
+ description: `ALB canonical hosted zone ID for ${appName} (sole ALB)`
67
+ });
68
+ }
69
+ }
@@ -47,6 +47,15 @@ export declare function getEnvironment(): string;
47
47
  * sentinel meaning "no signal at all".
48
48
  */
49
49
  export declare function hasExplicitEnvironmentSignal(): boolean;
50
+ /**
51
+ * True when this synth was driven by Fjall rather than run bare. deploy-core
52
+ * supplies `-c orgConfig=` on every synth it drives and nothing else does, so
53
+ * its presence separates "a developer is inspecting a template" from "an
54
+ * account is about to be deployed to" — a distinction consumers that pick a
55
+ * default on missing input need, because the safe default differs between the
56
+ * two. See `envAwareRemovalPolicyDefault`.
57
+ */
58
+ export declare function isFjallDrivenSynth(): boolean;
50
59
  /**
51
60
  * Resolve an environment-specific value at CDK synth time.
52
61
  *
@@ -77,6 +77,18 @@ export function hasExplicitEnvironmentSignal() {
77
77
  const ctxValue = parseContextArg("environment");
78
78
  return ctxValue !== undefined && ctxValue !== "";
79
79
  }
80
+ /**
81
+ * True when this synth was driven by Fjall rather than run bare. deploy-core
82
+ * supplies `-c orgConfig=` on every synth it drives and nothing else does, so
83
+ * its presence separates "a developer is inspecting a template" from "an
84
+ * account is about to be deployed to" — a distinction consumers that pick a
85
+ * default on missing input need, because the safe default differs between the
86
+ * two. See `envAwareRemovalPolicyDefault`.
87
+ */
88
+ export function isFjallDrivenSynth() {
89
+ const raw = parseContextArg(CDK_CONTEXT_KEYS.ORG_CONFIG);
90
+ return raw !== undefined && raw !== "";
91
+ }
80
92
  /**
81
93
  * Resolve an environment-specific value at CDK synth time.
82
94
  *