@evident-ai/runner-cdk 3.5.2-dev.a72611d → 3.5.2-dev.bbd799b

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 (36) hide show
  1. package/README.md +91 -40
  2. package/dist/controller-lambda/handler.js +74 -79
  3. package/dist/evident-scale-to-zero-construct.js +7 -0
  4. package/dist/image-version-pruner-lambda/handler.js +28250 -0
  5. package/dist/image-version-reporter-lambda/handler.js +15 -8
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/microvm/constants.d.ts +1 -1
  9. package/dist/microvm/constants.js +7 -4
  10. package/dist/microvm/construct.d.ts +12 -8
  11. package/dist/microvm/construct.js +42 -33
  12. package/dist/microvm/controller/{doorbell.d.ts → doorbell-request.d.ts} +6 -6
  13. package/dist/microvm/controller/{doorbell.js → doorbell-request.js} +2 -2
  14. package/dist/microvm/controller/handle-doorbell.d.ts +2 -2
  15. package/dist/microvm/controller/handle-doorbell.js +62 -62
  16. package/dist/microvm/image/stage-context.d.ts +13 -38
  17. package/dist/microvm/image/stage-context.js +31 -118
  18. package/dist/microvm/image-version-pruner/construct.d.ts +13 -0
  19. package/dist/microvm/image-version-pruner/construct.js +79 -0
  20. package/dist/microvm/image-version-pruner/handler.d.ts +20 -0
  21. package/dist/microvm/image-version-pruner/handler.js +116 -0
  22. package/dist/microvm/image-version-reporter/construct.d.ts +1 -1
  23. package/dist/microvm/image-version-reporter/construct.js +3 -3
  24. package/dist/microvm/image-version-reporter/handler.d.ts +2 -2
  25. package/dist/microvm/image-version-reporter/handler.js +9 -7
  26. package/dist/microvm/shapes.d.ts +10 -21
  27. package/dist/microvm/shapes.js +10 -17
  28. package/dist/waker-lambda/handler.js +3 -3
  29. package/package.json +3 -3
  30. package/dist/microvm-image-context/Dockerfile +0 -190
  31. package/dist/microvm-image-context/hook-server.js +0 -286
  32. package/dist/microvm-image-context/hooks/common.sh +0 -905
  33. package/dist/microvm-image-context/hooks/resume +0 -81
  34. package/dist/microvm-image-context/hooks/run +0 -95
  35. package/dist/microvm-image-context/hooks/suspend +0 -21
  36. package/dist/microvm-image-context/hooks/terminate +0 -36
@@ -4,7 +4,7 @@ exports.handleDoorbell = handleDoorbell;
4
4
  const node_crypto_1 = require("node:crypto");
5
5
  const sdk_1 = require("@evident/sdk");
6
6
  const constants_1 = require("../constants");
7
- const doorbell_1 = require("./doorbell");
7
+ const doorbell_request_1 = require("./doorbell-request");
8
8
  const throttle_retry_1 = require("./throttle-retry");
