@fjall/components-infrastructure 18.0.0 → 19.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.
package/dist/lib/app.js CHANGED
@@ -13,6 +13,7 @@ import { BuildkiteFactory } from "./patterns/aws/buildkite.js";
13
13
  import { DevSubstrate } from "./patterns/aws/devSubstrate.js";
14
14
  import { StandardTagsAspect } from "./utils/standardTagsAspect.js";
15
15
  import { emitAppLevelAlbAliasExports } from "./utils/albAliasTargetRegistry.js";
16
+ import { ensureFunctionLogGroups } from "./resources/aws/logging/logGroupHygiene.js";
16
17
  import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./utils/backupTierMapping.js";
17
18
  import { randomBytes } from "crypto";
18
19
  import { getConfig } from "./utils/getConfig.js";
@@ -844,6 +845,9 @@ export class App extends CdkApp {
844
845
  // Must precede super.synth(): the tree is still mutable here, and only
845
846
  // now is the ALB count per app final.
846
847
  emitAppLevelAlbAliasExports(this);
848
+ // Also pre-super, and before aspects run inside it, so the log groups
849
+ // this creates are still visited by the app-wide tag aspects.
850
+ ensureFunctionLogGroups(this);
847
851
  const assembly = super.synth(options);
848
852
  // After synthesis, write Fjall manifest to cdk.out
849
853
  try {
@@ -23,6 +23,26 @@ export interface SharedAlarmTopicProps {
23
23
  emails?: string[];
24
24
  httpsEndpoints?: string[];
25
25
  };
26
+ /**
27
+ * Encrypt the topic at rest with a customer-managed KMS key. Off by
28
+ * default: the key costs $1–3/month per account-region (base + up to two
29
+ * billed annual rotations), so the accessible
30
+ * default stays unencrypted and the SNS_TOPIC_NOT_ENCRYPTED posture
31
+ * finding stands as the signpost to this knob (2026-08-24 dogfood: all 14
32
+ * of our own findings for that rule were this construct).
33
+ *
34
+ * Opting OUT again on production does not delete the key: it is
35
+ * stack-scoped, so D17 RETAINs it there and the still-billed key becomes
36
+ * exactly the KMS_KEY_ORPHAN finding class — schedule its deletion
37
+ * manually after removing the flag.
38
+ *
39
+ * Opting in also grants `cloudwatch.amazonaws.com` decrypt/generate on the
40
+ * key's resource policy — without that, every alarm transition fails
41
+ * silently at publish and alarms page nobody. The AWS-managed `aws/sns`
42
+ * key cannot be used for exactly this reason: its policy is immutable, so
43
+ * CloudWatch can never be admitted.
44
+ */
45
+ encrypted?: boolean;
26
46
  }
27
47
  export declare class SharedAlarmTopic extends Construct {
28
48
  readonly topic: ITopic;
@@ -1,6 +1,8 @@
1
1
  import { CfnOutput } from "aws-cdk-lib";
2
+ import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
2
3
  import { EmailSubscription, UrlSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
3
4
  import { Construct } from "constructs";
5
+ import { CustomerManagedKey } from "../../resources/aws/secrets/kms.js";
4
6
  import { SNSTopic } from "../../resources/aws/messaging/sns.js";
5
7
  /**
6
8
  * The well-known cross-stack export name for the account's shared alarm
@@ -14,8 +16,26 @@ export class SharedAlarmTopic extends Construct {
14
16
  topicArn;
15
17
  constructor(scope, id, props) {
16
18
  super(scope, id);
19
+ let masterKey;
20
+ if (props?.encrypted === true) {
21
+ const cmk = new CustomerManagedKey(this, "AlarmTopicKey", {
22
+ description: "Encrypts the shared CloudWatch alarm notification topic",
23
+ protects: "stack-scoped"
24
+ });
25
+ // CloudWatch publishes alarm transitions through the topic's key; the
26
+ // exact action pair AWS documents for alarms-to-encrypted-topics. No
27
+ // aws:SourceAccount condition — CloudWatch does not reliably pass it on
28
+ // this path, and a mismatch fails silently (alarms page nobody).
29
+ cmk.key.addToResourcePolicy(new PolicyStatement({
30
+ principals: [new ServicePrincipal("cloudwatch.amazonaws.com")],
31
+ actions: ["kms:Decrypt", "kms:GenerateDataKey*"],
32
+ resources: ["*"]
33
+ }));
34
+ masterKey = cmk.key;
35
+ }
17
36
  const wrapped = new SNSTopic(this, "AlarmNotifications", {
18
- displayName: "Fjall CloudWatch Alarm Notifications"
37
+ displayName: "Fjall CloudWatch Alarm Notifications",
38
+ ...(masterKey !== undefined && { masterKey })
19
39
  });
20
40
  this.topic = wrapped.getTopic();
21
41
  for (const email of props?.subscriptions?.emails ?? []) {
@@ -34,6 +34,14 @@ export interface AccountProps extends StackProps {
34
34
  * `securityTier`.
35
35
  */
36
36
  alarmSubscriptions?: SharedAlarmTopicProps["subscriptions"];
37
+ /**
38
+ * Encrypt the shared alarm topic at rest with a customer-managed KMS key
39
+ * ($1–3/month per account-region once rotation accrues). Off by default — the accessible-default
40
+ * posture; the SNS_TOPIC_NOT_ENCRYPTED finding stands as the signpost to
41
+ * this knob. Sourced from the generated `account/infrastructure.ts`, same
42
+ * as `alarmSubscriptions`.
43
+ */
44
+ alarmTopicEncryption?: SharedAlarmTopicProps["encrypted"];
37
45
  /**
38
46
  * §4.4a Step-3 contract knob, forwarded to this account's `OidcConnector`.
39
47
  * Absent ⇒ `"expanded"` (the safe shared default — governance trust accepts
@@ -51,6 +51,9 @@ export class Account extends Stack {
51
51
  new SharedAlarmTopic(this, "AlarmTopic", {
52
52
  ...(props.alarmSubscriptions !== undefined && {
53
53
  subscriptions: props.alarmSubscriptions
54
+ }),
55
+ ...(props.alarmTopicEncryption !== undefined && {
56
+ encrypted: props.alarmTopicEncryption
54
57
  })
55
58
  });
56
59
  const isStandaloneAccount = this.constructor === Account;
@@ -995,7 +995,8 @@ export class EcsCompute extends Construct {
995
995
  alertsTopic: resolvedAlertsTopic,
996
996
  applicationId: props.applicationId ??
997
997
  (App.hasInstance() ? App.getInstance().getName() : undefined),
998
- ...(props.taskStopWatchdog === false && { taskStopWatchdog: false })
998
+ ...(props.taskStopWatchdog === false && { taskStopWatchdog: false }),
999
+ ...(props.containerInsights === true && { containerInsights: true })
999
1000
  };
1000
1001
  this.ecsCluster = new EcsCluster(this, `${id}Ecs`, ecsProps);
1001
1002
  this.connections = this.ecsCluster.connections;
@@ -848,7 +848,7 @@ export interface EcsServiceConfig {
848
848
  ssmSecretsPath?: string;
849
849
  /**
850
850
  * Per-service alarm configuration.
851
- * - undefined: use defaults (CPU, memory, running tasks, 5xx if ALB)
851
+ * - undefined: use defaults (CPU, memory, running tasks, 5xx + p99 response time if ALB)
852
852
  * - false: disable alarms for this service
853
853
  * - object: override specific thresholds
854
854
  */
@@ -1067,6 +1067,14 @@ export interface EcsComputeProps {
1067
1067
  * opt-out for cost-sensitive clusters.
1068
1068
  */
1069
1069
  taskStopWatchdog?: false;
1070
+ /**
1071
+ * CloudWatch Container Insights (default off). Enabling it bills
1072
+ * ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
1073
+ * plus performance-log ingestion; nothing in the constructs consumes that
1074
+ * namespace (alarms sit on `AWS/ECS` and `Fjall/ECS`), so it is an explicit
1075
+ * opt-in for clusters whose operators read the per-task console breakdown.
1076
+ */
1077
+ containerInsights?: true;
1070
1078
  /** Application ID for alarm tagging (used by webhook to map alarms to applications). */
1071
1079
  applicationId?: string;
1072
1080
  }
@@ -17,6 +17,7 @@ const COMPUTE_PROP_APPLICABILITY = {
17
17
  alertsTopic: ["ecs"],
18
18
  applicationId: ["ecs"],
19
19
  taskStopWatchdog: ["ecs"],
20
+ containerInsights: ["ecs"],
20
21
  deployment: ["lambda"],
21
22
  functionUrl: ["lambda"],
22
23
  handler: ["lambda"],
@@ -266,6 +266,10 @@ export class DevSubstrate extends Construct {
266
266
  // an explicit substrate destroy must not strand the repo (BUG-5's
267
267
  // orphan-repo wedge on rollback).
268
268
  removalPolicy: "DESTROY"
269
+ // Construct-default untagged-only expiry applies deliberately: slot
270
+ // task defs pin images by TAG, so nothing a running slot restarts
271
+ // from is ever untagged — while each same-branch repush orphans its
272
+ // predecessor as untagged, exactly what the default reclaims.
269
273
  });
270
274
  }
271
275
  // Buildx registry cache for slot builds, named by the same helper as the app
@@ -282,6 +286,9 @@ export class DevSubstrate extends Construct {
282
286
  else {
283
287
  const slotCacheRepository = new Ecr(this, `${slotEcrId}Cache`, {
284
288
  repositoryName: slotCacheRepositoryName,
289
+ // Owns its single untagged rule below; the construct default would
290
+ // add a second untagged selector, which ECR rejects.
291
+ lifecycle: false,
285
292
  // buildx overwrites the per-repo cache manifest tag on every export.
286
293
  tagMutability: TagMutability.MUTABLE,
287
294
  // Rebuildable cache blobs; pinned so an explicit destroy reclaims the
@@ -384,7 +384,9 @@ export default class EcsCluster extends Construct {
384
384
  const cluster = new CdkCluster(this, `${props.clusterName}Cluster`, {
385
385
  vpc: props.vpc,
386
386
  clusterName: props.clusterName,
387
- containerInsightsV2: ContainerInsights.ENABLED,
387
+ containerInsightsV2: props.containerInsights === true
388
+ ? ContainerInsights.ENABLED
389
+ : ContainerInsights.DISABLED,
388
390
  enableFargateCapacityProviders: needsFargate
389
391
  });
390
392
  new CfnOutput(this, `${this.outputName}DeployableCluster`, {
@@ -618,7 +618,7 @@ export interface EcsServiceProps {
618
618
  docker?: DockerBuild;
619
619
  /**
620
620
  * Per-service alarm configuration.
621
- * - undefined: use defaults (CPU, memory, running tasks, 5xx if ALB)
621
+ * - undefined: use defaults (CPU, memory, running tasks, 5xx + p99 response time if ALB)
622
622
  * - false: disable alarms for this service
623
623
  * - object: override specific thresholds
624
624
  */
@@ -748,6 +748,14 @@ export interface EcsClusterProps {
748
748
  * auditable opt-out for cost-sensitive clusters.
749
749
  */
750
750
  taskStopWatchdog?: false;
751
+ /**
752
+ * CloudWatch Container Insights (default off). Enabling it bills
753
+ * ~$0.30/metric-month of custom `ECS/ContainerInsights` metrics per cluster
754
+ * plus performance-log ingestion; nothing in the constructs consumes that
755
+ * namespace (alarms sit on `AWS/ECS` and `Fjall/ECS`), so it is an explicit
756
+ * opt-in for clusters whose operators read the per-task console breakdown.
757
+ */
758
+ containerInsights?: true;
751
759
  }
752
760
  /**
753
761
  * Data tracked for each service in the cluster.
@@ -0,0 +1,35 @@
1
+ import { type IConstruct } from "constructs";
2
+ /**
3
+ * Give every Lambda function in the tree an explicit, retention-capped log
4
+ * group.
5
+ *
6
+ * Fjall's own lambda constructs already pass `logGroup:` at creation, but
7
+ * aws-cdk-lib plants handler functions of its own the wrappers never see —
8
+ * custom-resource providers, bucket-notification handlers, cross-account
9
+ * zone-delegation singletons, the `AWS679f53fac` SDK-call framework. Left
10
+ * alone, each of those logs to a runtime-created group with NO retention
11
+ * (never-expire, billed forever) that is absent from the template and so
12
+ * survives stack teardown as an orphan. The 2026-08-24 posture dogfood found
13
+ * 73 such groups across our own six accounts
14
+ * (`CLOUDWATCH_LOG_RETENTION_MISSING`) and 12 orphaned by torn-down stacks
15
+ * (`ORPHANED_LAMBDA_LOG_GROUP`).
16
+ *
17
+ * For every `AWS::Lambda::Function` with no `loggingConfig`, this pass
18
+ * creates a sibling {@link LogGroup} (framework retention default, env-aware
19
+ * removal policy) and points the function at it, so the group lives IN the
20
+ * template: retention is capped, and teardown takes the group with the stack.
21
+ * A function that already carries a `loggingConfig` — every function built
22
+ * through Fjall's wrappers, or any explicit caller choice — is left alone.
23
+ *
24
+ * Runs as a pre-synth tree pass from `App.synth()`, not an Aspect: a
25
+ * construct added mid-aspect-traversal is skipped by the remaining aspects,
26
+ * so an Aspect-created group would silently miss the app-wide tag aspects.
27
+ * Runs once per root: the cross-region export reader/writer providers are
28
+ * planted DURING `super.synth()`, after any user hook can reach them — they
29
+ * keep their runtime-created groups (the one known gap), and re-running on a
30
+ * repeated `synth()` would trip `ConstructTreeModifiedAfterSynth` trying to
31
+ * cover them. Declared `AWS::Logs::LogGroup` resources are deliberately NOT touched — at
32
+ * the CFN layer an explicit `RetentionDays.INFINITE` choice renders as an
33
+ * absent `retentionInDays`, indistinguishable from an unset default.
34
+ */
35
+ export declare function ensureFunctionLogGroups(root: IConstruct): void;
@@ -0,0 +1,78 @@
1
+ import { CfnResource } from "aws-cdk-lib";
2
+ import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "./logGroup.js";
3
+ const HYGIENE_LOG_GROUP_ID_SUFFIX = "FjallLogs";
4
+ const processedRoots = new WeakSet();
5
+ /**
6
+ * Give every Lambda function in the tree an explicit, retention-capped log
7
+ * group.
8
+ *
9
+ * Fjall's own lambda constructs already pass `logGroup:` at creation, but
10
+ * aws-cdk-lib plants handler functions of its own the wrappers never see —
11
+ * custom-resource providers, bucket-notification handlers, cross-account
12
+ * zone-delegation singletons, the `AWS679f53fac` SDK-call framework. Left
13
+ * alone, each of those logs to a runtime-created group with NO retention
14
+ * (never-expire, billed forever) that is absent from the template and so
15
+ * survives stack teardown as an orphan. The 2026-08-24 posture dogfood found
16
+ * 73 such groups across our own six accounts
17
+ * (`CLOUDWATCH_LOG_RETENTION_MISSING`) and 12 orphaned by torn-down stacks
18
+ * (`ORPHANED_LAMBDA_LOG_GROUP`).
19
+ *
20
+ * For every `AWS::Lambda::Function` with no `loggingConfig`, this pass
21
+ * creates a sibling {@link LogGroup} (framework retention default, env-aware
22
+ * removal policy) and points the function at it, so the group lives IN the
23
+ * template: retention is capped, and teardown takes the group with the stack.
24
+ * A function that already carries a `loggingConfig` — every function built
25
+ * through Fjall's wrappers, or any explicit caller choice — is left alone.
26
+ *
27
+ * Runs as a pre-synth tree pass from `App.synth()`, not an Aspect: a
28
+ * construct added mid-aspect-traversal is skipped by the remaining aspects,
29
+ * so an Aspect-created group would silently miss the app-wide tag aspects.
30
+ * Runs once per root: the cross-region export reader/writer providers are
31
+ * planted DURING `super.synth()`, after any user hook can reach them — they
32
+ * keep their runtime-created groups (the one known gap), and re-running on a
33
+ * repeated `synth()` would trip `ConstructTreeModifiedAfterSynth` trying to
34
+ * cover them. Declared `AWS::Logs::LogGroup` resources are deliberately NOT touched — at
35
+ * the CFN layer an explicit `RetentionDays.INFINITE` choice renders as an
36
+ * absent `retentionInDays`, indistinguishable from an unset default.
37
+ */
38
+ export function ensureFunctionLogGroups(root) {
39
+ if (processedRoots.has(root)) {
40
+ return;
41
+ }
42
+ processedRoots.add(root);
43
+ for (const node of root.node.findAll()) {
44
+ if (!CfnResource.isCfnResource(node) ||
45
+ node.cfnResourceType !== "AWS::Lambda::Function") {
46
+ continue;
47
+ }
48
+ // Two shapes carry this resource type: a genuine CfnFunction (L1/L2),
49
+ // and the raw CfnResource the lightweight CustomResourceProvider
50
+ // framework renders. Only the former has the `loggingConfig` accessor;
51
+ // the latter cannot express a logging config at all, so unset there is
52
+ // structural. Prototype probing, not `instanceof` — a duplicate
53
+ // aws-cdk-lib copy in a consumer tree would fail the instanceof.
54
+ const isFunction = "loggingConfig" in node;
55
+ if (isFunction && node.loggingConfig !== undefined) {
56
+ continue;
57
+ }
58
+ const scope = node.node.scope;
59
+ if (scope === undefined) {
60
+ continue;
61
+ }
62
+ const id = `${node.node.id}${HYGIENE_LOG_GROUP_ID_SUFFIX}`;
63
+ if (scope.node.tryFindChild(id) !== undefined) {
64
+ continue;
65
+ }
66
+ const group = new LogGroup(scope, id, {
67
+ retention: DEFAULT_FRAMEWORK_LOG_RETENTION
68
+ });
69
+ if (isFunction) {
70
+ node.loggingConfig = { logGroup: group.logGroupName };
71
+ }
72
+ else {
73
+ node.addPropertyOverride("LoggingConfig", {
74
+ LogGroup: group.logGroupName
75
+ });
76
+ }
77
+ }
78
+ }
@@ -1,4 +1,5 @@
1
1
  import { Construct } from "constructs";
2
+ import { type IKey } from "aws-cdk-lib/aws-kms";
2
3
  import { type ITopic } from "aws-cdk-lib/aws-sns";
3
4
  import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
4
5
  import { type RemovalPolicyString } from "./utils.js";
@@ -21,6 +22,19 @@ export interface SNSTopicProps {
21
22
  * legacy consumer that cannot verify SHA-256.
22
23
  */
23
24
  signatureVersion?: "1" | "2";
25
+ /**
26
+ * KMS key for server-side encryption at rest. Absent ⇒ unencrypted — the
27
+ * accessible default (a customer-managed key costs $1–3/month per
28
+ * account-region once annual rotation accrues), so the
29
+ * SNS_TOPIC_NOT_ENCRYPTED posture finding stands
30
+ * as the signpost to this knob rather than being silently pre-paid.
31
+ *
32
+ * Every AWS service principal that publishes to the topic needs
33
+ * `kms:Decrypt` + `kms:GenerateDataKey*` on this key's RESOURCE policy or
34
+ * its publishes fail silently — the AWS-managed `aws/sns` key cannot be
35
+ * used for service publishers precisely because its policy is immutable.
36
+ */
37
+ masterKey?: IKey;
24
38
  }
25
39
  export declare class SNSTopic extends Construct {
26
40
  readonly id: string;
@@ -21,7 +21,8 @@ export class SNSTopic extends Construct {
21
21
  contentBasedDeduplication: isFifo
22
22
  ? (props.contentBasedDeduplication ?? true)
23
23
  : undefined,
24
- signatureVersion: props.signatureVersion ?? "2"
24
+ signatureVersion: props.signatureVersion ?? "2",
25
+ masterKey: props.masterKey
25
26
  });
26
27
  // An SNS topic is a transient fan-out medium: it holds no durable state
27
28
  // (subscriptions are re-created by the next deploy), so the wrapper defaults
@@ -16,6 +16,11 @@ export interface EcsServiceAlarmThresholds {
16
16
  /** 5xx error-rate % threshold (ALB services only). `false` disables the
17
17
  * 5xx alarm; the p99 response-time alarm is unaffected. */
18
18
  http5xxThreshold?: number | false;
19
+ /** p99 response-time threshold in milliseconds (ALB services only). `false`
20
+ * disables the p99 alarm — for services dominated by deliberately
21
+ * long-lived responses (SSE/streaming), which inflate ALB
22
+ * TargetResponseTime without any request being slow. */
23
+ p99ResponseTimeThresholdMs?: number | false;
19
24
  }
20
25
  export interface EcsServiceAlarmsProps {
21
26
  scope: Construct;
@@ -78,20 +78,27 @@ export function createEcsServiceAlarms(props) {
78
78
  });
79
79
  registerAlarm(http5xxAlarm, snsAction, alarms);
80
80
  }
81
- const p99Alarm = new Alarm(scope, `${serviceName}P99ResponseTimeAlarm`, {
82
- alarmDescription: buildAlarmDescription(`ECS service ${serviceName} p99 response time exceeds threshold`, applicationId),
83
- metric: targetGroup.metrics.targetResponseTime({
84
- period: ALARM_DEFAULTS.EVALUATION_PERIOD,
85
- statistic: "p99"
86
- }),
87
- // Response time metric is in seconds
88
- threshold: ALARM_DEFAULTS.ALB.P99_RESPONSE_TIME_MS / 1000,
89
- evaluationPeriods: 2,
90
- datapointsToAlarm: 2,
91
- comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
92
- treatMissingData: TreatMissingData.NOT_BREACHING
93
- });
94
- registerAlarm(p99Alarm, snsAction, alarms);
81
+ if (config.p99ResponseTimeThresholdMs !== false) {
82
+ const p99Alarm = new Alarm(scope, `${serviceName}P99ResponseTimeAlarm`, {
83
+ alarmDescription: buildAlarmDescription(`ECS service ${serviceName} p99 response time exceeds threshold`, applicationId),
84
+ metric: targetGroup.metrics.targetResponseTime({
85
+ period: ALARM_DEFAULTS.EVALUATION_PERIOD,
86
+ statistic: "p99"
87
+ }),
88
+ // Response time metric is in seconds
89
+ threshold: (config.p99ResponseTimeThresholdMs ??
90
+ ALARM_DEFAULTS.ALB.P99_RESPONSE_TIME_MS) / 1000,
91
+ // 3-of-3 (15 min sustained), not 2-of-2: at low traffic one
92
+ // long-lived SSE/streaming response IS the window's p99, so isolated
93
+ // one-to-two-window spikes are routine and carry no capacity signal.
94
+ // Only a sustained breach notifies.
95
+ evaluationPeriods: 3,
96
+ datapointsToAlarm: 3,
97
+ comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
98
+ treatMissingData: TreatMissingData.NOT_BREACHING
99
+ });
100
+ registerAlarm(p99Alarm, snsAction, alarms);
101
+ }
95
102
  }
96
103
  tagAlarmsWithApplicationId(alarms, applicationId);
97
104
  return alarms;
@@ -19,8 +19,8 @@ interface CustomerManagedKeyProps {
19
19
  * stack leaving a chargeable key behind forever.
20
20
  * - `"outlives-stack"` — at least one encrypted resource survives (an RDS
21
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.
22
+ * a backup vault). The key RETAINs in every environment: $1–3/month is
23
+ * not a reason to render a surviving snapshot unrestorable.
24
24
  *
25
25
  * A caller that knows better may still override with `removalPolicy` — the
26
26
  * teardown paths that delete the survivor first legitimately do.
@@ -32,6 +32,11 @@ export class CustomerManagedKey extends Construct {
32
32
  : toRemovalPolicy(envAwareRemovalPolicyDefault()));
33
33
  this.key = new Key(this, `${id}Key`, {
34
34
  description: props.description || `${id} KMS Key`,
35
+ // Rotation is NOT free: AWS bills +$1/month for each of the first two
36
+ // rotated key versions (capped there), so a rotated CMK converges on
37
+ // $3/month — still not a reason to skip rotation on a key guarding
38
+ // customer data, but the cost claim must stay honest.
39
+ enableKeyRotation: true,
35
40
  removalPolicy,
36
41
  // Keyed on the RESOLVED policy: reading props.removalPolicy here meant
37
42
  // a key that resolved to DESTROY any other way got no window at all.
@@ -1,5 +1,5 @@
1
1
  import { type Construct } from "constructs";
2
- import { Repository, type RepositoryProps, TagMutability } from "aws-cdk-lib/aws-ecr";
2
+ import { type LifecycleRule, Repository, type RepositoryProps, TagMutability } from "aws-cdk-lib/aws-ecr";
3
3
  import type App from "../../../app.js";
4
4
  import { type StackBuilder } from "../base/awsStack.js";
5
5
  interface EcrProps {
@@ -13,13 +13,53 @@ interface EcrProps {
13
13
  * takes the repository AND every image in it.
14
14
  */
15
15
  removalPolicy?: "DESTROY" | "RETAIN";
16
+ /**
17
+ * Image-reclaim policy (default `ECR_LIFECYCLE_DEFAULTS`): untagged images
18
+ * expire after `untaggedRetentionDays`. Every push from CI is one image, so
19
+ * without a rule a repository grows without bound (fjall.io's own web-app
20
+ * repo reached 1,473 images / ~55 GB before this default existed). `false`
21
+ * disables the rule set — callers that add their own rules MUST pass it,
22
+ * because ECR allows exactly one untagged-selecting rule per policy.
23
+ */
24
+ lifecycle?: EcrLifecycle | false;
25
+ }
26
+ export interface EcrLifecycle {
27
+ /**
28
+ * OPT-IN count cap: newest tagged images to keep; older ones are expired.
29
+ * Deliberately NOT part of the default — count expiry is release-blind
30
+ * (it cannot know which images retained Releases still reference), and the
31
+ * webapp's release-aware retention janitor treats any `tagStatus: any` +
32
+ * `imageCountMoreThan` rule as the retired shape it supersedes
33
+ * (`partitionCountBasedAnyTagRules`, webapp ecrRetentionSweep.ts) and strips it
34
+ * from release-referenced repositories on monitored accounts. Reach for it
35
+ * only on repositories outside release monitoring.
36
+ */
37
+ maxImageCount?: number;
38
+ /** Days an untagged image survives (a superseded manifest, a failed push).
39
+ * Safe for buildx provenance/SBOM pushes: ECR never expires an image still
40
+ * referenced by a live manifest list, so the untagged attestation/platform
41
+ * children of a tagged index only become reclaimable once the index goes. */
42
+ untaggedRetentionDays?: number;
16
43
  }
44
+ /**
45
+ * Untagged-only by default. Tagged-image retention on monitored accounts is
46
+ * the webapp janitor's job (release-aware: keep-floor, min-age, release
47
+ * references); a construct-side count rule would both duplicate it blindly
48
+ * and be stripped by it as the retired count-based shape — taking the
49
+ * untagged rule down with the policy before the 2026-08 surgical rewrite.
50
+ */
51
+ export declare const ECR_LIFECYCLE_DEFAULTS: {
52
+ readonly untaggedRetentionDays: 7;
53
+ };
17
54
  export declare class EcrFactory {
18
55
  static build(id: string, props?: EcrProps): (app: App, scope: Construct) => Ecr;
19
56
  }
20
57
  export declare class Ecr extends Repository {
21
58
  constructor(scope: Construct, id: string, props?: EcrProps);
22
59
  static getRepositoryProps(props?: EcrProps): RepositoryProps;
60
+ /** Untagged rule first — ECR requires the untagged selector to carry the
61
+ * highest priority, and CDK requires the `ANY` rule to carry the lowest. */
62
+ static lifecycleRules(lifecycle?: EcrLifecycle): LifecycleRule[];
23
63
  static build(id: string, props?: EcrProps): (scope: StackBuilder) => Ecr;
24
64
  }
25
65
  export {};
@@ -1,6 +1,16 @@
1
- import { Repository, TagMutability } from "aws-cdk-lib/aws-ecr";
2
- import { CfnOutput, RemovalPolicy } from "aws-cdk-lib";
1
+ import { Repository, TagMutability, TagStatus } from "aws-cdk-lib/aws-ecr";
2
+ import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
3
3
  import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
4
+ /**
5
+ * Untagged-only by default. Tagged-image retention on monitored accounts is
6
+ * the webapp janitor's job (release-aware: keep-floor, min-age, release
7
+ * references); a construct-side count rule would both duplicate it blindly
8
+ * and be stripped by it as the retired count-based shape — taking the
9
+ * untagged rule down with the policy before the 2026-08 surgical rewrite.
10
+ */
11
+ export const ECR_LIFECYCLE_DEFAULTS = {
12
+ untaggedRetentionDays: 7
13
+ };
4
14
  export class EcrFactory {
5
15
  static build(id, props) {
6
16
  return (app, scope) => {
@@ -35,9 +45,37 @@ export class Ecr extends Repository {
35
45
  // delete outright. Gotcha: DESTROY wipes every pushed image, so a
36
46
  // redeploy pulls "not found" for any tag the old repository held.
37
47
  emptyOnDelete: destroy,
38
- removalPolicy: destroy ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN
48
+ removalPolicy: destroy ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN,
49
+ ...(props?.lifecycle !== false && {
50
+ lifecycleRules: Ecr.lifecycleRules(props?.lifecycle)
51
+ })
39
52
  };
40
53
  }
54
+ /** Untagged rule first — ECR requires the untagged selector to carry the
55
+ * highest priority, and CDK requires the `ANY` rule to carry the lowest. */
56
+ static lifecycleRules(lifecycle) {
57
+ const untaggedRetentionDays = lifecycle?.untaggedRetentionDays ??
58
+ ECR_LIFECYCLE_DEFAULTS.untaggedRetentionDays;
59
+ const maxImageCount = lifecycle?.maxImageCount;
60
+ return [
61
+ {
62
+ rulePriority: 1,
63
+ description: `Expire untagged images after ${untaggedRetentionDays} days`,
64
+ tagStatus: TagStatus.UNTAGGED,
65
+ maxImageAge: Duration.days(untaggedRetentionDays)
66
+ },
67
+ ...(maxImageCount !== undefined
68
+ ? [
69
+ {
70
+ rulePriority: 2,
71
+ description: `Keep the newest ${maxImageCount} images`,
72
+ tagStatus: TagStatus.ANY,
73
+ maxImageCount
74
+ }
75
+ ]
76
+ : [])
77
+ ];
78
+ }
41
79
  static build(id, props) {
42
80
  return (scope) => new Ecr(scope.getStack(), id, props);
43
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "18.0.0",
3
+ "version": "19.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^18.0.0",
84
- "@fjall/util": "^18.0.0",
83
+ "@fjall/generator": "^19.0.0",
84
+ "@fjall/util": "^19.0.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {