@fjall/components-infrastructure 39.0.5 → 39.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,30 @@
1
1
  /**
2
- * ECS deployment lifecycle hook (PRE_SCALE_UP) runner. Inlined into a Lambda
3
- * at synth time by ecsLifecycleHookMigration.ts.
2
+ * ECS deployment lifecycle hook (PRE_SCALE_UP or POST_SCALE_UP) runner.
3
+ * Inlined into a Lambda at synth time by ecsLifecycleHookMigration.ts.
4
+ *
5
+ * Everything the hook runs comes from the deployment it serves, never from
6
+ * the Lambda's own configuration (FJL-901). ECS names the deployment's target
7
+ * service revision; the revision gives the cluster, the service's task
8
+ * definition and its network configuration; the service's task definition
9
+ * names the migration task definition in its fjall:migration-task-definition
10
+ * tag, or, on a revision registered by copying another (whose tags the copy
11
+ * drops), in its primary container's io.fjall.migration-task-definition
12
+ * docker label; and the migration task definition carries its own command,
13
+ * entry point and environment, plus, when the migration has security groups
14
+ * of its own, their ids in its fjall:migration-security-groups tag. So the hook
15
+ * never runs one release's migration for another release's code, however its
16
+ * own update and the service's are ordered. The Lambda changes only when its
17
+ * code does.
4
18
  *
5
19
  * Re-invocation safety: ECS hook responses do NOT carry state between
6
20
  * IN_PROGRESS invocations. On every invocation we reconstruct the
7
21
  * 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.
22
+ * executionDetails.targetServiceRevisionArn and use ListTasks to find the
23
+ * migration task already started for it, running or stopped. RunTask fires
24
+ * only when there is none.
10
25
  *
11
26
  * Event contract: the target revision ARN is nested under `executionDetails`
12
- * and the `executionId` is top-level — see AWS "Lambda hooks for Amazon ECS
27
+ * and the `executionId` is top-level - see AWS "Lambda hooks for Amazon ECS
13
28
  * service deployments" (developerguide/lambda-lifecycle-hooks.html). Reading
14
29
  * the revision ARN from the wrong level collapses the startedBy tag to a
15
30
  * shared constant across every deploy, so ListTasks can adopt another
@@ -23,19 +38,62 @@ const {
23
38
  ECSClient,
24
39
  RunTaskCommand,
25
40
  DescribeTasksCommand,
26
- ListTasksCommand
41
+ ListTasksCommand,
42
+ DescribeServiceRevisionsCommand,
43
+ DescribeTaskDefinitionCommand,
44
+ ListTagsForResourceCommand
27
45
  } = require("@aws-sdk/client-ecs");
28
46
 
29
47
  const POLL_INTERVAL_MS = 5_000;
30
48
  const MAX_POLLS_PER_INVOCATION = 20;
31
49
  const HOOK_CALLBACK_DELAY_SECONDS = 30;
32
50
 
51
+ /**
52
+ * How many times one invocation reads a service revision ECS reports
53
+ * MISSING before failing: a revision the deployment just created may not be
54
+ * visible to DescribeServiceRevisions at once.
55
+ */
56
+ const MAX_REVISION_READS = 6;
57
+
58
+ /**
59
+ * The tag on the service's task definition that names the migration task
60
+ * definition. MIGRATION_TASK_DEFINITION_TAG_KEY in @fjall/util/migration,
61
+ * which inline code cannot import; a test pins the two together.
62
+ */
63
+ const MIGRATION_TASK_DEFINITION_TAG_KEY = "fjall:migration-task-definition";
64
+
65
+ /**
66
+ * The docker label on the service's primary container that names the same
67
+ * migration task definition as the tag. A revision registered by copying
68
+ * another, as an engine before 40.0.0 registers one for a restart, a
69
+ * code-only deploy or a reconcile, keeps the container definitions and drops
70
+ * the tags, so the label is read when the tag is absent.
71
+ * MIGRATION_TASK_DEFINITION_LABEL_KEY in @fjall/util/migration.
72
+ */
73
+ const MIGRATION_TASK_DEFINITION_LABEL_KEY =
74
+ "io.fjall.migration-task-definition";
75
+
76
+ /**
77
+ * The tag on a migration task definition that names, space-separated, the
78
+ * security groups its task runs in, when they are not the service's.
79
+ * MIGRATION_SECURITY_GROUPS_TAG_KEY in @fjall/util/migration.
80
+ */
81
+ const MIGRATION_SECURITY_GROUPS_TAG_KEY = "fjall:migration-security-groups";
82
+
83
+ const TASK_DEFINITION_ARN =
84
+ /^arn:[^:]+:ecs:[^:]+:\d{12}:task-definition\/[^:]+:\d+$/;
85
+ const SECURITY_GROUP_ID = /^sg-[0-9a-f]+$/;
86
+
33
87
  let _defaultClient;
