@fjall/components-infrastructure 2.9.1 → 2.12.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/config/aws/disasterRecovery.js +23 -4
- package/dist/lib/config/aws/ecrDefaultImage.js +2 -2
- package/dist/lib/config/aws/s3BlockPublicAccess.d.ts +22 -4
- package/dist/lib/config/aws/s3BlockPublicAccess.js +33 -13
- package/dist/lib/config/aws/scpPreset.js +6 -1
- package/dist/lib/patterns/aws/account.d.ts +15 -0
- package/dist/lib/patterns/aws/account.js +32 -6
- package/dist/lib/patterns/aws/buildkite.js +1 -1
- package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
- package/dist/lib/patterns/aws/compute.d.ts +22 -0
- package/dist/lib/patterns/aws/compute.js +42 -0
- package/dist/lib/patterns/aws/computeEcs.d.ts +2 -1
- package/dist/lib/patterns/aws/computeEcs.js +67 -24
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -2
- package/dist/lib/patterns/aws/organisation.d.ts +19 -8
- package/dist/lib/patterns/aws/organisation.js +23 -10
- package/dist/lib/patterns/aws/organisationFactory.js +2 -1
- package/dist/lib/patterns/aws/platform.d.ts +1 -0
- package/dist/lib/patterns/aws/platform.js +3 -0
- package/dist/lib/resources/aws/backup/backupVault.js +5 -3
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +1 -1
- package/dist/lib/resources/aws/compute/ecsConstants.js +4 -1
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +2 -2
- package/dist/lib/resources/aws/compute/ecsRoles.js +4 -13
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +5 -3
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +76 -3
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +0 -5
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +2 -20
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +50 -8
- package/dist/lib/resources/aws/compute/ecsTypes.js +11 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +37 -0
- package/dist/lib/resources/aws/compute/lambda.d.ts +11 -0
- package/dist/lib/resources/aws/compute/lambda.js +23 -3
- package/dist/lib/resources/aws/secrets/parameter.js +3 -3
- package/dist/lib/resources/aws/secrets/secret.d.ts +32 -11
- package/dist/lib/resources/aws/secrets/secret.js +53 -0
- package/dist/lib/resources/aws/utilities/customResource.d.ts +4 -0
- package/dist/lib/resources/aws/utilities/customResource.js +4 -0
- package/dist/lib/utils/env.js +2 -2
- package/dist/lib/utils/getConfig.js +2 -2
- package/dist/lib/utils/orgConfigParser.js +16 -2
- package/package.json +4 -4
|
@@ -54,14 +54,33 @@ export class DisasterRecovery extends Construct {
|
|
|
54
54
|
const disasterRecoveryVaultArn = disasterRecoveryAccount && disasterRecoveryRegion
|
|
55
55
|
? `arn:aws:backup:${disasterRecoveryRegion}:${disasterRecoveryAccount.id}:backup-vault:${BACKUP_VAULT_NAME}`
|
|
56
56
|
: undefined;
|
|
57
|
-
// Compliance accounts
|
|
58
|
-
|
|
57
|
+
// Compliance accounts default to governance (removable), NOT the
|
|
58
|
+
// permanently-immutable compliance lock. See
|
|
59
|
+
// decisions/2026-06-03-backup-vault-lock-mode-by-intent.md.
|
|
60
|
+
const vaultLock = account?.vaultLock ?? (isComplianceAccount ? "governance" : "none");
|
|
61
|
+
// Adopt-mode references an already-locked vault and applies no lock config,
|
|
62
|
+
// so it is exempt from the acknowledgement gate.
|
|
63
|
+
if (!adoptExistingVault &&
|
|
64
|
+
vaultLock === "compliance" &&
|
|
65
|
+
account?.acknowledgeImmutableVaultLock !== true) {
|
|
66
|
+
throw new Error(`Account "${account?.name ?? props.accountId}" requests vaultLock: ` +
|
|
67
|
+
`"compliance" (a permanently immutable backup vault) without ` +
|
|
68
|
+
`acknowledgeImmutableVaultLock: true. After a 3-day cooling-off a ` +
|
|
69
|
+
`compliance lock cannot be removed by anyone, including AWS or the ` +
|
|
70
|
+
`root user. Set the acknowledgement explicitly to proceed.`);
|
|
71
|
+
}
|
|
72
|
+
const lockConfiguration = vaultLock === "compliance"
|
|
59
73
|
? {
|
|
60
74
|
minRetention: VAULT_LOCK.MIN_RETENTION,
|
|
61
75
|
maxRetention: VAULT_LOCK.MAX_RETENTION,
|
|
62
76
|
changeableFor: VAULT_LOCK.GRACE_PERIOD
|
|
63
77
|
}
|
|
64
|
-
:
|
|
78
|
+
: vaultLock === "governance"
|
|
79
|
+
? {
|
|
80
|
+
minRetention: VAULT_LOCK.MIN_RETENTION,
|
|
81
|
+
maxRetention: VAULT_LOCK.MAX_RETENTION
|
|
82
|
+
}
|
|
83
|
+
: undefined;
|
|
65
84
|
if (adoptExistingVault) {
|
|
66
85
|
this.vaultRef = Vault.fromBackupVaultName(this, "BackupVault", BACKUP_VAULT_NAME);
|
|
67
86
|
}
|
|
@@ -138,7 +157,7 @@ export class DisasterRecovery extends Construct {
|
|
|
138
157
|
exportName: `${id}ReplicationRegion`
|
|
139
158
|
});
|
|
140
159
|
}
|
|
141
|
-
if (
|
|
160
|
+
if (vaultLock !== "none") {
|
|
142
161
|
new CfnOutput(this, "VaultLockEnabled", {
|
|
143
162
|
key: "VaultLockEnabled",
|
|
144
163
|
value: "true",
|
|
@@ -2,7 +2,7 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { CodeBuildProject } from "../../resources/aws/utilities/codeBuild.js";
|
|
3
3
|
import { BuildSpec } from "aws-cdk-lib/aws-codebuild";
|
|
4
4
|
import { LogGroup } from "../../resources/aws/logging/logGroup.js";
|
|
5
|
-
import { RemovalPolicy } from "aws-cdk-lib";
|
|
5
|
+
import { RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
6
6
|
import { Role } from "../../resources/aws/iam/role.js";
|
|
7
7
|
import { PolicyDocument, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
8
8
|
import { EventBusMessaging, EventField } from "../../patterns/aws/messaging.js";
|
|
@@ -35,7 +35,7 @@ export class EcrDefaultImage extends Construct {
|
|
|
35
35
|
"ecr:PutImage"
|
|
36
36
|
],
|
|
37
37
|
resources: [
|
|
38
|
-
`arn:
|
|
38
|
+
`arn:${Stack.of(this).partition}:ecr:${props.region}:${props.accountId}:repository/*`
|
|
39
39
|
]
|
|
40
40
|
})
|
|
41
41
|
]
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
+
export interface S3BlockPublicAccessProps {
|
|
3
|
+
/**
|
|
4
|
+
* Account whose account-level S3 Block Public Access is managed. Taken as a
|
|
5
|
+
* prop rather than read from `Stack.of(this).account` — that is a synth token
|
|
6
|
+
* that never matches a provider-account id when looking up config.
|
|
7
|
+
*/
|
|
8
|
+
accountId: string;
|
|
9
|
+
}
|
|
2
10
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
11
|
+
* Manages account-level S3 Block Public Access via a Custom Resource.
|
|
12
|
+
*
|
|
13
|
+
* Off by default: Fjall observes-and-flags exposed buckets through the posture
|
|
14
|
+
* layer rather than hard-blocking at the account level. Set
|
|
15
|
+
* `s3BlockPublicAccess: "enforced"` on a provider account to switch all four
|
|
16
|
+
* flags on. The flags ride in the custom resource's properties (not hardcoded
|
|
17
|
+
* in the handler) so a config change reconciles on the next deploy. Stack
|
|
18
|
+
* deletion is a no-op — the account-level setting is left as-is.
|
|
19
|
+
*
|
|
20
|
+
* The handler runs under a fixed `/fjall/FjallS3BpaManager` role so the
|
|
21
|
+
* DenyS3PublicAccessChanges SCP can exempt its PutAccountPublicAccessBlock by
|
|
22
|
+
* ARN pattern. The role name is account-global, so this construct is created
|
|
23
|
+
* only in an account's home region (see Account's accountGlobals gate).
|
|
6
24
|
*/
|
|
7
25
|
export declare class S3BlockPublicAccess extends Construct {
|
|
8
|
-
constructor(scope: Construct, id: string);
|
|
26
|
+
constructor(scope: Construct, id: string, props: S3BlockPublicAccessProps);
|
|
9
27
|
}
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import { Duration } from "aws-cdk-lib";
|
|
2
2
|
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
4
|
-
import { Stack } from "aws-cdk-lib";
|
|
5
4
|
import { Construct } from "constructs";
|
|
6
5
|
import { CustomResource } from "../../resources/aws/utilities/customResource.js";
|
|
6
|
+
import { getConfig } from "../../utils/getConfig.js";
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Manages account-level S3 Block Public Access via a Custom Resource.
|
|
9
|
+
*
|
|
10
|
+
* Off by default: Fjall observes-and-flags exposed buckets through the posture
|
|
11
|
+
* layer rather than hard-blocking at the account level. Set
|
|
12
|
+
* `s3BlockPublicAccess: "enforced"` on a provider account to switch all four
|
|
13
|
+
* flags on. The flags ride in the custom resource's properties (not hardcoded
|
|
14
|
+
* in the handler) so a config change reconciles on the next deploy. Stack
|
|
15
|
+
* deletion is a no-op — the account-level setting is left as-is.
|
|
16
|
+
*
|
|
17
|
+
* The handler runs under a fixed `/fjall/FjallS3BpaManager` role so the
|
|
18
|
+
* DenyS3PublicAccessChanges SCP can exempt its PutAccountPublicAccessBlock by
|
|
19
|
+
* ARN pattern. The role name is account-global, so this construct is created
|
|
20
|
+
* only in an account's home region (see Account's accountGlobals gate).
|
|
11
21
|
*/
|
|
12
22
|
export class S3BlockPublicAccess extends Construct {
|
|
13
|
-
constructor(scope, id) {
|
|
23
|
+
constructor(scope, id, props) {
|
|
14
24
|
super(scope, id);
|
|
25
|
+
const account = getConfig().providerAccounts.find((pa) => pa.id === props.accountId);
|
|
26
|
+
const enforce = (account?.s3BlockPublicAccess ?? "off") === "enforced";
|
|
15
27
|
new CustomResource(this, "S3BlockPublicAccess", {
|
|
16
28
|
runtime: Runtime.NODEJS_22_X,
|
|
17
29
|
timeout: Duration.minutes(5),
|
|
18
|
-
lambdaDescription: "
|
|
30
|
+
lambdaDescription: "Manages S3 Block Public Access at account level",
|
|
31
|
+
rolePath: "/fjall/",
|
|
32
|
+
roleName: "FjallS3BpaManager",
|
|
19
33
|
properties: {
|
|
20
|
-
AccountId:
|
|
34
|
+
AccountId: props.accountId,
|
|
35
|
+
BlockPublicAcls: String(enforce),
|
|
36
|
+
IgnorePublicAcls: String(enforce),
|
|
37
|
+
BlockPublicPolicy: String(enforce),
|
|
38
|
+
RestrictPublicBuckets: String(enforce)
|
|
21
39
|
},
|
|
22
40
|
inlinePolicy: [
|
|
23
41
|
new PolicyStatement({
|
|
@@ -32,20 +50,22 @@ export class S3BlockPublicAccess extends Construct {
|
|
|
32
50
|
inlineCode: `
|
|
33
51
|
const { S3ControlClient, PutPublicAccessBlockCommand } = require('@aws-sdk/client-s3-control');
|
|
34
52
|
|
|
53
|
+
const toBool = (v) => v === 'true' || v === true;
|
|
54
|
+
|
|
35
55
|
exports.handler = async (event) => {
|
|
36
56
|
const physicalResourceId = event.PhysicalResourceId || event.LogicalResourceId || 's3-block-public-access';
|
|
37
57
|
if (event.RequestType === 'Delete') {
|
|
38
58
|
return { PhysicalResourceId: physicalResourceId };
|
|
39
59
|
}
|
|
40
|
-
const
|
|
60
|
+
const props = event.ResourceProperties;
|
|
41
61
|
const client = new S3ControlClient({});
|
|
42
62
|
await client.send(new PutPublicAccessBlockCommand({
|
|
43
|
-
AccountId:
|
|
63
|
+
AccountId: props.AccountId,
|
|
44
64
|
PublicAccessBlockConfiguration: {
|
|
45
|
-
BlockPublicAcls:
|
|
46
|
-
IgnorePublicAcls:
|
|
47
|
-
BlockPublicPolicy:
|
|
48
|
-
RestrictPublicBuckets:
|
|
65
|
+
BlockPublicAcls: toBool(props.BlockPublicAcls),
|
|
66
|
+
IgnorePublicAcls: toBool(props.IgnorePublicAcls),
|
|
67
|
+
BlockPublicPolicy: toBool(props.BlockPublicPolicy),
|
|
68
|
+
RestrictPublicBuckets: toBool(props.RestrictPublicBuckets)
|
|
49
69
|
}
|
|
50
70
|
}));
|
|
51
71
|
return { PhysicalResourceId: physicalResourceId };
|
|
@@ -5,7 +5,12 @@ import { OrganisationPolicy } from "../../resources/aws/organisation/organisatio
|
|
|
5
5
|
// default path.
|
|
6
6
|
const EXEMPT_ROLE_PATTERNS = [
|
|
7
7
|
"arn:aws:iam::*:role/fjall/FjallDeploy*",
|
|
8
|
-
"arn:aws:iam::*:role/OrganizationAccountAccessRole"
|
|
8
|
+
"arn:aws:iam::*:role/OrganizationAccountAccessRole",
|
|
9
|
+
// The account-level S3 Block Public Access manager Lambda re-applies BPA on
|
|
10
|
+
// every deploy; its PutAccountPublicAccessBlock must survive
|
|
11
|
+
// DenyS3PublicAccessChanges. Pathed under /fjall/ so a member admin cannot
|
|
12
|
+
// forge an exempt role at the default path.
|
|
13
|
+
"arn:aws:iam::*:role/fjall/FjallS3BpaManager*"
|
|
9
14
|
];
|
|
10
15
|
const SCP_BYTE_LIMIT = 5120;
|
|
11
16
|
const IAM_POLICY_VERSION = "2012-10-17";
|
|
@@ -19,6 +19,21 @@ export declare class Account extends Stack {
|
|
|
19
19
|
readonly organisationType: OrganisationType;
|
|
20
20
|
protected readonly resolvedRegion: string;
|
|
21
21
|
constructor(scope: Construct, id: string, props: AccountProps);
|
|
22
|
+
/**
|
|
23
|
+
* Whether this account receives the OIDC deploy connector (provider +
|
|
24
|
+
* FjallDeploy role) and so can be a deploy target. Standalone/member accounts
|
|
25
|
+
* do; Platform overrides to opt in. Deliberately separate from the
|
|
26
|
+
* IPAM-output gate (which stays `this.constructor === Account`) — the two are
|
|
27
|
+
* independent, and Platform must take one but not the other.
|
|
28
|
+
*/
|
|
29
|
+
protected receivesDeployRole(): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Whether this account creates the default-ECR-image helper (the CodeBuild
|
|
32
|
+
* project that seeds a placeholder application image). App-hosting accounts
|
|
33
|
+
* need it; the organisation root runs no workloads and overrides to `false`.
|
|
34
|
+
* Deliberately separate from the OIDC/IPAM gates — the axes are independent.
|
|
35
|
+
*/
|
|
36
|
+
protected createsEcrDefaultImage(): boolean;
|
|
22
37
|
enableGuardDuty(props?: GuardDutyDetectorProps): GuardDutyDetector;
|
|
23
38
|
enableSecurityHub(props?: SecurityHubHubProps): SecurityHubHub;
|
|
24
39
|
enableConfigRecorder(props?: ConfigRecorderProps): ConfigRecorder;
|
|
@@ -59,7 +59,7 @@ export class Account extends Stack {
|
|
|
59
59
|
// (OIDC provider/role, FjallMonitoring, FjallAudit) have fixed names that
|
|
60
60
|
// collide across regions, so they are created only in the home region.
|
|
61
61
|
const accountGlobalsConfigured = this.node.tryGetContext("fjallAccountGlobalsConfigured") === "true";
|
|
62
|
-
if (
|
|
62
|
+
if (this.receivesDeployRole() &&
|
|
63
63
|
fjallOrgId &&
|
|
64
64
|
!oidcAlreadyConfigured &&
|
|
65
65
|
!accountGlobalsConfigured) {
|
|
@@ -75,10 +75,12 @@ export class Account extends Stack {
|
|
|
75
75
|
accountId: this.account,
|
|
76
76
|
region: this.resolvedRegion
|
|
77
77
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
if (this.createsEcrDefaultImage()) {
|
|
79
|
+
new EcrDefaultImage(this, "EcrDefaultImage", {
|
|
80
|
+
region: this.resolvedRegion,
|
|
81
|
+
accountId: this.account
|
|
82
|
+
});
|
|
83
|
+
}
|
|
82
84
|
const environment = config.environment ?? "unknown";
|
|
83
85
|
if (config.disasterRecoveryRegion) {
|
|
84
86
|
const isComplianceAccount = environment === "compliance";
|
|
@@ -95,9 +97,33 @@ export class Account extends Stack {
|
|
|
95
97
|
exportName: "Environment",
|
|
96
98
|
description: "Environment type for this account (e.g., production, staging, development)"
|
|
97
99
|
});
|
|
98
|
-
|
|
100
|
+
// Account-level S3 Block Public Access is account-global and runs under a
|
|
101
|
+
// fixed-name role, so (like the OIDC/Monitoring/Audit globals above) it is
|
|
102
|
+
// created only in the home region to avoid a cross-region role collision.
|
|
103
|
+
if (!accountGlobalsConfigured) {
|
|
104
|
+
new S3BlockPublicAccess(this, "S3BlockPublicAccess", { accountId });
|
|
105
|
+
}
|
|
99
106
|
new EbsDefaultEncryption(this, "EbsDefaultEncryption");
|
|
100
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether this account receives the OIDC deploy connector (provider +
|
|
110
|
+
* FjallDeploy role) and so can be a deploy target. Standalone/member accounts
|
|
111
|
+
* do; Platform overrides to opt in. Deliberately separate from the
|
|
112
|
+
* IPAM-output gate (which stays `this.constructor === Account`) — the two are
|
|
113
|
+
* independent, and Platform must take one but not the other.
|
|
114
|
+
*/
|
|
115
|
+
receivesDeployRole() {
|
|
116
|
+
return this.constructor === Account;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether this account creates the default-ECR-image helper (the CodeBuild
|
|
120
|
+
* project that seeds a placeholder application image). App-hosting accounts
|
|
121
|
+
* need it; the organisation root runs no workloads and overrides to `false`.
|
|
122
|
+
* Deliberately separate from the OIDC/IPAM gates — the axes are independent.
|
|
123
|
+
*/
|
|
124
|
+
createsEcrDefaultImage() {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
101
127
|
enableGuardDuty(props) {
|
|
102
128
|
return new GuardDutyDetector(this, "GuardDuty", props);
|
|
103
129
|
}
|
|
@@ -130,7 +130,7 @@ export class Buildkite extends Stack {
|
|
|
130
130
|
new PolicyStatement({
|
|
131
131
|
actions: ["ssm:GetParameter"],
|
|
132
132
|
resources: [
|
|
133
|
-
`arn:
|
|
133
|
+
`arn:${this.partition}:ssm:${this.region}:${this.account}:parameter${agentToken.name}`
|
|
134
134
|
]
|
|
135
135
|
})
|
|
136
136
|
]
|
|
@@ -498,6 +498,7 @@ export class ClickHouseDatabase extends Construct {
|
|
|
498
498
|
].join("\n");
|
|
499
499
|
const region = Stack.of(this).region;
|
|
500
500
|
const account = Stack.of(this).account;
|
|
501
|
+
const partition = Stack.of(this).partition;
|
|
501
502
|
const reloadParameters = {
|
|
502
503
|
DocumentName: "AWS-RunShellScript",
|
|
503
504
|
Targets: [
|
|
@@ -528,13 +529,15 @@ export class ClickHouseDatabase extends Construct {
|
|
|
528
529
|
policy: AwsCustomResourcePolicy.fromStatements([
|
|
529
530
|
new PolicyStatement({
|
|
530
531
|
actions: ["ssm:SendCommand"],
|
|
531
|
-
resources: [
|
|
532
|
+
resources: [
|
|
533
|
+
`arn:${partition}:ssm:${region}::document/AWS-RunShellScript`
|
|
534
|
+
]
|
|
532
535
|
}),
|
|
533
536
|
// Keep this statement separate — merging into the document statement
|
|
534
537
|
// would drop the tag condition (IAM conditions are per-statement).
|
|
535
538
|
new PolicyStatement({
|
|
536
539
|
actions: ["ssm:SendCommand"],
|
|
537
|
-
resources: [`arn:
|
|
540
|
+
resources: [`arn:${partition}:ec2:${region}:${account}:instance/*`],
|
|
538
541
|
conditions: {
|
|
539
542
|
StringEquals: {
|
|
540
543
|
[`aws:ResourceTag/${CLICKHOUSE_SERVER_ROLE_TAG.key}`]: CLICKHOUSE_SERVER_ROLE_TAG.value
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
2
2
|
import { type Construct } from "constructs";
|
|
3
|
+
import { type SecretImport } from "../../resources/aws/secrets/index.js";
|
|
3
4
|
import { type ComputeType, type IEcsCompute, type ILambdaCompute, type IEc2Compute, type AnyCompute, isCompute, isEcsCompute, isLambdaCompute, isEc2Compute } from "./interfaces/compute.js";
|
|
4
5
|
import type App from "../../app.js";
|
|
5
6
|
import { EcsCompute, type EcsComputeProps, type EcsServiceConfig, type EcsContainerConfig, type EcsScalingConfig, type EcsClusterConfig, type EcsRoutingConfig, type EcsCapacityProviderConfig, type ContainerDependency, type EcsMigrationsConfig, ECS_CAPACITY_PROVIDER_CONFIG, getEcsCapacityProviderConfig, ScalingType, type EcsCapacityProvider, type Ec2CapacityConfig, validateEcsProps, buildContainerConfigs, expandMigrationsSugar, type ResolvedScalingConfig, resolveScalingConfig } from "./computeEcs.js";
|
|
@@ -22,6 +23,27 @@ export interface ComputeTypeConfig {
|
|
|
22
23
|
requiresVpc: boolean;
|
|
23
24
|
}
|
|
24
25
|
export declare const COMPUTE_TYPE_CONFIG: Record<ComputeType, ComputeTypeConfig>;
|
|
26
|
+
/**
|
|
27
|
+
* Collect literal externally-managed Secrets Manager import names from a
|
|
28
|
+
* `secretsImport` map into `acc`, for the manifest's `importedSecretNames`.
|
|
29
|
+
*
|
|
30
|
+
* Excludes two shapes deploy-core must NOT DescribeSecret-resolve:
|
|
31
|
+
* - the `{ arn }` escape hatch (already a complete ARN, possibly cross-account,
|
|
32
|
+
* which the fail-closed resolver would abort on);
|
|
33
|
+
* - CDK-managed token names (a `getImport()` of a managed/cross-stack secret),
|
|
34
|
+
* which render as an intrinsic, not a literal `fromSecretNameV2` import, and
|
|
35
|
+
* cannot be resolved by name at deploy time.
|
|
36
|
+
* What remains is the literal-name set of the passed `secretsImport` map. NB this is
|
|
37
|
+
* narrower than the deleted template scanner, which walked every
|
|
38
|
+
* `AWS::ECS::TaskDefinition`: a secret declared via raw `EcsSecret.fromSecretsManager(...)`
|
|
39
|
+
* outside a `secretsImport` block (e.g. a scheduled task) is not collected here. No
|
|
40
|
+
* current consumer constructs one — revisit if that changes.
|
|
41
|
+
*
|
|
42
|
+
* Shared with computeEcs.ts: the contributor-merge outage guard reconstructs the
|
|
43
|
+
* service's emitted manifest name-set with this same helper so its "is this literal
|
|
44
|
+
* deploy-resolvable?" test stays byte-identical to what the factory actually wrote.
|
|
45
|
+
*/
|
|
46
|
+
export declare function collectImportedSecretNames(secretsImport: Record<string, SecretImport> | undefined, acc: Set<string>): void;
|
|
25
47
|
/**
|
|
26
48
|
* Default values for compute resource configuration.
|
|
27
49
|
* Centralised constants to ensure consistency across the codebase.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Token } from "aws-cdk-lib";
|
|
1
2
|
import { Runtime } from "aws-cdk-lib/aws-lambda";
|
|
2
3
|
import { isCompute, isEcsCompute, isLambdaCompute, isEc2Compute } from "./interfaces/compute.js";
|
|
3
4
|
import { warnIfPropertiesIgnored } from "../../utils/validationLogger.js";
|
|
@@ -31,6 +32,33 @@ export const COMPUTE_TYPE_CONFIG = {
|
|
|
31
32
|
requiresVpc: false
|
|
32
33
|
}
|
|
33
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* Collect literal externally-managed Secrets Manager import names from a
|
|
37
|
+
* `secretsImport` map into `acc`, for the manifest's `importedSecretNames`.
|
|
38
|
+
*
|
|
39
|
+
* Excludes two shapes deploy-core must NOT DescribeSecret-resolve:
|
|
40
|
+
* - the `{ arn }` escape hatch (already a complete ARN, possibly cross-account,
|
|
41
|
+
* which the fail-closed resolver would abort on);
|
|
42
|
+
* - CDK-managed token names (a `getImport()` of a managed/cross-stack secret),
|
|
43
|
+
* which render as an intrinsic, not a literal `fromSecretNameV2` import, and
|
|
44
|
+
* cannot be resolved by name at deploy time.
|
|
45
|
+
* What remains is the literal-name set of the passed `secretsImport` map. NB this is
|
|
46
|
+
* narrower than the deleted template scanner, which walked every
|
|
47
|
+
* `AWS::ECS::TaskDefinition`: a secret declared via raw `EcsSecret.fromSecretsManager(...)`
|
|
48
|
+
* outside a `secretsImport` block (e.g. a scheduled task) is not collected here. No
|
|
49
|
+
* current consumer constructs one — revisit if that changes.
|
|
50
|
+
*
|
|
51
|
+
* Shared with computeEcs.ts: the contributor-merge outage guard reconstructs the
|
|
52
|
+
* service's emitted manifest name-set with this same helper so its "is this literal
|
|
53
|
+
* deploy-resolvable?" test stays byte-identical to what the factory actually wrote.
|
|
54
|
+
*/
|
|
55
|
+
export function collectImportedSecretNames(secretsImport, acc) {
|
|
56
|
+
for (const si of Object.values(secretsImport ?? {})) {
|
|
57
|
+
if (si.name !== undefined && !Token.isUnresolved(si.name)) {
|
|
58
|
+
acc.add(si.name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
/**
|
|
35
63
|
* Default values for compute resource configuration.
|
|
36
64
|
* Centralised constants to ensure consistency across the codebase.
|
|
@@ -149,16 +177,25 @@ export class ComputeFactory {
|
|
|
149
177
|
}
|
|
150
178
|
// Aggregate secrets from all containers (deduplicated)
|
|
151
179
|
const allSecrets = new Set();
|
|
180
|
+
const importedSecretNames = new Set();
|
|
152
181
|
for (const container of service.containers ?? []) {
|
|
153
182
|
if (container.secrets) {
|
|
154
183
|
for (const secret of container.secrets) {
|
|
155
184
|
allSecrets.add(secret);
|
|
156
185
|
}
|
|
157
186
|
}
|
|
187
|
+
collectImportedSecretNames(container.secretsImport, importedSecretNames);
|
|
158
188
|
}
|
|
189
|
+
// The synthetic migrate container resolves imports from its own
|
|
190
|
+
// `migrations.secretsImport` (the primary's are collected above).
|
|
191
|
+
collectImportedSecretNames(service.migrations?.secretsImport, importedSecretNames);
|
|
159
192
|
if (allSecrets.size > 0) {
|
|
160
193
|
manifestService.secrets = Array.from(allSecrets);
|
|
161
194
|
}
|
|
195
|
+
if (importedSecretNames.size > 0) {
|
|
196
|
+
manifestService.importedSecretNames =
|
|
197
|
+
Array.from(importedSecretNames);
|
|
198
|
+
}
|
|
162
199
|
// Include ssmSecretsPath (explicit or derived)
|
|
163
200
|
manifestService.ssmSecretsPath =
|
|
164
201
|
service.ssmSecretsPath ??
|
|
@@ -186,6 +223,11 @@ export class ComputeFactory {
|
|
|
186
223
|
lambdaComputeProps.secrets.length > 0) {
|
|
187
224
|
manifestLambda.secrets = lambdaComputeProps.secrets;
|
|
188
225
|
}
|
|
226
|
+
const lambdaImportedSecretNames = new Set();
|
|
227
|
+
collectImportedSecretNames(lambdaComputeProps.secretsImport, lambdaImportedSecretNames);
|
|
228
|
+
if (lambdaImportedSecretNames.size > 0) {
|
|
229
|
+
manifestLambda.importedSecretNames = Array.from(lambdaImportedSecretNames);
|
|
230
|
+
}
|
|
189
231
|
collector.addLambda(manifestLambda);
|
|
190
232
|
return new LambdaCompute(scope, id, lambdaComputeProps);
|
|
191
233
|
}
|
|
@@ -8,7 +8,7 @@ import { type SecretImport } from "../../resources/aws/secrets/index.js";
|
|
|
8
8
|
import EcsCluster, { type EcsClusterProps } from "../../resources/aws/compute/ecs.js";
|
|
9
9
|
export { ScalingType } from "./computeEcsTypes.js";
|
|
10
10
|
export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsServiceConfig, EcsComputeProps } from "./computeEcsTypes.js";
|
|
11
|
-
import { ScalingType, type EcsCapacityProviderConfig, type EcsCapacityProvider, type EcsContainerConfig, type EcsScalingConfig, type EcsServiceConfig, type EcsComputeProps } from "./computeEcsTypes.js";
|
|
11
|
+
import { ScalingType, type EcsCapacityProviderConfig, type EcsCapacityProvider, type EcsContainerConfig, type EcsScalingConfig, type EcsServiceConfig, type EcsComputeProps, type QueueScalingConfig } from "./computeEcsTypes.js";
|
|
12
12
|
export declare const ECS_CAPACITY_PROVIDER_CONFIG: Record<EcsCapacityProvider, EcsCapacityProviderConfig>;
|
|
13
13
|
export declare function getEcsCapacityProviderConfig(provider: EcsCapacityProvider): EcsCapacityProviderConfig;
|
|
14
14
|
/**
|
|
@@ -61,6 +61,7 @@ export interface ResolvedScalingConfig {
|
|
|
61
61
|
scalingType: ScalingType | undefined;
|
|
62
62
|
minCapacity: number | undefined;
|
|
63
63
|
maxCapacity: number | undefined;
|
|
64
|
+
queueScaling: QueueScalingConfig | undefined;
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
67
|
* Resolve scaling configuration from service props.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
|
|
3
3
|
import { Effect, Grant, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
|
-
import { Annotations, Stack } from "aws-cdk-lib";
|
|
4
|
+
import { Annotations, Stack, Token } from "aws-cdk-lib";
|
|
5
5
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
6
|
-
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
|
|
7
6
|
import { StringParameter } from "aws-cdk-lib/aws-ssm";
|
|
8
7
|
import { Construct } from "constructs";
|
|
9
8
|
import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
|
|
10
9
|
import { EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, isClickHouseDatabase, isDatabase, isRelationalDatabase } from "./interfaces/database.js";
|
|
11
10
|
import { isConnectionConfig } from "../../utils/connector.js";
|
|
12
11
|
import { isMigrationContributor } from "./interfaces/migrationContributor.js";
|
|
12
|
+
import { resolveImportedSecret } from "../../resources/aws/secrets/index.js";
|
|
13
13
|
import App from "../../app.js";
|
|
14
14
|
import EcsCluster from "../../resources/aws/compute/ecs.js";
|
|
15
15
|
import { createScheduledTaskDefinition, createMigrationTaskDefinition } from "../../resources/aws/compute/ecsTaskDefinition.js";
|
|
@@ -22,7 +22,7 @@ import { vpcHasNatGateways } from "../../utils/vpcUtils.js";
|
|
|
22
22
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
23
23
|
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
24
24
|
import { VALIDATION_PATTERNS } from "@fjall/generator";
|
|
25
|
-
import { COMPUTE_DEFAULTS } from "./compute.js";
|
|
25
|
+
import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
|
|
26
26
|
import { isHookMigrations } from "./computeEcsTypes.js";
|
|
27
27
|
export { ScalingType } from "./computeEcsTypes.js";
|
|
28
28
|
import { ScalingType } from "./computeEcsTypes.js";
|
|
@@ -373,6 +373,13 @@ function collectMigrationContributions(service) {
|
|
|
373
373
|
}
|
|
374
374
|
if (contribution.secretsImport !== undefined) {
|
|
375
375
|
for (const [key, value] of Object.entries(contribution.secretsImport)) {
|
|
376
|
+
// The literal-external-name outage guard runs POST-merge in
|
|
377
|
+
// `wireLifecycleHookMigrations`, not here: a literal a contributor supplies
|
|
378
|
+
// can be legitimately overridden by the author (`migrations.secretsImport.<key>
|
|
379
|
+
// = { arn }`) or coincide with a manifest-collected service import, both of
|
|
380
|
+
// which deploy fine. Throwing pre-merge here would pre-empt those escapes
|
|
381
|
+
// (the 2026-06-04 outage shape is only reachable if the merged literal is
|
|
382
|
+
// ALSO absent from the deploy manifest — checked once the merge has settled).
|
|
376
383
|
const prior = secretOrigin.get(key);
|
|
377
384
|
if (prior !== undefined &&
|
|
378
385
|
merged.secretsImport[key]?.name !== value.name) {
|
|
@@ -454,6 +461,45 @@ function mergeContributionsIntoSeparateTaskDef(separateTaskDef, contributions) {
|
|
|
454
461
|
...(mergedEgress.length > 0 && { egressTo: mergedEgress })
|
|
455
462
|
};
|
|
456
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Outage guard (2026-06-04): a literal (non-token) secret NAME in the migrate task's
|
|
466
|
+
* merged `secretsImport` renders a suffixless partial ARN that AccessDenies at task
|
|
467
|
+
* launch UNLESS @fjall/deploy-core resolves it to a complete ARN. deploy-core resolves
|
|
468
|
+
* exactly the names the build manifest carries in `importedSecretNames` — which the
|
|
469
|
+
* factory (compute.ts) collects from the service's own container + author-declared
|
|
470
|
+
* `migrations.secretsImport`, but NOT from contributor-derived credentials (merged AFTER
|
|
471
|
+
* the manifest was written). So a merged literal absent from that name-set is
|
|
472
|
+
* unresolvable and would ship the outage shape; fail loud at synth instead.
|
|
473
|
+
*
|
|
474
|
+
* Passes (correctly): an author `{ arn }` override (complete ARN, no name to resolve), a
|
|
475
|
+
* managed `getImport()` token (`Token.isUnresolved`), and a contributor literal that
|
|
476
|
+
* COINCIDES with a manifest-collected service import (deploy-core resolves it anyway).
|
|
477
|
+
* The reconstruction here reuses the factory's own `collectImportedSecretNames`, so the
|
|
478
|
+
* name-set stays identical to what was actually written.
|
|
479
|
+
*/
|
|
480
|
+
function assertMigrationSecretsResolvable(service, effectiveMigrations) {
|
|
481
|
+
const manifestResolvableNames = new Set();
|
|
482
|
+
for (const container of service.containers ?? []) {
|
|
483
|
+
collectImportedSecretNames(container.secretsImport, manifestResolvableNames);
|
|
484
|
+
}
|
|
485
|
+
collectImportedSecretNames(service.migrations?.secretsImport, manifestResolvableNames);
|
|
486
|
+
for (const [key, value] of Object.entries(effectiveMigrations.secretsImport ?? {})) {
|
|
487
|
+
if (value.name !== undefined &&
|
|
488
|
+
!Token.isUnresolved(value.name) &&
|
|
489
|
+
!manifestResolvableNames.has(value.name)) {
|
|
490
|
+
throw new Error(`Service '${service.name}': migration secretsImport '${key}' resolves to the ` +
|
|
491
|
+
`literal external secret name '${value.name}', contributed by a connected ` +
|
|
492
|
+
`database but neither declared on the service nor present in the deploy ` +
|
|
493
|
+
`manifest — deploy-core never resolves it to a complete ARN, so it renders a ` +
|
|
494
|
+
`suffixless partial ARN that AccessDenies at task launch (the 2026-06-04 ` +
|
|
495
|
+
`outage shape). This usually means the migration task targets a database whose ` +
|
|
496
|
+
`credentials are an imported/replicated secret (e.g. a GlobalAurora read-only ` +
|
|
497
|
+
`secondary). Run migrations against the primary region (a read-only secondary ` +
|
|
498
|
+
`cannot accept schema migrations), or pin the credential on the service: ` +
|
|
499
|
+
`migrations.secretsImport.${key} = { arn: "<complete-secret-arn>" }.`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
457
503
|
/**
|
|
458
504
|
* Build container configurations for an ECS service.
|
|
459
505
|
* Converts user-facing EcsContainerConfig to internal EcsClusterProps format.
|
|
@@ -607,20 +653,23 @@ export function resolveScalingConfig(scaling) {
|
|
|
607
653
|
return {
|
|
608
654
|
scalingType: undefined,
|
|
609
655
|
minCapacity: undefined,
|
|
610
|
-
maxCapacity: undefined
|
|
656
|
+
maxCapacity: undefined,
|
|
657
|
+
queueScaling: undefined
|
|
611
658
|
};
|
|
612
659
|
}
|
|
613
660
|
if (scaling === undefined) {
|
|
614
661
|
return {
|
|
615
662
|
scalingType: ScalingType.CPU,
|
|
616
663
|
minCapacity: undefined,
|
|
617
|
-
maxCapacity: undefined
|
|
664
|
+
maxCapacity: undefined,
|
|
665
|
+
queueScaling: undefined
|
|
618
666
|
};
|
|
619
667
|
}
|
|
620
668
|
return {
|
|
621
669
|
scalingType: scaling.scalingType ?? ScalingType.CPU,
|
|
622
670
|
minCapacity: scaling.minCapacity,
|
|
623
|
-
maxCapacity: scaling.maxCapacity
|
|
671
|
+
maxCapacity: scaling.maxCapacity,
|
|
672
|
+
queueScaling: scaling.queueScaling
|
|
624
673
|
};
|
|
625
674
|
}
|
|
626
675
|
/**
|
|
@@ -643,7 +692,7 @@ export class EcsCompute extends Construct {
|
|
|
643
692
|
const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
|
|
644
693
|
const chGate = this.resolveClickHouseSchemaVersionEnv(service);
|
|
645
694
|
const containers = buildContainerConfigs(service, schemaVersionEnv, this, chGate);
|
|
646
|
-
const { scalingType, minCapacity, maxCapacity } = resolveScalingConfig(service.scaling);
|
|
695
|
+
const { scalingType, minCapacity, maxCapacity, queueScaling } = resolveScalingConfig(service.scaling);
|
|
647
696
|
const cloudMapService = service.serviceDiscovery !== undefined
|
|
648
697
|
? App.getInstance().registerService({
|
|
649
698
|
name: service.serviceDiscovery.name,
|
|
@@ -662,6 +711,7 @@ export class EcsCompute extends Construct {
|
|
|
662
711
|
scalingType,
|
|
663
712
|
minCapacity,
|
|
664
713
|
maxCapacity,
|
|
714
|
+
...(queueScaling !== undefined && { queueScaling }),
|
|
665
715
|
routing: service.routing,
|
|
666
716
|
taskRoleInlinePolicies: service.taskRoleInlinePolicies,
|
|
667
717
|
taskRoleManagedPolicies: service.taskRoleManagedPolicies,
|
|
@@ -894,6 +944,7 @@ export class EcsCompute extends Construct {
|
|
|
894
944
|
}
|
|
895
945
|
const contributions = collectMigrationContributions(svcConfig);
|
|
896
946
|
const effectiveMigrations = mergeContributionsIntoMigrations(migrations, contributions, this, svcConfig.name);
|
|
947
|
+
assertMigrationSecretsResolvable(svcConfig, effectiveMigrations);
|
|
897
948
|
const effectiveSeparateTaskDef = migrations.separateTaskDef !== undefined
|
|
898
949
|
? mergeContributionsIntoSeparateTaskDef(migrations.separateTaskDef, contributions)
|
|
899
950
|
: undefined;
|
|
@@ -1001,7 +1052,7 @@ export class EcsCompute extends Construct {
|
|
|
1001
1052
|
executionRole,
|
|
1002
1053
|
taskRole
|
|
1003
1054
|
});
|
|
1004
|
-
const { secretsResolved,
|
|
1055
|
+
const { secretsResolved, hasSecretsManagerSecrets, ssmPath } = this.resolveMigrationSecrets(svcConfig, migrations, idPrefix);
|
|
1005
1056
|
const authoredEnvironment = migrations.environment ?? {};
|
|
1006
1057
|
const containerEnvironment = {
|
|
1007
1058
|
...authoredEnvironment
|
|
@@ -1027,16 +1078,9 @@ export class EcsCompute extends Construct {
|
|
|
1027
1078
|
logGroup
|
|
1028
1079
|
})
|
|
1029
1080
|
});
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
actions: [
|
|
1034
|
-
"secretsmanager:GetSecretValue",
|
|
1035
|
-
"secretsmanager:DescribeSecret"
|
|
1036
|
-
],
|
|
1037
|
-
resources: secretArns
|
|
1038
|
-
}));
|
|
1039
|
-
}
|
|
1081
|
+
// Gotcha: no manual `secretsImport` grant — `addContainer({ secrets })` auto-grants
|
|
1082
|
+
// `secret.grantRead(executionRole)` on the resolved complete ARN (exact, no wildcard).
|
|
1083
|
+
// A manual bare/`-*` statement is the 2026-06-04 outage shape; do not re-add it.
|
|
1040
1084
|
if (ssmPath !== undefined) {
|
|
1041
1085
|
executionRole.addToPolicy(new PolicyStatement({
|
|
1042
1086
|
effect: Effect.ALLOW,
|
|
@@ -1046,7 +1090,7 @@ export class EcsCompute extends Construct {
|
|
|
1046
1090
|
]
|
|
1047
1091
|
}));
|
|
1048
1092
|
}
|
|
1049
|
-
if (
|
|
1093
|
+
if (hasSecretsManagerSecrets || ssmPath !== undefined) {
|
|
1050
1094
|
executionRole.addToPolicy(new PolicyStatement({
|
|
1051
1095
|
effect: Effect.ALLOW,
|
|
1052
1096
|
actions: ["kms:Decrypt"],
|
|
@@ -1095,14 +1139,13 @@ export class EcsCompute extends Construct {
|
|
|
1095
1139
|
};
|
|
1096
1140
|
}
|
|
1097
1141
|
resolveMigrationSecrets(svcConfig, migrations, idPrefix) {
|
|
1098
|
-
const stack = Stack.of(this);
|
|
1099
1142
|
const secretsResolved = {};
|
|
1100
|
-
const
|
|
1143
|
+
const hasSecretsManagerSecrets = migrations.secretsImport !== undefined &&
|
|
1144
|
+
Object.keys(migrations.secretsImport).length > 0;
|
|
1101
1145
|
if (migrations.secretsImport) {
|
|
1102
1146
|
for (const [key, secretImport] of Object.entries(migrations.secretsImport)) {
|
|
1103
|
-
const secret =
|
|
1147
|
+
const secret = resolveImportedSecret(this, `${idPrefix}${key}Secret`, secretImport);
|
|
1104
1148
|
secretsResolved[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
|
|
1105
|
-
secretArns.push(`arn:${stack.partition}:secretsmanager:${stack.region}:${stack.account}:secret:${secretImport.name}-*`);
|
|
1106
1149
|
}
|
|
1107
1150
|
}
|
|
1108
1151
|
let ssmPath;
|
|
@@ -1123,7 +1166,7 @@ export class EcsCompute extends Construct {
|
|
|
1123
1166
|
secretsResolved[secretName] = EcsSecret.fromSsmParameter(param);
|
|
1124
1167
|
}
|
|
1125
1168
|
}
|
|
1126
|
-
return { secretsResolved,
|
|
1169
|
+
return { secretsResolved, hasSecretsManagerSecrets, ssmPath };
|
|
1127
1170
|
}
|
|
1128
1171
|
/** Get the ECS cluster. */
|
|
1129
1172
|
getCluster() {
|