@fjall/components-infrastructure 2.9.1 → 2.11.1
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/patterns/aws/account.d.ts +8 -0
- package/dist/lib/patterns/aws/account.js +11 -1
- 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/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.js +3 -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/utils/orgConfigParser.js +9 -1
- 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
|
]
|
|
@@ -19,6 +19,14 @@ 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;
|
|
22
30
|
enableGuardDuty(props?: GuardDutyDetectorProps): GuardDutyDetector;
|
|
23
31
|
enableSecurityHub(props?: SecurityHubHubProps): SecurityHubHub;
|
|
24
32
|
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) {
|
|
@@ -98,6 +98,16 @@ export class Account extends Stack {
|
|
|
98
98
|
new S3BlockPublicAccess(this, "S3BlockPublicAccess");
|
|
99
99
|
new EbsDefaultEncryption(this, "EbsDefaultEncryption");
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether this account receives the OIDC deploy connector (provider +
|
|
103
|
+
* FjallDeploy role) and so can be a deploy target. Standalone/member accounts
|
|
104
|
+
* do; Platform overrides to opt in. Deliberately separate from the
|
|
105
|
+
* IPAM-output gate (which stays `this.constructor === Account`) — the two are
|
|
106
|
+
* independent, and Platform must take one but not the other.
|
|
107
|
+
*/
|
|
108
|
+
receivesDeployRole() {
|
|
109
|
+
return this.constructor === Account;
|
|
110
|
+
}
|
|
101
111
|
enableGuardDuty(props) {
|
|
102
112
|
return new GuardDutyDetector(this, "GuardDuty", props);
|
|
103
113
|
}
|
|
@@ -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() {
|
|
@@ -7,13 +7,13 @@ import type { ITopic } from "aws-cdk-lib/aws-sns";
|
|
|
7
7
|
import { type ConnectionSpec } from "./interfaces/connector.js";
|
|
8
8
|
import { type RemoteConnectionSpec } from "../../resources/aws/compute/ecsRemoteConnections.js";
|
|
9
9
|
import { type EcsRoutingConfig, type EcsContainerDependency } from "../../resources/aws/compute/ecsTypes.js";
|
|
10
|
-
import { ScalingType, type DomainConfig, type EcsCapacityProvider, type Ec2CapacityConfig } from "../../resources/aws/compute/ecs.js";
|
|
10
|
+
import { ScalingType, type DomainConfig, type EcsCapacityProvider, type Ec2CapacityConfig, type QueueScalingConfig } from "../../resources/aws/compute/ecs.js";
|
|
11
11
|
import type { EcsServiceAlarmThresholds } from "../../resources/aws/monitoring/index.js";
|
|
12
12
|
import { type SecretImport } from "../../resources/aws/secrets/index.js";
|
|
13
13
|
import type { DockerBuild } from "@fjall/util/manifest/schemas";
|
|
14
14
|
export type { RemoteConnectionSpec };
|
|
15
15
|
export { ScalingType };
|
|
16
|
-
export type { EcsCapacityProvider, Ec2CapacityConfig };
|
|
16
|
+
export type { EcsCapacityProvider, Ec2CapacityConfig, QueueScalingConfig };
|
|
17
17
|
/**
|
|
18
18
|
* Configuration for ECS capacity providers.
|
|
19
19
|
*/
|
|
@@ -450,6 +450,12 @@ export interface EcsScalingConfig {
|
|
|
450
450
|
minCapacity?: number;
|
|
451
451
|
maxCapacity?: number;
|
|
452
452
|
scalingType?: ScalingType;
|
|
453
|
+
/**
|
|
454
|
+
* Queue-depth scaling config. REQUIRED when `scalingType` is
|
|
455
|
+
* `ScalingType.QUEUE`. Lets a `desiredCount: 0` service wake from zero on
|
|
456
|
+
* SQS backlog (the only scaling mode that does).
|
|
457
|
+
*/
|
|
458
|
+
queueScaling?: QueueScalingConfig;
|
|
453
459
|
}
|
|
454
460
|
/**
|
|
455
461
|
* Host-bind volume mounted into one or more containers in the same task.
|
|
@@ -10,5 +10,6 @@ export interface PlatformProps extends AccountProps {
|
|
|
10
10
|
export declare class Platform extends Account {
|
|
11
11
|
readonly organisationType: OrganisationType;
|
|
12
12
|
constructor(scope: Construct, id: string, props: PlatformProps);
|
|
13
|
+
protected receivesDeployRole(): boolean;
|
|
13
14
|
enableSecurityServicesAdmin(props: SecurityServicesAdminProps): SecurityServicesAdmin;
|
|
14
15
|
}
|
|
@@ -23,6 +23,9 @@ export class Platform extends Account {
|
|
|
23
23
|
ipamScope: ipam.privateDefaultScopeId
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
|
+
receivesDeployRole() {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
26
29
|
enableSecurityServicesAdmin(props) {
|
|
27
30
|
return new SecurityServicesAdmin(this, "SecurityServicesAdmin", props);
|
|
28
31
|
}
|
|
@@ -8,19 +8,21 @@ export class BackupVault extends Construct {
|
|
|
8
8
|
vaultName;
|
|
9
9
|
constructor(scope, id, props) {
|
|
10
10
|
super(scope, id);
|
|
11
|
+
// RETAIN default: a DESTROY-policy vault is silently deleted with the stack.
|
|
12
|
+
// Callers opt into DESTROY explicitly for ephemeral/dev vaults.
|
|
13
|
+
const removalPolicy = props.removalPolicy ?? RemovalPolicy.RETAIN;
|
|
11
14
|
const encryptionKey = props.encryptionKey ||
|
|
12
15
|
new CustomerManagedKey(this, `${props.vaultName}Key`, {
|
|
13
16
|
description: `Encryption key for backup vault ${props.vaultName}`,
|
|
14
17
|
aliasName: `cmk/backupVault/${props.vaultName}`,
|
|
15
|
-
removalPolicy
|
|
18
|
+
removalPolicy
|
|
16
19
|
});
|
|
17
20
|
this.vault = new Vault(this, `${props.vaultName}Vault`, {
|
|
18
21
|
backupVaultName: props.vaultName,
|
|
19
22
|
encryptionKey: encryptionKey.key,
|
|
20
23
|
accessPolicy: props.accessPolicy,
|
|
21
24
|
lockConfiguration: props.lockConfiguration,
|
|
22
|
-
|
|
23
|
-
removalPolicy: props.removalPolicy || RemovalPolicy.DESTROY
|
|
25
|
+
removalPolicy
|
|
24
26
|
});
|
|
25
27
|
this.vaultArn = new CfnOutput(this, `${props.vaultName}VaultArn`, {
|
|
26
28
|
key: `${props.vaultName}VaultArn`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
export declare const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
|
|
3
3
|
export declare const DEFAULT_WARM_POOL_MIN_SIZE = 1;
|
|
4
|
-
export declare const DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN =
|
|
4
|
+
export declare const DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN = false;
|
|
5
5
|
export declare const DEFAULT_LOG_RETENTION_DAYS = 14;
|
|
6
6
|
export declare const DEFAULT_FARGATE_CPU = 256;
|
|
7
7
|
export declare const DEFAULT_FARGATE_MEMORY_MIB = 512;
|
|
@@ -2,7 +2,10 @@ import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
|
|
|
2
2
|
// Canonical source: @fjall/generator schemas/constants.ts — keep in sync
|
|
3
3
|
export const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
|
|
4
4
|
export const DEFAULT_WARM_POOL_MIN_SIZE = 1;
|
|
5
|
-
|
|
5
|
+
// reuseOnScaleIn: true returns scaled-in instances to the warm pool Stopped,
|
|
6
|
+
// which can strand ECS tasks DRAINING indefinitely (the 2026-06-04 outage).
|
|
7
|
+
// Default false — terminate on scale-in. Mirrors the @fjall/generator twin.
|
|
8
|
+
export const DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN = false;
|
|
6
9
|
// 14 days balances cost against retaining enough history for post-mortem debugging
|
|
7
10
|
export const DEFAULT_LOG_RETENTION_DAYS = 14;
|
|
8
11
|
// Smallest valid (cpu, memory) pair on the Fargate matrix — must move together.
|
|
@@ -30,10 +30,10 @@ export class EcsLifecycleHookMigration extends Construct {
|
|
|
30
30
|
const migrationTaskDef = props.migrationTaskDef;
|
|
31
31
|
const targetTaskDefinitionArn = migrationTaskDef?.definitionArn ?? props.taskDefinitionArn;
|
|
32
32
|
const runTaskResources = [
|
|
33
|
-
`arn:
|
|
33
|
+
`arn:${stack.partition}:ecs:${stack.region}:${stack.account}:task-definition/${props.taskDefinitionFamily}:*`
|
|
34
34
|
];
|
|
35
35
|
if (migrationTaskDef !== undefined) {
|
|
36
|
-
runTaskResources.push(`arn:
|
|
36
|
+
runTaskResources.push(`arn:${stack.partition}:ecs:${stack.region}:${stack.account}:task-definition/${migrationTaskDef.family}:*`);
|
|
37
37
|
}
|
|
38
38
|
const passRoleResources = [props.taskExecutionRoleArn, props.taskRoleArn];
|
|
39
39
|
if (migrationTaskDef !== undefined) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Stack } from "aws-cdk-lib";
|
|
2
2
|
import { Effect, Policy, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
3
|
-
import {
|
|
3
|
+
import { deriveSsmSecretsPath } from "./ecsTaskDefinition.js";
|
|
4
4
|
/**
|
|
5
5
|
* Creates the execution role for ECS infrastructure operations.
|
|
6
6
|
* Used by the ECS agent to pull images, write logs, and inject secrets.
|
|
@@ -34,18 +34,9 @@ export function createExecutionRole(ctx, serviceName) {
|
|
|
34
34
|
],
|
|
35
35
|
resources: [logGroupArn, `${logGroupArn}:*`]
|
|
36
36
|
}));
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
executionRole.addToPolicy(new PolicyStatement({
|
|
41
|
-
effect: Effect.ALLOW,
|
|
42
|
-
actions: [
|
|
43
|
-
"secretsmanager:GetSecretValue",
|
|
44
|
-
"secretsmanager:DescribeSecret"
|
|
45
|
-
],
|
|
46
|
-
resources: secretArns
|
|
47
|
-
}));
|
|
48
|
-
}
|
|
37
|
+
// Gotcha: do NOT add a manual `secretsImport` grant here. `addContainer({ secrets })`
|
|
38
|
+
// auto-grants `secret.grantRead(executionRole)` on the resolved complete ARN (exact, no
|
|
39
|
+
// wildcard). A manual bare/`-*` statement is the 2026-06-04 outage shape and is dead.
|
|
49
40
|
const serviceProps = ctx.props.services.find((s) => s.name === serviceName);
|
|
50
41
|
const hasSsmSecrets = serviceProps?.containers.some((container) => container.secrets && container.secrets.length > 0) ?? false;
|
|
51
42
|
if (hasSsmSecrets && serviceProps) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FargateService, Ec2Service, AsgCapacityProvider, type DeploymentCircuitBreaker } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
import type { FargateTaskDefinition, Ec2TaskDefinition } from "aws-cdk-lib/aws-ecs";
|
|
3
3
|
import { type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
|
|
4
|
-
import { TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
4
|
+
import { TargetTrackingScalingPolicy, type StepScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
5
5
|
import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
|
|
6
6
|
import { type EcsServiceProps, type Ec2CapacityConfig } from "./ecsTypes.js";
|
|
7
7
|
import type { EcsConstructContext } from "./ecsContext.js";
|
|
@@ -37,6 +37,8 @@ export declare function getOrCreateAsgCapacityProvider(ctx: EcsConstructContext,
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function createService(ctx: EcsConstructContext, serviceName: string, serviceProps: EcsServiceProps, taskDefinition: FargateTaskDefinition | Ec2TaskDefinition, asgState: AsgCapacityState): FargateService | Ec2Service;
|
|
39
39
|
/**
|
|
40
|
-
* Adds auto-scaling to an ECS service
|
|
40
|
+
* Adds auto-scaling to an ECS service. `CPU`/`MEMORY` emit a target-tracking
|
|
41
|
+
* policy on utilisation; `QUEUE` emits a step-scaling policy on SQS backlog
|
|
42
|
+
* that can wake the service from `desiredCount: 0`.
|
|
41
43
|
*/
|
|
42
|
-
export declare function addServiceScaling(ctx: EcsConstructContext, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service): TargetTrackingScalingPolicy;
|
|
44
|
+
export declare function addServiceScaling(ctx: EcsConstructContext, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service): TargetTrackingScalingPolicy | StepScalingPolicy;
|
|
@@ -3,7 +3,8 @@ import { Peer, Port, SubnetType, UserData } from "aws-cdk-lib/aws-ec2";
|
|
|
3
3
|
import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
4
|
import { Role } from "../iam/role.js";
|
|
5
5
|
import { CfnOutput, Duration, Token } from "aws-cdk-lib";
|
|
6
|
-
import { PredefinedMetric, ScalableTarget, ServiceNamespace, TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
6
|
+
import { AdjustmentType, PredefinedMetric, ScalableTarget, ServiceNamespace, TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
7
|
+
import { MathExpression } from "aws-cdk-lib/aws-cloudwatch";
|
|
7
8
|
import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
|
|
8
9
|
import { SecurityGroup } from "../networking/securityGroup.js";
|
|
9
10
|
import { Ec2Instance } from "./ec2.js";
|
|
@@ -282,17 +283,39 @@ export function createService(ctx, serviceName, serviceProps, taskDefinition, as
|
|
|
282
283
|
return service;
|
|
283
284
|
}
|
|
284
285
|
/**
|
|
285
|
-
*
|
|
286
|
+
* Step-scaling intervals on the queue-backlog metric, with
|
|
287
|
+
* `AdjustmentType.EXACT_CAPACITY` (`change` is the absolute DesiredCount).
|
|
288
|
+
* Backlog 0 → 0 tasks (wake-to-zero); ≥1 → 1; ≥100 → 2; ≥500 → 3. Capacities
|
|
289
|
+
* are clamped to the service's `maxCapacity` at build time.
|
|
290
|
+
*/
|
|
291
|
+
const DEFAULT_QUEUE_SCALING_STEPS = [
|
|
292
|
+
{ lower: 0, upper: 1, capacity: 0 },
|
|
293
|
+
{ lower: 1, upper: 100, capacity: 1 },
|
|
294
|
+
{ lower: 100, upper: 500, capacity: 2 },
|
|
295
|
+
{ lower: 500, capacity: 3 }
|
|
296
|
+
];
|
|
297
|
+
const DEFAULT_QUEUE_SCALE_COOLDOWN = Duration.minutes(5);
|
|
298
|
+
/**
|
|
299
|
+
* Adds auto-scaling to an ECS service. `CPU`/`MEMORY` emit a target-tracking
|
|
300
|
+
* policy on utilisation; `QUEUE` emits a step-scaling policy on SQS backlog
|
|
301
|
+
* that can wake the service from `desiredCount: 0`.
|
|
286
302
|
*/
|
|
287
303
|
export function addServiceScaling(ctx, serviceName, serviceProps, service) {
|
|
288
304
|
const desiredCount = serviceProps.desiredCount ?? DEFAULT_DESIRED_COUNT;
|
|
305
|
+
const maxCapacity = serviceProps.maxCapacity ?? Math.max(desiredCount + 1, 3);
|
|
289
306
|
const scalableTarget = new ScalableTarget(ctx.scope, `${serviceName}ScalableTarget`, {
|
|
290
307
|
serviceNamespace: ServiceNamespace.ECS,
|
|
291
308
|
resourceId: `service/${ctx.cluster.clusterName}/${service.serviceName}`,
|
|
292
309
|
scalableDimension: "ecs:service:DesiredCount",
|
|
293
310
|
minCapacity: serviceProps.minCapacity ?? desiredCount,
|
|
294
|
-
maxCapacity
|
|
311
|
+
maxCapacity
|
|
295
312
|
});
|
|
313
|
+
// QUEUE must branch BEFORE the CPU/MEMORY path: it is a mode discriminator
|
|
314
|
+
// with no predefined metric — falling through would silently emit a CPU
|
|
315
|
+
// policy on a service that meant to scale on backlog.
|
|
316
|
+
if (serviceProps.scalingType === ScalingType.QUEUE) {
|
|
317
|
+
return buildQueueScalingPolicy(serviceName, serviceProps.queueScaling, scalableTarget, maxCapacity);
|
|
318
|
+
}
|
|
296
319
|
return new TargetTrackingScalingPolicy(ctx.scope, `${serviceName}ScalingPolicy`, {
|
|
297
320
|
scalingTarget: scalableTarget,
|
|
298
321
|
predefinedMetric: serviceProps.scalingType === ScalingType.MEMORY
|
|
@@ -303,3 +326,53 @@ export function addServiceScaling(ctx, serviceName, serviceProps, service) {
|
|
|
303
326
|
scaleOutCooldown: Duration.seconds(60)
|
|
304
327
|
});
|
|
305
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Builds the step-scaling policy for `ScalingType.QUEUE`. The alarm metric is a
|
|
331
|
+
* `MathExpression` SUM of `ApproximateNumberOfMessagesVisible`
|
|
332
|
+
* (+`…NotVisible` when `includeInFlight`) across the consumed queues, sampled at
|
|
333
|
+
* `Maximum` over 1-minute periods — published independent of running-task count,
|
|
334
|
+
* so the policy fires (and DesiredCount rises from 0) on backlog alone.
|
|
335
|
+
*/
|
|
336
|
+
function buildQueueScalingPolicy(serviceName, config, scalableTarget, maxCapacity) {
|
|
337
|
+
if (config === undefined || config.queues.length === 0) {
|
|
338
|
+
throw new Error(`ECS service '${serviceName}' uses ScalingType.QUEUE but provides no queueScaling.queues — at least one queue is required to compute backlog.`);
|
|
339
|
+
}
|
|
340
|
+
const includeInFlight = config.includeInFlight ?? true;
|
|
341
|
+
const usingMetrics = {};
|
|
342
|
+
const expressionTerms = [];
|
|
343
|
+
config.queues.forEach((queue, index) => {
|
|
344
|
+
const visibleId = `visible${index}`;
|
|
345
|
+
usingMetrics[visibleId] = queue.metricApproximateNumberOfMessagesVisible({
|
|
346
|
+
statistic: "Maximum",
|
|
347
|
+
period: Duration.minutes(1)
|
|
348
|
+
});
|
|
349
|
+
expressionTerms.push(visibleId);
|
|
350
|
+
if (includeInFlight) {
|
|
351
|
+
const inflightId = `inflight${index}`;
|
|
352
|
+
usingMetrics[inflightId] =
|
|
353
|
+
queue.metricApproximateNumberOfMessagesNotVisible({
|
|
354
|
+
statistic: "Maximum",
|
|
355
|
+
period: Duration.minutes(1)
|
|
356
|
+
});
|
|
357
|
+
expressionTerms.push(inflightId);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
const backlogMetric = new MathExpression({
|
|
361
|
+
expression: expressionTerms.join(" + "),
|
|
362
|
+
usingMetrics,
|
|
363
|
+
period: Duration.minutes(1),
|
|
364
|
+
label: `${serviceName}QueueBacklog`
|
|
365
|
+
});
|
|
366
|
+
return scalableTarget.scaleOnMetric(`${serviceName}QueueScaling`, {
|
|
367
|
+
metric: backlogMetric,
|
|
368
|
+
adjustmentType: AdjustmentType.EXACT_CAPACITY,
|
|
369
|
+
scalingSteps: DEFAULT_QUEUE_SCALING_STEPS.map((step) => ({
|
|
370
|
+
lower: step.lower,
|
|
371
|
+
...(step.upper !== undefined && { upper: step.upper }),
|
|
372
|
+
change: Math.min(step.capacity, maxCapacity)
|
|
373
|
+
})),
|
|
374
|
+
cooldown: config.cooldown ?? DEFAULT_QUEUE_SCALE_COOLDOWN,
|
|
375
|
+
evaluationPeriods: 1,
|
|
376
|
+
datapointsToAlarm: 1
|
|
377
|
+
});
|
|
378
|
+
}
|
|
@@ -14,11 +14,6 @@ export declare function getServiceCapacityProvider(serviceProps: EcsServiceProps
|
|
|
14
14
|
export declare function isServiceFargate(serviceProps: EcsServiceProps): boolean;
|
|
15
15
|
/** Checks if a service uses an EC2 capacity provider. */
|
|
16
16
|
export declare function isServiceEc2(serviceProps: EcsServiceProps): boolean;
|
|
17
|
-
/**
|
|
18
|
-
* Collects Secrets Manager secret names from secretsImport for a specific service.
|
|
19
|
-
* Scoped per service to enforce least-privilege on execution roles.
|
|
20
|
-
*/
|
|
21
|
-
export declare function collectSecretsManagerSecretNames(props: EcsClusterProps, serviceName: string): string[];
|
|
22
17
|
/**
|
|
23
18
|
* Derives the SSM secrets path for a service.
|
|
24
19
|
* Uses explicit path if provided, otherwise derives from app/cluster/service names.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { AwsLogDriver, ContainerDependencyCondition, FargateTaskDefinition, Ec2TaskDefinition, NetworkMode, CpuArchitecture, OperatingSystemFamily } from "aws-cdk-lib/aws-ecs";
|
|
2
2
|
import { Duration } from "aws-cdk-lib";
|
|
3
3
|
import { Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
|
|
4
|
-
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
|
|
5
4
|
import { StringParameter } from "aws-cdk-lib/aws-ssm";
|
|
6
5
|
import { resolveOrgId } from "../../../utils/cdkContext.js";
|
|
7
6
|
import { validateSsmPathComponent } from "./ecsValidation.js";
|
|
8
7
|
import { DEFAULT_LOG_RETENTION_DAYS, DEFAULT_FARGATE_CPU, DEFAULT_FARGATE_MEMORY_MIB, DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
|
|
9
8
|
import { getContainerImage } from "./ecsImages.js";
|
|
10
9
|
import { resolveRemoteConnections } from "./ecsRemoteConnections.js";
|
|
10
|
+
import { resolveImportedSecret } from "../secrets/index.js";
|
|
11
11
|
// Re-export extracted functions so existing consumers are not broken
|
|
12
12
|
export { createExecutionRole, createTaskRole } from "./ecsRoles.js";
|
|
13
13
|
export { getContainerImage } from "./ecsImages.js";
|
|
@@ -27,24 +27,6 @@ export function isServiceFargate(serviceProps) {
|
|
|
27
27
|
export function isServiceEc2(serviceProps) {
|
|
28
28
|
return getServiceCapacityProvider(serviceProps) === "EC2";
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Collects Secrets Manager secret names from secretsImport for a specific service.
|
|
32
|
-
* Scoped per service to enforce least-privilege on execution roles.
|
|
33
|
-
*/
|
|
34
|
-
export function collectSecretsManagerSecretNames(props, serviceName) {
|
|
35
|
-
const service = props.services.find((s) => s.name === serviceName);
|
|
36
|
-
if (!service)
|
|
37
|
-
return [];
|
|
38
|
-
const secretNames = new Set();
|
|
39
|
-
for (const container of service.containers) {
|
|
40
|
-
if (container.secretsImport) {
|
|
41
|
-
for (const secretImport of Object.values(container.secretsImport)) {
|
|
42
|
-
secretNames.add(secretImport.name);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return Array.from(secretNames);
|
|
47
|
-
}
|
|
48
30
|
/**
|
|
49
31
|
* Derives the SSM secrets path for a service.
|
|
50
32
|
* Uses explicit path if provided, otherwise derives from app/cluster/service names.
|
|
@@ -148,7 +130,7 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
|
|
|
148
130
|
const secrets = {};
|
|
149
131
|
if (containerConfig.secretsImport) {
|
|
150
132
|
for (const [key, secretImport] of Object.entries(containerConfig.secretsImport)) {
|
|
151
|
-
const secret =
|
|
133
|
+
const secret = resolveImportedSecret(ctx.scope, `${ctx.props.clusterName}${serviceName}${containerConfig.name}${key}Secret`, secretImport);
|
|
152
134
|
secrets[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
|
|
153
135
|
}
|
|
154
136
|
}
|
|
@@ -4,7 +4,9 @@ import { type Monitoring } from "aws-cdk-lib/aws-autoscaling";
|
|
|
4
4
|
import { type IService } from "aws-cdk-lib/aws-servicediscovery";
|
|
5
5
|
import { type IManagedPolicy, type PolicyDocument } from "aws-cdk-lib/aws-iam";
|
|
6
6
|
import type { DockerBuild } from "@fjall/util/manifest/schemas";
|
|
7
|
-
import { type TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
7
|
+
import { type TargetTrackingScalingPolicy, type StepScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
|
|
8
|
+
import { type IQueue } from "aws-cdk-lib/aws-sqs";
|
|
9
|
+
import { type Duration } from "aws-cdk-lib";
|
|
8
10
|
import { type GeoLocation } from "aws-cdk-lib/aws-route53";
|
|
9
11
|
import { type Repository } from "aws-cdk-lib/aws-ecr";
|
|
10
12
|
import { type FargateService, type Ec2Service, type FargateTaskDefinition, type Ec2TaskDefinition } from "aws-cdk-lib/aws-ecs";
|
|
@@ -23,9 +25,42 @@ export declare enum Protocol {
|
|
|
23
25
|
HTTP = 0,
|
|
24
26
|
HTTPS = 1
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Auto-scaling MODE for an ECS service.
|
|
30
|
+
*
|
|
31
|
+
* `CPU`/`MEMORY` double as their CDK `PredefinedMetric` value (target-tracking
|
|
32
|
+
* on utilisation). `QUEUE` is a pure mode discriminator — it has no predefined
|
|
33
|
+
* metric and drives a step-scaling policy on SQS backlog instead (see
|
|
34
|
+
* `addServiceScaling` → `buildQueueScalingPolicy`). Treat this as a scaling-MODE
|
|
35
|
+
* enum, NOT a `PredefinedMetric` alias: a new member must be branched on
|
|
36
|
+
* explicitly, never fall through the CPU/MEMORY utilisation path.
|
|
37
|
+
*/
|
|
26
38
|
export declare enum ScalingType {
|
|
27
39
|
CPU = "ECSServiceAverageCPUUtilization",
|
|
28
|
-
MEMORY = "ECSServiceAverageMemoryUtilization"
|
|
40
|
+
MEMORY = "ECSServiceAverageMemoryUtilization",
|
|
41
|
+
QUEUE = "QueueBacklog"
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Queue-depth (backlog) scaling configuration for `ScalingType.QUEUE`.
|
|
45
|
+
*
|
|
46
|
+
* Drives a step-scaling policy with `AdjustmentType.EXACT_CAPACITY` alarmed on
|
|
47
|
+
* a `MathExpression` SUM of `ApproximateNumberOfMessagesVisible`
|
|
48
|
+
* (+`…NotVisible` when `includeInFlight`) across `queues`. The queue-depth
|
|
49
|
+
* metric publishes every minute independent of running-task count, so the
|
|
50
|
+
* service wakes from `desiredCount: 0` on backlog — unlike CPU/MEMORY
|
|
51
|
+
* target-tracking, which has no datapoint at zero tasks.
|
|
52
|
+
*/
|
|
53
|
+
export interface QueueScalingConfig {
|
|
54
|
+
/** Queues whose combined backlog drives DesiredCount. At least one required. */
|
|
55
|
+
queues: IQueue[];
|
|
56
|
+
/**
|
|
57
|
+
* Include in-flight (received-but-not-deleted) messages in the backlog sum.
|
|
58
|
+
* Default: true — an in-flight message is still work the service is doing,
|
|
59
|
+
* so scaling in to zero while messages are in flight would strand them.
|
|
60
|
+
*/
|
|
61
|
+
includeInFlight?: boolean;
|
|
62
|
+
/** Cooldown after a scale action. Default: 5 minutes. */
|
|
63
|
+
cooldown?: Duration;
|
|
29
64
|
}
|
|
30
65
|
import type { EcsCapacityProvider } from "@fjall/generator";
|
|
31
66
|
export type { EcsCapacityProvider };
|
|
@@ -301,11 +336,12 @@ export interface EcsServiceProps {
|
|
|
301
336
|
/** Desired number of tasks. Default: 2 */
|
|
302
337
|
desiredCount?: number;
|
|
303
338
|
/**
|
|
304
|
-
* Scaling
|
|
305
|
-
* `ScalableTarget` is registered, and `minCapacity`/`maxCapacity` below
|
|
306
|
-
* no effect. The `desiredCount: 0 + minCapacity > 0` validation throw
|
|
307
|
-
* fires regardless, so operator-intent contradictions surface at synth
|
|
308
|
-
* when scaling is disabled.
|
|
339
|
+
* Scaling mode (`CPU`, `MEMORY`, or `QUEUE`). Omit to disable auto-scaling —
|
|
340
|
+
* no `ScalableTarget` is registered, and `minCapacity`/`maxCapacity` below
|
|
341
|
+
* have no effect. The `desiredCount: 0 + minCapacity > 0` validation throw
|
|
342
|
+
* still fires regardless, so operator-intent contradictions surface at synth
|
|
343
|
+
* even when scaling is disabled. `QUEUE` additionally requires `queueScaling`
|
|
344
|
+
* and is the only mode that wakes a `desiredCount: 0` service from zero.
|
|
309
345
|
*/
|
|
310
346
|
scalingType?: ScalingType;
|
|
311
347
|
/**
|
|
@@ -318,6 +354,12 @@ export interface EcsServiceProps {
|
|
|
318
354
|
* Only consulted when `scalingType` is set.
|
|
319
355
|
*/
|
|
320
356
|
maxCapacity?: number;
|
|
357
|
+
/**
|
|
358
|
+
* Queue-depth scaling config. REQUIRED when `scalingType` is
|
|
359
|
+
* `ScalingType.QUEUE`; ignored otherwise. Lets a `desiredCount: 0` service
|
|
360
|
+
* wake from zero on SQS backlog.
|
|
361
|
+
*/
|
|
362
|
+
queueScaling?: QueueScalingConfig;
|
|
321
363
|
/**
|
|
322
364
|
* Routing rules for this service on the cluster's ALB.
|
|
323
365
|
* Required when cluster has multiple services with ports.
|
|
@@ -500,5 +542,5 @@ export interface ServiceData {
|
|
|
500
542
|
containers: ContainerDefinition[];
|
|
501
543
|
primaryContainer?: ContainerDefinition;
|
|
502
544
|
targetGroup?: IApplicationTargetGroup;
|
|
503
|
-
scalingPolicy?: TargetTrackingScalingPolicy;
|
|
545
|
+
scalingPolicy?: TargetTrackingScalingPolicy | StepScalingPolicy;
|
|
504
546
|
}
|
|
@@ -3,8 +3,19 @@ export var Protocol;
|
|
|
3
3
|
Protocol[Protocol["HTTP"] = 0] = "HTTP";
|
|
4
4
|
Protocol[Protocol["HTTPS"] = 1] = "HTTPS";
|
|
5
5
|
})(Protocol || (Protocol = {}));
|
|
6
|
+
/**
|
|
7
|
+
* Auto-scaling MODE for an ECS service.
|
|
8
|
+
*
|
|
9
|
+
* `CPU`/`MEMORY` double as their CDK `PredefinedMetric` value (target-tracking
|
|
10
|
+
* on utilisation). `QUEUE` is a pure mode discriminator — it has no predefined
|
|
11
|
+
* metric and drives a step-scaling policy on SQS backlog instead (see
|
|
12
|
+
* `addServiceScaling` → `buildQueueScalingPolicy`). Treat this as a scaling-MODE
|
|
13
|
+
* enum, NOT a `PredefinedMetric` alias: a new member must be branched on
|
|
14
|
+
* explicitly, never fall through the CPU/MEMORY utilisation path.
|
|
15
|
+
*/
|
|
6
16
|
export var ScalingType;
|
|
7
17
|
(function (ScalingType) {
|
|
8
18
|
ScalingType["CPU"] = "ECSServiceAverageCPUUtilization";
|
|
9
19
|
ScalingType["MEMORY"] = "ECSServiceAverageMemoryUtilization";
|
|
20
|
+
ScalingType["QUEUE"] = "QueueBacklog";
|
|
10
21
|
})(ScalingType || (ScalingType = {}));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NetworkMode } from "aws-cdk-lib/aws-ecs";
|
|
2
|
+
import { ScalingType } from "./ecsTypes.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validates ECS cluster props before construction.
|
|
4
5
|
* Pure function — does not depend on class state.
|
|
@@ -66,6 +67,19 @@ export function validateEcsClusterProps(props) {
|
|
|
66
67
|
`stopTimeout must be an integer between 1 and 120 seconds (got ${container.stopTimeout}).`);
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
// A host-bind volume silently no-ops on Fargate (no host filesystem),
|
|
71
|
+
// so a /var/run/docker.sock bind for `docker buildx` would fail at
|
|
72
|
+
// runtime with no synth error. Reject the un-mountable shape at synth.
|
|
73
|
+
if (service.capacityProvider !== "EC2" && container.volumes) {
|
|
74
|
+
const hostBind = container.volumes.find((v) => v.hostSourcePath !== undefined);
|
|
75
|
+
if (hostBind !== undefined) {
|
|
76
|
+
throw new Error(`Service '${service.name}', container '${container.name ?? "(default)"}': ` +
|
|
77
|
+
`volume '${hostBind.name}' sets hostSourcePath ('${hostBind.hostSourcePath}') but the service uses ` +
|
|
78
|
+
`capacityProvider '${service.capacityProvider}'. Host-bind mounts require the EC2 launch type — ` +
|
|
79
|
+
"Fargate has no host filesystem, so the mount is silently dropped at deploy (a /var/run/docker.sock " +
|
|
80
|
+
"bind for in-container Docker builds would never appear). Set capacityProvider: 'EC2' or remove hostSourcePath.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
69
83
|
}
|
|
70
84
|
if (service.capacityProvider === "EC2" && !service.ec2Config) {
|
|
71
85
|
throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
|
|
@@ -97,6 +111,29 @@ export function validateEcsClusterProps(props) {
|
|
|
97
111
|
"Application Auto Scaling would immediately scale the service back up, defeating the desiredCount: 0 toggle. " +
|
|
98
112
|
"Either set scaling.minCapacity to 0 (placeholder service) or raise desiredCount to match scaling.minCapacity.");
|
|
99
113
|
}
|
|
114
|
+
// A service that starts at zero can only ever come up again on QUEUE
|
|
115
|
+
// backlog scaling — CPU/MEMORY target-tracking produces no datapoint at
|
|
116
|
+
// zero running tasks, so DesiredCount never rises (the 2026-06-04
|
|
117
|
+
// asset-discovery outage). Reject the un-wakeable shape at synth.
|
|
118
|
+
if (service.desiredCount === 0 &&
|
|
119
|
+
service.scalingType !== ScalingType.QUEUE) {
|
|
120
|
+
throw new Error(`Service '${service.name}': desiredCount is 0 but scalingType is ` +
|
|
121
|
+
`${service.scalingType ?? "unset"} — a service that starts at zero can ` +
|
|
122
|
+
"only wake on queue backlog. Use ScalingType.QUEUE with queueScaling.queues " +
|
|
123
|
+
"so it scales up from 0, or raise desiredCount to keep at least one task running. " +
|
|
124
|
+
"CPU/MEMORY target-tracking produces no datapoint at zero tasks and never scales up.");
|
|
125
|
+
}
|
|
126
|
+
if (service.scalingType === ScalingType.QUEUE) {
|
|
127
|
+
if (service.queueScaling === undefined ||
|
|
128
|
+
service.queueScaling.queues.length === 0) {
|
|
129
|
+
throw new Error(`Service '${service.name}': scalingType is ScalingType.QUEUE but provides no ` +
|
|
130
|
+
"queueScaling.queues — at least one queue is required to compute backlog.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else if (service.queueScaling !== undefined) {
|
|
134
|
+
throw new Error(`Service '${service.name}': queueScaling is set but scalingType is not ` +
|
|
135
|
+
"ScalingType.QUEUE — queueScaling only applies to the QUEUE scaling mode.");
|
|
136
|
+
}
|
|
100
137
|
if (service.capacityProvider === "EC2" &&
|
|
101
138
|
service.securityGroups !== undefined &&
|
|
102
139
|
service.securityGroups.length > 0) {
|
|
@@ -8,9 +8,9 @@ import { EventType } from "aws-cdk-lib/aws-s3";
|
|
|
8
8
|
import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
|
|
9
9
|
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
10
10
|
import { LogGroup } from "../logging/logGroup.js";
|
|
11
|
-
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
|
|
12
11
|
import { v4 as uuid } from "uuid";
|
|
13
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
import { resolveImportedSecret } from "../secrets/index.js";
|
|
14
14
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
15
15
|
import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
|
|
16
16
|
import { createLambdaAlarms } from "../monitoring/index.js";
|
|
@@ -250,7 +250,7 @@ export class LambdaFunction extends Function {
|
|
|
250
250
|
*/
|
|
251
251
|
addSecretsManagerSupport(secretsImport) {
|
|
252
252
|
for (const [key, secretImport] of Object.entries(secretsImport)) {
|
|
253
|
-
const secret =
|
|
253
|
+
const secret = resolveImportedSecret(this, `${this.node.id}ImportedSecret${key}`, secretImport);
|
|
254
254
|
this.addEnvironment(`${key}_SECRET_ARN`, secret.secretArn);
|
|
255
255
|
if (secretImport.field) {
|
|
256
256
|
this.addEnvironment(`${key}_SECRET_FIELD`, secretImport.field);
|
|
@@ -281,7 +281,7 @@ export class LambdaFunction extends Function {
|
|
|
281
281
|
effect: Effect.ALLOW,
|
|
282
282
|
actions: ["ssm:GetParameter", "ssm:GetParameters"],
|
|
283
283
|
resources: [
|
|
284
|
-
`arn:
|
|
284
|
+
`arn:${Stack.of(this).partition}:ssm:${Stack.of(this).region}:${Stack.of(this).account}:parameter${scopedPath}`
|
|
285
285
|
]
|
|
286
286
|
}));
|
|
287
287
|
this.addToRolePolicy(new PolicyStatement({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { aws_ssm as ssm } from "aws-cdk-lib";
|
|
1
|
+
import { aws_ssm as ssm, Stack } from "aws-cdk-lib";
|
|
2
2
|
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import { AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
|
|
4
4
|
import { Construct } from "constructs";
|
|
@@ -73,7 +73,7 @@ export class SecureStringParameter extends Construct {
|
|
|
73
73
|
new PolicyStatement({
|
|
74
74
|
actions: ["kms:Encrypt"],
|
|
75
75
|
resources: [
|
|
76
|
-
`arn:
|
|
76
|
+
`arn:${Stack.of(this).partition}:kms:${props.region}:${props.accountId}:key/${this.cmk.key.keyId}`
|
|
77
77
|
]
|
|
78
78
|
}),
|
|
79
79
|
new PolicyStatement({
|
|
@@ -85,7 +85,7 @@ export class SecureStringParameter extends Construct {
|
|
|
85
85
|
"logs:PutRetentionPolicy"
|
|
86
86
|
],
|
|
87
87
|
resources: [
|
|
88
|
-
`arn:
|
|
88
|
+
`arn:${Stack.of(this).partition}:ssm:${props.region}:${props.accountId}:parameter${props.name}`
|
|
89
89
|
]
|
|
90
90
|
})
|
|
91
91
|
])
|
|
@@ -24,20 +24,41 @@ interface SecretProps {
|
|
|
24
24
|
/** Set to true to import an existing secret instead of creating a new one (for replicated secrets) */
|
|
25
25
|
importExisting?: boolean;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Reference to an existing (externally-managed) Secrets Manager secret.
|
|
29
|
+
*
|
|
30
|
+
* Provide EXACTLY ONE of `name` (the common case — resolved to its complete ARN at
|
|
31
|
+
* deploy time, see {@link resolveImportedSecret}) or `arn` (a complete-ARN escape
|
|
32
|
+
* hatch for cross-account/region secrets the deploy identity cannot DescribeSecret).
|
|
33
|
+
*/
|
|
27
34
|
export type SecretImport = {
|
|
28
|
-
/**
|
|
29
|
-
* Secret ID
|
|
30
|
-
*/
|
|
35
|
+
/** Construct ID for the imported secret. */
|
|
31
36
|
id: string;
|
|
32
|
-
/**
|
|
33
|
-
* Secret name
|
|
34
|
-
*/
|
|
35
|
-
name: string;
|
|
36
|
-
/**
|
|
37
|
-
* Optional - may be used to import a specific field from the secret
|
|
38
|
-
*/
|
|
37
|
+
/** Optional — import a single JSON field from the secret. */
|
|
39
38
|
field?: string;
|
|
40
|
-
}
|
|
39
|
+
} & ({
|
|
40
|
+
name: string;
|
|
41
|
+
arn?: undefined;
|
|
42
|
+
} | {
|
|
43
|
+
arn: string;
|
|
44
|
+
name?: undefined;
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Resolves an imported (externally-managed) Secrets Manager secret to an `ISecret`
|
|
48
|
+
* whose ARN is COMPLETE (with the AWS 6-char suffix) wherever possible.
|
|
49
|
+
*
|
|
50
|
+
* `Secret.fromSecretNameV2` renders a SUFFIXLESS partial ARN into the ECS container
|
|
51
|
+
* `valueFrom`, which real Secrets Manager rejects with AccessDenied at task launch (the
|
|
52
|
+
* 2026-06-04 outage). `fromSecretCompleteArn` renders the full ARN AND an exact-ARN
|
|
53
|
+
* `grantRead`, which authorises. The full ARN is obtained three ways, in order:
|
|
54
|
+
* 1. an explicit `arn` on the import (cross-account/region escape hatch);
|
|
55
|
+
* 2. the `fjallResolvedSecretArns` context map injected by @fjall/deploy-core, which
|
|
56
|
+
* DescribeSecret-resolves every name -> full ARN at deploy time;
|
|
57
|
+
* 3. fallback to `fromSecretNameV2` ONLY when neither is present — i.e. offline
|
|
58
|
+
* `cdk synth` for template generation. A real deploy ALWAYS runs deploy-core's
|
|
59
|
+
* resolution, so the suffixless shape never reaches a deployed task-def.
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveImportedSecret(scope: Construct, id: string, secretImport: SecretImport): ISecret;
|
|
41
62
|
export declare class Secret extends Construct {
|
|
42
63
|
id: string;
|
|
43
64
|
readonly secret: ISecret;
|
|
@@ -2,6 +2,59 @@ import { SecretValue } from "aws-cdk-lib";
|
|
|
2
2
|
import { Secret as CdkSecret } from "aws-cdk-lib/aws-secretsmanager";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { CustomerManagedKey } from "./kms.js";
|
|
5
|
+
/**
|
|
6
|
+
* Context key carrying the deploy-time `secretName -> completeArn` map. Written by
|
|
7
|
+
* @fjall/deploy-core (`CdkArgumentBuilder.buildContextArgs` emits
|
|
8
|
+
* `-c fjallResolvedSecretArns=<json>`); read here. Keep the literal in sync with that
|
|
9
|
+
* canonical writer — matches the `ipamPoolId` / `orgConfig` literal-key house style.
|
|
10
|
+
*/
|
|
11
|
+
const RESOLVED_SECRET_ARNS_CONTEXT_KEY = "fjallResolvedSecretArns";
|
|
12
|
+
function isStringRecord(value) {
|
|
13
|
+
return (typeof value === "object" &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
!Array.isArray(value) &&
|
|
16
|
+
Object.values(value).every((entry) => typeof entry === "string"));
|
|
17
|
+
}
|
|
18
|
+
function readResolvedSecretArn(scope, secretName) {
|
|
19
|
+
const raw = scope.node.tryGetContext(RESOLVED_SECRET_ARNS_CONTEXT_KEY);
|
|
20
|
+
if (typeof raw !== "string" || raw === "")
|
|
21
|
+
return undefined;
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (!isStringRecord(parsed))
|
|
30
|
+
return undefined;
|
|
31
|
+
return parsed[secretName];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves an imported (externally-managed) Secrets Manager secret to an `ISecret`
|
|
35
|
+
* whose ARN is COMPLETE (with the AWS 6-char suffix) wherever possible.
|
|
36
|
+
*
|
|
37
|
+
* `Secret.fromSecretNameV2` renders a SUFFIXLESS partial ARN into the ECS container
|
|
38
|
+
* `valueFrom`, which real Secrets Manager rejects with AccessDenied at task launch (the
|
|
39
|
+
* 2026-06-04 outage). `fromSecretCompleteArn` renders the full ARN AND an exact-ARN
|
|
40
|
+
* `grantRead`, which authorises. The full ARN is obtained three ways, in order:
|
|
41
|
+
* 1. an explicit `arn` on the import (cross-account/region escape hatch);
|
|
42
|
+
* 2. the `fjallResolvedSecretArns` context map injected by @fjall/deploy-core, which
|
|
43
|
+
* DescribeSecret-resolves every name -> full ARN at deploy time;
|
|
44
|
+
* 3. fallback to `fromSecretNameV2` ONLY when neither is present — i.e. offline
|
|
45
|
+
* `cdk synth` for template generation. A real deploy ALWAYS runs deploy-core's
|
|
46
|
+
* resolution, so the suffixless shape never reaches a deployed task-def.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveImportedSecret(scope, id, secretImport) {
|
|
49
|
+
if (secretImport.arn !== undefined) {
|
|
50
|
+
return CdkSecret.fromSecretCompleteArn(scope, id, secretImport.arn);
|
|
51
|
+
}
|
|
52
|
+
const resolvedArn = readResolvedSecretArn(scope, secretImport.name);
|
|
53
|
+
if (resolvedArn !== undefined) {
|
|
54
|
+
return CdkSecret.fromSecretCompleteArn(scope, id, resolvedArn);
|
|
55
|
+
}
|
|
56
|
+
return CdkSecret.fromSecretNameV2(scope, id, secretImport.name);
|
|
57
|
+
}
|
|
5
58
|
export class Secret extends Construct {
|
|
6
59
|
id;
|
|
7
60
|
secret;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { VAULT_LOCK_MODES } from "@fjall/util/config";
|
|
1
2
|
import { maskSensitiveOutput } from "@fjall/util";
|
|
2
3
|
import { FjallLogger } from "./validationLogger.js";
|
|
3
4
|
/**
|
|
@@ -27,7 +28,14 @@ export function parseOrgConfig(raw) {
|
|
|
27
28
|
(item.managed === undefined ||
|
|
28
29
|
typeof item.managed === "boolean") &&
|
|
29
30
|
(item.oidcRoleArn === undefined ||
|
|
30
|
-
typeof item.oidcRoleArn ===
|
|
31
|
+
typeof item.oidcRoleArn ===
|
|
32
|
+
"string") &&
|
|
33
|
+
(item.vaultLock === undefined ||
|
|
34
|
+
VAULT_LOCK_MODES.includes(item.vaultLock)) &&
|
|
35
|
+
(item.acknowledgeImmutableVaultLock ===
|
|
36
|
+
undefined ||
|
|
37
|
+
typeof item
|
|
38
|
+
.acknowledgeImmutableVaultLock === "boolean"))
|
|
31
39
|
: [];
|
|
32
40
|
const primaryRegion = typeof obj.primaryRegion === "string" ? obj.primaryRegion : undefined;
|
|
33
41
|
const secondaryRegions = Array.isArray(obj.secondaryRegions)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/components-infrastructure",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@aws-sdk/client-organizations": "^3.1038.0",
|
|
66
|
-
"@fjall/generator": "^2.
|
|
67
|
-
"@fjall/util": "^2.
|
|
66
|
+
"@fjall/generator": "^2.11.1",
|
|
67
|
+
"@fjall/util": "^2.11.1",
|
|
68
68
|
"constructs": "^10.0.0",
|
|
69
69
|
"uuid": "^14.0.0"
|
|
70
70
|
},
|
|
@@ -79,5 +79,5 @@
|
|
|
79
79
|
"engines": {
|
|
80
80
|
"node": ">=18.0.0"
|
|
81
81
|
},
|
|
82
|
-
"gitHead": "
|
|
82
|
+
"gitHead": "69823c3d7f2eacba419657464381119c5b5b5fd6"
|
|
83
83
|
}
|