9
9
  const REASON = {
10
10
  invalidSignature: 'invalid_signature',
@@ -65,7 +65,7 @@ function decide({ statusCode, action, reason, state, runnerId, microvmId, starte
65
65
  }
66
66
  /**
67
67
  * `RunMicrovm`'s idempotency key. Keyed on `runner_id` plus a "generation"
68
- * marker: the dead VM's `microvm_id` on the recovery arms (`doorbell.microvmId`
68
+ * marker: the dead VM's `microvm_id` on the recovery arms (`doorbellRequest.microvmId`
69
69
  * is defined — TERMINATED/TERMINATING/not_found and the resume-failed
70
70
  * fallback), else `occurred_at` on the cold arm (no `microvm_id` yet). The id
71
71
  * advances every VM generation — `mvm-A` dies, the token keyed on it starts
@@ -92,9 +92,9 @@ function decide({ statusCode, action, reason, state, runnerId, microvmId, starte
92
92
  * that retried the same event with a different shape starts TWO concurrent
93
93
  * 8-hour VMs instead of deduping to one.
94
94
  */
95
- function clientTokenFor(doorbell) {
96
- const generation = doorbell.microvmId ?? doorbell.occurredAt;
97
- return (0, node_crypto_1.createHash)('sha256').update(`${doorbell.runnerId}:${generation}`).digest('hex');
95
+ function clientTokenFor(doorbellRequest) {
96
+ const generation = doorbellRequest.microvmId ?? doorbellRequest.occurredAt;
97
+ return (0, node_crypto_1.createHash)('sha256').update(`${doorbellRequest.runnerId}:${generation}`).digest('hex');
98
98
  }
99
99
  /**
100
100
  * MicroVM image versions are dotted decimals (`48.0`, `50.0`), so they must NOT
@@ -140,15 +140,15 @@ function compareImageVersions(a, b) {
140
140
  * unparseable all warn and return false, i.e. resume exactly as before
141
141
  * (`development-workflow.mdc`: never fail a gate on absent evidence).
142
142
  */
143
- async function shouldRecreateForNewerImage(doorbell, shape, runningVersion, microvm) {
143
+ async function shouldRecreateForNewerImage(doorbellRequest, shape, runningVersion, microvm) {
144
144
  if (runningVersion === undefined) {
145
- console.warn(`[doorbell] GetMicrovm reported no imageVersion for runner ${doorbell.runnerId}; resuming`);
145
+ console.warn(`[doorbell] GetMicrovm reported no imageVersion for runner ${doorbellRequest.runnerId}; resuming`);
146
146
  return false;
147
147
  }
148
148
  const running = parseImageVersion(runningVersion);
149
149
  if (running === undefined) {
150
150
  console.warn(`[doorbell] unparseable running imageVersion "${runningVersion}" for runner ` +
151
- `${doorbell.runnerId}; resuming`);
151
+ `${doorbellRequest.runnerId}; resuming`);
152
152
  return false;
153
153
  }
154
154
  let latestVersion;
@@ -159,14 +159,14 @@ async function shouldRecreateForNewerImage(doorbell, shape, runningVersion, micr
159
159
  latestVersion = await microvm.latestImageVersion(shape.imageArn);
160
160
  }
161
161
  catch (error) {
162
- console.warn(`[doorbell] GetMicrovmImage failed for runner ${doorbell.runnerId}; resuming on the ` +
162
+ console.warn(`[doorbell] GetMicrovmImage failed for runner ${doorbellRequest.runnerId}; resuming on the ` +
163
163
  `running image ${runningVersion}: ${error instanceof Error ? error.message : String(error)}`);
164
164
  return false;
165
165
  }
166
166
  const latest = latestVersion === undefined ? undefined : parseImageVersion(latestVersion);
167
167
  if (latest === undefined) {
168
168
  console.warn(`[doorbell] no comparable latest image version (${latestVersion ?? 'none'}) for runner ` +
169
- `${doorbell.runnerId}; resuming on the running image ${runningVersion}`);
169
+ `${doorbellRequest.runnerId}; resuming on the running image ${runningVersion}`);
170
170
  return false;
171
171
  }
172
172
  // Strictly below, not merely different: a running version ABOVE the latest
@@ -174,15 +174,15 @@ async function shouldRecreateForNewerImage(doorbell, shape, runningVersion, micr
174
174
  // been deactivated, and recreating would not roll it forward. Resume.
175
175
  return compareImageVersions(running, latest) < 0;
176
176
  }
177
- async function handleDoorbell({ rawBody, signatureHeader, doorbellSecret, shapes, microvm, sleep, random, }) {
178
- if (!(0, sdk_1.verifyEvidentSignature)(rawBody, signatureHeader, doorbellSecret)) {
177
+ async function handleDoorbell({ rawBody, signatureHeader, controllerSigningSecret, shapes, microvm, sleep, random, }) {
178
+ if (!(0, sdk_1.verifyEvidentSignature)(rawBody, signatureHeader, controllerSigningSecret)) {
179
179
  return decide({
180
180
  statusCode: 401,
181
181
  action: 'rejected',
182
182
  reason: REASON.invalidSignature,
183
183
  });
184
184
  }
185
- const parsed = (0, doorbell_1.parseDoorbell)(rawBody);
185
+ const parsed = (0, doorbell_request_1.parseDoorbellRequest)(rawBody);
186
186
  if (!parsed.ok) {
187
187
  return decide({
188
188
  statusCode: 400,
@@ -190,10 +190,10 @@ async function handleDoorbell({ rawBody, signatureHeader, doorbellSecret, shapes
190
190
  reason: parsed.reason,
191
191
  });
192
192
  }
193
- const doorbell = parsed.doorbell;
193
+ const doorbellRequest = parsed.doorbell;
194
194
  const timing = { sleep, random };
195
- if (doorbell.type === doorbell_1.SHAPES_EVENT_TYPE) {
196
- // A pure read of deploy-time metadata — see doorbell.ts's header comment
195
+ if (doorbellRequest.type === doorbell_request_1.SHAPES_EVENT_TYPE) {
196
+ // A pure read of deploy-time metadata — see doorbell-request.ts's header comment
197
197
  // (D2) for why replay is deliberately undefended here.
198
198
  return decide({
199
199
  statusCode: 200,
@@ -202,46 +202,46 @@ async function handleDoorbell({ rawBody, signatureHeader, doorbellSecret, shapes
202
202
  shapes: shapes.advertise(),
203
203
  });
204
204
  }
205
- // Resolve the requested shape immediately after parseDoorbell/the describe
205
+ // Resolve the requested shape immediately after parseDoorbellRequest/the describe
206
206
  // arm — BEFORE the suspend_requested branch and the whole state machine
207
207
  // (D5). Resolving lazily inside runMicrovm would return 200 and silently
208
208
  // ignore a removed shape on the RUNNING/PENDING no-op path and the
209
209
  // successful-resume path, since runMicrovm is never reached on either. A
210
210
  // suspend doorbell has no `shape` field, so nothing here runs for it.
211
- if (doorbell.type !== 'runner.suspend_requested') {
212
- const resolvedShape = shapes.resolve(doorbell.shape);
211
+ if (doorbellRequest.type !== 'runner.suspend_requested') {
212
+ const resolvedShape = shapes.resolve(doorbellRequest.shape);
213
213
  if (resolvedShape === undefined) {
214
214
  // 400, not 404/409: a redelivery of the identical body cannot fix this.
215
215
  // Never echo the requested shape name — the closed-constant rule
216
- // (doorbell.ts's DOORBELL_REJECTION) applies to every reason here too,
216
+ // (doorbell-request.ts's DOORBELL_REJECTION) applies to every reason here too,
217
217
  // and Evident persists response_body.
218
218
  return decide({
219
219
  statusCode: 400,
220
220
  action: 'rejected',
221
221
  reason: REASON.unknownShape,
222
- runnerId: doorbell.runnerId,
223
- microvmId: doorbell.microvmId,
222
+ runnerId: doorbellRequest.runnerId,
223
+ microvmId: doorbellRequest.microvmId,
224
224
  });
225
225
  }
226
- return handleWakeDoorbell(doorbell, resolvedShape, microvm, timing);
226
+ return handleWakeDoorbell(doorbellRequest, resolvedShape, microvm, timing);
227
227
  }
228
- return suspendMicrovm(doorbell, microvm, timing);
228
+ return suspendMicrovm(doorbellRequest, microvm, timing);
229
229
  }
230
- async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
231
- if (doorbell.microvmId === undefined) {
232
- return runMicrovm(doorbell, shape, microvm, timing, false, REASON.noMicrovmId);
230
+ async function handleWakeDoorbell(doorbellRequest, shape, microvm, timing) {
231
+ if (doorbellRequest.microvmId === undefined) {
232
+ return runMicrovm(doorbellRequest, shape, microvm, timing, false, REASON.noMicrovmId);
233
233
  }
234
234
  let described;
235
235
  let polled = false;
236
236
  try {
237
- described = await microvm.describe(doorbell.microvmId);
237
+ described = await microvm.describe(doorbellRequest.microvmId);
238
238
  if (described.state === 'SUSPENDING') {
239
- described = await pollWhileSuspending(doorbell.microvmId, microvm, timing.sleep);
239
+ described = await pollWhileSuspending(doorbellRequest.microvmId, microvm, timing.sleep);
240
240
  polled = true;
241
241
  }
242
242
  }
243
243
  catch (error) {
244
- console.error(`[doorbell] GetMicrovm failed for runner ${doorbell.runnerId}: ` +
244
+ console.error(`[doorbell] GetMicrovm failed for runner ${doorbellRequest.runnerId}: ` +
245
245
  `${error instanceof Error ? error.message : String(error)}`);
246
246
  // `polled` is omitted here, not passed as `false`: it is only assigned
247
247
  // `true` after `pollWhileSuspending` returns, so a throw mid-poll would
@@ -250,8 +250,8 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
250
250
  statusCode: 500,
251
251
  action: 'none',
252
252
  reason: REASON.getStateFailed,
253
- runnerId: doorbell.runnerId,
254
- microvmId: doorbell.microvmId,
253
+ runnerId: doorbellRequest.runnerId,
254
+ microvmId: doorbellRequest.microvmId,
255
255
  });
256
256
  }
257
257
  const state = described.state;
@@ -264,8 +264,8 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
264
264
  action: 'none',
265
265
  reason: REASON.suspendingPollExhausted,
266
266
  state,
267
- runnerId: doorbell.runnerId,
268
- microvmId: doorbell.microvmId,
267
+ runnerId: doorbellRequest.runnerId,
268
+ microvmId: doorbellRequest.microvmId,
269
269
  polled,
270
270
  });
271
271
  }
@@ -279,8 +279,8 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
279
279
  action: 'none',
280
280
  reason: state === 'RUNNING' ? REASON.alreadyRunning : REASON.alreadyStarting,
281
281
  state,
282
- runnerId: doorbell.runnerId,
283
- microvmId: doorbell.microvmId,
282
+ runnerId: doorbellRequest.runnerId,
283
+ microvmId: doorbellRequest.microvmId,
284
284
  polled,
285
285
  startedAt: described.startedAt,
286
286
  imageVersion: described.imageVersion,
@@ -290,15 +290,15 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
290
290
  // request (#1906) can still set the same wire flag. Recreate loses VM-local
291
291
  // filesystem state; durable provider credentials survive in object storage,
292
292
  // but a manual paste/upload not yet synced there may be lost (#2071).
293
- if (doorbell.recreateOnOutdatedImage === true &&
294
- (await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm))) {
295
- return runMicrovm(doorbell, shape, microvm, timing, polled, REASON.imageVersionOutdated, state);
293
+ if (doorbellRequest.recreateOnOutdatedImage === true &&
294
+ (await shouldRecreateForNewerImage(doorbellRequest, shape, described.imageVersion, microvm))) {
295
+ return runMicrovm(doorbellRequest, shape, microvm, timing, polled, REASON.imageVersionOutdated, state);
296
296
  }
297
- return resumeMicrovm(doorbell, doorbell.microvmId, shape, microvm, timing, polled, described.startedAt, described.imageVersion);
297
+ return resumeMicrovm(doorbellRequest, doorbellRequest.microvmId, shape, microvm, timing, polled, described.startedAt, described.imageVersion);
298
298
  default:
299
299
  // TERMINATED, TERMINATING, not_found — gone or going, so a fresh VM either
300
300
  // way. This is what self-heals a stale `microvm_id`.
301
- return runMicrovm(doorbell, shape, microvm, timing, polled, state === 'not_found' ? REASON.microvmNotFound : REASON.terminated, state);
301
+ return runMicrovm(doorbellRequest, shape, microvm, timing, polled, state === 'not_found' ? REASON.microvmNotFound : REASON.terminated, state);
302
302
  }
303
303
  }
304
304
  /**
@@ -333,13 +333,13 @@ function isClientTokenParameterMismatch(error) {
333
333
  const message = typeof e.message === 'string' ? e.message.toLowerCase() : '';
334
334
  return message.includes('clienttoken') && message.includes('different request parameters');
335
335
  }
336
- async function runMicrovm(doorbell, shape, microvm, timing, polled, reason, state) {
336
+ async function runMicrovm(doorbellRequest, shape, microvm, timing, polled, reason, state) {
337
337
  let started;
338
338
  try {
339
339
  started = await (0, throttle_retry_1.withThrottleRetry)(() => microvm.run({
340
340
  imageIdentifier: shape.imageArn,
341
- runHookPayload: doorbell.runHookPayload,
342
- clientToken: clientTokenFor(doorbell),
341
+ runHookPayload: doorbellRequest.runHookPayload,
342
+ clientToken: clientTokenFor(doorbellRequest),
343
343
  }), timing);
344
344
  }
345
345
  catch (error) {
@@ -353,7 +353,7 @@ async function runMicrovm(doorbell, shape, microvm, timing, polled, reason, stat
353
353
  // wakes.
354
354
  const name = error instanceof Error ? error.name : 'unknown';
355
355
  const message = error instanceof Error ? error.message : String(error);
356
- console.warn(`[doorbell] RunMicrovm clientToken already used for runner ${doorbell.runnerId}: ` +
356
+ console.warn(`[doorbell] RunMicrovm clientToken already used for runner ${doorbellRequest.runnerId}: ` +
357
357
  `${name}: ${message}`);
358
358
  // No `microvmId` (see the comment above), and therefore deliberately no
359
359
  // `startedAt` either: `decide`'s invariant is that `microvm_started_at`
@@ -365,18 +365,18 @@ async function runMicrovm(doorbell, shape, microvm, timing, polled, reason, stat
365
365
  action: 'run',
366
366
  reason: REASON.runAlreadyStartedForToken,
367
367
  state,
368
- runnerId: doorbell.runnerId,
368
+ runnerId: doorbellRequest.runnerId,
369
369
  polled,
370
370
  });
371
371
  }
372
- console.error(`[doorbell] RunMicrovm failed for runner ${doorbell.runnerId}: ` +
372
+ console.error(`[doorbell] RunMicrovm failed for runner ${doorbellRequest.runnerId}: ` +
373
373
  `${error instanceof Error ? error.message : String(error)}`);
374
374
  return decide({
375
375
  statusCode: 500,
376
376
  action: 'run',
377
377
  reason: REASON.runFailed,
378
378
  state,
379
- runnerId: doorbell.runnerId,
379
+ runnerId: doorbellRequest.runnerId,
380
380
  polled,
381
381
  });
382
382
  }
@@ -385,14 +385,14 @@ async function runMicrovm(doorbell, shape, microvm, timing, polled, reason, stat
385
385
  action: 'run',
386
386
  reason,
387
387
  state,
388
- runnerId: doorbell.runnerId,
388
+ runnerId: doorbellRequest.runnerId,
389
389
  microvmId: started.microvmId,
390
390
  polled,
391
391
  startedAt: started.startedAt,
392
392
  imageVersion: started.imageVersion,
393
393
  });
394
394
  }
395
- async function resumeMicrovm(doorbell, microvmId, shape, microvm, timing, polled,
395
+ async function resumeMicrovm(doorbellRequest, microvmId, shape, microvm, timing, polled,
396
396
  // Both values come from the `describe()` result that led here because
397
397
  // `ResumeMicrovm` returns no lifecycle metadata of its own.
398
398
  startedAt, imageVersion) {
@@ -400,24 +400,24 @@ startedAt, imageVersion) {
400
400
  await (0, throttle_retry_1.withThrottleRetry)(() => microvm.resume(microvmId), timing);
401
401
  }
402
402
  catch (error) {
403
- console.error(`[doorbell] ResumeMicrovm failed for runner ${doorbell.runnerId}: ` +
403
+ console.error(`[doorbell] ResumeMicrovm failed for runner ${doorbellRequest.runnerId}: ` +
404
404
  `${error instanceof Error ? error.message : String(error)}`);
405
- return runMicrovm(doorbell, shape, microvm, timing, polled, REASON.resumeFailedFallingBackToRun, 'SUSPENDED');
405
+ return runMicrovm(doorbellRequest, shape, microvm, timing, polled, REASON.resumeFailedFallingBackToRun, 'SUSPENDED');
406
406
  }
407
407
  return decide({
408
408
  statusCode: 200,
409
409
  action: 'resume',
410
410
  reason: REASON.resumed,
411
411
  state: 'SUSPENDED',
412
- runnerId: doorbell.runnerId,
412
+ runnerId: doorbellRequest.runnerId,
413
413
  microvmId,
414
414
  polled,
415
415
  startedAt,
416
416
  imageVersion,
417
417
  });
418
418
  }
419
- async function suspendMicrovm(doorbell, microvm, timing) {
420
- const microvmId = doorbell.microvmId;
419
+ async function suspendMicrovm(doorbellRequest, microvm, timing) {
420
+ const microvmId = doorbellRequest.microvmId;
421
421
  if (microvmId === undefined) {
422
422
  // Nothing to suspend, and a redelivery would not change that — 200 so the
423
423
  // doorbell is not retried, but loudly enough to notice a mis-wired sender.
@@ -425,7 +425,7 @@ async function suspendMicrovm(doorbell, microvm, timing) {
425
425
  statusCode: 200,
426
426
  action: 'none',
427
427
  reason: REASON.suspendWithoutMicrovmId,
428
- runnerId: doorbell.runnerId,
428
+ runnerId: doorbellRequest.runnerId,
429
429
  });
430
430
  }
431
431
  let state;
@@ -434,13 +434,13 @@ async function suspendMicrovm(doorbell, microvm, timing) {
434
434
  ({ state } = await microvm.describe(microvmId));
435
435
  }
436
436
  catch (error) {
437
- console.error(`[doorbell] GetMicrovm failed for runner ${doorbell.runnerId}: ` +
437
+ console.error(`[doorbell] GetMicrovm failed for runner ${doorbellRequest.runnerId}: ` +
438
438
  `${error instanceof Error ? error.message : String(error)}`);
439
439
  return decide({
440
440
  statusCode: 500,
441
441
  action: 'none',
442
442
  reason: REASON.getStateFailed,
443
- runnerId: doorbell.runnerId,
443
+ runnerId: doorbellRequest.runnerId,
444
444
  microvmId,
445
445
  });
446
446
  }
@@ -453,7 +453,7 @@ async function suspendMicrovm(doorbell, microvm, timing) {
453
453
  action: 'none',
454
454
  reason: REASON.suspendAlreadyGone,
455
455
  state,
456
- runnerId: doorbell.runnerId,
456
+ runnerId: doorbellRequest.runnerId,
457
457
  microvmId,
458
458
  });
459
459
  }
@@ -461,14 +461,14 @@ async function suspendMicrovm(doorbell, microvm, timing) {
461
461
  await (0, throttle_retry_1.withThrottleRetry)(() => microvm.suspend(microvmId), timing);
462
462
  }
463
463
  catch (error) {
464
- console.error(`[doorbell] SuspendMicrovm failed for runner ${doorbell.runnerId}: ` +
464
+ console.error(`[doorbell] SuspendMicrovm failed for runner ${doorbellRequest.runnerId}: ` +
465
465
  `${error instanceof Error ? error.message : String(error)}`);
466
466
  return decide({
467
467
  statusCode: 500,
468
468
  action: 'suspend',
469
469
  reason: REASON.suspendFailed,
470
470
  state,
471
- runnerId: doorbell.runnerId,
471
+ runnerId: doorbellRequest.runnerId,
472
472
  microvmId,
473
473
  });
474
474
  }
@@ -477,7 +477,7 @@ async function suspendMicrovm(doorbell, microvm, timing) {
477
477
  action: 'suspend',
478
478
  reason: REASON.suspended,
479
479
  state,
480
- runnerId: doorbell.runnerId,
480
+ runnerId: doorbellRequest.runnerId,
481
481
  microvmId,
482
482
  });
483
483
  }
@@ -1,42 +1,17 @@
1
- export interface StageMicrovmImageContextOptions {
2
- /**
3
- * The repository baked into the image as the agent's workspace. This is the
4
- * CONSUMER's repository, not Evident's — the image is a dev box for whatever
5
- * codebase the agent works on. Must be a full (non-shallow) clone.
6
- */
7
- readonly repositoryPath: string;
8
- /**
9
- * `origin` of the baked repository, as the VM will see it. Must be a
10
- * credential-free URL (typically `https://github.com/<owner>/<repo>.git`):
11
- * this string ships inside the shared snapshot, so a token in it would be
12
- * baked into every VM. The credential arrives per-VM instead.
13
- */
14
- readonly originUrl: string;
15
- /** Directory to write the build context to. Removed and recreated. */
16
- readonly destination: string;
17
- /**
18
- * Overlay scripts the image build runs as root and as `runner`. The overlay
19
- * ships inside the shared snapshot, so it may install software but must carry
20
- * no credential. When omitted, deterministic no-op scripts are written.
21
- */
22
- readonly overlayDir?: string;
23
- /**
24
- * The published template to copy from. Defaults to the one inside this
25
- * package; named directly by the tests, which assert against a template they
26
- * built themselves rather than whichever `dist/` happens to be present.
27
- */
28
- readonly templateDir?: string;
1
+ /** The fixed directory that contains the workspace in a staged build context. */
2
+ export declare const MICROVM_WORKSPACE_DIRECTORY = "workspace";
3
+ export interface StageMicrovmBuildContextOptions {
4
+ /** The customer's directory containing their Dockerfile and COPY sources. */
5
+ readonly buildContextPath: string;
6
+ /** A full local checkout to bake into the image's `workspace/` directory. */
7
+ readonly workspaceRepositoryPath: string;
8
+ /** Credential-free origin URL carried by the baked checkout. */
9
+ readonly workspaceOriginUrl: string;
29
10
  }
30
11
  /**
31
- * Writes the build context AWS unpacks — the Dockerfile, the hook server, the
32
- * per-phase hook scripts, the deployment overlay and the repository — and
33
- * returns its path. The result is what `EvidentMicrovmConstruct`'s `imageSource`
34
- * takes.
12
+ * Stages customer-owned image inputs in a fresh temporary directory.
35
13
  *
36
- * The Dockerfile, hook server and phase hooks come from this package's
37
- * published `dist/`, so a consumer needs no checkout of the source repository and no
38
- * copy of those files (#1528). A caller may supply an overlay; the default is
39
- * deterministic no-op scripts. `repositoryPath` is the only required caller
40
- * input.
14
+ * Staging keeps the image asset hash a pure function of the workspace commit while
15
+ * leaving the customer in control of the Dockerfile and its build context.
41
16
  */
42
- export declare function stageMicrovmImageContext(options: StageMicrovmImageContextOptions): string;
17
+ export declare function stageMicrovmBuildContext(options: StageMicrovmBuildContextOptions): string;
@@ -33,143 +33,56 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.stageMicrovmImageContext = stageMicrovmImageContext;
36
+ exports.MICROVM_WORKSPACE_DIRECTORY = void 0;
37
+ exports.stageMicrovmBuildContext = stageMicrovmBuildContext;
37
38
  const node_child_process_1 = require("node:child_process");
38
39
  const node_fs_1 = require("node:fs");
40
+ const node_os_1 = require("node:os");
39
41
  const path = __importStar(require("node:path"));
40
- /**
41
- * The image build context template published inside this package.
42
- *
43
- * Resolved from the PACKAGE ROOT, THREE directories above `__dirname` — either
44
- * compiled (`dist/microvm/image`) or via ts-node (`src/microvm/image`) — the
45
- * same way `microvm/construct.ts` resolves the controller Lambda asset, but one
46
- * `..` deeper because this file sits a directory below it. It is
47
- * written by `scripts/build.ts` at PACKAGE build time, not at stage time, so a
48
- * consumer needs no esbuild and no checkout of this repo (#1528).
49
- */
50
- const TEMPLATE_DIR = path.resolve(__dirname, '..', '..', '..', 'dist', 'microvm-image-context');
42
+ /** The fixed directory that contains the workspace in a staged build context. */
43
+ exports.MICROVM_WORKSPACE_DIRECTORY = 'workspace';
51
44
  function git(args, cwd) {
52
45
  return (0, node_child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
53
46
  }
54
- /**
55
- * `git rev-parse --is-shallow-repository` throws an opaque `execFileSync`
56
- * error (e.g. "not a git repository") when `repositoryPath` isn't usable at
57
- * all, so that failure is rethrown naming the path.
58
- */
59
- function isShallowRepository(repositoryPath) {
47
+ /** Names the path when git cannot determine whether the repository is shallow. */
48
+ function isShallowRepository(sourcePath) {
60
49
  try {
61
- return git(['rev-parse', '--is-shallow-repository'], repositoryPath) === 'true';
50
+ return git(['rev-parse', '--is-shallow-repository'], sourcePath) === 'true';
62
51
  }
63
52
  catch (err) {
64
- throw new Error(`${repositoryPath} is not a usable git repository: ` +
53
+ throw new Error(`${sourcePath} is not a usable git repository: ` +
65
54
  `${err instanceof Error ? err.message : String(err)}`);
66
55
  }
67
56
  }
68
57
  /**
69
- * Clones the caller's repository into the build context for the Dockerfile to
70
- * bake as a checked-out but uninstalled workspace.
71
- *
72
- * `file://` rather than a plain path: a path triggers git's local-clone
73
- * optimisation, which copies the whole object store — every branch and worktree
74
- * ref on the staging machine (50 MB here). The file transport negotiates like a
75
- * network fetch and packs only what the checked-out branch reaches (9 MB), with
76
- * one branch and one remote-tracking ref. Ignored files, `node_modules` first
77
- * among them, are not part of a clone at all.
58
+ * Clones a single branch with full history while normalising bytes that affect the image asset hash.
59
+ * Its remote refspec covers only that branch, so other branches need an explicit refspec.
78
60
  */
79
- function stageRepository(repositoryPath, destination, originUrl) {
80
- if (isShallowRepository(repositoryPath)) {
81
- throw new Error(`refusing to stage a shallow checkout (${repositoryPath}): the agent branches, ` +
61
+ function stageRepository(sourcePath, stagedWorkspacePath, workspaceOriginUrl) {
62
+ if (isShallowRepository(sourcePath)) {
63
+ throw new Error(`refusing to stage a shallow checkout (${sourcePath}): the agent branches, ` +
82
64
  'commits and opens PRs, so the image needs real history. Run `git fetch --unshallow`.');
83
65
  }
84
- // Absolute on both sides: `file://` needs an absolute path to mean anything,
85
- // and the clone's cwd is the destination's parent (the only directory both
86
- // are guaranteed to resolve against).
87
- git(['clone', '--quiet', '--single-branch', `file://${repositoryPath}`, destination], path.dirname(destination));
88
- // The clone points `origin` at this machine's filesystem; the VM's origin is
89
- // the caller's own remote over HTTPS, with no credential in it.
90
- git(['remote', 'set-url', 'origin', originUrl], destination);
91
- // The asset hash of the resulting image must be a pure function of the commit,
92
- // or every deploy publishes a new `AWS::Lambda::MicrovmImage` version and wedges
93
- // the stack at its per-image version ceiling. Two things break that purity:
94
- // - `pack-objects`' delta search is multithreaded (`pack.threads` defaults to
95
- // the CPU count) and its output varies run to run; `-f`/`-F`
96
- // (`--no-reuse-delta`/`--no-reuse-object`) additionally stop it from reusing
97
- // whatever packing the source checkout happened to have. All three together
98
- // make the pack a pure function of the object set, independent of the source.
99
- // - `.git/index` carries stat data (inode/mtime/size) and `.git/logs/**` carries
100
- // wall-clock reflog timestamps; neither is byte-stable for a fixed commit.
101
- git(['-c', 'pack.threads=1', 'repack', '-adfqF'], destination);
102
- (0, node_fs_1.rmSync)(path.join(destination, '.git', 'index'), { force: true });
103
- (0, node_fs_1.rmSync)(path.join(destination, '.git', 'logs'), { recursive: true, force: true });
104
- console.log(`[stage] repo ${git(['rev-parse', 'HEAD'], destination)}`);
105
- }
106
- function stageScriptDirectory(source, destination) {
107
- (0, node_fs_1.mkdirSync)(destination);
108
- for (const entry of (0, node_fs_1.readdirSync)(source, { withFileTypes: true })) {
109
- const sourcePath = path.join(source, entry.name);
110
- const staged = path.join(destination, entry.name);
111
- if (entry.isDirectory()) {
112
- stageScriptDirectory(sourcePath, staged);
113
- continue;
114
- }
115
- (0, node_fs_1.copyFileSync)(sourcePath, staged);
116
- // Set modes here rather than inheriting them from the template, so they stay
117
- // correct after an npm pack, zip, CI-cache or hand-copy round trip. The image
118
- // executes extensionless scripts and sources `.sh` files.
119
- (0, node_fs_1.chmodSync)(staged, entry.name.endsWith('.sh') ? 0o644 : 0o755);
120
- }
66
+ git(['clone', '--quiet', '--single-branch', `file://${sourcePath}`, stagedWorkspacePath], path.dirname(stagedWorkspacePath));
67
+ git(['remote', 'set-url', 'origin', workspaceOriginUrl], stagedWorkspacePath);
68
+ git(['-c', 'pack.threads=1', 'repack', '-adfqF'], stagedWorkspacePath);
69
+ (0, node_fs_1.rmSync)(path.join(stagedWorkspacePath, '.git', 'index'), { force: true });
70
+ (0, node_fs_1.rmSync)(path.join(stagedWorkspacePath, '.git', 'logs'), { recursive: true, force: true });
71
+ console.log(`[stage] repo ${git(['rev-parse', 'HEAD'], stagedWorkspacePath)}`);
121
72
  }
122
73
  /**
123
- * Writes the build context AWS unpacks — the Dockerfile, the hook server, the
124
- * per-phase hook scripts, the deployment overlay and the repository — and
125
- * returns its path. The result is what `EvidentMicrovmConstruct`'s `imageSource`
126
- * takes.
74
+ * Stages customer-owned image inputs in a fresh temporary directory.
127
75
  *
128
- * The Dockerfile, hook server and phase hooks come from this package's
129
- * published `dist/`, so a consumer needs no checkout of the source repository and no
130
- * copy of those files (#1528). A caller may supply an overlay; the default is
131
- * deterministic no-op scripts. `repositoryPath` is the only required caller
132
- * input.
76
+ * Staging keeps the image asset hash a pure function of the workspace commit while
77
+ * leaving the customer in control of the Dockerfile and its build context.
133
78
  */
134
- function stageMicrovmImageContext(options) {
135
- const { originUrl, overlayDir, templateDir = TEMPLATE_DIR } = options;
136
- // `file://` and the clone below only mean anything against absolute paths,
137
- // and a caller may reasonably pass either.
138
- const repositoryPath = path.resolve(options.repositoryPath);
139
- const destination = path.resolve(options.destination);
140
- if (!(0, node_fs_1.existsSync)(templateDir)) {
141
- throw new Error(`the MicroVM image context template is missing from ${templateDir}. It is written by ` +
142
- "this package's build (`pnpm --filter @evident-ai/runner-cdk build`) and ships in the " +
143
- 'published tarball, so an installed copy should always have it.');
144
- }
145
- (0, node_fs_1.rmSync)(destination, { recursive: true, force: true });
146
- (0, node_fs_1.mkdirSync)(destination, { recursive: true });
147
- (0, node_fs_1.copyFileSync)(path.join(templateDir, 'Dockerfile'), path.join(destination, 'Dockerfile'));
148
- (0, node_fs_1.copyFileSync)(path.join(templateDir, 'hook-server.js'), path.join(destination, 'hook-server.js'));
149
- const hooksSource = path.join(templateDir, 'hooks');
150
- const hooksStage = path.join(destination, 'hooks');
151
- stageScriptDirectory(hooksSource, hooksStage);
152
- const overlayStage = path.join(destination, 'overlay');
153
- if (overlayDir === undefined) {
154
- (0, node_fs_1.mkdirSync)(overlayStage);
155
- for (const name of ['setup-root', 'setup-workspace']) {
156
- const staged = path.join(overlayStage, name);
157
- (0, node_fs_1.writeFileSync)(staged, '#!/usr/bin/env bash\n# No overlay supplied.\n', { mode: 0o755 });
158
- (0, node_fs_1.chmodSync)(staged, 0o755);
159
- }
160
- }
161
- else {
162
- const resolvedOverlayDir = path.resolve(overlayDir);
163
- if (!(0, node_fs_1.existsSync)(resolvedOverlayDir)) {
164
- throw new Error(`overlay directory does not exist: ${resolvedOverlayDir}`);
165
- }
166
- for (const required of ['setup-root', 'setup-workspace']) {
167
- if (!(0, node_fs_1.existsSync)(path.join(resolvedOverlayDir, required))) {
168
- throw new Error(`overlay directory ${resolvedOverlayDir} is missing required script ${required}`);
169
- }
170
- }
171
- stageScriptDirectory(resolvedOverlayDir, overlayStage);
79
+ function stageMicrovmBuildContext(options) {
80
+ const buildContextPath = path.resolve(options.buildContextPath);
81
+ if (!(0, node_fs_1.existsSync)(buildContextPath) || !(0, node_fs_1.existsSync)(path.join(buildContextPath, 'Dockerfile'))) {
82
+ throw new Error(`MicroVM build context ${buildContextPath} does not exist or has no Dockerfile`);
172
83
  }
173
- stageRepository(repositoryPath, path.join(destination, 'repo'), originUrl);
174
- return destination;
84
+ const stagedPath = (0, node_fs_1.mkdtempSync)(path.join((0, node_os_1.tmpdir)(), 'evident-microvm-'));
85
+ (0, node_fs_1.cpSync)(buildContextPath, stagedPath, { recursive: true });
86
+ stageRepository(path.resolve(options.workspaceRepositoryPath), path.join(stagedPath, exports.MICROVM_WORKSPACE_DIRECTORY), options.workspaceOriginUrl);
87
+ return stagedPath;
175
88
  }
@@ -0,0 +1,13 @@
1
+ import { Construct } from 'constructs';
2
+ import type { MicrovmImageBuildInputs } from '@evident-ai/lambda-microvm-cdk';
3
+ export type ImageVersionPrunerProps = {
4
+ readonly imageArns: readonly string[];
5
+ /**
6
+ * Build inputs are a CloudFormation-visible invocation trigger. The build role
7
+ * ARN is excluded because it belongs to the image subtree and would create a cycle.
8
+ */
9
+ readonly buildTriggers: Record<string, Omit<MicrovmImageBuildInputs, 'buildRoleArn'>>;
10
+ };
11
+ export declare class ImageVersionPruner extends Construct {
12
+ constructor(scope: Construct, id: string, props: ImageVersionPrunerProps);
13
+ }