@fjall/components-infrastructure 5.0.0 → 6.0.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 (31) hide show
  1. package/dist/lib/config/aws/ebsDefaultEncryption.js +1 -1
  2. package/dist/lib/config/aws/inspectorEnablement.js +1 -1
  3. package/dist/lib/config/aws/oidcConnector.js +1 -1
  4. package/dist/lib/config/aws/s3BlockPublicAccess.js +1 -1
  5. package/dist/lib/config/aws/securityServicesAdmin.js +2 -2
  6. package/dist/lib/lambda-assets/cert-generator/asset/index.js +31 -31
  7. package/dist/lib/patterns/aws/compute.js +1 -1
  8. package/dist/lib/patterns/aws/computeEcs.js +5 -0
  9. package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
  10. package/dist/lib/patterns/aws/devSubstrate.js +1 -1
  11. package/dist/lib/patterns/aws/payload.js +3 -3
  12. package/dist/lib/patterns/aws/staticSite.js +1 -1
  13. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +1 -1
  14. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +43 -0
  15. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +161 -0
  16. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +4 -0
  17. package/dist/lib/resources/aws/compute/ecsConstants.js +9 -0
  18. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
  19. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +4 -10
  20. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +7 -101
  21. package/dist/lib/resources/aws/compute/ecsValidation.js +6 -0
  22. package/dist/lib/resources/aws/compute/persistentDataVolume.js +1 -1
  23. package/dist/lib/resources/aws/database/rdsInstance.js +1 -1
  24. package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
  25. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
  26. package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
  27. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
  28. package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
  29. package/dist/lib/utils/engineCompat.d.ts +12 -5
  30. package/dist/lib/utils/engineCompat.js +41 -22
  31. package/package.json +10 -5
