@fjall/components-infrastructure 2.9.1 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) 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/config/aws/s3BlockPublicAccess.d.ts +22 -4
  4. package/dist/lib/config/aws/s3BlockPublicAccess.js +33 -13
  5. package/dist/lib/config/aws/scpPreset.js +6 -1
  6. package/dist/lib/patterns/aws/account.d.ts +15 -0
  7. package/dist/lib/patterns/aws/account.js +32 -6
  8. package/dist/lib/patterns/aws/buildkite.js +1 -1
  9. package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
  10. package/dist/lib/patterns/aws/compute.d.ts +22 -0
  11. package/dist/lib/patterns/aws/compute.js +42 -0
  12. package/dist/lib/patterns/aws/computeEcs.d.ts +2 -1
  13. package/dist/lib/patterns/aws/computeEcs.js +67 -24
  14. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -2
  15. package/dist/lib/patterns/aws/organisation.d.ts +19 -8
  16. package/dist/lib/patterns/aws/organisation.js +23 -10
  17. package/dist/lib/patterns/aws/organisationFactory.js +2 -1
  18. package/dist/lib/patterns/aws/platform.d.ts +1 -0
  19. package/dist/lib/patterns/aws/platform.js +3 -0
  20. package/dist/lib/resources/aws/backup/backupVault.js +5 -3
  21. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +1 -1
  22. package/dist/lib/resources/aws/compute/ecsConstants.js +4 -1
  23. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +2 -2
  24. package/dist/lib/resources/aws/compute/ecsRoles.js +4 -13
  25. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +5 -3
  26. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +76 -3
  27. package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +0 -5
  28. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +2 -20
  29. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +50 -8
  30. package/dist/lib/resources/aws/compute/ecsTypes.js +11 -0
  31. package/dist/lib/resources/aws/compute/ecsValidation.js +37 -0
  32. package/dist/lib/resources/aws/compute/lambda.d.ts +11 -0
  33. package/dist/lib/resources/aws/compute/lambda.js +23 -3
  34. package/dist/lib/resources/aws/secrets/parameter.js +3 -3
  35. package/dist/lib/resources/aws/secrets/secret.d.ts +32 -11
  36. package/dist/lib/resources/aws/secrets/secret.js +53 -0
  37. package/dist/lib/resources/aws/utilities/customResource.d.ts +4 -0
  38. package/dist/lib/resources/aws/utilities/customResource.js +4 -0
  39. package/dist/lib/utils/env.js +2 -2
  40. package/dist/lib/utils/getConfig.js +2 -2
  41. package/dist/lib/utils/orgConfigParser.js +16 -2
  42. 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
  ]
@@ -1,9 +1,27 @@
1
1
  import { Construct } from "constructs";
2
+ export interface S3BlockPublicAccessProps {
3
+ /**
4
+ * Account whose account-level S3 Block Public Access is managed. Taken as a
5
+ * prop rather than read from `Stack.of(this).account` — that is a synth token
6
+ * that never matches a provider-account id when looking up config.
7
+ */
8
+ accountId: string;
9
+ }
2
10
  /**
3
- * Enables S3 Block Public Access at the account level via a Custom Resource.
4
- * All four public access flags are set to true (block all public access).
5
- * Stack deletion does NOT revert this setting security features are preserved.
11
+ * Manages account-level S3 Block Public Access via a Custom Resource.
12
+ *
13
+ * Off by default: Fjall observes-and-flags exposed buckets through the posture
14
+ * layer rather than hard-blocking at the account level. Set
15
+ * `s3BlockPublicAccess: "enforced"` on a provider account to switch all four
16
+ * flags on. The flags ride in the custom resource's properties (not hardcoded
17
+ * in the handler) so a config change reconciles on the next deploy. Stack
18
+ * deletion is a no-op — the account-level setting is left as-is.
19
+ *
20
+ * The handler runs under a fixed `/fjall/FjallS3BpaManager` role so the
21
+ * DenyS3PublicAccessChanges SCP can exempt its PutAccountPublicAccessBlock by
22
+ * ARN pattern. The role name is account-global, so this construct is created
23
+ * only in an account's home region (see Account's accountGlobals gate).
6
24
  */
7
25
  export declare class S3BlockPublicAccess extends Construct {
8
- constructor(scope: Construct, id: string);
26
+ constructor(scope: Construct, id: string, props: S3BlockPublicAccessProps);
9
27
  }
@@ -1,23 +1,41 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
2
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
3
  import { Runtime } from "aws-cdk-lib/aws-lambda";
4
- import { Stack } from "aws-cdk-lib";
5
4
  import { Construct } from "constructs";
6
5
  import { CustomResource } from "../../resources/aws/utilities/customResource.js";