34
88
  function getDefaultClient() {
35
89
  if (!_defaultClient) _defaultClient = new ECSClient({});
36
90
  return _defaultClient;
37
91
  }
38
92
 
93
+ function defaultSleep(ms) {
94
+ return new Promise((resolve) => setTimeout(resolve, ms));
95
+ }
96
+
39
97
  function startedByTag(targetServiceRevisionArn) {
40
98
  // ECS startedBy is bounded to 36 chars; revision id alone is shorter.
41
99
  const suffix =
@@ -65,31 +123,194 @@ function extractExecutionId(event) {
65
123
  return topLevel || nested || undefined;
66
124
  }
67
125
 
68
- function buildContainerOverrides(
69
- name,
70
- command,
71
- image,
72
- entryPoint,
73
- environment
74
- ) {
75
- const override = { name, command };
76
- if (image) override.image = image;
77
- if (entryPoint && entryPoint.length > 0) override.entryPoint = entryPoint;
78
- if (environment && Object.keys(environment).length > 0) {
79
- override.environment = Object.entries(environment).map(([key, value]) => ({
80
- name: key,
81
- value
82
- }));
126
+ function failed(reason) {
127
+ return { hookStatus: "FAILED", reason };
128
+ }
129
+
130
+ /**
131
+ * The cluster, the service's task definition and the network configuration
132
+ * of the deployment's target revision, or a FAILED result naming why.
133
+ */
134
+ async function readServiceRevision(client, revisionArn, sleep) {
135
+ let lastReason = "no failure reported";
136
+ for (let attempt = 0; attempt < MAX_REVISION_READS; attempt++) {
137
+ const answer = await client.send(
138
+ new DescribeServiceRevisionsCommand({
139
+ serviceRevisionArns: [revisionArn]
140
+ })
141
+ );
142
+ const revision = (answer.serviceRevisions || []).find(
143
+ (candidate) => candidate.serviceRevisionArn === revisionArn
144
+ );
145
+ if (revision !== undefined) {
146
+ const awsvpcConfiguration =
147
+ revision.networkConfiguration &&
148
+ revision.networkConfiguration.awsvpcConfiguration;
149
+ if (
150
+ typeof revision.clusterArn !== "string" ||
151
+ typeof revision.taskDefinition !== "string" ||
152
+ awsvpcConfiguration === undefined
153
+ ) {
154
+ return {
155
+ failure: failed(
156
+ `Service revision ${revisionArn} names no cluster, task definition or awsvpc network configuration`
157
+ )
158
+ };
159
+ }
160
+ return {
161
+ clusterArn: revision.clusterArn,
162
+ taskDefinition: revision.taskDefinition,
163
+ awsvpcConfiguration
164
+ };
165
+ }
166
+ const failure = answer.failures && answer.failures[0];
167
+ lastReason = failure && failure.reason ? failure.reason : lastReason;
168
+ if (lastReason !== "MISSING") break;
169
+ if (attempt < MAX_REVISION_READS - 1) await sleep(POLL_INTERVAL_MS);
83
170
  }
84
- return [override];
171
+ return {
172
+ failure: failed(`DescribeServiceRevisions(${revisionArn}): ${lastReason}`)
173
+ };
85
174
  }
86
175
 
87
- async function findExistingTaskArn(client, clusterArn, startedBy) {
176
+ async function tagValue(client, resourceArn, key) {
177
+ const answer = await client.send(
178
+ new ListTagsForResourceCommand({ resourceArn })
179
+ );
180
+ const tag = (answer.tags || []).find((candidate) => candidate.key === key);
181
+ return tag === undefined ? undefined : tag.value;
182
+ }
183
+
184
+ /** The distinct values the task definition's containers give a docker label. */
185
+ async function labelValues(client, taskDefinitionArn, key) {
186
+ const answer = await client.send(
187
+ new DescribeTaskDefinitionCommand({ taskDefinition: taskDefinitionArn })
188
+ );
189
+ const containers =
190
+ (answer.taskDefinition && answer.taskDefinition.containerDefinitions) || [];
191
+ const values = containers
192
+ .map((container) => container.dockerLabels && container.dockerLabels[key])
193
+ .filter((value) => typeof value === "string");
194
+ return [...new Set(values)];
195
+ }
196
+
197
+ /**
198
+ * The migration task definition the service's task definition names, and
199
+ * which of the two named it: its tag, else its primary container's docker
200
+ * label; or a FAILED result naming why.
201
+ */
202
+ async function namedMigration(client, serviceTaskDefinition) {
203
+ const tagged = await tagValue(
204
+ client,
205
+ serviceTaskDefinition,
206
+ MIGRATION_TASK_DEFINITION_TAG_KEY
207
+ );
208
+ const migrationNamedBy = tagged !== undefined ? "tag" : "label";
209
+ const [source, named] =
210
+ tagged !== undefined
211
+ ? [`${MIGRATION_TASK_DEFINITION_TAG_KEY} tag`, [tagged]]
212
+ : [
213
+ `${MIGRATION_TASK_DEFINITION_LABEL_KEY} docker label`,
214
+ await labelValues(
215
+ client,
216
+ serviceTaskDefinition,
217
+ MIGRATION_TASK_DEFINITION_LABEL_KEY
218
+ )
219
+ ];
220
+ if (named.length === 0) {
221
+ return {
222
+ failure: failed(
223
+ `Task definition ${serviceTaskDefinition} carries neither a ${MIGRATION_TASK_DEFINITION_TAG_KEY} tag nor a ${MIGRATION_TASK_DEFINITION_LABEL_KEY} docker label naming its migration task definition`
224
+ )
225
+ };
226
+ }
227
+ if (named.length > 1) {
228
+ return {
229
+ failure: failed(
230
+ `The containers of ${serviceTaskDefinition} name different migration task definitions in their ${source}s`
231
+ )
232
+ };
233
+ }
234
+ const [migrationTaskDefinition] = named;
235
+ if (!TASK_DEFINITION_ARN.test(migrationTaskDefinition)) {
236
+ return {
237
+ failure: failed(
238
+ `The ${source} on ${serviceTaskDefinition} is not a task-definition ARN`
239
+ )
240
+ };
241
+ }
242
+ return { migrationTaskDefinition, migrationNamedBy };
243
+ }
244
+
245
+ /**
246
+ * The migration task definition the service's task definition names, what
247
+ * named it, and the security groups its task runs in, or a FAILED result
248
+ * naming why.
249
+ */
250
+ async function readMigrationPlan(client, serviceTaskDefinition) {
251
+ const named = await namedMigration(client, serviceTaskDefinition);
252
+ if (named.failure) return named;
253
+ const { migrationTaskDefinition } = named;
254
+ const groups = await tagValue(
255
+ client,
256
+ migrationTaskDefinition,
257
+ MIGRATION_SECURITY_GROUPS_TAG_KEY
258
+ );
259
+ if (groups === undefined) return named;
260
+ const securityGroups = groups.split(" ").filter((id) => id !== "");
261
+ if (
262
+ securityGroups.length === 0 ||
263
+ !securityGroups.every((id) => SECURITY_GROUP_ID.test(id))
264
+ ) {
265
+ return {
266
+ failure: failed(
267
+ `The ${MIGRATION_SECURITY_GROUPS_TAG_KEY} tag on ${migrationTaskDefinition} names no security group ids`
268
+ )
269
+ };
270
+ }
271
+ return { ...named, securityGroups };
272
+ }
273
+
274
+ async function tasksStartedBy(client, clusterArn, startedBy, desiredStatus) {
88
275
  const result = await client.send(
89
- new ListTasksCommand({ cluster: clusterArn, startedBy })
276
+ new ListTasksCommand({ cluster: clusterArn, startedBy, desiredStatus })
90
277
  );
91
- if (!result.taskArns || result.taskArns.length === 0) return undefined;
92
- return result.taskArns[0];
278
+ return result.taskArns || [];
279
+ }
280
+
281
+ /**
282
+ * The migration task already started for this revision: a running one, or
283
+ * else the newest stopped one. ListTasks answers RUNNING tasks unless asked,
284
+ * and a re-invocation can land after the task stopped; ECS keeps a stopped
285
+ * task listable for about an hour, longer than any one deployment's hook.
286
+ */
287
+ async function findExistingTaskArn(client, clusterArn, startedBy) {
288
+ const running = await tasksStartedBy(
289
+ client,
290
+ clusterArn,
291
+ startedBy,
292
+ "RUNNING"
293
+ );
294
+ if (running.length > 0) return running[0];
295
+ const stopped = await tasksStartedBy(
296
+ client,
297
+ clusterArn,
298
+ startedBy,
299
+ "STOPPED"
300
+ );
301
+ if (stopped.length <= 1) return stopped[0];
302
+ const desc = await client.send(
303
+ new DescribeTasksCommand({ cluster: clusterArn, tasks: stopped })
304
+ );
305
+ const newest = (desc.tasks || [])
306
+ .slice()
307
+ .sort((a, b) => timeOf(b.createdAt) - timeOf(a.createdAt))[0];
308
+ return newest && newest.taskArn ? newest.taskArn : stopped[0];
309
+ }
310
+
311
+ function timeOf(value) {
312
+ const time = value === undefined ? NaN : new Date(value).getTime();
313
+ return Number.isFinite(time) ? time : 0;
93
314
  }
94
315
 
95
316
  function describeFailureReason(desc) {
@@ -102,11 +323,10 @@ function describeFailureReason(desc) {
102
323
  // ARN RunTask just returned. Until the task has been seen once, a miss is
103
324
  // "not visible yet" and the poll continues; once it has been seen, a miss is
104
325
  // the task vanishing, which is loud. A task never seen inside one
105
- // invocation's poll budget is loud too — that is a hundred seconds, not
326
+ // invocation's poll budget is loud too - that is a hundred seconds, not
106
327
  // consistency lag.
107
328
  async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
108
- const sleepFn =
109
- sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
329
+ const sleepFn = sleep || defaultSleep;
110
330
  let seen = false;
111
331
  let lastMissReason = "no failure reported";
112
332
  for (let i = 0; i < MAX_POLLS_PER_INVOCATION; i++) {
@@ -122,18 +342,16 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
122
342
  ? task.containers[0].exitCode
123
343
  : undefined;
124
344
  if (exitCode === 0) return { hookStatus: "SUCCEEDED" };
125
- return {
126
- hookStatus: "FAILED",
127
- reason: `Task exited ${exitCode}: ${task.stoppedReason || "no reason"}`
128
- };
345
+ return failed(
346
+ `Task exited ${exitCode}: ${task.stoppedReason || "no reason"}`
347
+ );
129
348
  }
130
349
  } else {
131
350
  lastMissReason = describeFailureReason(desc);
132
351
  if (seen) {
133
- return {
134
- hookStatus: "FAILED",
135
- reason: `DescribeTasks returned no task after it had been seen (${lastMissReason})`
136
- };
352
+ return failed(
353
+ `DescribeTasks returned no task after it had been seen (${lastMissReason})`
354
+ );
137
355
  }
138
356
  }
139
357
  if (i < MAX_POLLS_PER_INVOCATION - 1) {
@@ -141,10 +359,9 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
141
359
  }
142
360
  }
143
361
  if (!seen) {
144
- return {
145
- hookStatus: "FAILED",
146
- reason: `DescribeTasks returned no task in ${MAX_POLLS_PER_INVOCATION} polls (${lastMissReason})`
147
- };
362
+ return failed(
363
+ `DescribeTasks returned no task in ${MAX_POLLS_PER_INVOCATION} polls (${lastMissReason})`
364
+ );
148
365
  }
149
366
  return {
150
367
  hookStatus: "IN_PROGRESS",
@@ -152,70 +369,90 @@ async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
152
369
  };
153
370
  }
154
371
 
372
+ /**
373
+ * The hook's verdict, and the cluster and migration it ran, for its log. The
374
+ * log says whether the tag or the label named the migration: a revision
375
+ * copied without its tags runs the migration its copied label names.
376
+ */
155
377
  async function runHandler(event, deps) {
156
378
  const targetServiceRevisionArn = extractTargetServiceRevisionArn(event);
157
379
  if (!targetServiceRevisionArn) {
158
380
  return {
159
- hookStatus: "FAILED",
160
- reason:
381
+ result: failed(
161
382
  "Lifecycle event missing executionDetails.targetServiceRevisionArn"
383
+ )
162
384
  };
163
385
  }
164
386
  const client = (deps && deps.client) || getDefaultClient();
165
- const sleep = deps && deps.sleep;
166
- const config = JSON.parse(
167
- (deps && deps.migrateConfig) || process.env.MIGRATE_CONFIG
387
+ const sleep = (deps && deps.sleep) || defaultSleep;
388
+
389
+ const revision = await readServiceRevision(
390
+ client,
391
+ targetServiceRevisionArn,
392
+ sleep
168
393
  );
394
+ if (revision.failure) return { result: revision.failure };
395
+ const plan = await readMigrationPlan(client, revision.taskDefinition);
396
+ if (plan.failure) return { result: plan.failure };
397
+ const ran = {
398
+ clusterArn: revision.clusterArn,
399
+ migrationTaskDefinition: plan.migrationTaskDefinition,
400
+ migrationNamedBy: plan.migrationNamedBy
401
+ };
169
402
  const startedBy = startedByTag(targetServiceRevisionArn);
170
403
 
171
404
  const existingTaskArn = await findExistingTaskArn(
172
405
  client,
173
- config.clusterArn,
406
+ revision.clusterArn,
174
407
  startedBy
175
408
  );
176
409
  if (existingTaskArn) {
177
- return pollTaskUntilStopped(
178
- client,
179
- config.clusterArn,
180
- existingTaskArn,
181
- sleep
182
- );
410
+ return {
411
+ ...ran,
412
+ result: await pollTaskUntilStopped(
413
+ client,
414
+ revision.clusterArn,
415
+ existingTaskArn,
416
+ sleep
417
+ )
418
+ };
183
419
  }
184
420
 
185
- const result = await client.send(
421
+ const awsvpcConfiguration =
422
+ plan.securityGroups === undefined
423
+ ? revision.awsvpcConfiguration
424
+ : {
425
+ ...revision.awsvpcConfiguration,
426
+ securityGroups: plan.securityGroups
427
+ };
428
+ const launched = await client.send(
186
429
  new RunTaskCommand({
187
- cluster: config.clusterArn,
188
- taskDefinition: config.taskDefinitionArn,
430
+ cluster: revision.clusterArn,
431
+ taskDefinition: plan.migrationTaskDefinition,
189
432
  launchType: "FARGATE",
190
- networkConfiguration: {
191
- awsvpcConfiguration: config.networkConfiguration
192
- },
193
- overrides: {
194
- containerOverrides: buildContainerOverrides(
195
- config.name,
196
- config.command,
197
- config.hasSeparateMigrationTaskDef ? undefined : config.image,
198
- config.hasSeparateMigrationTaskDef ? undefined : config.entryPoint,
199
- config.environment
200
- )
201
- },
433
+ networkConfiguration: { awsvpcConfiguration },
202
434
  startedBy
203
435
  })
204
436
  );
205
437
  const taskArn =
206
- result.tasks && result.tasks[0] ? result.tasks[0].taskArn : undefined;
438
+ launched.tasks && launched.tasks[0] ? launched.tasks[0].taskArn : undefined;
207
439
  if (!taskArn) {
208
- return {
209
- hookStatus: "FAILED",
210
- reason: "RunTask did not return a task ARN"
211
- };
440
+ return { ...ran, result: failed("RunTask did not return a task ARN") };
212
441
  }
213
-
214
- return pollTaskUntilStopped(client, config.clusterArn, taskArn, sleep);
442
+ return {
443
+ ...ran,
444
+ result: await pollTaskUntilStopped(
445
+ client,
446
+ revision.clusterArn,
447
+ taskArn,
448
+ sleep
449
+ )
450
+ };
215
451
  }
216
452
 
217
- exports.handler = async (event) => {
218
- // Log invocation context + outcome — ECS hooks only surface "hook execution
453
+ /** The handler, with its dependencies injectable: it never rejects. */
454
+ async function handle(event, deps) {
455
+ // Log invocation context + outcome - ECS hooks only surface "hook execution
219
456
  // failure" to the deployment; without these the Lambda log is silent and
220
457
  // the failure cause is unrecoverable.
221
458
  console.log(
@@ -227,8 +464,10 @@ exports.handler = async (event) => {
227
464
  })
228
465
  );
229
466
  try {
230
- const result = await runHandler(event);
231
- console.log(JSON.stringify({ msg: "lifecycle-hook-result", ...result }));
467
+ const { result, ...ran } = await runHandler(event, deps);
468
+ console.log(
469
+ JSON.stringify({ msg: "lifecycle-hook-result", ...ran, ...result })
470
+ );
232
471
  return result;
233
472
  } catch (err) {
234
473
  const reason = err && err.message ? err.message : String(err);
@@ -236,19 +475,27 @@ exports.handler = async (event) => {
236
475
  console.error(
237
476
  JSON.stringify({ msg: "lifecycle-hook-crashed", reason, stack })
238
477
  );
239
- return { hookStatus: "FAILED", reason: `Runner crashed: ${reason}` };
478
+ return failed(`Runner crashed: ${reason}`);
240
479
  }
241
- };
480
+ }
481
+
482
+ exports.handler = (event) => handle(event);
242
483
 
243
484
  exports._internals = {
244
485
  startedByTag,
245
486
  extractTargetServiceRevisionArn,
246
487
  extractExecutionId,
247
- buildContainerOverrides,
488
+ readServiceRevision,
489
+ readMigrationPlan,
248
490
  findExistingTaskArn,
249
491
  pollTaskUntilStopped,
250
492
  runHandler,
493
+ handle,
494
+ MIGRATION_TASK_DEFINITION_TAG_KEY,
495
+ MIGRATION_TASK_DEFINITION_LABEL_KEY,
496
+ MIGRATION_SECURITY_GROUPS_TAG_KEY,
251
497
  POLL_INTERVAL_MS,
252
498
  MAX_POLLS_PER_INVOCATION,
499
+ MAX_REVISION_READS,
253
500
  HOOK_CALLBACK_DELAY_SECONDS
254
501
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "39.0.5",
3
+ "version": "39.0.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -40,7 +40,7 @@
40
40
  "clean:node": "rm -rf ./node_modules",
41
41
  "build:cert-gen-lambda": "node lib/lambda-assets/cert-generator/src/build.mjs",
42
42
  "build": "node ../../scripts/run-build.mjs --purpose=build -- npm run build:steps",
43
- "build:steps": "npm run clean && npm run build:cert-gen-lambda && tsc && cp -r lib/layers dist/lib/layers && mkdir -p dist/lib/lambda-assets/cert-generator/asset && cp lib/lambda-assets/cert-generator/asset/index.js dist/lib/lambda-assets/cert-generator/asset/index.js && cp lib/lambda-assets/cert-generator/asset/package.json dist/lib/lambda-assets/cert-generator/asset/package.json && mkdir -p dist/lib/lambda-assets/identity-store-user/asset && cp lib/lambda-assets/identity-store-user/asset/index.js dist/lib/lambda-assets/identity-store-user/asset/index.js && cp lib/lambda-assets/identity-store-user/asset/package.json dist/lib/lambda-assets/identity-store-user/asset/package.json && mkdir -p dist/lib/lambda-assets/static-site-forms/asset && cp lib/lambda-assets/static-site-forms/asset/index.js dist/lib/lambda-assets/static-site-forms/asset/index.js && cp lib/lambda-assets/static-site-forms/asset/package.json dist/lib/lambda-assets/static-site-forms/asset/package.json && cp lib/resources/aws/compute/lifecycleHookLambda.source.cjs dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs && cp lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs && cp lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs && cp lib/patterns/aws/devSubstrate.waker.source.cjs dist/lib/patterns/aws/devSubstrate.waker.source.cjs && node ../../scripts/stamp-build.mjs",
43
+ "build:steps": "npm run clean && npm run build:cert-gen-lambda && tsc && cp -r lib/layers dist/lib/layers && mkdir -p dist/lib/lambda-assets/cert-generator/asset && cp lib/lambda-assets/cert-generator/asset/index.js dist/lib/lambda-assets/cert-generator/asset/index.js && cp lib/lambda-assets/cert-generator/asset/package.json dist/lib/lambda-assets/cert-generator/asset/package.json && mkdir -p dist/lib/lambda-assets/identity-store-user/asset && cp lib/lambda-assets/identity-store-user/asset/index.js dist/lib/lambda-assets/identity-store-user/asset/index.js && cp lib/lambda-assets/identity-store-user/asset/package.json dist/lib/lambda-assets/identity-store-user/asset/package.json && mkdir -p dist/lib/lambda-assets/static-site-forms/asset && cp lib/lambda-assets/static-site-forms/asset/index.js dist/lib/lambda-assets/static-site-forms/asset/index.js && cp lib/lambda-assets/static-site-forms/asset/package.json dist/lib/lambda-assets/static-site-forms/asset/package.json && cp lib/resources/aws/compute/lifecycleHookLambda.source.cjs dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs && cp lib/resources/aws/compute/ecsRolloutBarrierLambda.source.cjs dist/lib/resources/aws/compute/ecsRolloutBarrierLambda.source.cjs && cp lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs && cp lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs && cp lib/patterns/aws/devSubstrate.waker.source.cjs dist/lib/patterns/aws/devSubstrate.waker.source.cjs && node ../../scripts/stamp-build.mjs",
44
44
  "prepack": "node ../../scripts/check-dist-freshness.mjs",
45
45
  "prepublishOnly": "node ../../scripts/audit-package-tarball.mjs",
46
46
  "watch": "tsc -w",
@@ -65,6 +65,7 @@
65
65
  "format:check": "prettier --check \"lib/**/*.{ts,tsx,js,jsx,json}\" \"scripts/**/*.mts\" \"deploy-tests/**/*.mts\""
66
66
  },
67
67
  "devDependencies": {
68
+ "@aws-sdk/client-ecs": "^3.1098.0",
68
69
  "@aws-sdk/client-elastic-load-balancing-v2": "^3.1098.0",
69
70
  "@aws-sdk/client-iam": "^3.1098.0",
70
71
  "@aws-sdk/client-identitystore": "^3.1098.0",
@@ -80,8 +81,8 @@
80
81
  },
81
82
  "dependencies": {
82
83
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^39.0.5",
84
- "@fjall/util": "^39.0.5",
84
+ "@fjall/generator": "^39.0.6",
85
+ "@fjall/util": "^39.0.6",
85
86
  "constructs": "^10.7.2",
86
87
  "zod": "^4.4.3"
87
88
  },