@fjall/components-infrastructure 2.13.0 → 2.15.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.
package/dist/lib/app.d.ts CHANGED
@@ -72,10 +72,11 @@ export interface IAppOptions {
72
72
  *
73
73
  * - `name?` overrides the AWS `EventBus.Name` (defaults to the app name).
74
74
  * - `removalPolicy?` overrides the env-resolved default. Default resolves
75
- * via `env({ default: "DESTROY", production: "RETAIN" })` per D17 NOT
76
- * `process.env.NODE_ENV` (which is not set during CDK synth in Fjall's
77
- * deployment paths). The shape is the normalised string union per
78
- * D18(d), NOT the raw CDK `RemovalPolicy` enum.
75
+ * via `envAwareRemovalPolicyDefault()` per D17 (production → RETAIN,
76
+ * other recognised stages DESTROY, unrecognised values fail synth)
77
+ * NOT `process.env.NODE_ENV` (which is not set during CDK synth in
78
+ * Fjall's deployment paths). The shape is the normalised string union
79
+ * per D18(d), NOT the raw CDK `RemovalPolicy` enum.
79
80
  */
80
81
  eventBus?: {
81
82
  name?: string;
@@ -175,9 +176,8 @@ export declare class App extends CdkApp {
175
176
  * Buses are recreatable in non-prod; the env-resolved default keeps prod
176
177
  * history. The override (`App.getApp({ eventBus: { name?, removalPolicy? } })`)
177
178
  * is consulted first; absent override falls back to the app name and the
178
- * `env({ default: "DESTROY", production: "RETAIN" })` resolution per D17.
179
- * NODE_ENV is not consulted — it is not set during CDK synth in Fjall's
180
- * deployment paths.
179
+ * `envAwareRemovalPolicyDefault()` resolution per D17. NODE_ENV is not
180
+ * consulted — it is not set during CDK synth in Fjall's deployment paths.
181
181
  */
182
182
  getEventBus(): EventBusMessaging;
183
183
  /**
package/dist/lib/app.js CHANGED
@@ -277,9 +277,8 @@ export class App extends CdkApp {
277
277
  * Buses are recreatable in non-prod; the env-resolved default keeps prod
278
278
  * history. The override (`App.getApp({ eventBus: { name?, removalPolicy? } })`)
279
279
  * is consulted first; absent override falls back to the app name and the
280
- * `env({ default: "DESTROY", production: "RETAIN" })` resolution per D17.
281
- * NODE_ENV is not consulted — it is not set during CDK synth in Fjall's
282
- * deployment paths.
280
+ * `envAwareRemovalPolicyDefault()` resolution per D17. NODE_ENV is not
281
+ * consulted — it is not set during CDK synth in Fjall's deployment paths.
283
282
  */
284
283
  getEventBus() {
285
284
  if (this.defaultEventBus) {
@@ -1,3 +1,4 @@
1
+ import { ACCOUNT_MONITORING_ROLE_NAME } from "@fjall/util/aws";
1
2
  import { CfnOutput } from "aws-cdk-lib";
2
3
  import { Role, AccountPrincipal, PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
3
4
  import { Construct } from "constructs";
@@ -16,7 +17,7 @@ export class AccountMonitoringRole extends Construct {
16
17
  constructor(scope, id, props) {
17
18
  super(scope, id);
18
19
  this.role = new Role(this, "Role", {
19
- roleName: "FjallMonitoring",
20
+ roleName: ACCOUNT_MONITORING_ROLE_NAME,
20
21
  path: "/",
21
22
  assumedBy: new AccountPrincipal(FJALL_PLATFORM_ACCOUNT_ID),
22
23
  description: "Cross-account monitoring role for the Fjall platform. Grants read access to CloudWatch, ECS, RDS, S3, Lambda, ALB, Logs, and Cost Explorer.",
@@ -29,7 +29,16 @@ function buildFoundationGuardrails(allowedRegions) {
29
29
  {
30
30
  Sid: "DenyRootUserActions",
31
31
  Effect: "Deny",
32
- Action: "*",
32
+ // Deny + NotAction denies everything except the policy-management
33
+ // scopes a root-task unlock session needs; the AWS root-task policies
34
+ // constrain those sessions further.
35
+ NotAction: [
36
+ "s3:GetBucketPolicy",
37
+ "s3:PutBucketPolicy",
38
+ "s3:DeleteBucketPolicy",
39
+ "sqs:GetQueueAttributes",
40
+ "sqs:SetQueueAttributes"
41
+ ],
33
42
  Resource: "*",
34
43
  Condition: {
35
44
  StringLike: {
@@ -38,7 +38,7 @@ export interface S3Props {
38
38
  readonly backupVaultTier?: BackupTier;
39
39
  readonly cors?: CorsRule[];
40
40
  readonly deployment?: S3DeploymentConfig;
41
- /** When true, sets RemovalPolicy.RETAIN and disables autoDeleteObjects. Used for imported buckets. */
41
+ /** When true, sets RemovalPolicy.RETAIN (overriding the env-aware default). Used for imported buckets. */
42
42
  readonly retain?: boolean;
43
43
  }
44
44
  export interface StorageBuildProps extends S3Props {
@@ -21,7 +21,11 @@ function toHttpMethod(method) {
21
21
  DELETE: HttpMethods.DELETE,
22
22
  HEAD: HttpMethods.HEAD
23
23
  };
24
- return methodMap[method] ?? HttpMethods.GET;
24
+ const mapped = methodMap[method];
25
+ if (mapped === undefined) {
26
+ throw new Error(`Unsupported CORS method "${method}" — expected one of: ${Object.keys(methodMap).join(", ")}.`);
27
+ }
28
+ return mapped;
25
29
  }
26
30
  function toCorsRules(cors) {
27
31
  if (!cors)
@@ -6,10 +6,10 @@ import { S3Bucket } from "../storage/index.js";
6
6
  interface CloudTrailProps extends CloudTrail.TrailProps {
7
7
  bucketName: string;
8
8
  /**
9
- * Bucket + CMK survive stack deletion (RemovalPolicy.RETAIN, auto-delete
10
- * off). The trail resource itself stays DESTROY regardless — a retained
11
- * trail keeps logging (and charging) as an unmanaged orphan. Default false
12
- * preserves the historical TrailStack behaviour.
9
+ * Bucket + CMK survive stack deletion (RemovalPolicy.RETAIN). The trail
10
+ * resource itself stays DESTROY regardless — a retained trail keeps
11
+ * logging (and charging) as an unmanaged orphan. Default false preserves
12
+ * the historical TrailStack behaviour.
13
13
  */
14
14
  retainAuditHistory?: boolean;
15
15
  /**
@@ -25,6 +25,28 @@ export declare class Trail extends Construct {
25
25
  readonly bucket: S3Bucket;
26
26
  readonly encryptionKey: CustomerManagedKey;
27
27
  constructor(scope: Construct, id: string, props: CloudTrailProps);
28
+ private trailArn;
29
+ /**
30
+ * Canonical CloudTrail delivery policy (ACL probe + log write), scoped to
31
+ * this trail's ARN via aws:SourceArn so no other trail — including one in
32
+ * another account — can deliver into or probe the bucket. Emitted from
33
+ * this construct rather than left to the L2 Trail because draining mode
34
+ * (omitTrail) skips the L2 construct while the trail being deleted may
35
+ * still deliver mid-update. Fjall-prefixed sids avoid colliding with the
36
+ * sid-less statements the L2 Trail adds when it is constructed.
37
+ */
38
+ private addAccountTrailBucketPolicy;
39
+ /**
40
+ * Conditioned replacement for the KMS half of the retired grantReadWrite:
41
+ * `Bucket.grantReadWrite` silently granted the CloudTrail principal
42
+ * unconditioned Encrypt/Decrypt/ReEncrypt on the CMK because the bucket
43
+ * carries an encryptionKey. CloudTrail's actual needs are
44
+ * kms:GenerateDataKey* + kms:DescribeKey (trail-side log encryption,
45
+ * validated at CreateTrail) plus digest delivery via the bucket's default
46
+ * SSE-KMS — without these, trail creation fails with
47
+ * InsufficientEncryptionPolicyException.
48
+ */
49
+ private addAccountTrailKeyPolicy;
28
50
  /**
29
51
  * The CDK L2 Trail contributes no KMS key-policy statements at all — it
30
52
  * only sets kmsKeyId on the CfnTrail (verified in aws-cdk-lib 2.251.0).
@@ -41,6 +41,7 @@ export class Trail extends Construct {
41
41
  removalPolicy: storagePolicy,
42
42
  lifecycleRules: [{ expiration: Duration.days(365), enabled: true }]
43
43
  });
44
+ const effectiveTrailName = props.trailName || `${id}Trail`;
44
45
  if (props.isOrganizationTrail === true) {
45
46
  // The L2 Trail emits the organisation-wide bucket policy itself
46
47
  // (AWSLogs/<orgId>/* delivery); granting read/write here would shadow
@@ -48,7 +49,9 @@ export class Trail extends Construct {
48
49
  this.addOrganisationTrailKeyPolicy(props, bucketName);
49
50
  }
50
51
  else {
51
- this.bucket.grantReadWrite(new ServicePrincipal("cloudtrail.amazonaws.com"));
52
+ const accountTrailArn = this.trailArn(effectiveTrailName);
53
+ this.addAccountTrailBucketPolicy(accountTrailArn);
54
+ this.addAccountTrailKeyPolicy(accountTrailArn, bucketName);
52
55
  }
53
56
  if (omitTrail === true) {
54
57
  return;
@@ -56,22 +59,92 @@ export class Trail extends Construct {
56
59
  this.trail = new CloudTrail.Trail(this, `${id}CloudTrail`, {
57
60
  ...trailProps,
58
61
  bucket: this.bucket,
59
- trailName: props.trailName || `${id}Trail`,
62
+ trailName: effectiveTrailName,
60
63
  encryptionKey: this.encryptionKey.key
61
64
  });
62
65
  // Always DESTROY even when storage is retained: a RETAINED trail would
63
66
  // keep logging (and charging) as an unmanaged orphan after stack
64
67
  // deletion. Audit durability lives on the bucket + CMK removal policies.
65
68
  this.trail.applyRemovalPolicy(RemovalPolicy.DESTROY);
66
- // Ensure the autoDeleteObjects custom resource is fully provisioned before
67
- // the trail starts writing to the bucket. Without this, a create-rollback
68
- // leaves the bucket non-empty (CloudTrail writes logs immediately) and the
69
- // auto-delete Lambda was never created — causing ROLLBACK_FAILED.
70
- // (No-op in retain mode: auto-delete is off, so the child is absent.)
71
- const autoDeleteResource = this.bucket.node.tryFindChild("AutoDeleteObjectsCustomResource");
72
- if (autoDeleteResource) {
73
- this.trail.node.addDependency(autoDeleteResource);
74
- }
69
+ }
70
+ trailArn(trailName) {
71
+ const { partition, region, account } = Stack.of(this);
72
+ return `arn:${partition}:cloudtrail:${region}:${account}:trail/${trailName}`;
73
+ }
74
+ /**
75
+ * Canonical CloudTrail delivery policy (ACL probe + log write), scoped to
76
+ * this trail's ARN via aws:SourceArn so no other trail — including one in
77
+ * another account — can deliver into or probe the bucket. Emitted from
78
+ * this construct rather than left to the L2 Trail because draining mode
79
+ * (omitTrail) skips the L2 construct while the trail being deleted may
80
+ * still deliver mid-update. Fjall-prefixed sids avoid colliding with the
81
+ * sid-less statements the L2 Trail adds when it is constructed.
82
+ */
83
+ addAccountTrailBucketPolicy(trailArn) {
84
+ const { account } = Stack.of(this);
85
+ const cloudTrailPrincipal = new ServicePrincipal("cloudtrail.amazonaws.com");
86
+ this.bucket.addToResourcePolicy(new PolicyStatement({
87
+ sid: "FjallCloudTrailAclCheck",
88
+ principals: [cloudTrailPrincipal],
89
+ actions: ["s3:GetBucketAcl"],
90
+ resources: [this.bucket.bucketArn],
91
+ conditions: {
92
+ StringEquals: { "aws:SourceArn": trailArn }
93
+ }
94
+ }));
95
+ this.bucket.addToResourcePolicy(new PolicyStatement({
96
+ sid: "FjallCloudTrailWrite",
97
+ principals: [cloudTrailPrincipal],
98
+ actions: ["s3:PutObject"],
99
+ resources: [this.bucket.arnForObjects(`AWSLogs/${account}/*`)],
100
+ conditions: {
101
+ StringEquals: {
102
+ "aws:SourceArn": trailArn,
103
+ "s3:x-amz-acl": "bucket-owner-full-control"
104
+ }
105
+ }
106
+ }));
107
+ }
108
+ /**
109
+ * Conditioned replacement for the KMS half of the retired grantReadWrite:
110
+ * `Bucket.grantReadWrite` silently granted the CloudTrail principal
111
+ * unconditioned Encrypt/Decrypt/ReEncrypt on the CMK because the bucket
112
+ * carries an encryptionKey. CloudTrail's actual needs are
113
+ * kms:GenerateDataKey* + kms:DescribeKey (trail-side log encryption,
114
+ * validated at CreateTrail) plus digest delivery via the bucket's default
115
+ * SSE-KMS — without these, trail creation fails with
116
+ * InsufficientEncryptionPolicyException.
117
+ */
118
+ addAccountTrailKeyPolicy(trailArn, bucketName) {
119
+ const { partition } = Stack.of(this);
120
+ const cloudTrailPrincipal = new ServicePrincipal("cloudtrail.amazonaws.com");
121
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
122
+ sid: "FjallCloudTrailKeyUse",
123
+ principals: [cloudTrailPrincipal],
124
+ actions: ["kms:GenerateDataKey*", "kms:DescribeKey"],
125
+ resources: ["*"],
126
+ conditions: {
127
+ StringEquals: { "aws:SourceArn": trailArn }
128
+ }
129
+ }));
130
+ this.encryptionKey.key.addToResourcePolicy(new PolicyStatement({
131
+ sid: "FjallCloudTrailDigestDelivery",
132
+ principals: [cloudTrailPrincipal],
133
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
134
+ resources: ["*"],
135
+ conditions: {
136
+ // Digest files arrive via the bucket's default SSE-KMS. Two exact
137
+ // entries (bucket-level context + per-object contexts) — a bare
138
+ // `${bucketName}*` would also match sibling bucket NAMES sharing
139
+ // the prefix.
140
+ StringLike: {
141
+ "kms:EncryptionContext:aws:s3:arn": [
142
+ `arn:${partition}:s3:::${bucketName}`,
143
+ `arn:${partition}:s3:::${bucketName}/*`
144
+ ]
145
+ }
146
+ }
147
+ }));
75
148
  }
76
149
  /**
77
150
  * The CDK L2 Trail contributes no KMS key-policy statements at all — it
@@ -14,8 +14,9 @@ export interface EventBridgeBusProps {
14
14
  /** Override the default description ("EventBus <appName> — Fjall app event bus"). */
15
15
  description?: string;
16
16
  /**
17
- * Removal policy. Default resolves via the `env()` helper (production →
18
- * RETAIN; non-prod → DESTROY) — D17 explicitly rejects `process.env.NODE_ENV`
17
+ * Removal policy. Default resolves via `envAwareRemovalPolicyDefault()`
18
+ * (production → RETAIN; other recognised stages → DESTROY; unrecognised
19
+ * values fail synth) — D17 explicitly rejects `process.env.NODE_ENV`
19
20
  * because it is not set during CDK synth in Fjall's deployment paths.
20
21
  * Buses are recreatable in non-prod; production keeps history.
21
22
  */
@@ -1,7 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput } from "aws-cdk-lib";
3
3
  import { EventBus } from "aws-cdk-lib/aws-events";
4
- import { env } from "../../../utils/env.js";
4
+ import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
5
5
  import { toRemovalPolicy } from "./utils.js";
6
6
  export class EventBridgeBus extends Construct {
7
7
  id;
@@ -17,7 +17,7 @@ export class EventBridgeBus extends Construct {
17
17
  (props.appName
18
18
  ? `EventBus ${props.appName} — Fjall app event bus`
19
19
  : undefined);
20
- const removalPolicyValue = props.removalPolicy ?? env({ default: "DESTROY", production: "RETAIN" });
20
+ const removalPolicyValue = props.removalPolicy ?? envAwareRemovalPolicyDefault();
21
21
  const ownedBus = new EventBus(this, `${id}EventBus`, {
22
22
  eventBusName: props.eventBusName,
23
23
  description
@@ -8,8 +8,11 @@ import { CustomResource } from "../utilities/customResource.js";
8
8
  import getAccountId from "../../../utils/getAccountId.js";
9
9
  import { accountConstructKey, findAccountNameCollision } from "../../../utils/capitaliseString.js";
10
10
  import { FjallLogger } from "../../../utils/validationLogger.js";
11
+ import { formatIpamPairTagValue, IPAM_OPERATIONS_POOL_TAG_KEY } from "@fjall/util/aws";
11
12
  const IPAM_TAGS = {
12
- OPERATIONS_POOL: "fjall:operations:pool",
13
+ // Shared with the deploy-core readiness probe, which matches pools by this
14
+ // key + the pair value format — see @fjall/util/aws ipamTags.
15
+ OPERATIONS_POOL: IPAM_OPERATIONS_POOL_TAG_KEY,
13
16
  COST_ALLOCATION_ENVIRONMENT: "fjall:costAllocation:environment",
14
17
  COST_ALLOCATION_ACCOUNT_NAME: "fjall:costAllocation:accountName"
15
18
  };
@@ -134,14 +137,14 @@ export class IpamPool extends Construct {
134
137
  allocationResourceTags: [
135
138
  {
136
139
  key: IPAM_TAGS.OPERATIONS_POOL,
137
- value: `${accountId}-${region}`
140
+ value: formatIpamPairTagValue(accountId, region)
138
141
  }
139
142
  ],
140
143
  autoImport: true,
141
144
  tags: [
142
145
  {
143
146
  key: IPAM_TAGS.OPERATIONS_POOL,
144
- value: `${accountId}-${region}`
147
+ value: formatIpamPairTagValue(accountId, region)
145
148
  },
146
149
  {
147
150
  key: IPAM_TAGS.COST_ALLOCATION_ACCOUNT_NAME,
@@ -18,9 +18,10 @@ export interface ServiceDiscoveryNamespaceProps {
18
18
  /** Override the default description (`"ServiceDiscovery <name> — Fjall private DNS namespace"`). */
19
19
  description?: string;
20
20
  /**
21
- * Removal policy. Default resolves via the `env()` helper (production →
22
- * RETAIN; non-prod DESTROY) matches the convention codified in D17 of
23
- * the EventBridge promotion design. NODE_ENV is intentionally NOT consulted
21
+ * Removal policy. Default resolves via `envAwareRemovalPolicyDefault()`
22
+ * (productionRETAIN; other recognised stages DESTROY; unrecognised
23
+ * values fail synth) matches the convention codified in D17 of the
24
+ * EventBridge promotion design. NODE_ENV is intentionally NOT consulted
24
25
  * because it is unset during CDK synth in Fjall's deployment paths.
25
26
  */
26
27
  removalPolicy?: "DESTROY" | "RETAIN";
@@ -1,8 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput, Duration } from "aws-cdk-lib";
3
3
  import { PrivateDnsNamespace, DnsRecordType } from "aws-cdk-lib/aws-servicediscovery";
4
- import { env } from "../../../utils/env.js";
5
- import { toRemovalPolicy } from "../../../utils/removalPolicy.js";
4
+ import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
6
5
  export class ServiceDiscoveryNamespace extends Construct {
7
6
  id;
8
7
  #namespace;
@@ -12,7 +11,7 @@ export class ServiceDiscoveryNamespace extends Construct {
12
11
  this.id = id;
13
12
  const description = props.description ??
14
13
  `ServiceDiscovery ${props.name} — Fjall private DNS namespace`;
15
- const removalPolicyValue = props.removalPolicy ?? env({ default: "DESTROY", production: "RETAIN" });
14
+ const removalPolicyValue = props.removalPolicy ?? envAwareRemovalPolicyDefault();
16
15
  const namespace = new PrivateDnsNamespace(this, `${id}Namespace`, {
17
16
  vpc: props.vpc,
18
17
  name: props.name,
@@ -1,10 +1,18 @@
1
1
  import { Bucket, type BucketProps } from "aws-cdk-lib/aws-s3";
2
2
  import { type Construct } from "constructs";
3
3
  import { type BackupTier } from "../../../utils/backupTierMapping.js";
4
+ export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
4
5
  export interface WebsiteHostingConfig {
5
6
  readonly indexDocument: string;
6
7
  readonly errorDocument?: string;
7
8
  }
9
+ /**
10
+ * Props for {@link S3Bucket}.
11
+ *
12
+ * `autoDeleteObjects` is accepted for source compatibility but always
13
+ * overridden to `false` (ADR D4.3) — passing `true` raises a synth-time
14
+ * warning rather than a compile error so older scaffolds keep building.
15
+ */
8
16
  export interface S3BucketProps extends BucketProps {
9
17
  backupVaultTier?: BackupTier;
10
18
  publicReadAccess?: boolean;
@@ -1,7 +1,10 @@
1
- import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
2
+ import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
2
3
  import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
3
4
  import { RegionInfo } from "aws-cdk-lib/region-info";
4
5
  import { toPascalCase } from "../../../utils/capitaliseString.js";
6
+ import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
7
+ export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
5
8
  function shouldAutoVersion(tier) {
6
9
  return tier === "resilient" || tier === "enterprise";
7
10
  }
@@ -10,13 +13,16 @@ export class S3Bucket extends Bucket {
10
13
  constructor(scope, id, props = {}) {
11
14
  const { websiteHosting, backupVaultTier, ...cdkProps } = props;
12
15
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
13
- const isRetained = props.removalPolicy === RemovalPolicy.RETAIN;
14
16
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
17
+ const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
15
18
  super(scope, id, {
16
19
  ...cdkProps,
17
20
  enforceSSL: true,
18
- autoDeleteObjects: !isRetained,
19
- removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
21
+ // autoDeleteObjects retired (ADR D4.3): the CDK custom resource's deny
22
+ // policy is the quarantine mechanism — destroy-time emptying is done
23
+ // SDK-side by deploy-core's pre-empty pass, keyed off the marker tag.
24
+ autoDeleteObjects: false,
25
+ removalPolicy,
20
26
  publicReadAccess: isPublic,
21
27
  ...(isPublic && {
22
28
  blockPublicAccess: new BlockPublicAccess({
@@ -36,6 +42,15 @@ export class S3Bucket extends Bucket {
36
42
  : props.lifecycleRules
37
43
  });
38
44
  this.backupVaultTier = backupVaultTier;
45
+ if (props.autoDeleteObjects === true) {
46
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:autoDeleteObjectsIgnored", "autoDeleteObjects: true is ignored — the CDK auto-delete custom " +
47
+ "resource is retired (ADR D4.3). DESTROY buckets are emptied " +
48
+ "SDK-side by deploy-core's pre-empty pass via the " +
49
+ `${SDK_PRE_EMPTY_TAG_KEY} marker tag. Remove the prop.`);
50
+ }
51
+ if (removalPolicy === RemovalPolicy.DESTROY) {
52
+ Tags.of(this).add(SDK_PRE_EMPTY_TAG_KEY, "true");
53
+ }
39
54
  if (websiteHosting) {
40
55
  const safeBucket = toPascalCase((props.bucketName ?? id).replace(/[^A-Za-z0-9-]/g, ""));
41
56
  new CfnOutput(this, `${safeBucket}WebsiteEndpoint`, {
@@ -16,6 +16,12 @@
16
16
  type EnvConfig<T> = {
17
17
  default: T;
18
18
  } & Partial<Record<string, T>>;
19
+ /**
20
+ * Sentinel returned by `getEnvironment()` when no environment signal exists.
21
+ * Consumers that need to distinguish "no signal" from "unrecognised value"
22
+ * (e.g. `envAwareRemovalPolicyDefault()`) compare against this.
23
+ */
24
+ export declare const UNKNOWN_ENVIRONMENT = "unknown";
19
25
  /**
20
26
  * Resolve the current deployment environment without App dependency.
21
27
  *
@@ -33,6 +39,14 @@ type EnvConfig<T> = {
33
39
  * ENVIRONMENT as the dominant ambient signal.
34
40
  */
35
41
  export declare function getEnvironment(): string;
42
+ /**
43
+ * True when the environment came from an explicit operator signal
44
+ * (`ENVIRONMENT` env var or `-c environment=`) rather than account-lookup
45
+ * inference or the no-signal fallback. Lets consumers distinguish an
46
+ * explicitly-supplied literal "unknown" from the `UNKNOWN_ENVIRONMENT`
47
+ * sentinel meaning "no signal at all".
48
+ */
49
+ export declare function hasExplicitEnvironmentSignal(): boolean;
36
50
  /**
37
51
  * Resolve an environment-specific value at CDK synth time.
38
52
  *
@@ -1,6 +1,12 @@
1
1
  import { CDK_CONTEXT_KEYS } from "./cdkContext.js";
2
2
  import { parseOrgConfig, resolveSynthEnvironment } from "./orgConfigParser.js";
3
3
  import { FjallLogger } from "./validationLogger.js";
4
+ /**
5
+ * Sentinel returned by `getEnvironment()` when no environment signal exists.
6
+ * Consumers that need to distinguish "no signal" from "unrecognised value"
7
+ * (e.g. `envAwareRemovalPolicyDefault()`) compare against this.
8
+ */
9
+ export const UNKNOWN_ENVIRONMENT = "unknown";
4
10
  let cachedEnvironment;
5
11
  /**
6
12
  * Resolve the current deployment environment without App dependency.
@@ -54,9 +60,23 @@ export function getEnvironment() {
54
60
  // 5. Fallback
55
61
  FjallLogger.warn("[fjall] Could not determine environment. " +
56
62
  "Set ENVIRONMENT env var or pass -c environment=<value>.");
57
- cachedEnvironment = "unknown";
63
+ cachedEnvironment = UNKNOWN_ENVIRONMENT;
58
64
  return cachedEnvironment;
59
65
  }
66
+ /**
67
+ * True when the environment came from an explicit operator signal
68
+ * (`ENVIRONMENT` env var or `-c environment=`) rather than account-lookup
69
+ * inference or the no-signal fallback. Lets consumers distinguish an
70
+ * explicitly-supplied literal "unknown" from the `UNKNOWN_ENVIRONMENT`
71
+ * sentinel meaning "no signal at all".
72
+ */
73
+ export function hasExplicitEnvironmentSignal() {
74
+ const envVar = process.env.ENVIRONMENT;
75
+ if (envVar !== undefined && envVar !== "")
76
+ return true;
77
+ const ctxValue = parseContextArg("environment");
78
+ return ctxValue !== undefined && ctxValue !== "";
79
+ }
60
80
  /**
61
81
  * Resolve an environment-specific value at CDK synth time.
62
82
  *
@@ -1,2 +1,17 @@
1
1
  import { RemovalPolicy } from "aws-cdk-lib";
2
2
  export declare function toRemovalPolicy(value?: "DESTROY" | "RETAIN" | "SNAPSHOT"): RemovalPolicy;
3
+ /**
4
+ * Resolve the env-aware removal-policy default (D17): production → RETAIN,
5
+ * every other recognised environment → DESTROY.
6
+ *
7
+ * Unlike the generic `env()` resolver (where unrecognised → default is
8
+ * benign), an unrecognised value here throws at synth: silently landing a
9
+ * typo like `ENVIRONMENT=prod` on DESTROY deletes data when the stack is
10
+ * deleted. The accept-set derives from `ACCOUNT_STAGES_WITH_ROOT` so a new
11
+ * stage added in `@fjall/util` widens it automatically. The no-signal
12
+ * sentinel keeps the historical warn-and-DESTROY behaviour — raw `cdk synth`
13
+ * without context and null-stage cascade synths rely on it. An explicitly
14
+ * supplied `ENVIRONMENT=unknown` collides with the sentinel string and would
15
+ * otherwise ride the silent-DESTROY path, so it is treated as unrecognised.
16
+ */
17
+ export declare function envAwareRemovalPolicyDefault(): "DESTROY" | "RETAIN";
@@ -1,4 +1,6 @@
1
+ import { ACCOUNT_STAGES_WITH_ROOT } from "@fjall/util";
1
2
  import { RemovalPolicy } from "aws-cdk-lib";
3
+ import { getEnvironment, hasExplicitEnvironmentSignal, UNKNOWN_ENVIRONMENT } from "./env.js";
2
4
  export function toRemovalPolicy(value) {
3
5
  switch (value) {
4
6
  case "DESTROY":
@@ -10,3 +12,33 @@ export function toRemovalPolicy(value) {
10
12
  return RemovalPolicy.RETAIN;
11
13
  }
12
14
  }
15
+ const REMOVAL_DEFAULT_ENVIRONMENTS = new Set(ACCOUNT_STAGES_WITH_ROOT);
16
+ /**
17
+ * Resolve the env-aware removal-policy default (D17): production → RETAIN,
18
+ * every other recognised environment → DESTROY.
19
+ *
20
+ * Unlike the generic `env()` resolver (where unrecognised → default is
21
+ * benign), an unrecognised value here throws at synth: silently landing a
22
+ * typo like `ENVIRONMENT=prod` on DESTROY deletes data when the stack is
23
+ * deleted. The accept-set derives from `ACCOUNT_STAGES_WITH_ROOT` so a new
24
+ * stage added in `@fjall/util` widens it automatically. The no-signal
25
+ * sentinel keeps the historical warn-and-DESTROY behaviour — raw `cdk synth`
26
+ * without context and null-stage cascade synths rely on it. An explicitly
27
+ * supplied `ENVIRONMENT=unknown` collides with the sentinel string and would
28
+ * otherwise ride the silent-DESTROY path, so it is treated as unrecognised.
29
+ */
30
+ export function envAwareRemovalPolicyDefault() {
31
+ const environment = getEnvironment();
32
+ if (environment === UNKNOWN_ENVIRONMENT && !hasExplicitEnvironmentSignal()) {
33
+ return "DESTROY";
34
+ }
35
+ if (!REMOVAL_DEFAULT_ENVIRONMENTS.has(environment)) {
36
+ throw new Error(`Unrecognised environment "${environment}" — refusing to resolve the ` +
37
+ `env-aware removal-policy default (non-production environments ` +
38
+ `default to DESTROY, which deletes data when the stack is deleted). ` +
39
+ `Valid values: ${ACCOUNT_STAGES_WITH_ROOT.join(", ")}. ` +
40
+ `Set ENVIRONMENT or pass -c environment=<value>, or set an explicit ` +
41
+ `removalPolicy on the construct.`);
42
+ }
43
+ return environment === "production" ? "RETAIN" : "DESTROY";
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { CfnResource, Stack, Tags } from "aws-cdk-lib";
2
2
  import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./backupTierMapping.js";
3
+ import { formatIpamPairTagValue, IPAM_OPERATIONS_POOL_TAG_KEY } from "@fjall/util/aws";
3
4
  /**
4
5
  * Aspect to apply special Fjall tags to specific resource types.
5
6
  *
@@ -49,7 +50,7 @@ export class StandardTagsAspect {
49
50
  process.env.CDK_DEFAULT_ACCOUNT;
50
51
  const region = stack.region || process.env.CDK_DEFAULT_REGION;
51
52
  if (accountId && region) {
52
- Tags.of(vpc).add("fjall:operations:pool", `${accountId}-${region}`);
53
+ Tags.of(vpc).add(IPAM_OPERATIONS_POOL_TAG_KEY, formatIpamPairTagValue(accountId, region));
53
54
  }
54
55
  }
55
56
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@aws-sdk/client-organizations": "^3.1038.0",
66
- "@fjall/generator": "^2.13.0",
67
- "@fjall/util": "^2.13.0",
66
+ "@fjall/generator": "^2.15.0",
67
+ "@fjall/util": "^2.15.0",
68
68
  "constructs": "^10.0.0",
69
69
  "uuid": "^14.0.0"
70
70
  },
@@ -79,5 +79,5 @@
79
79
  "engines": {
80
80
  "node": ">=18.0.0"
81
81
  },
82
- "gitHead": "5b16c5731256628f829d4168c65cf165b3516f9a"
82
+ "gitHead": "b2223855907cb6d467e47df073b2a5b28c684ede"
83
83
  }