6
+ import { getConfig } from "../../utils/getConfig.js";
7
7
  /**
8
- * Enables S3 Block Public Access at the account level via a Custom Resource.
9
- * All four public access flags are set to true (block all public access).
10
- * Stack deletion does NOT revert this setting security features are preserved.
8
+ * Manages account-level S3 Block Public Access via a Custom Resource.
9
+ *
10
+ * Off by default: Fjall observes-and-flags exposed buckets through the posture
11
+ * layer rather than hard-blocking at the account level. Set
12
+ * `s3BlockPublicAccess: "enforced"` on a provider account to switch all four
13
+ * flags on. The flags ride in the custom resource's properties (not hardcoded
14
+ * in the handler) so a config change reconciles on the next deploy. Stack
15
+ * deletion is a no-op — the account-level setting is left as-is.
16
+ *
17
+ * The handler runs under a fixed `/fjall/FjallS3BpaManager` role so the
18
+ * DenyS3PublicAccessChanges SCP can exempt its PutAccountPublicAccessBlock by
19
+ * ARN pattern. The role name is account-global, so this construct is created
20
+ * only in an account's home region (see Account's accountGlobals gate).
11
21
  */
12
22
  export class S3BlockPublicAccess extends Construct {
13
- constructor(scope, id) {
23
+ constructor(scope, id, props) {
14
24
  super(scope, id);
25
+ const account = getConfig().providerAccounts.find((pa) => pa.id === props.accountId);
26
+ const enforce = (account?.s3BlockPublicAccess ?? "off") === "enforced";
15
27
  new CustomResource(this, "S3BlockPublicAccess", {
16
28
  runtime: Runtime.NODEJS_22_X,
17
29
  timeout: Duration.minutes(5),
18
- lambdaDescription: "Enables S3 Block Public Access at account level",
30
+ lambdaDescription: "Manages S3 Block Public Access at account level",
31
+ rolePath: "/fjall/",
32
+ roleName: "FjallS3BpaManager",
19
33
  properties: {
20
- AccountId: Stack.of(this).account
34
+ AccountId: props.accountId,
35
+ BlockPublicAcls: String(enforce),
36
+ IgnorePublicAcls: String(enforce),
37
+ BlockPublicPolicy: String(enforce),
38
+ RestrictPublicBuckets: String(enforce)
21
39
  },
22
40
  inlinePolicy: [
23
41
  new PolicyStatement({
@@ -32,20 +50,22 @@ export class S3BlockPublicAccess extends Construct {
32
50
  inlineCode: `
33
51
  const { S3ControlClient, PutPublicAccessBlockCommand } = require('@aws-sdk/client-s3-control');
34
52
 
53
+ const toBool = (v) => v === 'true' || v === true;
54
+
35
55
  exports.handler = async (event) => {
36
56
  const physicalResourceId = event.PhysicalResourceId || event.LogicalResourceId || 's3-block-public-access';
37
57
  if (event.RequestType === 'Delete') {
38
58
  return { PhysicalResourceId: physicalResourceId };
39
59
  }
40
- const accountId = event.ResourceProperties.AccountId;
60
+ const props = event.ResourceProperties;
41
61
  const client = new S3ControlClient({});
42
62
  await client.send(new PutPublicAccessBlockCommand({
43
- AccountId: accountId,
63
+ AccountId: props.AccountId,
44
64
  PublicAccessBlockConfiguration: {
45
- BlockPublicAcls: true,
46
- IgnorePublicAcls: true,
47
- BlockPublicPolicy: true,
48
- RestrictPublicBuckets: true
65
+ BlockPublicAcls: toBool(props.BlockPublicAcls),
66
+ IgnorePublicAcls: toBool(props.IgnorePublicAcls),
67
+ BlockPublicPolicy: toBool(props.BlockPublicPolicy),
68
+ RestrictPublicBuckets: toBool(props.RestrictPublicBuckets)
49
69
  }
50
70
  }));
51
71
  return { PhysicalResourceId: physicalResourceId };
@@ -5,7 +5,12 @@ import { OrganisationPolicy } from "../../resources/aws/organisation/organisatio
5
5
  // default path.
6
6
  const EXEMPT_ROLE_PATTERNS = [
7
7
  "arn:aws:iam::*:role/fjall/FjallDeploy*",
8
- "arn:aws:iam::*:role/OrganizationAccountAccessRole"
8
+ "arn:aws:iam::*:role/OrganizationAccountAccessRole",
9
+ // The account-level S3 Block Public Access manager Lambda re-applies BPA on
10
+ // every deploy; its PutAccountPublicAccessBlock must survive
11
+ // DenyS3PublicAccessChanges. Pathed under /fjall/ so a member admin cannot
12
+ // forge an exempt role at the default path.
13
+ "arn:aws:iam::*:role/fjall/FjallS3BpaManager*"
9
14
  ];
10
15
  const SCP_BYTE_LIMIT = 5120;
11
16
  const IAM_POLICY_VERSION = "2012-10-17";
@@ -19,6 +19,21 @@ export declare class Account extends Stack {
19
19
  readonly organisationType: OrganisationType;
20
20
  protected readonly resolvedRegion: string;
21
21
  constructor(scope: Construct, id: string, props: AccountProps);
22
+ /**
23
+ * Whether this account receives the OIDC deploy connector (provider +
24
+ * FjallDeploy role) and so can be a deploy target. Standalone/member accounts
25
+ * do; Platform overrides to opt in. Deliberately separate from the
26
+ * IPAM-output gate (which stays `this.constructor === Account`) — the two are
27
+ * independent, and Platform must take one but not the other.
28
+ */
29
+ protected receivesDeployRole(): boolean;
30
+ /**
31
+ * Whether this account creates the default-ECR-image helper (the CodeBuild
32
+ * project that seeds a placeholder application image). App-hosting accounts
33
+ * need it; the organisation root runs no workloads and overrides to `false`.
34
+ * Deliberately separate from the OIDC/IPAM gates — the axes are independent.
35
+ */
36
+ protected createsEcrDefaultImage(): boolean;
22
37
  enableGuardDuty(props?: GuardDutyDetectorProps): GuardDutyDetector;
23
38
  enableSecurityHub(props?: SecurityHubHubProps): SecurityHubHub;
24
39
  enableConfigRecorder(props?: ConfigRecorderProps): ConfigRecorder;
@@ -59,7 +59,7 @@ export class Account extends Stack {
59
59
  // (OIDC provider/role, FjallMonitoring, FjallAudit) have fixed names that
60
60
  // collide across regions, so they are created only in the home region.
61
61
  const accountGlobalsConfigured = this.node.tryGetContext("fjallAccountGlobalsConfigured") === "true";
62
- if (isStandaloneAccount &&
62
+ if (this.receivesDeployRole() &&
63
63
  fjallOrgId &&
64
64
  !oidcAlreadyConfigured &&
65
65
  !accountGlobalsConfigured) {
@@ -75,10 +75,12 @@ export class Account extends Stack {
75
75
  accountId: this.account,
76
76
  region: this.resolvedRegion
77
77
  });
78
- new EcrDefaultImage(this, "EcrDefaultImage", {
79
- region: this.resolvedRegion,
80
- accountId: this.account
81
- });
78
+ if (this.createsEcrDefaultImage()) {
79
+ new EcrDefaultImage(this, "EcrDefaultImage", {
80
+ region: this.resolvedRegion,
81
+ accountId: this.account
82
+ });
83
+ }
82
84
  const environment = config.environment ?? "unknown";
83
85
  if (config.disasterRecoveryRegion) {
84
86
  const isComplianceAccount = environment === "compliance";
@@ -95,9 +97,33 @@ export class Account extends Stack {
95
97
  exportName: "Environment",
96
98
  description: "Environment type for this account (e.g., production, staging, development)"
97
99
  });
98
- new S3BlockPublicAccess(this, "S3BlockPublicAccess");
100
+ // Account-level S3 Block Public Access is account-global and runs under a
101
+ // fixed-name role, so (like the OIDC/Monitoring/Audit globals above) it is
102
+ // created only in the home region to avoid a cross-region role collision.
103
+ if (!accountGlobalsConfigured) {
104
+ new S3BlockPublicAccess(this, "S3BlockPublicAccess", { accountId });
105
+ }
99
106
  new EbsDefaultEncryption(this, "EbsDefaultEncryption");
100
107
  }
108
+ /**
109
+ * Whether this account receives the OIDC deploy connector (provider +
110
+ * FjallDeploy role) and so can be a deploy target. Standalone/member accounts
111
+ * do; Platform overrides to opt in. Deliberately separate from the
112
+ * IPAM-output gate (which stays `this.constructor === Account`) — the two are
113
+ * independent, and Platform must take one but not the other.
114
+ */
115
+ receivesDeployRole() {
116
+ return this.constructor === Account;
117
+ }
118
+ /**
119
+ * Whether this account creates the default-ECR-image helper (the CodeBuild
120
+ * project that seeds a placeholder application image). App-hosting accounts
121
+ * need it; the organisation root runs no workloads and overrides to `false`.
122
+ * Deliberately separate from the OIDC/IPAM gates — the axes are independent.
123
+ */
124
+ createsEcrDefaultImage() {
125
+ return true;
126
+ }
101
127
  enableGuardDuty(props) {
102
128
  return new GuardDutyDetector(this, "GuardDuty", props);
103
129
  }
@@ -130,7 +130,7 @@ export class Buildkite extends Stack {
130
130
  new PolicyStatement({
131
131
  actions: ["ssm:GetParameter"],
132
132
  resources: [
133
- `arn: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() {