@@ -11,7 +11,7 @@ export class EbsDefaultEncryption extends Construct {
11
11
  constructor(scope, id) {
12
12
  super(scope, id);
13
13
  new CustomResource(this, "EbsDefaultEncryption", {
14
- runtime: Runtime.NODEJS_22_X,
14
+ runtime: Runtime.NODEJS_24_X,
15
15
  timeout: Duration.minutes(5),
16
16
  lambdaDescription: "Enables EBS default encryption at account level",
17
17
  inlinePolicy: [
@@ -12,7 +12,7 @@ export class InspectorEnablement extends Construct {
12
12
  constructor(scope, id) {
13
13
  super(scope, id);
14
14
  new CustomResource(this, "InspectorEnable", {
15
- runtime: Runtime.NODEJS_22_X,
15
+ runtime: Runtime.NODEJS_24_X,
16
16
  timeout: Duration.minutes(5),
17
17
  lambdaDescription: "Enables/disables Inspector v2 for the account",
18
18
  inlinePolicy: [
@@ -74,7 +74,7 @@ export class OidcConnector extends Construct {
74
74
  const issuerDomain = FJALL_OIDC_ISSUER_DOMAIN;
75
75
  const issuerUrl = `https://${issuerDomain}`;
76
76
  const providerResource = new CustomResource(this, "OidcProvider", {
77
- runtime: Runtime.NODEJS_22_X,
77
+ runtime: Runtime.NODEJS_24_X,
78
78
  timeout: Duration.minutes(5),
79
79
  lambdaDescription: "Adopt-or-create the account-global Fjall OIDC provider",
80
80
  resourceType: "Custom::FjallOidcProvider",
@@ -25,7 +25,7 @@ export class S3BlockPublicAccess extends Construct {
25
25
  const account = getConfig().providerAccounts.find((pa) => pa.id === props.accountId);
26
26
  const enforce = (account?.s3BlockPublicAccess ?? "off") === "enforced";
27
27
  new CustomResource(this, "S3BlockPublicAccess", {
28
- runtime: Runtime.NODEJS_22_X,
28
+ runtime: Runtime.NODEJS_24_X,
29
29
  timeout: Duration.minutes(5),
30
30
  lambdaDescription: "Manages S3 Block Public Access at account level",
31
31
  rolePath: "/fjall/",
@@ -35,7 +35,7 @@ export class SecurityServicesAdmin extends Construct {
35
35
  }
36
36
  createGuardDutyAdmin() {
37
37
  new CustomResource(this, "GuardDutyOrgConfig", {
38
- runtime: Runtime.NODEJS_22_X,
38
+ runtime: Runtime.NODEJS_24_X,
39
39
  timeout: Duration.minutes(5),
40
40
  lambdaDescription: "Configures GuardDuty organisation-wide auto-enablement",
41
41
  inlinePolicy: [
@@ -70,7 +70,7 @@ exports.handler = async (event) => {
70
70
  }
71
71
  createSecurityHubAdmin() {
72
72
  new CustomResource(this, "SecurityHubOrgConfig", {
73
- runtime: Runtime.NODEJS_22_X,
73
+ runtime: Runtime.NODEJS_24_X,
74
74
  timeout: Duration.minutes(5),
75
75
  lambdaDescription: "Configures SecurityHub organisation-wide auto-enablement",
76
76
  inlinePolicy: [
@@ -6549,15 +6549,15 @@ var require_parser = __commonJS({
6549
6549
  var helper_1 = require_helper();
6550
6550
  var storage_1 = require_storage();
6551
6551
  var AsnParser = class {
6552
- static parse(data, target) {
6553
- const asn1Parsed = asn1js.fromBER((0, bytes_1.toArrayBuffer)(data));
6552
+ static parse(data, target, options) {
6553
+ const asn1Parsed = asn1js.fromBER((0, bytes_1.toArrayBuffer)(data), options?.berOptions);
6554
6554
  if (asn1Parsed.result.error) {
6555
6555
  throw new Error(asn1Parsed.result.error);
6556
6556
  }
6557
- const res = this.fromASN(asn1Parsed.result, target);
6557
+ const res = this.fromASN(asn1Parsed.result, target, options);
6558
6558
  return res;
6559
6559
  }
6560
- static fromASN(asn1Schema, target) {
6560
+ static fromASN(asn1Schema, target, options) {
6561
6561
  try {
6562
6562
  if ((0, helper_1.isConvertible)(target)) {
6563
6563
  const value = new target();
@@ -6566,7 +6566,7 @@ var require_parser = __commonJS({
6566
6566
  const schema = storage_1.schemaStorage.get(target);
6567
6567
  storage_1.schemaStorage.cache(target);
6568
6568
  let targetSchema = schema.schema;
6569
- const choiceResult = this.handleChoiceTypes(asn1Schema, schema, target, targetSchema);
6569
+ const choiceResult = this.handleChoiceTypes(asn1Schema, schema, target, targetSchema, options);
6570
6570
  if (choiceResult?.result) {
6571
6571
  return choiceResult.result;
6572
6572
  }
@@ -6576,9 +6576,9 @@ var require_parser = __commonJS({
6576
6576
  const sequenceResult = this.handleSequenceTypes(asn1Schema, schema, target, targetSchema);
6577
6577
  const res = new target();
6578
6578
  if ((0, helper_1.isTypeOfArray)(target)) {
6579
- return this.handleArrayTypes(asn1Schema, schema, target);
6579
+ return this.handleArrayTypes(asn1Schema, schema, target, options);
6580
6580
  }
6581
- this.processSchemaItems(schema, sequenceResult, res);
6581
+ this.processSchemaItems(schema, sequenceResult, res, options);
6582
6582
  return res;
6583
6583
  } catch (error) {
6584
6584
  if (error instanceof errors_1.AsnSchemaValidationError) {
@@ -6587,7 +6587,7 @@ var require_parser = __commonJS({
6587
6587
  throw error;
6588
6588
  }
6589
6589
  }
6590
- static handleChoiceTypes(asn1Schema, schema, target, targetSchema) {
6590
+ static handleChoiceTypes(asn1Schema, schema, target, targetSchema, options) {
6591
6591
  if (asn1Schema.constructor === asn1js.Constructed && schema.type === enums_1.AsnTypeTypes.Choice && asn1Schema.idBlock.tagClass === 3) {
6592
6592
  for (const key in schema.items) {
6593
6593
  const schemaItem = schema.items[key];
@@ -6598,7 +6598,7 @@ var require_parser = __commonJS({
6598
6598
  const newSeq = new asn1js.Sequence();
6599
6599
  if ("value" in asn1Schema.valueBlock && Array.isArray(asn1Schema.valueBlock.value) && "value" in newSeq.valueBlock) {
6600
6600
  newSeq.valueBlock.value = asn1Schema.valueBlock.value;
6601
- const fieldValue = this.fromASN(newSeq, schemaItem.type);
6601
+ const fieldValue = this.fromASN(newSeq, schemaItem.type, options);
6602
6602
  const res = new target();
6603
6603
  res[key] = fieldValue;
6604
6604
  return { result: res };
@@ -6689,7 +6689,7 @@ var require_parser = __commonJS({
6689
6689
  throw err;
6690
6690
  }
6691
6691
  }
6692
- static handleArrayTypes(asn1Schema, schema, target) {
6692
+ static handleArrayTypes(asn1Schema, schema, target, options) {
6693
6693
  if (!("value" in asn1Schema.valueBlock && Array.isArray(asn1Schema.valueBlock.value))) {
6694
6694
  throw new Error("Cannot get items from the ASN.1 parsed value. ASN.1 object is not constructed.");
6695
6695
  }
@@ -6701,10 +6701,10 @@ var require_parser = __commonJS({
6701
6701
  }
6702
6702
  return target.from(asn1Schema.valueBlock.value, (element) => converter.fromASN(element));
6703
6703
  } else {
6704
- return target.from(asn1Schema.valueBlock.value, (element) => this.fromASN(element, itemType));
6704
+ return target.from(asn1Schema.valueBlock.value, (element) => this.fromASN(element, itemType, options));
6705
6705
  }
6706
6706
  }
6707
- static processSchemaItems(schema, asn1ComparedSchema, res) {
6707
+ static processSchemaItems(schema, asn1ComparedSchema, res, options) {
6708
6708
  for (const key in schema.items) {
6709
6709
  const asn1SchemaValue = asn1ComparedSchema.result[key];
6710
6710
  if (!asn1SchemaValue) {
@@ -6714,9 +6714,9 @@ var require_parser = __commonJS({
6714
6714
  const schemaItemType = schemaItem.type;
6715
6715
  let parsedValue;
6716
6716
  if (typeof schemaItemType === "number" || (0, helper_1.isConvertible)(schemaItemType)) {
6717
- parsedValue = this.processPrimitiveSchemaItem(asn1SchemaValue, schemaItem, schemaItemType);
6717
+ parsedValue = this.processPrimitiveSchemaItem(asn1SchemaValue, schemaItem, schemaItemType, options);
6718
6718
  } else {
6719
- parsedValue = this.processComplexSchemaItem(asn1SchemaValue, schemaItem, schemaItemType);
6719
+ parsedValue = this.processComplexSchemaItem(asn1SchemaValue, schemaItem, schemaItemType, options);
6720
6720
  }
6721
6721
  if (parsedValue && typeof parsedValue === "object" && "value" in parsedValue && "raw" in parsedValue) {
6722
6722
  res[key] = parsedValue.value;
@@ -6726,23 +6726,23 @@ var require_parser = __commonJS({
6726
6726
  }
6727
6727
  }
6728
6728
  }
6729
- static processPrimitiveSchemaItem(asn1SchemaValue, schemaItem, schemaItemType) {
6729
+ static processPrimitiveSchemaItem(asn1SchemaValue, schemaItem, schemaItemType, options) {
6730
6730
  const converter = schemaItem.converter ?? ((0, helper_1.isConvertible)(schemaItemType) ? new schemaItemType() : null);
6731
6731
  if (!converter) {
6732
6732
  throw new Error("Converter is empty");
6733
6733
  }
6734
6734
  if (schemaItem.repeated) {
6735
- return this.processRepeatedPrimitiveItem(asn1SchemaValue, schemaItem, converter);
6735
+ return this.processRepeatedPrimitiveItem(asn1SchemaValue, schemaItem, converter, options);
6736
6736
  } else {
6737
- return this.processSinglePrimitiveItem(asn1SchemaValue, schemaItem, schemaItemType, converter);
6737
+ return this.processSinglePrimitiveItem(asn1SchemaValue, schemaItem, schemaItemType, converter, options);
6738
6738
  }
6739
6739
  }
6740
- static processRepeatedPrimitiveItem(asn1SchemaValue, schemaItem, converter) {
6740
+ static processRepeatedPrimitiveItem(asn1SchemaValue, schemaItem, converter, options) {
6741
6741
  if (schemaItem.implicit) {
6742
6742
  const Container = schemaItem.repeated === "sequence" ? asn1js.Sequence : asn1js.Set;
6743
6743
  const newItem = new Container();
6744
6744
  newItem.valueBlock = asn1SchemaValue.valueBlock;
6745
- const newItemAsn = asn1js.fromBER(newItem.toBER(false));
6745
+ const newItemAsn = asn1js.fromBER(newItem.toBER(false), options?.berOptions);
6746
6746
  if (newItemAsn.offset === -1) {
6747
6747
  throw new Error(`Cannot parse the child item. ${newItemAsn.result.error}`);
6748
6748
  }
@@ -6755,7 +6755,7 @@ var require_parser = __commonJS({
6755
6755
  return Array.from(asn1SchemaValue, (element) => converter.fromASN(element));
6756
6756
  }
6757
6757
  }
6758
- static processSinglePrimitiveItem(asn1SchemaValue, schemaItem, schemaItemType, converter) {
6758
+ static processSinglePrimitiveItem(asn1SchemaValue, schemaItem, schemaItemType, converter, options) {
6759
6759
  let value = asn1SchemaValue;
6760
6760
  if (schemaItem.implicit) {
6761
6761
  let newItem;
@@ -6770,21 +6770,21 @@ var require_parser = __commonJS({
6770
6770
  newItem = new Asn1Type();
6771
6771
  }
6772
6772
  newItem.valueBlock = value.valueBlock;
6773
- value = asn1js.fromBER(newItem.toBER(false)).result;
6773
+ value = asn1js.fromBER(newItem.toBER(false), options?.berOptions).result;
6774
6774
  }
6775
6775
  return converter.fromASN(value);
6776
6776
  }
6777
- static processComplexSchemaItem(asn1SchemaValue, schemaItem, schemaItemType) {
6777
+ static processComplexSchemaItem(asn1SchemaValue, schemaItem, schemaItemType, options) {
6778
6778
  if (schemaItem.repeated) {
6779
6779
  if (!Array.isArray(asn1SchemaValue)) {
6780
6780
  throw new Error("Cannot get list of items from the ASN.1 parsed value. ASN.1 value should be iterable.");
6781
6781
  }
6782
- return Array.from(asn1SchemaValue, (element) => this.fromASN(element, schemaItemType));
6782
+ return Array.from(asn1SchemaValue, (element) => this.fromASN(element, schemaItemType, options));
6783
6783
  } else {
6784
6784
  const valueToProcess = this.handleImplicitTagging(asn1SchemaValue, schemaItem, schemaItemType);
6785
6785
  if (this.isOptionalChoiceField(schemaItem)) {
6786
6786
  try {
6787
- return this.fromASN(valueToProcess, schemaItemType);
6787
+ return this.fromASN(valueToProcess, schemaItemType, options);
6788
6788
  } catch (err) {
6789
6789
  if (err instanceof errors_1.AsnSchemaValidationError && /Wrong values for Choice type/.test(err.message)) {
6790
6790
  return void 0;
@@ -6792,7 +6792,7 @@ var require_parser = __commonJS({
6792
6792
  throw err;
6793
6793
  }
6794
6794
  } else {
6795
- const parsedValue = this.fromASN(valueToProcess, schemaItemType);
6795
+ const parsedValue = this.fromASN(valueToProcess, schemaItemType, options);
6796
6796
  if (schemaItem.raw) {
6797
6797
  return {
6798
6798
  value: parsedValue,
@@ -7009,12 +7009,12 @@ var require_convert = __commonJS({
7009
7009
  static serialize(obj) {
7010
7010
  return serializer_1.AsnSerializer.serialize(obj);
7011
7011
  }
7012
- static parse(data, target) {
7013
- return parser_1.AsnParser.parse(data, target);
7012
+ static parse(data, target, options) {
7013
+ return parser_1.AsnParser.parse(data, target, options);
7014
7014
  }
7015
- static toString(data) {
7015
+ static toString(data, options) {
7016
7016
  const buf = (0, bytes_1.isBufferSource)(data) ? (0, bytes_1.toArrayBuffer)(data) : _AsnConvert.serialize(data);
7017
- const asn = asn1js.fromBER(buf);
7017
+ const asn = asn1js.fromBER(buf, options?.berOptions);
7018
7018
  if (asn.offset === -1) {
7019
7019
  throw new Error(`Cannot decode ASN.1 data. ${asn.result.error}`);
7020
7020
  }
@@ -14678,9 +14678,9 @@ var require_cjs11 = __commonJS({
14678
14678
  }
14679
14679
  });
14680
14680
 
14681
- // node_modules/@peculiar/x509/build/x509.cjs.js
14681
+ // ../../node_modules/@peculiar/x509/build/x509.cjs.js
14682
14682
  var require_x509_cjs = __commonJS({
14683
- "node_modules/@peculiar/x509/build/x509.cjs.js"(exports2) {
14683
+ "../../node_modules/@peculiar/x509/build/x509.cjs.js"(exports2) {
14684
14684
  "use strict";
14685
14685
  var asn1Schema = require_cjs();
14686
14686
  var asn1X509 = require_cjs2();
@@ -78,7 +78,7 @@ export const COMPUTE_DEFAULTS = {
78
78
  },
79
79
  LAMBDA: {
80
80
  HANDLER: "index.handler",
81
- RUNTIME: Runtime.NODEJS_22_X,
81
+ RUNTIME: Runtime.NODEJS_24_X,
82
82
  ARCHITECTURE: Architecture.ARM_64
83
83
  }
84
84
  };
@@ -26,6 +26,7 @@ import { VALIDATION_PATTERNS } from "@fjall/generator";
26
26
  import { evaluateBakeGuard } from "@fjall/util/docker";
27
27
  import { toKebab, buildParameterPath } from "@fjall/util";
28
28
  import { validateEc2ServiceSizing, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
29
+ import { validateSharedEc2CapacityConfig } from "../../resources/aws/compute/ecsCapacityConfig.js";
29
30
  import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
30
31
  import { isHookMigrations } from "./computeEcsTypes.js";
31
32
  export { ScalingType } from "./computeEcsTypes.js";
@@ -174,6 +175,10 @@ export function validateEcsProps(props) {
174
175
  validateSeparateTaskDef(service.name, service.migrations.separateTaskDef);
175
176
  }
176
177
  }
178
+ // Mirror of the resources-layer shared-ASG capacity check (drift on a reused
179
+ // ASG, shared persistentDataVolume singleton) — defence-in-depth per
180
+ // generator-standards § "Validate at the Lowest Layer the Field Belongs To".
181
+ validateSharedEc2CapacityConfig(props.services);
177
182
  // Validate awaitMigrationsFrom cross-service migration ordering.
178
183
  const serviceByName = new Map(props.services.map((s) => [s.name, s]));
179
184
  for (const service of props.services) {
@@ -201,7 +201,7 @@ export interface CodeLambdaProps extends BaseLambdaProps {
201
201
  code: Code;
202
202
  /** Handler function. Default: "index.handler" */
203
203
  handler?: string;
204
- /** Lambda runtime. Default: NODEJS_22_X */
204
+ /** Lambda runtime. Default: NODEJS_24_X */
205
205
  runtime?: Runtime;
206
206
  }
207
207
  /**
@@ -317,7 +317,7 @@ export class DevSubstrate extends Construct {
317
317
  // control plane (ECS / Aurora / SSM), never over the VPC, and the ALB invokes
318
318
  // it through the Lambda API — not the network path.
319
319
  const waker = new LambdaFunction(this, DEV_SUBSTRATE_WAKER_ID, {
320
- runtime: Runtime.NODEJS_22_X,
320
+ runtime: Runtime.NODEJS_24_X,
321
321
  handler: "index.handler",
322
322
  code: Code.fromInline(source),
323
323
  lambdaDescription: "Dev substrate slot waker",
@@ -308,7 +308,7 @@ export class Payload extends Construct {
308
308
  deployment: "code",
309
309
  code: Code.fromAsset(`${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.SERVER}`),
310
310
  handler: "index.handler",
311
- runtime: Runtime.NODEJS_22_X,
311
+ runtime: Runtime.NODEJS_24_X,
312
312
  architecture: Architecture.ARM_64,
313
313
  memorySize,
314
314
  timeout,
@@ -370,7 +370,7 @@ export class Payload extends Construct {
370
370
  deployment: "code",
371
371
  code: Code.fromAsset(`${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.IMAGE}`),
372
372
  handler: "index.handler",
373
- runtime: Runtime.NODEJS_22_X,
373
+ runtime: Runtime.NODEJS_24_X,
374
374
  architecture: Architecture.ARM_64,
375
375
  memorySize,
376
376
  timeout,
@@ -396,7 +396,7 @@ export class Payload extends Construct {
396
396
  deployment: "code",
397
397
  code: Code.fromAsset(`${PAYLOAD_DEFAULTS.SOURCE}/${PAYLOAD_DEFAULTS.OPENNEXT.REVALIDATION}`),
398
398
  handler: "index.handler",
399
- runtime: Runtime.NODEJS_22_X,
399
+ runtime: Runtime.NODEJS_24_X,
400
400
  architecture: Architecture.ARM_64,
401
401
  memorySize,
402
402
  timeout,
@@ -42,7 +42,7 @@ import { toPascalCase } from "../../utils/capitaliseString.js";
42
42
  const __dirname = dirname(fileURLToPath(import.meta.url));
43
43
  const FORMS_ASSET_DIR = resolve(__dirname, "../../lambda-assets/static-site-forms/asset");
44
44
  const FORMS_DEFAULTS = {
45
- RUNTIME: Runtime.NODEJS_22_X,
45
+ RUNTIME: Runtime.NODEJS_24_X,
46
46
  ARCHITECTURE: Architecture.ARM_64,
47
47
  TIMEOUT_SECONDS: 10,
48
48
  MEMORY_MB: 256,
@@ -144,7 +144,7 @@ export class Ec2GracefulTerminationHandler extends Construct {
144
144
  ]
145
145
  : [];
146
146
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
147
- runtime: Runtime.NODEJS_22_X,
147
+ runtime: Runtime.NODEJS_24_X,
148
148
  handler: "index.handler",
149
149
  code: Code.fromInline(source),
150
150
  lambdaDescription: `${id} ${EC2_GRACEFUL_TERMINATION_HANDLER_DESCRIPTION}`,
@@ -0,0 +1,43 @@
1
+ import type { Ec2CapacityConfig, EcsCapacityProvider } from "./ecsTypes.js";
2
+ /** The service + ec2Config that first claimed an EC2 capacity key. */
3
+ export interface Ec2AsgOrigin {
4
+ serviceName: string;
5
+ ec2Config: Ec2CapacityConfig;
6
+ }
7
+ /**
8
+ * Rejects at synth when a service reusing a shared ASG is incompatible with
9
+ * it: either the origin's `persistentDataVolume` makes the ASG a singleton
10
+ * (two tasks would race to attach the same EBS volume), or the reusing
11
+ * service asks for ASG settings that differ from the ones the ASG was built
12
+ * with — those settings would be silently ignored (same silent-ignore class
13
+ * as `validateEc2ServiceSizing`).
14
+ */
15
+ export declare function assertSharedAsgConfigMatches(serviceName: string, ec2Config: Ec2CapacityConfig, key: string, origin: Ec2AsgOrigin): void;
16
+ /**
17
+ * Generates a unique key for EC2 config so services with matching
18
+ * configurations share an ASG.
19
+ */
20
+ export declare function getEc2ConfigKey(ec2Config: Ec2CapacityConfig): string;
21
+ /**
22
+ * Minimal structural input for {@link validateSharedEc2CapacityConfig} —
23
+ * exactly the fields the check reads. Both the resources-layer
24
+ * `EcsServiceProps` and the patterns-layer `EcsServiceConfig` structurally
25
+ * satisfy it (typescript-standards § "Helpers consume resolved state, not the
26
+ * dispatch enum").
27
+ */
28
+ export interface SharedEc2CapacityServiceInput {
29
+ name: string;
30
+ capacityProvider?: EcsCapacityProvider;
31
+ ec2Config?: Ec2CapacityConfig;
32
+ }
33
+ /**
34
+ * Cross-service shared-ASG compatibility: groups EC2 services by their
35
+ * capacity key and rejects a reuse that is incompatible with the ASG's origin
36
+ * — config drift the shared ASG would silently discard, or a
37
+ * `persistentDataVolume` singleton being shared. Pure; called from BOTH
38
+ * validation layers — resources `validateEcsClusterProps` and patterns
39
+ * `validateEcsProps` (generator-standards § "Validate at the Lowest Layer the
40
+ * Field Belongs To") — with the same check at the construction site
41
+ * (`getOrCreateAsgCapacityProvider`) as defence-in-depth.
42
+ */
43
+ export declare function validateSharedEc2CapacityConfig(services: readonly SharedEc2CapacityServiceInput[]): void;
@@ -0,0 +1,161 @@
1
+ import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
+ import { Token } from "aws-cdk-lib";
3
+ import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CAPACITY, DEFAULT_EC2_INSTANCE_MONITORING, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, inferAmiHardwareType } from "./ecsConstants.js";
4
+ /**
5
+ * Every `Ec2CapacityConfig` field, classified. The `Record<keyof …, …>`
6
+ * satisfies-shape is a compile-time exhaustiveness guard in compiled `src/`
7
+ * (typescript-standards § "Compile-time guards must live in compiled src/"):
8
+ * adding a field to `Ec2CapacityConfig` without classifying it here fails
9
+ * `tsc`, so a new ASG-shaping field cannot silently join the "not compared,
10
+ * silently discarded on a shared ASG" bucket.
11
+ */
12
+ const EC2_CONFIG_FIELD_ROLES = {
13
+ instanceType: "keyed",
14
+ amiHardwareType: "keyed",
15
+ warmPool: "keyed",
16
+ persistentDataVolume: "keyed",
17
+ availabilityZones: "keyed",
18
+ minCapacity: "scalar",
19
+ maxCapacity: "scalar",
20
+ desiredCapacity: "scalar",
21
+ instanceMonitoring: "scalar",
22
+ associatePublicIpAddress: "scalar",
23
+ machineImage: "opaque",
24
+ userData: "opaque",
25
+ blockDevices: "opaque",
26
+ tags: "tags",
27
+ memoryLimitMiB: "taskDefinition"
28
+ };
29
+ function fieldsWithRole(role) {
30
+ return Object.keys(EC2_CONFIG_FIELD_ROLES).filter((field) => EC2_CONFIG_FIELD_ROLES[field] === role);
31
+ }
32
+ const KEYED_ASG_FIELDS = fieldsWithRole("keyed");
33
+ const SCALAR_ASG_FIELDS = fieldsWithRole("scalar");
34
+ const OPAQUE_ASG_FIELDS = fieldsWithRole("opaque");
35
+ /**
36
+ * Defaults resolved before a scalar field is compared — the SAME values the
37
+ * ASG construction applies (`getOrCreateAsgCapacityProvider`), imported from
38
+ * their `ecsConstants.ts` single source, so an explicit value equal to the
39
+ * default compares equal to an omitted one instead of tripping the drift
40
+ * throw on a pair that synthesises identically. Fields with no construction
41
+ * default (`desiredCapacity`, `associatePublicIpAddress`) compare raw.
42
+ */
43
+ const SCALAR_ASG_FIELD_DEFAULTS = {
44
+ minCapacity: DEFAULT_EC2_MIN_CAPACITY,
45
+ maxCapacity: DEFAULT_EC2_MAX_CAPACITY,
46
+ instanceMonitoring: DEFAULT_EC2_INSTANCE_MONITORING
47
+ };
48
+ function resolvedScalar(config, field) {
49
+ return config[field] ?? SCALAR_ASG_FIELD_DEFAULTS[field];
50
+ }
51
+ /** Deep tag-record equality, treating `{}` and absent as the same ASG shape. */
52
+ function tagRecordsEqual(a, b) {
53
+ const aKeys = a === undefined ? [] : Object.keys(a);
54
+ const bKeys = b === undefined ? [] : Object.keys(b);
55
+ if (aKeys.length !== bKeys.length)
56
+ return false;
57
+ if (a === undefined || b === undefined)
58
+ return true;
59
+ return aKeys.every((key) => Object.is(a[key], b[key]));
60
+ }
61
+ function findDifferingAsgFields(ec2Config, origin) {
62
+ const differing = SCALAR_ASG_FIELDS.filter((field) => !Object.is(resolvedScalar(ec2Config, field), resolvedScalar(origin, field)));
63
+ if (!tagRecordsEqual(ec2Config.tags, origin.tags))
64
+ differing.push("tags");
65
+ for (const field of OPAQUE_ASG_FIELDS) {
66
+ if ((ec2Config[field] === undefined) !== (origin[field] === undefined)) {
67
+ differing.push(field);
68
+ }
69
+ }
70
+ return differing;
71
+ }
72
+ /**
73
+ * Rejects at synth when a service reusing a shared ASG is incompatible with
74
+ * it: either the origin's `persistentDataVolume` makes the ASG a singleton
75
+ * (two tasks would race to attach the same EBS volume), or the reusing
76
+ * service asks for ASG settings that differ from the ones the ASG was built
77
+ * with — those settings would be silently ignored (same silent-ignore class
78
+ * as `validateEc2ServiceSizing`).
79
+ */
80
+ export function assertSharedAsgConfigMatches(serviceName, ec2Config, key, origin) {
81
+ // Cross-service only: the construction path legitimately re-resolves the
82
+ // SAME service's provider (e.g. a second task definition riding its ASG),
83
+ // and that re-entry must not trip the singleton throw.
84
+ if (origin.ec2Config.persistentDataVolume !== undefined &&
85
+ serviceName !== origin.serviceName) {
86
+ throw new Error(`Service '${serviceName}': its ec2Config resolves to the same EC2 capacity key ('${key}') ` +
87
+ `as service '${origin.serviceName}', whose persistentDataVolume pairs that auto scaling ` +
88
+ `group with a single EBS data volume. A persistent-data-volume service is a singleton — ` +
89
+ `two services sharing its ASG would race to attach the same volume. Change a keyed field ` +
90
+ `(${KEYED_ASG_FIELDS.map((f) => `'${f}'`).join(", ")}) so this service gets its own ASG.`);
91
+ }
92
+ const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
93
+ if (differing.length === 0)
94
+ return;
95
+ throw new Error(`Service '${serviceName}': ec2Config ${differing.map((f) => `'${f}'`).join(", ")} ` +
96
+ `${differing.length === 1 ? "is" : "are"} ignored — it shares an auto scaling group with ` +
97
+ `service '${origin.serviceName}' (both resolve to EC2 capacity key '${key}'), and that ASG ` +
98
+ `is built from the first service's ec2Config. Either give the two services the same value ` +
99
+ `for ${differing.map((f) => `'${f}'`).join(", ")}, or give this service its own ASG by ` +
100
+ `changing a keyed field (${KEYED_ASG_FIELDS.map((f) => `'${f}'`).join(", ")}).`);
101
+ }
102
+ /**
103
+ * Generates a unique key for EC2 config so services with matching
104
+ * configurations share an ASG.
105
+ */
106
+ export function getEc2ConfigKey(ec2Config) {
107
+ const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
108
+ const amiHardwareType = ec2Config.amiHardwareType ??
109
+ (inferAmiHardwareType(instanceType) === AmiHardwareType.ARM
110
+ ? "ARM"
111
+ : "STANDARD");
112
+ const warmPoolKey = ec2Config.warmPool
113
+ ? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
114
+ : "nowp";
115
+ const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
116
+ // PDV services are implicit singletons — two identical PDV configs still
117
+ // collide on this key, so sharing is rejected in assertSharedAsgConfigMatches.
118
+ const pdv = ec2Config.persistentDataVolume;
119
+ const extras = [];
120
+ if (pdv !== undefined) {
121
+ extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
122
+ }
123
+ // Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
124
+ // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep logical IDs deterministic.
125
+ if (ec2Config.availabilityZones !== undefined) {
126
+ const azKey = [...ec2Config.availabilityZones]
127
+ .map(stableAzSegment)
128
+ .sort()
129
+ .join(",");
130
+ extras.push(`az-${azKey}`);
131
+ }
132
+ return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
133
+ }
134
+ function stableAzSegment(az) {
135
+ return Token.isUnresolved(az) ? "synthAz" : az;
136
+ }
137
+ /**
138
+ * Cross-service shared-ASG compatibility: groups EC2 services by their
139
+ * capacity key and rejects a reuse that is incompatible with the ASG's origin
140
+ * — config drift the shared ASG would silently discard, or a
141
+ * `persistentDataVolume` singleton being shared. Pure; called from BOTH
142
+ * validation layers — resources `validateEcsClusterProps` and patterns
143
+ * `validateEcsProps` (generator-standards § "Validate at the Lowest Layer the
144
+ * Field Belongs To") — with the same check at the construction site
145
+ * (`getOrCreateAsgCapacityProvider`) as defence-in-depth.
146
+ */
147
+ export function validateSharedEc2CapacityConfig(services) {
148
+ const origins = new Map();
149
+ for (const service of services) {
150
+ if (service.capacityProvider !== "EC2")
151
+ continue;
152
+ const ec2Config = service.ec2Config ?? {};
153
+ const key = getEc2ConfigKey(ec2Config);
154
+ const origin = origins.get(key);
155
+ if (origin === undefined) {
156
+ origins.set(key, { serviceName: service.name, ec2Config });
157
+ continue;
158
+ }
159
+ assertSharedAsgConfigMatches(service.name, ec2Config, key, origin);
160
+ }
161
+ }
@@ -1,3 +1,4 @@
1
+ import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
1
2
  import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
3
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
3
4
  export declare const DEFAULT_EC2_INSTANCE_TYPE = "t4g.micro";
@@ -8,6 +9,9 @@ export declare const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
8
9
  export declare const DEFAULT_FARGATE_CPU = 256;
9
10
  export declare const DEFAULT_FARGATE_MEMORY_MIB = 512;
10
11
  export declare const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
12
+ export declare const DEFAULT_EC2_MIN_CAPACITY = 2;
13
+ export declare const DEFAULT_EC2_MAX_CAPACITY = 3;
14
+ export declare const DEFAULT_EC2_INSTANCE_MONITORING = Monitoring.BASIC;
11
15
  /**
12
16
  * The container hard memory limit for an EC2-capacity ECS service.
13
17
  *
@@ -1,3 +1,4 @@
1
+ import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
1
2
  import { AmiHardwareType } from "aws-cdk-lib/aws-ecs";
2
3
  import { RetentionDays } from "aws-cdk-lib/aws-logs";
3
4
  // Canonical source: @fjall/generator schemas/constants.ts — keep in sync
@@ -16,6 +17,14 @@ export const DEFAULT_LOG_RETENTION = RetentionDays.TWO_WEEKS;
16
17
  export const DEFAULT_FARGATE_CPU = 256;
17
18
  export const DEFAULT_FARGATE_MEMORY_MIB = 512;
18
19
  export const DEFAULT_EC2_CONTAINER_MEMORY_MIB = 1024;
20
+ // Read at two sites that must agree (code-quality § coupled values): the ASG
21
+ // construction (`getOrCreateAsgCapacityProvider`) and the shared-ASG drift
22
+ // compare (`ecsCapacityConfig.ts` SCALAR_ASG_FIELD_DEFAULTS) — the compare
23
+ // resolves the same defaults the construction applies, or an explicit value
24
+ // equal to the default would read as drift.
25
+ export const DEFAULT_EC2_MIN_CAPACITY = 2;
26
+ export const DEFAULT_EC2_MAX_CAPACITY = 3;
27
+ export const DEFAULT_EC2_INSTANCE_MONITORING = Monitoring.BASIC;
19
28
  /**
20
29
  * The container hard memory limit for an EC2-capacity ECS service.
21
30
  *
@@ -49,7 +49,7 @@ export class EcsLifecycleHookMigration extends Construct {
49
49
  : "DISABLED"
50
50
  };
51
51
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
52
- runtime: Runtime.NODEJS_22_X,
52
+ runtime: Runtime.NODEJS_24_X,
53
53
  handler: "index.handler",
54
54
  code: Code.fromInline(source),
55
55
  lambdaDescription: `${id} ECS deployment lifecycle hook for migration`,
@@ -3,7 +3,8 @@ import type { FargateTaskDefinition, Ec2TaskDefinition } from "aws-cdk-lib/aws-e
3
3
  import { type ISecurityGroup } from "aws-cdk-lib/aws-ec2";
4
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, type QueueScalingResources } from "./ecsTypes.js";
6
+ import { type Ec2AsgOrigin } from "./ecsCapacityConfig.js";
7
+ import { type EcsServiceProps, type QueueScalingResources } from "./ecsTypes.js";
7
8
  import type { EcsConstructContext } from "./ecsContext.js";
8
9
  /**
9
10
  * Resolves the user's `circuitBreaker` config to the CDK
@@ -22,18 +23,11 @@ export interface AsgCapacityState {
22
23
  * service sharing the key can be checked against it
23
24
  * ({@link assertSharedAsgConfigMatches}).
24
25
  */
25
- asgOrigins: Map<string, {
26
- serviceName: string;
27
- ec2Config: Ec2CapacityConfig;
28
- }>;
26
+ asgOrigins: Map<string, Ec2AsgOrigin>;
29
27
  autoScalingGroup?: AutoScalingGroup;
30
28
  asgSecurityGroup?: ISecurityGroup;
31
29
  }
32
- /**
33
- * Generates a unique key for EC2 config so services with matching
34
- * configurations share an ASG.
35
- */
36
- export declare function getEc2ConfigKey(ec2Config: Ec2CapacityConfig): string;
30
+ export { getEc2ConfigKey, validateSharedEc2CapacityConfig, type Ec2AsgOrigin, type SharedEc2CapacityServiceInput } from "./ecsCapacityConfig.js";
37
31
  /**
38
32
  * Gets or creates an ASG capacity provider for an EC2-backed service.
39
33
  * Services with matching EC2 configs share the same ASG.
@@ -2,16 +2,16 @@ import { FargateService, Ec2Service, PropagatedTagSource, PlacementStrategy, Asg
2
2
  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
- import { CfnOutput, Duration, Token } from "aws-cdk-lib";
5
+ import { CfnOutput, Duration } from "aws-cdk-lib";
6
6
  import { AdjustmentType, PredefinedMetric, ScalableTarget, ServiceNamespace, StepScalingAction, TargetTrackingScalingPolicy } from "aws-cdk-lib/aws-applicationautoscaling";
7
7
  import { Alarm, ComparisonOperator, MathExpression } from "aws-cdk-lib/aws-cloudwatch";
8
8
  import { ApplicationScalingAction } from "aws-cdk-lib/aws-cloudwatch-actions";
9
- import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
10
9
  import { SecurityGroup } from "../networking/securityGroup.js";
11
10
  import { Ec2Instance } from "./ec2.js";
12
11
  import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
13
12
  import { toPascalCase } from "../../../utils/capitaliseString.js";
14
- import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, DEFAULT_HEALTH_CHECK_GRACE_SECONDS, DEFAULT_MIN_HEALTHY_PERCENT, DEFAULT_MAX_HEALTHY_PERCENT, DEFAULT_DESIRED_COUNT, inferAmiHardwareType } from "./ecsConstants.js";
13
+ import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CAPACITY, DEFAULT_EC2_INSTANCE_MONITORING, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, DEFAULT_HEALTH_CHECK_GRACE_SECONDS, DEFAULT_MIN_HEALTHY_PERCENT, DEFAULT_MAX_HEALTHY_PERCENT, DEFAULT_DESIRED_COUNT, inferAmiHardwareType } from "./ecsConstants.js";
14
+ import { assertSharedAsgConfigMatches, getEc2ConfigKey } from "./ecsCapacityConfig.js";
15
15
  import { ScalingType } from "./ecsTypes.js";
16
16
  import { isServiceFargate, isServiceEc2 } from "./ecsTaskDefinition.js";
17
17
  /**
@@ -26,101 +26,7 @@ export function resolveCircuitBreaker(config) {
26
26
  const rollback = config?.rollback ?? true;
27
27
  return { enable: true, rollback };
28
28
  }
29
- /**
30
- * `ec2Config` fields that shape the ASG but are NOT part of
31
- * {@link getEc2ConfigKey}, split by how a difference can be detected without
32
- * false positives. A second service whose key matches reuses the first
33
- * service's ASG, so any of these it sets is silently discarded.
34
- *
35
- * Scalars compare by value. `tags` is plain data, so it compares deeply.
36
- * `machineImage` / `userData` / `blockDevices` are CDK objects with no stable
37
- * structural identity — two equivalent literals are different references, so
38
- * only a defined-vs-undefined asymmetry is safe to report. They deliberately
39
- * under-report: over-reporting is a hard synth failure on config that
40
- * previously worked, which is the more damaging direction.
41
- */
42
- const SCALAR_ASG_FIELDS = [
43
- "minCapacity",
44
- "maxCapacity",
45
- "desiredCapacity",
46
- "instanceMonitoring",
47
- "associatePublicIpAddress"
48
- ];
49
- const OPAQUE_ASG_FIELDS = [
50
- "machineImage",
51
- "userData",
52
- "blockDevices"
53
- ];
54
- function tagRecordsEqual(a, b) {
55
- if (a === undefined || b === undefined)
56
- return a === b;
57
- const aKeys = Object.keys(a);
58
- if (aKeys.length !== Object.keys(b).length)
59
- return false;
60
- return aKeys.every((key) => Object.is(a[key], b[key]));
61
- }
62
- function findDifferingAsgFields(ec2Config, origin) {
63
- const differing = SCALAR_ASG_FIELDS.filter((field) => !Object.is(ec2Config[field], origin[field]));
64
- if (!tagRecordsEqual(ec2Config.tags, origin.tags))
65
- differing.push("tags");
66
- for (const field of OPAQUE_ASG_FIELDS) {
67
- if ((ec2Config[field] === undefined) !== (origin[field] === undefined)) {
68
- differing.push(field);
69
- }
70
- }
71
- return differing;
72
- }
73
- /**
74
- * Rejects at synth when a service reusing a shared ASG asks for ASG settings
75
- * that differ from the ones the ASG was built with — those settings would be
76
- * silently ignored (same silent-ignore class as `validateEc2ServiceSizing`).
77
- */
78
- function assertSharedAsgConfigMatches(serviceName, ec2Config, key, origin) {
79
- const differing = findDifferingAsgFields(ec2Config, origin.ec2Config);
80
- if (differing.length === 0)
81
- return;
82
- throw new Error(`Service '${serviceName}': ec2Config ${differing.map((f) => `'${f}'`).join(", ")} ` +
83
- `${differing.length === 1 ? "is" : "are"} ignored — it shares an auto scaling group with ` +
84
- `service '${origin.serviceName}' (both resolve to EC2 capacity key '${key}'), and that ASG ` +
85
- `is built from the first service's ec2Config. Either give the two services the same value ` +
86
- `for ${differing.map((f) => `'${f}'`).join(", ")}, or give this service its own ASG by ` +
87
- `changing a keyed field ('instanceType', 'amiHardwareType', 'warmPool', ` +
88
- `'persistentDataVolume', 'availabilityZones').`);
89
- }
90
- /**
91
- * Generates a unique key for EC2 config so services with matching
92
- * configurations share an ASG.
93
- */
94
- export function getEc2ConfigKey(ec2Config) {
95
- const instanceType = ec2Config.instanceType ?? DEFAULT_EC2_INSTANCE_TYPE;
96
- const amiHardwareType = ec2Config.amiHardwareType ??
97
- (inferAmiHardwareType(instanceType) === AmiHardwareType.ARM
98
- ? "ARM"
99
- : "STANDARD");
100
- const warmPoolKey = ec2Config.warmPool
101
- ? `wp${ec2Config.warmPool.minSize ?? DEFAULT_WARM_POOL_MIN_SIZE}-${ec2Config.warmPool.reuseOnScaleIn ?? DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN}`
102
- : "nowp";
103
- const baseKey = `${instanceType}-${amiHardwareType}-${warmPoolKey}`;
104
- // PDV services are implicit singletons — sharing an ASG would let two tasks race for the same EBS volume.
105
- const pdv = ec2Config.persistentDataVolume;
106
- const extras = [];
107
- if (pdv !== undefined) {
108
- extras.push(`pdv-${pdv.deviceName}-${pdv.sizeGb}-${stableAzSegment(pdv.availabilityZone)}`);
109
- }
110
- // Serialise actual AZ names — `az${length}` would let services pinned to different AZs collide on `az1`.
111
- // CDK Tokens (env-agnostic stacks) substitute a stable sentinel to keep logical IDs deterministic.
112
- if (ec2Config.availabilityZones !== undefined) {
113
- const azKey = [...ec2Config.availabilityZones]
114
- .map(stableAzSegment)
115
- .sort()
116
- .join(",");
117
- extras.push(`az-${azKey}`);
118
- }
119
- return extras.length === 0 ? baseKey : `${baseKey}-${extras.join("-")}`;
120
- }
121
- function stableAzSegment(az) {
122
- return Token.isUnresolved(az) ? "synthAz" : az;
123
- }
29
+ export { getEc2ConfigKey, validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
124
30
  /**
125
31
  * Gets or creates an ASG capacity provider for an EC2-backed service.
126
32
  * Services with matching EC2 configs share the same ASG.
@@ -146,8 +52,8 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
146
52
  ? AmiHardwareType.STANDARD
147
53
  : AmiHardwareType.ARM
148
54
  : inferAmiHardwareType(instanceType);
149
- const minCapacity = ec2Config.minCapacity ?? 2;
150
- const maxCapacity = ec2Config.maxCapacity ?? 3;
55
+ const minCapacity = ec2Config.minCapacity ?? DEFAULT_EC2_MIN_CAPACITY;
56
+ const maxCapacity = ec2Config.maxCapacity ?? DEFAULT_EC2_MAX_CAPACITY;
151
57
  const asgSecurityGroup = ctx.props.cluster?.securityGroup ??
152
58
  new SecurityGroup(ctx.scope, `${safeKey}AsgSecurityGroup`, {
153
59
  vpc: ctx.cluster.vpc,
@@ -195,7 +101,7 @@ export function getOrCreateAsgCapacityProvider(ctx, serviceProps, state) {
195
101
  EcsOptimizedImage.amazonLinux2023(amiHardwareType),
196
102
  userData: ec2Config.userData ?? UserData.forLinux(),
197
103
  role: instanceRole,
198
- instanceMonitoring: ec2Config.instanceMonitoring ?? Monitoring.BASIC,
104
+ instanceMonitoring: ec2Config.instanceMonitoring ?? DEFAULT_EC2_INSTANCE_MONITORING,
199
105
  capacityRebalance: true,
200
106
  ecsClusterArn: ctx.cluster.clusterArn,
201
107
  ...(ec2Config.desiredCapacity !== undefined && {
@@ -3,6 +3,7 @@ import { evaluateBakeGuard } from "@fjall/util/docker";
3
3
  import { toKebab, SSM_COMPONENT_PATTERN, SSM_COMPONENT_ERROR, SECRET_NAME_PATTERN, SECRET_NAME_ERROR } from "@fjall/util";
4
4
  import { ScalingType } from "./ecsTypes.js";
5
5
  import { DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
6
+ import { validateSharedEc2CapacityConfig } from "./ecsCapacityConfig.js";
6
7
  /**
7
8
  * Validates ECS cluster props before construction.
8
9
  * Pure function — does not depend on class state.
@@ -196,6 +197,11 @@ export function validateEcsClusterProps(props) {
196
197
  }
197
198
  }
198
199
  }
200
+ // Cross-service, so it runs after the per-service loop: EC2 services whose
201
+ // ec2Config resolves to the same capacity key share one ASG — reject config
202
+ // drift the shared ASG would silently discard, and any sharing of a
203
+ // persistentDataVolume (singleton) ASG.
204
+ validateSharedEc2CapacityConfig(props.services);
199
205
  }
200
206
  /**
201
207
  * Rejects service-level task sizing that is silently ignored for EC2 capacity.
@@ -157,7 +157,7 @@ export class PersistentDataVolume extends Construct {
157
157
  resources: [asgArnWildcard]
158
158
  });
159
159
  this.lambda = new LambdaFunction(this, `${id}Fn`, {
160
- runtime: Runtime.NODEJS_22_X,
160
+ runtime: Runtime.NODEJS_24_X,
161
161
  handler: "index.handler",
162
162
  code: Code.fromInline(source),
163
163
  lambdaDescription: `${id} ${PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION}`,
@@ -252,7 +252,7 @@ export class RdsInstance extends Construct {
252
252
  const instanceId = ResourceNaming.dbInstanceId(this.constructId);
253
253
  const waiterTimeout = Duration.minutes(10);
254
254
  const deletionWaiter = new CustomResource(this, `${this.databaseNameValue}DeletionWaiter`, {
255
- runtime: Runtime.NODEJS_22_X,
255
+ runtime: Runtime.NODEJS_24_X,
256
256
  timeout: waiterTimeout.plus(Duration.seconds(30)),
257
257
  lambdaDescription: `${this.databaseNameValue} deletion waiter`,
258
258
  inlineCode: `
@@ -33,7 +33,7 @@ export class IdentityStoreUserProvider extends Construct {
33
33
  const identityStoreArn = `arn:${stack.partition}:identitystore::${stack.account}:identitystore/${props.identityStoreId}`;
34
34
  const userArnPattern = identityStoreUserArnPattern(stack.partition);
35
35
  const handler = new SingletonFunction(this, "Handler", {
36
- runtime: Runtime.NODEJS_22_X,
36
+ runtime: Runtime.NODEJS_24_X,
37
37
  code: Code.fromAsset(LAMBDA_ASSET_DIR),
38
38
  handler: "index.handler",
39
39
  timeout: HANDLER_TIMEOUT_SECONDS,
@@ -21,7 +21,7 @@ export class CrossAccountReturnRoutes extends Construct {
21
21
  constructor(scope, id, props) {
22
22
  super(scope, id);
23
23
  this.customResource = new CustomResource(this, "Handler", {
24
- runtime: Runtime.NODEJS_22_X,
24
+ runtime: Runtime.NODEJS_24_X,
25
25
  timeout: Duration.minutes(5),
26
26
  lambdaDescription: `${id} cross-account return-route manager`,
27
27
  roleDescription: `${id} return-route Lambda execution role`,
@@ -166,7 +166,7 @@ export class IpamPool extends Construct {
166
166
  // On stack deletion, VPC allocations (not managed by CloudFormation)
167
167
  // block CIDR deprovisioning. This custom resource releases them first.
168
168
  const allocationCleanup = new CustomResource(this, `${constructKey}IpamCleanup${regionSuffix}`, {
169
- runtime: Runtime.NODEJS_22_X,
169
+ runtime: Runtime.NODEJS_24_X,
170
170
  timeout: Duration.minutes(5),
171
171
  lambdaDescription: `${account} IPAM pool allocation cleanup - ${region}`,
172
172
  properties: {
@@ -36,7 +36,7 @@ export class CostAllocationTagActivator extends Construct {
36
36
  this.lambda = new LambdaFunction(this, "TagActivatorFunction", {
37
37
  // Referenced cross-environment by the App messaging stack's schedule rule.
38
38
  functionName: PhysicalName.GENERATE_IF_NEEDED,
39
- runtime: Runtime.NODEJS_22_X,
39
+ runtime: Runtime.NODEJS_24_X,
40
40
  handler: "index.handler",
41
41
  code: Code.fromInline(HANDLER_CODE),
42
42
  lambdaDescription: "Auto-discovers and activates cost allocation tags in Cost Explorer",
@@ -66,7 +66,7 @@ export class TlsCertGenerator extends Construct {
66
66
  resources: [caCmk.key.keyArn, serverCmk.key.keyArn]
67
67
  });
68
68
  const cr = new CustomResource(this, "Generator", {
69
- runtime: Runtime.NODEJS_22_X,
69
+ runtime: Runtime.NODEJS_24_X,
70
70
  timeout: LAMBDA_TIMEOUT,
71
71
  codePath: LAMBDA_ASSET_DIR,
72
72
  handler: "index.handler",
@@ -1,11 +1,18 @@
1
1
  import { type EngineCompat } from "@fjall/util/manifest/schemas";
2
2
  /**
3
3
  * Normalise an npm dependency RANGE to its minimum X.Y.Z floor: strip a leading
4
- * range operator (`^`, `~`, `>=`, `>`, `=`, `v`) and any prerelease/build
5
- * metadata, then reconstruct `${major}.${minor}.${patch}` (missing minor/patch
6
- * default to 0). Returns undefined for anything without a valid integer major —
7
- * so a non-semver range (a git URL, `*`) yields no floor and the caller omits
8
- * the field rather than emitting garbage.
4
+ * inclusive range operator (`^`, `~`, `>=`, `=`, `v`), then reconstruct
5
+ * `${major}.${minor}.${patch}` (missing minor/patch default to 0).
6
+ *
7
+ * Deliberately conservative returns undefined ( the caller omits the field,
8
+ * FM1 degrades to no-op) for every shape whose true floor the first-token strip
9
+ * would MIS-state rather than merely fail to state:
10
+ * - unions (`^3 || ^2` floors at 2.0.0, not the first branch's 3.0.0)
11
+ * - strict lower bounds (`>2.1134.0` excludes its own literal)
12
+ * - prerelease bounds (`^2.0.0-rc.1` floors below the stripped 2.0.0)
13
+ * - non-decimal segments (`1e2`, hex, `2.x`, `*`, git URLs, 4+ segments)
14
+ * A wrong floor is worse than no floor: over-strict refuses a valid engine,
15
+ * under-strict admits an incompatible one — both silently.
9
16
  */
10
17
  export declare function rangeFloor(range: string | undefined): string | undefined;
11
18
  /**
@@ -56,29 +56,39 @@ import { FjallLogger } from "./validationLogger.js";
56
56
  const PACKAGE_NAME = "@fjall/components-infrastructure";
57
57
  /**
58
58
  * Normalise an npm dependency RANGE to its minimum X.Y.Z floor: strip a leading
59
- * range operator (`^`, `~`, `>=`, `>`, `=`, `v`) and any prerelease/build
60
- * metadata, then reconstruct `${major}.${minor}.${patch}` (missing minor/patch
61
- * default to 0). Returns undefined for anything without a valid integer major —
62
- * so a non-semver range (a git URL, `*`) yields no floor and the caller omits
63
- * the field rather than emitting garbage.
59
+ * inclusive range operator (`^`, `~`, `>=`, `=`, `v`), then reconstruct
60
+ * `${major}.${minor}.${patch}` (missing minor/patch default to 0).
61
+ *
62
+ * Deliberately conservative returns undefined ( the caller omits the field,
63
+ * FM1 degrades to no-op) for every shape whose true floor the first-token strip
64
+ * would MIS-state rather than merely fail to state:
65
+ * - unions (`^3 || ^2` floors at 2.0.0, not the first branch's 3.0.0)
66
+ * - strict lower bounds (`>2.1134.0` excludes its own literal)
67
+ * - prerelease bounds (`^2.0.0-rc.1` floors below the stripped 2.0.0)
68
+ * - non-decimal segments (`1e2`, hex, `2.x`, `*`, git URLs, 4+ segments)
69
+ * A wrong floor is worse than no floor: over-strict refuses a valid engine,
70
+ * under-strict admits an incompatible one — both silently.
64
71
  */
65
72
  export function rangeFloor(range) {
66
73
  if (range === undefined || range.length === 0)
67
74
  return undefined;
68
- const firstToken = range.trim().split(/\s+/)[0] ?? "";
69
- const core = firstToken.replace(/^[\^~>=v ]+/i, "").split(/[-+]/)[0] ?? "";
70
- if (core.length === 0)
75
+ const trimmed = range.trim();
76
+ if (trimmed.includes("||"))
77
+ return undefined;
78
+ const firstToken = trimmed.split(/\s+/)[0] ?? "";
79
+ if (/^>(?!=)/.test(firstToken))
80
+ return undefined;
81
+ const core = firstToken.replace(/^[\^~>=v ]+/i, "");
82
+ if (/[-+]/.test(core))
71
83
  return undefined;
72
84
  const parts = core.split(".");
85
+ if (parts.length === 0 || parts.length > 3)
86
+ return undefined;
87
+ if (!parts.every((segment) => /^\d+$/.test(segment)))
88
+ return undefined;
73
89
  const major = Number(parts[0]);
74
90
  const minor = parts.length > 1 ? Number(parts[1]) : 0;
75
91
  const patch = parts.length > 2 ? Number(parts[2]) : 0;
76
- if (!Number.isInteger(major) || major < 0)
77
- return undefined;
78
- if (!Number.isInteger(minor) || minor < 0)
79
- return undefined;
80
- if (!Number.isInteger(patch) || patch < 0)
81
- return undefined;
82
92
  return `${major}.${minor}.${patch}`;
83
93
  }
84
94
  /**
@@ -100,12 +110,20 @@ function resolveConstructsPackageMeta() {
100
110
  typeof pkg.version === "string" &&
101
111
  pkg.version.length > 0) {
102
112
  const awsCdkPeer = pkg.peerDependencies?.["aws-cdk"];
103
- return {
104
- version: pkg.version,
105
- awsCdkCliFloor: typeof awsCdkPeer === "string"
106
- ? rangeFloor(awsCdkPeer)
107
- : undefined
108
- };
113
+ const awsCdkCliFloor = typeof awsCdkPeer === "string" ? rangeFloor(awsCdkPeer) : undefined;
114
+ if (awsCdkCliFloor === undefined) {
115
+ // The FM1 omission is fail-safe but must never be silent — an
116
+ // assembly without minimumAwsCdkCli skips the deploy engine's
117
+ // CLI-floor check entirely.
118
+ FjallLogger.warn(typeof awsCdkPeer === "string"
119
+ ? `engineCompat: aws-cdk peer range "${awsCdkPeer}" has no ` +
120
+ `derivable minimum floor — minimumAwsCdkCli will be ` +
121
+ `omitted and the deploy engine's CLI-floor check skipped`
122
+ : "engineCompat: no aws-cdk peerDependency found — " +
123
+ "minimumAwsCdkCli will be omitted and the deploy " +
124
+ "engine's CLI-floor check skipped");
125
+ }
126
+ return { version: pkg.version, awsCdkCliFloor };
109
127
  }
110
128
  }
111
129
  if (dir === root)
@@ -150,9 +168,10 @@ export const AWS_CDK_CLI_FLOOR = CONSTRUCTS_PACKAGE_META?.awsCdkCliFloor;
150
168
  export function buildEngineCompatFrom(version, awsCdkCliFloor) {
151
169
  if (version === undefined || version.length === 0)
152
170
  return undefined;
153
- const major = Number(version.split(".")[0]);
154
- if (!Number.isInteger(major) || major < 0)
171
+ const majorToken = version.split(".")[0] ?? "";
172
+ if (!/^\d+$/.test(majorToken))
155
173
  return undefined;
174
+ const major = Number(majorToken);
156
175
  return {
157
176
  synthesisedBy: version,
158
177
  minimumEngineVersion: `${major}.0.0`,
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/fjall-tech/fjall.git",
7
+ "directory": "components/infrastructure"
8
+ },
4
9
  "license": "SEE LICENSE IN LICENSE",
5
10
  "type": "module",
6
11
  "bin": {
@@ -69,8 +74,8 @@
69
74
  },
70
75
  "dependencies": {
71
76
  "@aws-sdk/client-organizations": "^3.1098.0",
72
- "@fjall/generator": "^5.0.0",
73
- "@fjall/util": "^5.0.0",
77
+ "@fjall/generator": "^6.0.0",
78
+ "@fjall/util": "^6.0.0",
74
79
  "constructs": "^10.7.2"
75
80
  },
76
81
  "overrides": {
@@ -78,11 +83,11 @@
78
83
  },
79
84
  "peerDependencies": {
80
85
  "aws-cdk": "^2.1134.0",
81
- "aws-cdk-lib": "^2.262.2",
86
+ "aws-cdk-lib": "^2.263.0",
82
87
  "constructs": "^10.7.2"
83
88
  },
84
89
  "engines": {
85
90
  "node": ">=18.0.0"
86
91
  },
87
- "gitHead": "78dded6f582368076e8b5f351399219e5a954659"
92
+ "gitHead": "7620fc7c93bbef1046580d6568546afe3cd450a7"
88
93
  }