@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.
Files changed (31) hide show
  1. package/dist/lib/config/aws/disasterRecovery.js +23 -4
  2. package/dist/lib/config/aws/ecrDefaultImage.js +2 -2
  3. package/dist/lib/patterns/aws/account.d.ts +8 -0
  4. package/dist/lib/patterns/aws/account.js +11 -1
  5. package/dist/lib/patterns/aws/buildkite.js +1 -1
  6. package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
  7. package/dist/lib/patterns/aws/compute.d.ts +22 -0
  8. package/dist/lib/patterns/aws/compute.js +42 -0
  9. package/dist/lib/patterns/aws/computeEcs.d.ts +2 -1
  10. package/dist/lib/patterns/aws/computeEcs.js +67 -24
  11. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -2
  12. package/dist/lib/patterns/aws/platform.d.ts +1 -0
  13. package/dist/lib/patterns/aws/platform.js +3 -0
  14. package/dist/lib/resources/aws/backup/backupVault.js +5 -3
  15. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +1 -1
  16. package/dist/lib/resources/aws/compute/ecsConstants.js +4 -1
  17. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +2 -2
  18. package/dist/lib/resources/aws/compute/ecsRoles.js +4 -13
  19. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +5 -3
  20. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +76 -3
  21. package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +0 -5
  22. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +2 -20
  23. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +50 -8
  24. package/dist/lib/resources/aws/compute/ecsTypes.js +11 -0
  25. package/dist/lib/resources/aws/compute/ecsValidation.js +37 -0
  26. package/dist/lib/resources/aws/compute/lambda.js +3 -3
  27. package/dist/lib/resources/aws/secrets/parameter.js +3 -3
  28. package/dist/lib/resources/aws/secrets/secret.d.ts +32 -11
  29. package/dist/lib/resources/aws/secrets/secret.js +53 -0
  30. package/dist/lib/utils/orgConfigParser.js +9 -1
  31. 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 get vault locks for protection
58
- const lockConfiguration = isComplianceAccount
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
- : undefined;
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 (isComplianceAccount) {
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:aws:ecr:${props.region}:${props.accountId}:repository/*`
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 (isStandaloneAccount &&
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:aws:ssm:${props.env?.region}:${props.env?.account}:parameter${agentToken.name}`
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: [`arn:aws:ssm:${region}::document/AWS-RunShellScript`]
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:aws:ec2:${region}:${account}:instance/*`],
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, secretArns, ssmPath } = this.resolveMigrationSecrets(svcConfig, migrations, idPrefix);
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
- if (secretArns.length > 0) {
1031
- executionRole.addToPolicy(new PolicyStatement({
1032
- effect: Effect.ALLOW,
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 (secretArns.length > 0 || ssmPath !== undefined) {
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 secretArns = [];
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 = Secret.fromSecretNameV2(this, `${idPrefix}${key}Secret`, secretImport.name);
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, secretArns, ssmPath };
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: props.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
- // TODO: Revert to RemovalPolicy.RETAIN for production
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 = true;
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
- export const DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN = true;
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:aws:ecs:${stack.region}:${stack.account}:task-definition/${props.taskDefinitionFamily}:*`
33
+ `arn:${stack.partition}:ecs:${stack.region}:${stack.account}:task-definition/${props.taskDefinitionFamily}:*`
34
34
  ];
35
35
  if (migrationTaskDef !== undefined) {
36
- runTaskResources.push(`arn:aws:ecs:${stack.region}:${stack.account}:task-definition/${migrationTaskDef.family}:*`);
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 { collectSecretsManagerSecretNames, deriveSsmSecretsPath } from "./ecsTaskDefinition.js";
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
- const secretNames = collectSecretsManagerSecretNames(ctx.props, serviceName);
38
- if (secretNames.length > 0) {
39
- const secretArns = secretNames.map((secretName) => `arn:${partition}:secretsmanager:${region}:${account}:secret:${secretName}-*`);
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 based on CPU or memory utilisation.
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
- * Adds auto-scaling to an ECS service based on CPU or memory utilisation.
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: serviceProps.maxCapacity ?? Math.max(desiredCount + 1, 3)
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 = Secret.fromSecretNameV2(ctx.scope, `${ctx.props.clusterName}${serviceName}${containerConfig.name}${key}Secret`, secretImport.name);
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 type (CPU or MEMORY). Omit to disable auto-scaling — no
305
- * `ScalableTarget` is registered, and `minCapacity`/`maxCapacity` below have
306
- * no effect. The `desiredCount: 0 + minCapacity > 0` validation throw still
307
- * fires regardless, so operator-intent contradictions surface at synth even
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 = Secret.fromSecretNameV2(this, `${this.node.id}ImportedSecret${key}`, secretImport.name);
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:aws:ssm:${Stack.of(this).region}:*:parameter${scopedPath}`
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:aws:kms:${props.region}:${props.accountId}:key/${this.cmk.key.keyId}`
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:aws:ssm:${props.region}:${props.accountId}:parameter${props.name}`
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 === "string"))
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.9.1",
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.9.1",
67
- "@fjall/util": "^2.9.1",
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": "a97423cf3df727994364a0907fa2b5c544a86b0d"
82
+ "gitHead": "69823c3d7f2eacba419657464381119c5b5b5fd6"
83
83
  }