@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 +4 -0
- package/dist/lib/config/aws/alarmTopic.d.ts +20 -0
- package/dist/lib/config/aws/alarmTopic.js +21 -1
- package/dist/lib/patterns/aws/account.d.ts +8 -0
- package/dist/lib/patterns/aws/account.js +3 -0
- package/dist/lib/patterns/aws/computeEcs.js +2 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +9 -1
- package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
- package/dist/lib/patterns/aws/devSubstrate.js +7 -0
- package/dist/lib/resources/aws/compute/ecs.js +3 -1
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +9 -1
- package/dist/lib/resources/aws/logging/logGroupHygiene.d.ts +35 -0
- package/dist/lib/resources/aws/logging/logGroupHygiene.js +78 -0
- package/dist/lib/resources/aws/messaging/sns.d.ts +14 -0
- package/dist/lib/resources/aws/messaging/sns.js +2 -1
- package/dist/lib/resources/aws/monitoring/ecsAlarms.d.ts +5 -0
- package/dist/lib/resources/aws/monitoring/ecsAlarms.js +21 -14
- package/dist/lib/resources/aws/secrets/kms.d.ts +2 -2
- package/dist/lib/resources/aws/secrets/kms.js +5 -0
- package/dist/lib/resources/aws/storage/ecr.d.ts +41 -1
- package/dist/lib/resources/aws/storage/ecr.js +41 -3
- package/package.json +3 -3
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
|
}
|
|
@@ -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:
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
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": "
|
|
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": "^
|
|
84
|
-
"@fjall/util": "^
|
|
83
|
+
"@fjall/generator": "^19.0.0",
|
|
84
|
+
"@fjall/util": "^19.0.0",
|
|
85
85
|
"constructs": "^10.7.2"
|
|
86
86
|
},
|
|
87
87
|
"overrides": {
|