@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
@@ -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.
@@ -1,21 +1,21 @@
1
- import { type Environment, Stack, type StackProps } from "aws-cdk-lib";
1
+ import { type Environment } from "aws-cdk-lib";
2
+ import { type Construct } from "constructs";
3
+ import { Account, type AccountProps } from "./account.js";
2
4
  import { type CustomPermissionSets } from "../../config/aws/identityCenter.js";
3
5
  import { ScpPreset } from "../../config/aws/scpPreset.js";
4
6
  import type { ScpPresetProps } from "../../config/aws/scpPreset.js";
5
7
  import { OrganisationResource } from "../../resources/aws/organisation/index.js";
6
- type ExtendedStackProps = Omit<StackProps, "env"> & {
7
- env?: Required<Pick<Environment, "region" | "account">> & Partial<Omit<Environment, "region" | "account">>;
8
- };
9
8
  export type AccountsConfig = {
10
9
  readonly [key: string]: readonly string[] | string | AccountsConfig;
11
10
  };
12
- export interface OrganisationProps extends ExtendedStackProps {
11
+ export interface OrganisationProps extends AccountProps {
13
12
  organisationName: string;
14
13
  accounts: AccountsConfig;
15
14
  orgEmail: string;
16
15
  accountIds?: Record<string, string>;
17
16
  identityCenter?: boolean;
18
17
  allowedRegions?: string[];
18
+ env?: Required<Pick<Environment, "region" | "account">> & Partial<Omit<Environment, "region" | "account">>;
19
19
  }
20
20
  /**
21
21
  * Organisation CDK stack.
@@ -27,14 +27,26 @@ export interface OrganisationProps extends ExtendedStackProps {
27
27
  * - Identity Centre configuration
28
28
  * - Cost allocation tag auto-activation (daily Lambda)
29
29
  */
30
- export declare class Organisation extends Stack {
30
+ export declare class Organisation extends Account {
31
31
  readonly organisationType: "organisation";
32
32
  private org;
33
33
  private accountRefs;
34
34
  private accountMap;
35
35
  private accountsConfig;
36
36
  private identityCenter?;
37
- constructor(id: string, props: OrganisationProps);
37
+ constructor(scope: Construct, id: string, props: OrganisationProps);
38
+ /**
39
+ * The organisation root's OIDC deploy connector is owned by the customer-run
40
+ * Quick-Create CloudFormation stack, never the inherited Account connector.
41
+ * Returning `false` is also the short-circuit first clause of Account's OIDC
42
+ * gate, so no `OidcConnector` is synthesised on the root stack.
43
+ */
44
+ protected receivesDeployRole(): boolean;
45
+ /**
46
+ * The organisation root runs no application workloads, so it needs no
47
+ * default-ECR-image helper.
48
+ */
49
+ protected createsEcrDefaultImage(): boolean;
38
50
  private createAccountReferences;
39
51
  private setupOrganisationFeatures;
40
52
  private setupCostAllocationTagActivator;
@@ -45,4 +57,3 @@ export declare class Organisation extends Stack {
45
57
  getAccounts(): Record<string, string>;
46
58
  enableScps(props: ScpPresetProps): ScpPreset;
47
59
  }
48
- export {};
@@ -1,5 +1,5 @@
1
- import { Stack } from "aws-cdk-lib";
2
1
  import App from "../../app.js";
2
+ import { Account } from "./account.js";
3
3
  import { IdentityCenter } from "../../config/aws/identityCenter.js";
4
4
  import { ScpPreset } from "../../config/aws/scpPreset.js";
5
5
  import { OrganisationResource, OrganisationAccount, CostAllocationTagActivator } from "../../resources/aws/organisation/index.js";
@@ -17,23 +17,20 @@ import { extractAccountNames } from "../../utils/accountsUtils.js";
17
17
  * - Identity Centre configuration
18
18
  * - Cost allocation tag auto-activation (daily Lambda)
19
19
  */
20
- export class Organisation extends Stack {
20
+ export class Organisation extends Account {
21
21
  organisationType = "organisation";
22
22
  org;
23
23
  accountRefs = [];
24
24
  accountMap;
25
25
  accountsConfig;
26
26
  identityCenter;
27
- constructor(id, props) {
27
+ constructor(scope, id, props) {
28
28
  const config = getConfig();
29
- const env = props.env ?? {
30
- region: config.region,
31
- account: config.accountId ?? ""
32
- };
33
- if (!env.account) {
34
- throw new Error("Organisation requires an account ID. Provide it via env.account or ensure CDK context includes accountId.");
29
+ const accountId = props.accountId ?? props.env?.account ?? config.accountId;
30
+ if (!accountId) {
31
+ throw new Error("Organisation requires an account ID. Provide it via env.account, accountId, or ensure CDK context includes accountId.");
35
32
  }
36
- super(App.getInstance(), id, { ...props, env });
33
+ super(scope, id, { ...props, accountId });
37
34
  // Normalise account map keys to lowercase for case-insensitive lookups.
38
35
  // providerAccounts stores names as lowercase; ACCOUNTS config uses PascalCase.
39
36
  const rawMap = props.accountIds ?? config.accountIds ?? {};
@@ -57,6 +54,22 @@ export class Organisation extends Stack {
57
54
  this.createAccountReferences(props);
58
55
  this.setupOrganisationFeatures(props.identityCenter ?? true, managementAccountId);
59
56
  }
57
+ /**
58
+ * The organisation root's OIDC deploy connector is owned by the customer-run
59
+ * Quick-Create CloudFormation stack, never the inherited Account connector.
60
+ * Returning `false` is also the short-circuit first clause of Account's OIDC
61
+ * gate, so no `OidcConnector` is synthesised on the root stack.
62
+ */
63
+ receivesDeployRole() {
64
+ return false;
65
+ }
66
+ /**
67
+ * The organisation root runs no application workloads, so it needs no
68
+ * default-ECR-image helper.
69
+ */
70
+ createsEcrDefaultImage() {
71
+ return false;
72
+ }
60
73
  createAccountReferences(props) {
61
74
  const allNames = extractAccountNames(props.accounts);
62
75
  for (const accountName of allNames) {
@@ -1,3 +1,4 @@
1
+ import App from "../../app.js";
1
2
  import { Organisation } from "./organisation.js";
2
3
  import { Platform } from "./platform.js";
3
4
  import { Account } from "./account.js";
@@ -5,7 +6,7 @@ export class OrganisationFactory {
5
6
  static build(id, props) {
6
7
  switch (props.type) {
7
8
  case "organisation":
8
- return new Organisation(id, props);
9
+ return new Organisation(App.getInstance(), id, props);
9
10
  case "platform":
10
11
  return (scope) => new Platform(scope, id, props);
11
12
  case "account":
@@ -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) {