@fjall/components-infrastructure 4.0.0 → 4.2.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.
@@ -25,7 +25,7 @@ import { FjallLogger } from "../../utils/validationLogger.js";
25
25
  import { VALIDATION_PATTERNS } from "@fjall/generator";
26
26
  import { evaluateBakeGuard } from "@fjall/util/docker";
27
27
  import { toKebab, buildParameterPath } from "@fjall/util";
28
- import { validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
28
+ import { validateEc2ServiceSizing, validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
29
29
  import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
30
30
  import { isHookMigrations } from "./computeEcsTypes.js";
31
31
  export { ScalingType } from "./computeEcsTypes.js";
@@ -150,6 +150,7 @@ export function validateEcsProps(props) {
150
150
  FjallLogger.warn(`Service '${service.name}' has ec2Config but capacityProvider is not 'EC2'. ` +
151
151
  "The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
152
152
  }
153
+ validateEc2ServiceSizing(service);
153
154
  if (service.deployment !== undefined) {
154
155
  const min = service.deployment.minHealthyPercent;
155
156
  const max = service.deployment.maxHealthyPercent;
@@ -38,6 +38,18 @@ export interface IDevSubstrateProps {
38
38
  * account where the repo was provisioned out-of-band.
39
39
  */
40
40
  readonly adoptSlotEcr?: boolean;
41
+ /**
42
+ * When `true`, adopt an already-existing slot build-cache ECR repository
43
+ * (`fjall-dev-<appKebab>-slots-cache`) by reference instead of creating one.
44
+ * Independent of {@link adoptSlotEcr} on purpose: an app whose slots repo
45
+ * predates the build-cache feature runs the substrate in slot-adopt mode, yet
46
+ * its cache repo does NOT exist and MUST be created on the next redeploy —
47
+ * coupling the two would import a non-existent cache repo and the slot builds
48
+ * would stay permanently uncached. The producer probes each repo's existence
49
+ * separately (webapp `shouldAdoptSlotEcr` / `shouldAdoptCacheEcr`). Defaults to
50
+ * `false`: create the cache repo with the untagged-image lifecycle rule.
51
+ */
52
+ readonly adoptCacheEcr?: boolean;
41
53
  /**
42
54
  * Deploy phase for the R2 two-step cert-hang guard. `"zone"` synthesises the
43
55
  * `devDomain` zone + cross-account NS delegation only; `"full"` additionally
@@ -251,12 +251,13 @@ export class DevSubstrate extends Construct {
251
251
  tagMutability: TagMutability.MUTABLE
252
252
  });
253
253
  // Buildx registry cache for slot builds, named by the same helper as the app
254
- // path's `<repo>-cache` so `fjall dev up` derives this URI from the slot
255
- // repo's without a second wire field. Adopts on the same flag: both repos are
256
- // RETAIN, so a rolled-back create leaves BOTH behind, and a create-only cache
257
- // repo would fail the very retry `adoptSlotEcr` exists to rescue.
254
+ // path's `<repo>-cache` so `fjall dev up` derives this URI from the slot repo's
255
+ // without a second wire field. Adopt is gated on its OWN `adoptCacheEcr` flag,
256
+ // NOT `adoptSlotEcr`: an app whose slots repo predates the cache feature
257
+ // redeploys in slot-adopt mode with no cache repo yet — coupling the two would
258
+ // import a non-existent repo and slot builds would stay permanently uncached.
258
259
  const slotCacheRepositoryName = buildCacheRepositoryName(slotEcrRepositoryName);
259
- if (props.adoptSlotEcr === true) {
260
+ if (props.adoptCacheEcr === true) {
260
261
  Repository.fromRepositoryName(this, `${slotEcrId}Cache`, slotCacheRepositoryName);
261
262
  }
262
263
  else {
@@ -214,6 +214,9 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
214
214
  stopTimeout: containerConfig.stopTimeout !== undefined
215
215
  ? Duration.seconds(containerConfig.stopTimeout)
216
216
  : undefined,
217
+ // EC2 container memory is ec2Config-only by design — do NOT add a
218
+ // `?? serviceProps.memoryLimitMiB` fallback (that is the silent-ignore
219
+ // footgun `validateEc2ServiceSizing` exists to reject).
217
220
  ...(isServiceEc2(serviceProps) && {
218
221
  memoryLimitMiB: serviceProps.ec2Config?.memoryLimitMiB ??
219
222
  DEFAULT_EC2_CONTAINER_MEMORY_MIB
@@ -102,7 +102,12 @@ export interface Ec2CapacityConfig {
102
102
  minCapacity?: number;
103
103
  /** Maximum number of instances. Default: 3 */
104
104
  maxCapacity?: number;
105
- /** Memory limit in MiB for the container. Default: 1024 */
105
+ /**
106
+ * Memory limit in MiB for the container. Default: 1024. This is the ONLY
107
+ * memory knob for an EC2 service — the service-level `memoryLimitMiB` on
108
+ * `EcsServiceProps` is Fargate-only and is rejected at synth for EC2 capacity
109
+ * (see `validateEc2ServiceSizing`).
110
+ */
106
111
  memoryLimitMiB?: number;
107
112
  /** Warm pool keeps stopped instances for faster start (10-15s vs 60-90s).
108
113
  * Mirrors generator WarmPool type (generator/src/schemas/computeSchemas.ts). */
@@ -442,9 +447,19 @@ export interface EcsServiceProps {
442
447
  * The first container with a port is the **primary container** (receives ALB traffic).
443
448
  */
444
449
  containers: EcsClusterContainerConfig[];
445
- /** CPU units for this service's tasks (256-4096) */
450
+ /**
451
+ * CPU units for this service's tasks (256-4096). Fargate only — EC2 task
452
+ * definitions carry no task-level CPU reservation (CPU is bounded by the
453
+ * instance type), so a value here is rejected at synth when `capacityProvider`
454
+ * is 'EC2' (see `validateEc2ServiceSizing`).
455
+ */
446
456
  cpu?: number;
447
- /** Memory in MiB for this service's tasks (512-30720) */
457
+ /**
458
+ * Memory in MiB for this service's tasks (512-30720). Fargate only — for EC2
459
+ * capacity the container memory comes from `ec2Config.memoryLimitMiB`; a
460
+ * service-level value here is rejected at synth unless it exactly mirrors
461
+ * that field (see `validateEc2ServiceSizing`).
462
+ */
448
463
  memoryLimitMiB?: number;
449
464
  /** Desired number of tasks. Default: 2 */
450
465
  desiredCount?: number;
@@ -1,4 +1,4 @@
1
- import type { DomainConfig, EcsClusterProps } from "./ecsTypes.js";
1
+ import type { DomainConfig, EcsCapacityProvider, EcsClusterProps } from "./ecsTypes.js";
2
2
  /**
3
3
  * Validates ECS cluster props before construction.
4
4
  * Pure function — does not depend on class state.
@@ -23,6 +23,60 @@ import type { DomainConfig, EcsClusterProps } from "./ecsTypes.js";
23
23
  * @throws Error if validation fails
24
24
  */
25
25
  export declare function validateEcsClusterProps(props: EcsClusterProps): void;
26
+ /**
27
+ * Minimal structural input for {@link validateEc2ServiceSizing} — exactly the
28
+ * fields the guard reads. Both the resources-layer `EcsServiceProps` and the
29
+ * patterns-layer `EcsServiceConfig` structurally satisfy it, so one guard serves
30
+ * both validation layers without coupling to either full service type
31
+ * (typescript-standards § "Helpers consume resolved state, not the dispatch enum":
32
+ * the parameter type is the fields the body actually reads, nothing wider).
33
+ */
34
+ interface Ec2ServiceSizingInput {
35
+ name: string;
36
+ capacityProvider?: EcsCapacityProvider;
37
+ cpu?: number;
38
+ memoryLimitMiB?: number;
39
+ ec2Config?: {
40
+ memoryLimitMiB?: number;
41
+ };
42
+ }
43
+ /**
44
+ * Rejects service-level task sizing that is silently ignored for EC2 capacity.
45
+ *
46
+ * For an EC2-capacity ECS service the container's hard memory limit is derived
47
+ * from `ec2Config.memoryLimitMiB` (default {@link DEFAULT_EC2_CONTAINER_MEMORY_MIB}),
48
+ * NOT from the service-level `memoryLimitMiB` — that field only feeds the
49
+ * Fargate task definition (`createTaskDefinition` in `ecsTaskDefinition.ts`: the
50
+ * EC2 branch passes neither `cpu` nor `memoryLimitMiB`; the container's memory
51
+ * comes solely from the `ec2Config.memoryLimitMiB ?? default` spread). A
52
+ * service-level `cpu` has no EC2 analogue at all — EC2 task definitions carry no
53
+ * task-level CPU reservation and `Ec2CapacityConfig` has no `cpu` field — so it
54
+ * is always dead on an EC2 service. Both fields read as "size the task to N"
55
+ * while the deployed container ignores the value.
56
+ *
57
+ * Fail loud at synth rather than silently honour the value: silently honouring
58
+ * memory would rewrite deployed container memory in BOTH directions — raise it
59
+ * past the EC2 instance's capacity (failure-to-place mid-rollout) or lower it
60
+ * into the service-level [512,1023] band below the 1024 EC2 default (fresh OOM).
61
+ * Rejecting changes nothing deployed; it converts two silent misconfigs into a
62
+ * synth error with a named cure.
63
+ *
64
+ * `both-set-and-equal` is allowed for memory — a config that mirrors
65
+ * `ec2Config.memoryLimitMiB` into the service-level field is self-consistent and
66
+ * the derived container memory is unchanged. `cpu` has no such carve-out (there
67
+ * is no `ec2Config.cpu` to agree with); it always throws on EC2.
68
+ *
69
+ * Called from BOTH validation layers — resources `validateEcsClusterProps` and
70
+ * patterns `validateEcsProps` — so the guard cannot drift between them
71
+ * (code-quality § "Coupled values: shared source at 2 occurrences"; mirrors the
72
+ * `validateEcsDomainConfig` shared-helper precedent below).
73
+ *
74
+ * @param service - The service props to validate (no-op unless EC2 capacity)
75
+ * @throws Error if an EC2 service carries an ignored service-level `cpu`, or a
76
+ * service-level `memoryLimitMiB` that is absent from / conflicts with
77
+ * `ec2Config.memoryLimitMiB`
78
+ */
79
+ export declare function validateEc2ServiceSizing(service: Ec2ServiceSizingInput): void;
26
80
  /**
27
81
  * Domain-config constraints shared by the resources-layer hook
28
82
  * (`validateEcsClusterProps`) and the patterns-layer mirror
@@ -52,3 +106,4 @@ export declare function validateSsmPathComponent(component: string, fieldName: s
52
106
  * reference the fjall secrets tooling can never write.
53
107
  */
54
108
  export declare function validateSecretName(name: string, context: string): void;
109
+ export {};
@@ -2,6 +2,7 @@ import { NetworkMode } from "aws-cdk-lib/aws-ecs";
2
2
  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
+ import { DEFAULT_EC2_CONTAINER_MEMORY_MIB } from "./ecsConstants.js";
5
6
  /**
6
7
  * Validates ECS cluster props before construction.
7
8
  * Pure function — does not depend on class state.
@@ -121,6 +122,7 @@ export function validateEcsClusterProps(props) {
121
122
  throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
122
123
  "Provide ec2Config on the service.");
123
124
  }
125
+ validateEc2ServiceSizing(service);
124
126
  if (service.deployment !== undefined) {
125
127
  const min = service.deployment.minHealthyPercent;
126
128
  const max = service.deployment.maxHealthyPercent;
@@ -188,6 +190,70 @@ export function validateEcsClusterProps(props) {
188
190
  }
189
191
  }
190
192
  }
193
+ /**
194
+ * Rejects service-level task sizing that is silently ignored for EC2 capacity.
195
+ *
196
+ * For an EC2-capacity ECS service the container's hard memory limit is derived
197
+ * from `ec2Config.memoryLimitMiB` (default {@link DEFAULT_EC2_CONTAINER_MEMORY_MIB}),
198
+ * NOT from the service-level `memoryLimitMiB` — that field only feeds the
199
+ * Fargate task definition (`createTaskDefinition` in `ecsTaskDefinition.ts`: the
200
+ * EC2 branch passes neither `cpu` nor `memoryLimitMiB`; the container's memory
201
+ * comes solely from the `ec2Config.memoryLimitMiB ?? default` spread). A
202
+ * service-level `cpu` has no EC2 analogue at all — EC2 task definitions carry no
203
+ * task-level CPU reservation and `Ec2CapacityConfig` has no `cpu` field — so it
204
+ * is always dead on an EC2 service. Both fields read as "size the task to N"
205
+ * while the deployed container ignores the value.
206
+ *
207
+ * Fail loud at synth rather than silently honour the value: silently honouring
208
+ * memory would rewrite deployed container memory in BOTH directions — raise it
209
+ * past the EC2 instance's capacity (failure-to-place mid-rollout) or lower it
210
+ * into the service-level [512,1023] band below the 1024 EC2 default (fresh OOM).
211
+ * Rejecting changes nothing deployed; it converts two silent misconfigs into a
212
+ * synth error with a named cure.
213
+ *
214
+ * `both-set-and-equal` is allowed for memory — a config that mirrors
215
+ * `ec2Config.memoryLimitMiB` into the service-level field is self-consistent and
216
+ * the derived container memory is unchanged. `cpu` has no such carve-out (there
217
+ * is no `ec2Config.cpu` to agree with); it always throws on EC2.
218
+ *
219
+ * Called from BOTH validation layers — resources `validateEcsClusterProps` and
220
+ * patterns `validateEcsProps` — so the guard cannot drift between them
221
+ * (code-quality § "Coupled values: shared source at 2 occurrences"; mirrors the
222
+ * `validateEcsDomainConfig` shared-helper precedent below).
223
+ *
224
+ * @param service - The service props to validate (no-op unless EC2 capacity)
225
+ * @throws Error if an EC2 service carries an ignored service-level `cpu`, or a
226
+ * service-level `memoryLimitMiB` that is absent from / conflicts with
227
+ * `ec2Config.memoryLimitMiB`
228
+ */
229
+ export function validateEc2ServiceSizing(service) {
230
+ if (service.capacityProvider !== "EC2")
231
+ return;
232
+ if (service.memoryLimitMiB !== undefined) {
233
+ const ec2Memory = service.ec2Config?.memoryLimitMiB;
234
+ if (ec2Memory === undefined) {
235
+ throw new Error(`Service '${service.name}': service-level memoryLimitMiB (${service.memoryLimitMiB}) is ignored ` +
236
+ "for EC2 capacity — the container's hard memory limit comes from ec2Config.memoryLimitMiB " +
237
+ `(default ${DEFAULT_EC2_CONTAINER_MEMORY_MIB} MiB), not the service-level field. Set ` +
238
+ `ec2Config.memoryLimitMiB: ${service.memoryLimitMiB} to size the container, or remove the ` +
239
+ "service-level memoryLimitMiB. (Service-level memoryLimitMiB only applies to Fargate services.)");
240
+ }
241
+ if (ec2Memory !== service.memoryLimitMiB) {
242
+ throw new Error(`Service '${service.name}': conflicting memory limits — service-level memoryLimitMiB ` +
243
+ `(${service.memoryLimitMiB}) disagrees with ec2Config.memoryLimitMiB (${ec2Memory}). For EC2 ` +
244
+ "capacity only ec2Config.memoryLimitMiB sizes the container; the service-level value is ignored. " +
245
+ "Set both to the same value or remove the service-level memoryLimitMiB.");
246
+ }
247
+ // both-set-and-equal falls through untouched (allowed): the config is
248
+ // self-consistent and the derived container memory is unchanged.
249
+ }
250
+ if (service.cpu !== undefined) {
251
+ throw new Error(`Service '${service.name}': service-level cpu (${service.cpu}) is ignored for EC2 capacity — ` +
252
+ "EC2 task definitions carry no task-level CPU reservation (CPU is bounded by the instance type) " +
253
+ "and there is no ec2Config.cpu field. Remove the service-level cpu. " +
254
+ "(Service-level cpu only applies to Fargate services.)");
255
+ }
256
+ }
191
257
  /**
192
258
  * Domain-config constraints shared by the resources-layer hook
193
259
  * (`validateEcsClusterProps`) and the patterns-layer mirror
@@ -4,9 +4,17 @@
4
4
  *
5
5
  * Re-invocation safety: ECS hook responses do NOT carry state between
6
6
  * IN_PROGRESS invocations. On every invocation we reconstruct the
7
- * deterministic startedBy tag from the event's targetServiceRevisionArn and
8
- * use ListTasks to find an existing running migration. RunTask fires only on
9
- * the first invocation.
7
+ * deterministic startedBy tag from the event's
8
+ * executionDetails.targetServiceRevisionArn and use ListTasks to find an
9
+ * existing running migration. RunTask fires only on the first invocation.
10
+ *
11
+ * Event contract: the target revision ARN is nested under `executionDetails`
12
+ * and the `executionId` is top-level — see AWS "Lambda hooks for Amazon ECS
13
+ * service deployments" (developerguide/lambda-lifecycle-hooks.html). Reading
14
+ * the revision ARN from the wrong level collapses the startedBy tag to a
15
+ * shared constant across every deploy, so ListTasks can adopt another
16
+ * deployment's task (cross-deploy race). A missing revision ARN therefore
17
+ * FAILS the hook loudly rather than proceeding with a shared tag.
10
18
  *
11
19
  * CommonJS rather than ESM because Code.fromInline lands the source as
12
20
  * `index.js`, which Lambda treats as CommonJS by default.
@@ -37,6 +45,26 @@ function startedByTag(targetServiceRevisionArn) {
37
45
  return `fjall-migrate-${suffix}`.slice(0, 36);
38
46
  }
39
47
 
48
+ // The revision ARN is nested under executionDetails in the ECS lifecycle-hook
49
+ // event; the top-level fallback tolerates a future/legacy shape without
50
+ // reopening the shared-tag race (nested is read first).
51
+ function extractTargetServiceRevisionArn(event) {
52
+ const nested =
53
+ event &&
54
+ event.executionDetails &&
55
+ event.executionDetails.targetServiceRevisionArn;
56
+ const topLevel = event && event.targetServiceRevisionArn;
57
+ return nested || topLevel || undefined;
58
+ }
59
+
60
+ // executionId is top-level in the AWS contract; nested fallback is defensive.
61
+ function extractExecutionId(event) {
62
+ const topLevel = event && event.executionId;
63
+ const nested =
64
+ event && event.executionDetails && event.executionDetails.executionId;
65
+ return topLevel || nested || undefined;
66
+ }
67
+
40
68
  function buildContainerOverrides(
41
69
  name,
42
70
  command,
@@ -97,12 +125,20 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
97
125
  }
98
126
 
99
127
  async function runHandler(event, deps) {
128
+ const targetServiceRevisionArn = extractTargetServiceRevisionArn(event);
129
+ if (!targetServiceRevisionArn) {
130
+ return {
131
+ hookStatus: "FAILED",
132
+ reason:
133
+ "Lifecycle event missing executionDetails.targetServiceRevisionArn"
134
+ };
135
+ }
100
136
  const client = (deps && deps.client) || getDefaultClient();
101
137
  const sleep = deps && deps.sleep;
102
138
  const config = JSON.parse(
103
139
  (deps && deps.migrateConfig) || process.env.MIGRATE_CONFIG
104
140
  );
105
- const startedBy = startedByTag(event && event.targetServiceRevisionArn);
141
+ const startedBy = startedByTag(targetServiceRevisionArn);
106
142
 
107
143
  const existingTaskArn = await findExistingTaskArn(
108
144
  client,
@@ -158,12 +194,8 @@ exports.handler = async (event) => {
158
194
  JSON.stringify({
159
195
  msg: "lifecycle-hook-invoked",
160
196
  targetServiceRevisionArn:
161
- (event && event.targetServiceRevisionArn) || "(missing)",
162
- executionId:
163
- (event &&
164
- event.executionDetails &&
165
- event.executionDetails.executionId) ||
166
- "(missing)"
197
+ extractTargetServiceRevisionArn(event) || "(missing)",
198
+ executionId: extractExecutionId(event) || "(missing)"
167
199
  })
168
200
  );
169
201
  try {
@@ -182,6 +214,8 @@ exports.handler = async (event) => {
182
214
 
183
215
  exports._internals = {
184
216
  startedByTag,
217
+ extractTargetServiceRevisionArn,
218
+ extractExecutionId,
185
219
  buildContainerOverrides,
186
220
  findExistingTaskArn,
187
221
  pollTaskUntilStopped,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
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": "^4.0.0",
71
- "@fjall/util": "^4.0.0",
70
+ "@fjall/generator": "^4.2.0",
71
+ "@fjall/util": "^4.2.0",
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": "14031063ccd2685e390b70f6a3f4efe2bac7ced9"
85
+ "gitHead": "c06f7ad6b4b63a99cc8e2fcf4f216ff1c14748c6"
86
86
  }