@fjall/components-infrastructure 3.3.0 → 3.4.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.
@@ -1,9 +1,9 @@
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, type StepScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
4
+ import { TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
5
5
  import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
6
- import { type EcsServiceProps, type Ec2CapacityConfig } from "./ecsTypes.js";
6
+ import { type EcsServiceProps, type Ec2CapacityConfig, type QueueScalingResources } from "./ecsTypes.js";
7
7
  import type { EcsConstructContext } from "./ecsContext.js";
8
8
  /**
9
9
  * Resolves the user's `circuitBreaker` config to the CDK
@@ -41,4 +41,4 @@ export declare function createService(ctx: EcsConstructContext, serviceName: str
41
41
  * policy on utilisation; `QUEUE` emits a step-scaling policy on SQS backlog
42
42
  * that can wake the service from `desiredCount: 0`.
43
43
  */
44
- export declare function addServiceScaling(ctx: EcsConstructContext, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service): TargetTrackingScalingPolicy | StepScalingPolicy;
44
+ export declare function addServiceScaling(ctx: EcsConstructContext, serviceName: string, serviceProps: EcsServiceProps, service: FargateService | Ec2Service): TargetTrackingScalingPolicy | QueueScalingResources;
@@ -3,8 +3,9 @@ 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 { AdjustmentType, PredefinedMetric, ScalableTarget, ServiceNamespace, TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
7
- import { MathExpression } from "aws-cdk-lib/aws-cloudwatch";
6
+ import { AdjustmentType, PredefinedMetric, ScalableTarget, ServiceNamespace, StepScalingAction, TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
7
+ import { Alarm, ComparisonOperator, MathExpression } from "aws-cdk-lib/aws-cloudwatch";
8
+ import { ApplicationScalingAction } from "aws-cdk-lib/aws-cloudwatch-actions";
8
9
  import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
9
10
  import { SecurityGroup } from "../networking/securityGroup.js";
10
11
  import { Ec2Instance } from "./ec2.js";
@@ -283,13 +284,14 @@ export function createService(ctx, serviceName, serviceProps, taskDefinition, as
283
284
  return service;
284
285
  }
285
286
  /**
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.
287
+ * Wake-ladder intervals on the queue-backlog metric, with
288
+ * `AdjustmentType.EXACT_CAPACITY` (`capacity` is the absolute DesiredCount).
289
+ * Backlog ≥1 → 1 task; ≥100 → 2; ≥500 → 3. Capacities are clamped to the
290
+ * service's `maxCapacity` at build time. Bounds are absolute backlog values;
291
+ * `buildQueueScalingPolicy` re-expresses them relative to the wake alarm's
292
+ * threshold (the first interval's `lower`).
290
293
  */
291
- const DEFAULT_QUEUE_SCALING_STEPS = [
292
- { lower: 0, upper: 1, capacity: 0 },
294
+ const QUEUE_WAKE_SCALING_STEPS = [
293
295
  { lower: 1, upper: 100, capacity: 1 },
294
296
  { lower: 100, upper: 500, capacity: 2 },
295
297
  { lower: 500, capacity: 3 }
@@ -327,11 +329,21 @@ export function addServiceScaling(ctx, serviceName, serviceProps, service) {
327
329
  });
328
330
  }
329
331
  /**
330
- * Builds the step-scaling policy for `ScalingType.QUEUE`. The alarm metric is a
331
- * `MathExpression` SUM of `ApproximateNumberOfMessagesVisible`
332
+ * Builds the wake/sleep step-scaling pair for `ScalingType.QUEUE`. The alarm
333
+ * metric is a `MathExpression` SUM of `ApproximateNumberOfMessagesVisible`
332
334
  * (+`…NotVisible` when `includeInFlight`) across the consumed queues, sampled at
333
335
  * `Maximum` over 1-minute periods — published independent of running-task count,
334
- * so the policy fires (and DesiredCount rises from 0) on backlog alone.
336
+ * so the alarms fire (and DesiredCount rises from 0) on backlog alone.
337
+ *
338
+ * The actions and alarms are wired explicitly rather than via
339
+ * `ScalableTarget.scaleOnMetric`: CDK's `StepScalingPolicy` middle-splits an
340
+ * all-`EXACT_CAPACITY` interval ladder to place its two alarm thresholds
341
+ * (`findAlarmThresholds` in aws-autoscaling-common), which for a
342
+ * 0/1/100/500 ladder puts BOTH thresholds at 100. Step policies are only
343
+ * invoked on alarm state transitions, so backlog 1–99 produced no transition
344
+ * and could never wake a zero-task service. The explicit pair pins the wake
345
+ * threshold at the ladder's first bound (backlog ≥ 1) and the sleep threshold
346
+ * at 0, making every 0↔1 backlog crossing a state transition.
335
347
  */
336
348
  function buildQueueScalingPolicy(serviceName, config, scalableTarget, maxCapacity) {
337
349
  if (config === undefined || config.queues.length === 0) {
@@ -363,16 +375,46 @@ function buildQueueScalingPolicy(serviceName, config, scalableTarget, maxCapacit
363
375
  period: Duration.minutes(1),
364
376
  label: `${serviceName}QueueBacklog`
365
377
  });
366
- return scalableTarget.scaleOnMetric(`${serviceName}QueueScaling`, {
378
+ const cooldown = config.cooldown ?? DEFAULT_QUEUE_SCALE_COOLDOWN;
379
+ const wakeThreshold = QUEUE_WAKE_SCALING_STEPS[0]?.lower ?? 1;
380
+ const wakeAction = new StepScalingAction(scalableTarget, `${serviceName}QueueWakeAction`, {
381
+ scalingTarget: scalableTarget,
382
+ adjustmentType: AdjustmentType.EXACT_CAPACITY,
383
+ cooldown
384
+ });
385
+ QUEUE_WAKE_SCALING_STEPS.forEach((step, index) => {
386
+ wakeAction.addAdjustment({
387
+ adjustment: Math.min(step.capacity, maxCapacity),
388
+ lowerBound: step.lower - wakeThreshold,
389
+ ...(index < QUEUE_WAKE_SCALING_STEPS.length - 1 &&
390
+ step.upper !== undefined && { upperBound: step.upper - wakeThreshold })
391
+ });
392
+ });
393
+ const wakeAlarm = new Alarm(scalableTarget, `${serviceName}QueueWakeAlarm`, {
367
394
  metric: backlogMetric,
395
+ alarmDescription: `Scale out '${serviceName}' on queue backlog`,
396
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
397
+ threshold: wakeThreshold,
398
+ evaluationPeriods: 1,
399
+ datapointsToAlarm: 1
400
+ });
401
+ wakeAlarm.addAlarmAction(new ApplicationScalingAction(wakeAction));
402
+ const sleepAction = new StepScalingAction(scalableTarget, `${serviceName}QueueSleepAction`, {
403
+ scalingTarget: scalableTarget,
368
404
  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,
405
+ cooldown
406
+ });
407
+ // upperBound is relative to the alarm threshold (0), so this single
408
+ // open-ended interval covers exactly backlog ≤ 0.
409
+ sleepAction.addAdjustment({ adjustment: 0, upperBound: 0 });
410
+ const sleepAlarm = new Alarm(scalableTarget, `${serviceName}QueueSleepAlarm`, {
411
+ metric: backlogMetric,
412
+ alarmDescription: `Scale '${serviceName}' to zero on empty queue`,
413
+ comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD,
414
+ threshold: 0,
375
415
  evaluationPeriods: 1,
376
416
  datapointsToAlarm: 1
377
417
  });
418
+ sleepAlarm.addAlarmAction(new ApplicationScalingAction(sleepAction));
419
+ return { wakeAction, wakeAlarm, sleepAction, sleepAlarm };
378
420
  }
@@ -4,7 +4,8 @@ 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, type StepScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
7
+ import { type TargetTrackingScalingPolicy, type StepScalingAction } from "aws-cdk-lib/aws-applicationautoscaling";
8
+ import { type Alarm } from "aws-cdk-lib/aws-cloudwatch";
8
9
  import { type IQueue } from "aws-cdk-lib/aws-sqs";
9
10
  import { type Duration } from "aws-cdk-lib";
10
11
  import { type GeoLocation } from "aws-cdk-lib/aws-route53";
@@ -63,6 +64,29 @@ export interface QueueScalingConfig {
63
64
  /** Cooldown after a scale action. Default: 5 minutes. */
64
65
  cooldown?: Duration;
65
66
  }
67
+ /**
68
+ * The explicitly-wired wake/sleep alarm pair emitted for `ScalingType.QUEUE`.
69
+ *
70
+ * Built by hand (NOT via `ScalableTarget.scaleOnMetric`) because CDK's
71
+ * `StepScalingPolicy` places its two alarm thresholds by middle-splitting the
72
+ * interval ladder when every `EXACT_CAPACITY` step carries a change — for the
73
+ * default 4-step ladder that puts BOTH thresholds at backlog 100, and since
74
+ * step policies are only invoked on alarm state transitions, a backlog of
75
+ * 1–99 can never wake a `desiredCount: 0` service (observed live: the prod
76
+ * deploy-worker sat at 0 tasks with 1 queued message indefinitely). The
77
+ * explicit pair pins the wake boundary at backlog ≥ 1 and the sleep boundary
78
+ * at backlog 0.
79
+ */
80
+ export interface QueueScalingResources {
81
+ /** Scale-out action: backlog ≥ 1 → capacity ladder (1/2/3, clamped). */
82
+ wakeAction: StepScalingAction;
83
+ /** Alarm at backlog ≥ 1 driving `wakeAction`. */
84
+ wakeAlarm: Alarm;
85
+ /** Scale-in action: backlog 0 → capacity 0. */
86
+ sleepAction: StepScalingAction;
87
+ /** Alarm at backlog ≤ 0 driving `sleepAction`. */
88
+ sleepAlarm: Alarm;
89
+ }
66
90
  import type { EcsCapacityProvider } from "@fjall/generator";
67
91
  export type { EcsCapacityProvider };
68
92
  /**
@@ -607,7 +631,7 @@ export interface ServiceData {
607
631
  containers: ContainerDefinition[];
608
632
  primaryContainer?: ContainerDefinition;
609
633
  targetGroup?: IApplicationTargetGroup;
610
- scalingPolicy?: TargetTrackingScalingPolicy | StepScalingPolicy;
634
+ scalingPolicy?: TargetTrackingScalingPolicy | QueueScalingResources;
611
635
  /** Explicit log group shared by all of the service's containers. */
612
636
  logGroup: ILogGroup;
613
637
  }
@@ -1,6 +1,7 @@
1
1
  import { CfnOutput, Tags } from "aws-cdk-lib";
2
2
  import { Certificate, CertificateValidation } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { getDomainExportNames } from "@fjall/util";
4
+ import { sanitiseAcmTagValue } from "../../../utils/acmTagValue.js";
4
5
  import { toPascalCase } from "../../../utils/capitaliseString.js";
5
6
  import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
6
7
  /**
@@ -33,10 +34,12 @@ export class DomainCertificate extends Certificate {
33
34
  this.description =
34
35
  props.description ??
35
36
  `Fjall-managed ACM certificate for ${props.domainName}`;
36
- Tags.of(this).add("fjall:description", this.description);
37
+ // ACM's tag-value charset excludes `*`, so a wildcard domainName in either
38
+ // tag fails CreateCertificate — sanitise both (see utils/acmTagValue.ts).
39
+ Tags.of(this).add("fjall:description", sanitiseAcmTagValue(this.description));
37
40
  applyCostAllocationTags(this, {
38
41
  service: "certificate",
39
- domain: props.costAllocationDomain ?? props.domainName,
42
+ domain: sanitiseAcmTagValue(props.costAllocationDomain ?? props.domainName),
40
43
  environment: props.costAllocationEnvironment,
41
44
  inheritEnvironment: props.inheritCostAllocationEnvironment
42
45
  });
@@ -0,0 +1,2 @@
1
+ /** Replace every ACM-disallowed character with `_` (an allowed character). */
2
+ export declare function sanitiseAcmTagValue(value: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ACM rejects tag values containing characters outside
3
+ * `[\p{L}\p{Z}\p{N}_.:/=+\-@]*` (letters, spaces, numbers, `_ . : / = + - @`)
4
+ * with a CreateCertificate ValidationException — notably `*`, so any tag
5
+ * embedding a wildcard domain (`*.dev.example.com`) fails the whole stack at
6
+ * deploy time. Most other taggable services (S3, EC2, Route53) accept `*`,
7
+ * which is why the constraint only surfaces at the certificate boundary.
8
+ */
9
+ const ACM_TAG_VALUE_DISALLOWED = /[^\p{L}\p{Z}\p{N}_.:/=+\-@]/gu;
10
+ /** Replace every ACM-disallowed character with `_` (an allowed character). */
11
+ export function sanitiseAcmTagValue(value) {
12
+ return value.replace(ACM_TAG_VALUE_DISALLOWED, "_");
13
+ }
@@ -1,11 +1,6 @@
1
+ import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "@fjall/util/aws";
1
2
  import type { IConstruct } from "constructs";
2
- export declare const COST_ALLOCATION_TAGS: {
3
- readonly ENVIRONMENT: "fjall:costAllocation:environment";
4
- readonly SERVICE: "fjall:costAllocation:service";
5
- readonly DOMAIN: "fjall:costAllocation:domain";
6
- readonly OWNER: "fjall:costAllocation:owner";
7
- };
8
- export declare const DEFAULT_COST_ALLOCATION_ENVIRONMENT: "management";
3
+ export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT };
9
4
  export interface CostAllocationTagsArgs {
10
5
  readonly service: string;
11
6
  readonly domain: string;
@@ -1,11 +1,9 @@
1
+ import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "@fjall/util/aws";
1
2
  import { Tags } from "aws-cdk-lib";
2
- export const COST_ALLOCATION_TAGS = {
3
- ENVIRONMENT: "fjall:costAllocation:environment",
4
- SERVICE: "fjall:costAllocation:service",
5
- DOMAIN: "fjall:costAllocation:domain",
6
- OWNER: "fjall:costAllocation:owner"
7
- };
8
- export const DEFAULT_COST_ALLOCATION_ENVIRONMENT = "management";
3
+ // Canonical home: @fjall/util (`util/src/aws/costAllocationTags.ts`) — shared
4
+ // with deploy-core's `cdk bootstrap --tags` stamping. Re-exported here so
5
+ // construct-package consumers keep their existing import path.
6
+ export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT };
9
7
  export function applyCostAllocationTags(scope, args) {
10
8
  if (args.environment !== undefined) {
11
9
  Tags.of(scope).add(COST_ALLOCATION_TAGS.ENVIRONMENT, args.environment);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-organizations": "^3.1038.0",
70
- "@fjall/generator": "^3.3.0",
71
- "@fjall/util": "^3.3.0",
70
+ "@fjall/generator": "^3.4.1",
71
+ "@fjall/util": "^3.4.1",
72
72
  "constructs": "^10.6.0"
73
73
  },
74
74
  "overrides": {
@@ -82,5 +82,5 @@
82
82
  "engines": {
83
83
  "node": ">=18.0.0"
84
84
  },
85
- "gitHead": "cc1e0f520f4b6b5312e16df3751d60ea746dfb7d"
85
+ "gitHead": "6d8ef9582d5abe6a8199431815dae761887c18d4"
86
86
  }