@fjall/components-infrastructure 5.0.0 → 7.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 (52) 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/clickhouseDatabase.js +4 -0
  8. package/dist/lib/patterns/aws/compute.js +1 -1
  9. package/dist/lib/patterns/aws/computeEcs.js +5 -0
  10. package/dist/lib/patterns/aws/computeLambda.d.ts +1 -1
  11. package/dist/lib/patterns/aws/devSubstrate.js +1 -1
  12. package/dist/lib/patterns/aws/payload.js +3 -3
  13. package/dist/lib/patterns/aws/staticSite.js +1 -1
  14. package/dist/lib/resources/aws/compute/ec2.d.ts +31 -14
  15. package/dist/lib/resources/aws/compute/ec2.js +34 -10
  16. package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +1 -1
  17. package/dist/lib/resources/aws/compute/ecsCapacityConfig.d.ts +83 -0
  18. package/dist/lib/resources/aws/compute/ecsCapacityConfig.js +323 -0
  19. package/dist/lib/resources/aws/compute/ecsConstants.d.ts +19 -0
  20. package/dist/lib/resources/aws/compute/ecsConstants.js +24 -0
  21. package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +1 -1
  22. package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +20 -16
  23. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +73 -118
  24. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +33 -4
  25. package/dist/lib/resources/aws/compute/ecsValidation.js +6 -0
  26. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +15 -5
  27. package/dist/lib/resources/aws/compute/persistentDataVolume.js +10 -7
  28. package/dist/lib/resources/aws/database/rdsInstance.js +1 -1
  29. package/dist/lib/resources/aws/iam/identityCenter/user.js +1 -1
  30. package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +1 -1
  31. package/dist/lib/resources/aws/networking/ipamPool.js +1 -1
  32. package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +1 -1
  33. package/dist/lib/resources/aws/utilities/tlsCertGenerator.js +1 -1
  34. package/dist/lib/utils/capacityIdentityContext.d.ts +20 -0
  35. package/dist/lib/utils/capacityIdentityContext.js +43 -0
  36. package/dist/lib/utils/engineCompat.d.ts +12 -5
  37. package/dist/lib/utils/engineCompat.js +41 -22
  38. package/dist/lib/utils/manifestWriter.d.ts +19 -2
  39. package/dist/lib/utils/manifestWriter.js +35 -0
  40. package/package.json +10 -6
  41. package/dist/lib/config/aws/identityCenterMembership.d.ts +0 -11
  42. package/dist/lib/config/aws/identityCenterMembership.js +0 -61
  43. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets +0 -30
  44. package/dist/lib/layers/layers/secrets-resolver/bin/resolve-secrets.mjs +0 -212
  45. package/dist/lib/patterns/aws/buildkite/alarms.d.ts +0 -25
  46. package/dist/lib/patterns/aws/buildkite/alarms.js +0 -78
  47. package/dist/lib/patterns/aws/domainDelegation.d.ts +0 -8
  48. package/dist/lib/patterns/aws/domainDelegation.js +0 -45
  49. package/dist/lib/patterns/aws/domainFactory.d.ts +0 -23
  50. package/dist/lib/patterns/aws/domainFactory.js +0 -61
  51. package/dist/lib/utils/addSuffixToEmail.d.ts +0 -1
  52. package/dist/lib/utils/addSuffixToEmail.js +0 -3
@@ -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();
@@ -389,6 +389,10 @@ export class ClickHouseDatabase extends Construct {
389
389
  securityGroups: [securityGroup],
390
390
  deployment: { minHealthyPercent: 0, maxHealthyPercent: 100 },
391
391
  ec2Config: {
392
+ // Pattern-chosen stable slot: the composed cluster must never
393
+ // collide with the app's own EC2 compute defaulting "primary"
394
+ // (slots are app-scoped; collision is a hard synth error).
395
+ slot: "clickhouse",
392
396
  instanceType,
393
397
  machineImage: EcsOptimizedImage.amazonLinux2023(amiHardwareType),
394
398
  instanceMonitoring: Monitoring.BASIC,
@@ -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,
@@ -4,28 +4,37 @@ import { Duration, Stack, type StackProps } from "aws-cdk-lib";
4
4
  import { type Role } from "aws-cdk-lib/aws-iam";
5
5
  import { AutoScalingGroup, Monitoring, type WarmPoolOptions } from "aws-cdk-lib/aws-autoscaling";
6
6
  import { type PersistentDataVolumeProps } from "./persistentDataVolume.js";
7
- export type Ec2InstancePersistentDataVolumeConfig = Omit<PersistentDataVolumeProps, "autoScalingGroup">;
7
+ export type Ec2InstancePersistentDataVolumeConfig = Omit<PersistentDataVolumeProps, "autoScalingGroup" | "stableOwnerId">;
8
8
  /**
9
9
  * Caller-supplied ASG `UpdatePolicy` resolution. Default (`undefined`) →
10
- * `rollingUpdate({ minInstancesInService: 0, maxBatchSize: 1, pauseTime:
11
- * Duration.minutes(5) })`. The rolling shape propagates userdata mutations
12
- * to running instances on every `LaunchTemplateVersion` change; data
13
- * continuity for stateful patterns is preserved by `persistentDataVolume`.
10
+ * `instanceRefresh` with `minHealthyPercentage 0 / maxHealthyPercentage 100`
11
+ * when `persistentDataVolume` is set (single-attach EBS forbids a surge
12
+ * instance), `100 / 200` otherwise (surge-then-shrink, no capacity dip).
13
+ * Either shape propagates userdata/launch-template mutations to running
14
+ * instances on every `LaunchTemplateVersion` change; data continuity for
15
+ * stateful patterns is preserved by `persistentDataVolume`.
14
16
  *
15
17
  * Variants:
16
- * - `rollingUpdate` — single-instance roll; `pauseTime` overridable.
17
- * `minInstancesInService` and `maxBatchSize` are fixed at `0` and `1`.
18
+ * - `instanceRefresh` — ASG-native rolling replacement via CFN
19
+ * `UpdatePolicy.InstanceRefresh`; percentages overridable.
20
+ * - `rollingUpdate` — legacy CFN-driven single-instance roll; `pauseTime`
21
+ * overridable. `minInstancesInService` and `maxBatchSize` are fixed at
22
+ * `0` and `1`.
18
23
  * - `replacingUpdate` — explicit opt-in to the legacy full-replacement shape.
19
24
  * No Fjall consumer uses this today; retained for future bare-EC2 patterns.
20
- * - `none` — no caller-driven `UpdatePolicy` (omits both
21
- * `AutoScalingRollingUpdate` and `AutoScalingReplacingUpdate`). CDK still
22
- * auto-emits `AutoScalingScheduledAction.IgnoreUnmodifiedGroupSizeProperties`
25
+ * - `none` — no caller-driven `UpdatePolicy` (omits the rolling, replacing
26
+ * AND refresh blocks). CDK still auto-emits
27
+ * `AutoScalingScheduledAction.IgnoreUnmodifiedGroupSizeProperties`
23
28
  * regardless. For callers owning rollout externally.
24
29
  *
25
30
  * See `aiDocs/troubleshooting/clickhouse-rolling-update-window.md` for the
26
31
  * ClickHouse-specific downtime expectations.
27
32
  */
28
33
  export type Ec2InstanceUpdatePolicyConfig = {
34
+ type: "instanceRefresh";
35
+ minHealthyPercentage?: number;
36
+ maxHealthyPercentage?: number;
37
+ } | {
29
38
  type: "rollingUpdate";
30
39
  pauseTime?: Duration;
31
40
  } | {
@@ -105,10 +114,18 @@ export interface Ec2InstanceProps extends StackProps {
105
114
  */
106
115
  persistentDataVolume?: Ec2InstancePersistentDataVolumeConfig;
107
116
  /**
108
- * ASG `UpdatePolicy` resolution. Absent
109
- * `UpdatePolicy.rollingUpdate({ minInstancesInService: 0, maxBatchSize: 1,
110
- * pauseTime: Duration.minutes(5) })`. Userdata mutations propagate to
111
- * running instances via a single-batch rolling roll. See
117
+ * Stable owner identifier for the persistent data volume — becomes the
118
+ * volume's `fjall:OwnerLogicalId` tag and the re-attach Lambdas'
119
+ * `OWNER_LOGICAL_ID`. REQUIRED whenever `persistentDataVolume` is set
120
+ * (rejected at synth otherwise) so the re-attach identity is an explicit
121
+ * caller decision, never an accident of construct-tree position. Must
122
+ * remain byte-stable for the stack's whole life — changing it strands the
123
+ * deployed volume (the Lambda filter stops matching).
124
+ */
125
+ dataVolumeStableOwnerId?: string;
126
+ /**
127
+ * ASG `UpdatePolicy` resolution. Absent → `UpdatePolicy.instanceRefresh`
128
+ * (0/100 with `persistentDataVolume`, 100/200 otherwise). See
112
129
  * `Ec2InstanceUpdatePolicyConfig` for the variant menu and
113
130
  * `aiDocs/troubleshooting/clickhouse-rolling-update-window.md` for the
114
131
  * downtime-window runbook.
@@ -3,7 +3,8 @@ import { Construct } from "constructs";
3
3
  import { resolvePrivateSubnetType } from "../../../utils/vpcUtils.js";
4
4
  import { safeEbs } from "./blockDeviceVolume.js";
5
5
  import { Duration, Stack, Tags } from "aws-cdk-lib";
6
- import { AutoScalingGroup, GroupMetrics, Monitoring, TerminationPolicy, UpdatePolicy, OnDemandAllocationStrategy, SpotAllocationStrategy } from "aws-cdk-lib/aws-autoscaling";
6
+ import { AutoScalingGroup, GroupMetrics, InstanceRefreshStrategy, Monitoring, TerminationPolicy, UpdatePolicy, OnDemandAllocationStrategy, SpotAllocationStrategy } from "aws-cdk-lib/aws-autoscaling";
7
+ import { DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS, INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY, INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY, INSTANCE_REFRESH_PDV_MAX_HEALTHY, INSTANCE_REFRESH_PDV_MIN_HEALTHY } from "./ecsConstants.js";
7
8
  import { AwsCustomResource } from "../utilities/awsCustomResource.js";
8
9
  import { PhysicalResourceId } from "aws-cdk-lib/custom-resources";
9
10
  import { Vpc } from "../networking/vpc.js";
@@ -22,17 +23,17 @@ const DEFAULT_ROOT_DEVICE_NAME = "/dev/xvda";
22
23
  const DEFAULT_ROOT_VOLUME_SIZE_GIB = 30;
23
24
  /**
24
25
  * Resolve the caller's `updatePolicy` prop to a CDK `UpdatePolicy`. Default
25
- * (`config` absent) → single-instance rolling roll on every
26
- * `LaunchTemplateVersion` change. The `none` branch returns `undefined` so
27
- * `addAutoScalingGroup` can conditionally spread the field — passing
28
- * `updatePolicy: undefined` directly emits an empty `UpdatePolicy: {}` block
29
- * on some CDK versions.
26
+ * (`config` absent) → instance refresh, percentages keyed on
27
+ * `hasPersistentDataVolume` (see the constants above). The `none` branch
28
+ * returns `undefined` so `addAutoScalingGroup` can conditionally spread the
29
+ * field — passing `updatePolicy: undefined` directly emits an empty
30
+ * `UpdatePolicy: {}` block on some CDK versions.
30
31
  *
31
32
  * Free function rather than a private method: `this` state is not read, and
32
33
  * a free function is easier to reach from focused tests.
33
34
  */
34
- function resolveUpdatePolicy(config) {
35
- const resolved = config ?? { type: "rollingUpdate" };
35
+ function resolveUpdatePolicy(config, hasPersistentDataVolume) {
36
+ const resolved = config ?? { type: "instanceRefresh" };
36
37
  switch (resolved.type) {
37
38
  case "none":
38
39
  return undefined;
@@ -42,7 +43,20 @@ function resolveUpdatePolicy(config) {
42
43
  return UpdatePolicy.rollingUpdate({
43
44
  minInstancesInService: 0,
44
45
  maxBatchSize: 1,
45
- pauseTime: resolved.pauseTime ?? Duration.minutes(5)
46
+ pauseTime: resolved.pauseTime ??
47
+ Duration.seconds(DEFAULT_ROLLING_UPDATE_PAUSE_SECONDS)
48
+ });
49
+ case "instanceRefresh":
50
+ return UpdatePolicy.instanceRefresh({
51
+ strategy: InstanceRefreshStrategy.ROLLING,
52
+ minHealthyPercentage: resolved.minHealthyPercentage ??
53
+ (hasPersistentDataVolume
54
+ ? INSTANCE_REFRESH_PDV_MIN_HEALTHY
55
+ : INSTANCE_REFRESH_DEFAULT_MIN_HEALTHY),
56
+ maxHealthyPercentage: resolved.maxHealthyPercentage ??
57
+ (hasPersistentDataVolume
58
+ ? INSTANCE_REFRESH_PDV_MAX_HEALTHY
59
+ : INSTANCE_REFRESH_DEFAULT_MAX_HEALTHY)
46
60
  });
47
61
  default:
48
62
  return assertNever(resolved);
@@ -139,7 +153,7 @@ export class Ec2Instance extends Construct {
139
153
  ? SubnetType.PUBLIC
140
154
  : resolvePrivateSubnetType(this.vpc)
141
155
  };
142
- const resolvedUpdatePolicy = resolveUpdatePolicy(props.updatePolicy);
156
+ const resolvedUpdatePolicy = resolveUpdatePolicy(props.updatePolicy, props.persistentDataVolume !== undefined);
143
157
  const baseAsgProps = {
144
158
  vpc: this.vpc,
145
159
  vpcSubnets,
@@ -242,8 +256,11 @@ export class Ec2Instance extends Construct {
242
256
  addPersistentDataVolume(props) {
243
257
  if (props.persistentDataVolume === undefined)
244
258
  return;
259
+ if (props.dataVolumeStableOwnerId === undefined)
260
+ return; // unreachable: validateEc2InstanceProps rejects the pair
245
261
  this.persistentDataVolume = new PersistentDataVolume(this, `${props.serviceName}PersistentDataVolume`, {
246
262
  autoScalingGroup: this.autoScalingGroup,
263
+ stableOwnerId: props.dataVolumeStableOwnerId,
247
264
  ...props.persistentDataVolume
248
265
  });
249
266
  }
@@ -282,6 +299,13 @@ function validateEc2InstanceProps(props) {
282
299
  throw new Error(`Ec2Instance.maxCapacity (${props.maxCapacity}) must be >= minCapacity (${props.minCapacity})`);
283
300
  }
284
301
  if (props.persistentDataVolume !== undefined) {
302
+ if (props.dataVolumeStableOwnerId === undefined ||
303
+ props.dataVolumeStableOwnerId === "") {
304
+ throw new Error("Ec2Instance.persistentDataVolume requires dataVolumeStableOwnerId — " +
305
+ "the volume's re-attach identity must be an explicit, stable " +
306
+ "caller decision (it becomes the fjall:OwnerLogicalId tag the " +
307
+ "lifecycle Lambdas filter on)");
308
+ }
285
309
  const azs = props.vpcSubnets?.availabilityZones;
286
310
  if (azs === undefined || azs.length !== 1) {
287
311
  throw new Error(`Ec2Instance.persistentDataVolume requires vpcSubnets.availabilityZones to be exactly 1 entry; got ${azs?.length ?? 0}`);
@@ -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,83 @@
1
+ import { type CapacityIdentityPin } from "@fjall/util";
2
+ import type { Ec2CapacityConfig, EcsCapacityProvider } from "./ecsTypes.js";
3
+ /** The service + ec2Config that first claimed a capacity slot. */
4
+ export interface Ec2AsgOrigin {
5
+ serviceName: string;
6
+ ec2Config: Ec2CapacityConfig;
7
+ /** Identity anchor the slot's ASG was built with (pin-frozen or slot-derived). */
8
+ anchor: string;
9
+ }
10
+ /** The slot this ec2Config binds to (`"primary"` when undeclared). */
11
+ export declare function resolveCapacitySlot(ec2Config: Ec2CapacityConfig): string;
12
+ /** Rejects at synth a slot name outside the kebab grammar. */
13
+ export declare function assertValidCapacitySlot(slot: string): void;
14
+ /**
15
+ * The identity anchor every CloudFormation identity surface derives from.
16
+ * Precedence: explicit `identityAnchor` config pin, then the deploy-injected
17
+ * context pin's `legacyAnchor`, then `toPascalCase(slot)`. The anchor grammar
18
+ * is validated whichever source wins — a bad anchor corrupts every logical
19
+ * ID and physical name downstream.
20
+ */
21
+ export declare function resolveCapacityAnchor(ec2Config: Ec2CapacityConfig, pin?: CapacityIdentityPin): string;
22
+ /**
23
+ * Rejects at synth two slots whose anchors collide (`web-1` and `web1` both
24
+ * normalise to `Web1`) — colliding anchors mean colliding logical IDs.
25
+ * `anchorsBySlot` carries every anchor already resolved for the cluster.
26
+ */
27
+ export declare function assertAnchorUnique(slot: string, anchor: string, anchorsBySlot: ReadonlyMap<string, string>): void;
28
+ /**
29
+ * Rejects at synth when a service reusing a shared ASG is incompatible with
30
+ * it: the slot's identity is contested (`identityAnchor` disagreement), the
31
+ * origin's `persistentDataVolume` makes the slot a singleton (two tasks
32
+ * would race to attach the same EBS volume), or the reusing service asks for
33
+ * ASG settings that differ from the ones the ASG was built with — those
34
+ * settings would be silently ignored (same silent-ignore class as
35
+ * `validateEc2ServiceSizing`).
36
+ */
37
+ export declare function assertSharedAsgConfigMatches(serviceName: string, ec2Config: Ec2CapacityConfig, slot: string, origin: Ec2AsgOrigin): void;
38
+ /**
39
+ * Config fields that fed the pre-6.0 (config-keyed) identity derivation —
40
+ * the "identity derives from …" causes named in manifest identity aliases.
41
+ */
42
+ export declare const LEGACY_KEY_FIELDS: readonly string[];
43
+ /**
44
+ * The pre-6.0 config-derived identity key. SURVIVES ONLY as the legacy
45
+ * checksum behind manifest identity-alias hints (`legacyKey`/`legacyAnchor`)
46
+ * — it feeds no construct IDs, no physical names and no template properties.
47
+ * ASG sharing keys on the capacity SLOT.
48
+ */
49
+ export declare function getEc2ConfigKey(ec2Config: Ec2CapacityConfig): string;
50
+ /**
51
+ * The anchor a pre-6.0 synth derived from a config key — the exact `safeKey`
52
+ * strip the factory used (`key.replace(/[^a-zA-Z0-9]/g, "")`). Feeds the
53
+ * manifest alias hints and pin capture; never a live identity surface.
54
+ */
55
+ export declare function legacyAnchorFromKey(key: string): string;
56
+ /**
57
+ * Minimal structural input for {@link validateSharedEc2CapacityConfig} —
58
+ * exactly the fields the check reads. Both the resources-layer
59
+ * `EcsServiceProps` and the patterns-layer `EcsServiceConfig` structurally
60
+ * satisfy it (typescript-standards § "Helpers consume resolved state, not the
61
+ * dispatch enum").
62
+ */
63
+ export interface SharedEc2CapacityServiceInput {
64
+ name: string;
65
+ capacityProvider?: EcsCapacityProvider;
66
+ ec2Config?: Ec2CapacityConfig;
67
+ }
68
+ /**
69
+ * Cross-service capacity-slot compatibility: validates slot grammar, rejects
70
+ * anchor collisions across slots, and rejects a slot reuse that is
71
+ * incompatible with the slot's origin — config drift the shared ASG would
72
+ * silently discard, a contested identity, or a `persistentDataVolume`
73
+ * singleton being shared. Pure; called from BOTH validation layers —
74
+ * resources `validateEcsClusterProps` and patterns `validateEcsProps`
75
+ * (generator-standards § "Validate at the Lowest Layer the Field Belongs To")
76
+ * — with the same checks at the construction site
77
+ * (`getOrCreateAsgCapacityProvider`) as defence-in-depth.
78
+ *
79
+ * Anchor collisions are checked on the PIN-BLIND anchor (`identityAnchor` or
80
+ * slot derivation) — this layer is pure and cannot read the deploy-injected
81
+ * context pins. The factory re-checks with pins applied.
82
+ */
83
+ export declare function validateSharedEc2CapacityConfig(services: readonly SharedEc2CapacityServiceInput[]): void;