@evident-ai/runner-cdk 3.4.1-dev.2f1b44b → 3.4.1-dev.38181ff

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.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Reusable AWS CDK constructs for running an [Evident](https://evident.run) agent
11
11
  - **`EvidentMicrovmConstruct`** — a per-session AWS Lambda MicroVM that boots on demand
12
12
  and suspends between messages. Installing the `@dev` tag of this package is all you
13
13
  need: the image build context ships inside it (see [The MicroVM image](#the-microvm-image)
14
- below), so there is no checkout of `sroze/evident` involved. See
14
+ below), so there is no checkout of the source repository involved. See
15
15
  [the AWS runner doc](https://evident.run/docs/aws-runner) for the full picture of both
16
16
  strategies.
17
17
 
@@ -76,8 +76,8 @@ agent.selfStopRoleArn;
76
76
  agent.replicaPrefix;
77
77
  ```
78
78
 
79
- For a complete working example, see `infrastructure/evident-runner/` in this repo — it is
80
- the construct's own dogfooding consumer.
79
+ For a complete working example, see [`GETTING-STARTED.md`](./GETTING-STARTED.md) in this
80
+ package — it is the construct's own dogfooding consumer.
81
81
 
82
82
  ## Props
83
83
 
@@ -121,10 +121,11 @@ context, which `cdk deploy` zips and uploads. The Lambda MicroVM service then bu
121
121
  image in *your* account, from a base image ARN you discover there — so, unlike the Fargate
122
122
  strategy, there is no registry image to pull.
123
123
 
124
- The context has two halves, and this package ships the half that is ours: the Dockerfile,
125
- the per-phase hook scripts and the bundled hook server, published inside the tarball at
126
- `dist/microvm-image-context/`. The other half is **your** repository, which is baked in as
127
- the agent's workspace. `stageMicrovmImageContext()` puts the two together:
124
+ The context has three parts: this package supplies the Dockerfile, the per-phase hook
125
+ scripts and the bundled hook server, published inside the tarball at
126
+ `dist/microvm-image-context/`; **your** repository is baked in as the agent's workspace;
127
+ and an optional overlay supplies deployment-specific build steps. `stageMicrovmImageContext()`
128
+ puts them together:
128
129
 
129
130
  ```ts
130
131
  import path from 'node:path';
@@ -143,6 +144,8 @@ new EvidentMicrovmConstruct(this, 'Runner', {
143
144
  // token the agent pushes with arrives per-session, never baked in.
144
145
  originUrl: 'https://github.com/acme/widgets.git',
145
146
  destination: path.join(__dirname, '..', 'build', 'image'),
147
+ // Optional deployment-specific installs and build warm-up.
148
+ overlayDir: '/path/to/microvm-overlay',
146
149
  }),
147
150
  // Must match the published image these were baked into, so export them
148
151
  // rather than restating the numbers.
@@ -156,6 +159,11 @@ new EvidentMicrovmConstruct(this, 'Runner', {
156
159
  });
157
160
  ```
158
161
 
162
+ Pass `overlayDir` to add deployment-specific build steps to the staged image. The
163
+ directory provides `setup-root` for root-owned installs and `setup-workspace` for
164
+ workspace build warm-up after dependency installation. See the [MicroVM image
165
+ README](../../runner/docker-images/microvm/README.md) for the full overlay contract.
166
+
159
167
  Your repo does not have to be a pnpm workspace. If it commits a `pnpm-lock.yaml` the image
160
168
  pre-installs dependencies at build time (a faster first boot); if not, that step is skipped
161
169
  and the agent installs on first use.
@@ -164,7 +172,14 @@ and the agent installs on first use.
164
172
  | --- | --- |
165
173
  | `runnerSecret` | No GitHub or MCP credentials are exported at `/run`. When supplied, `/run` reads the JSON secret and exports every non-empty value whose key is a valid environment-variable name. |
166
174
  | `runnerOpencodeConfigPath` | OpenCode uses the baked project configuration. Relative paths resolve from the workspace; absolute paths resolve in the image. |
175
+ | `extraImageEnvironment` | Non-secret values baked into every VM launched from this image version; every VM can read them, so never put secrets here. |
167
176
  | `gitUserName` / `gitUserEmail` | The hook uses the Evident bot defaults for git identity. |
177
+ | `evidentApiUrl` / `evidentRunnerId` | No deploy-time report of the published image version — a byte-identical template to not having these props at all. Both non-secret (a URL, a public pool UUID) and both required together: set them if you have an Evident runner id configured for this provisioner, so each deploy that publishes a new image version tells Evident about it (a CloudFormation custom resource, HMAC-signed with `doorbellSecret`). A failure to report never fails your deploy — it warns and Evident reads that provisioner as *unknown*, never as up to date or stale. |
178
+
179
+ `extraImageEnvironment` is the runtime counterpart to an overlay. Use it when an
180
+ overlay-installed component needs an environment variable after the VM boots. The
181
+ values are baked into every VM launched from that image version and are readable by
182
+ all of them, so never put a secret in this prop.
168
183
 
169
184
  ## Status / limitations
170
185
 
@@ -172,7 +187,6 @@ and the agent installs on first use.
172
187
  `npm install @evident-ai/runner-cdk@dev` (or `pnpm add`/`yarn add`); no checkout of
173
188
  this repo is required. It builds to a self-contained `dist/` (`pnpm --filter
174
189
  @evident-ai/runner-cdk build`) with no `workspace:`/`@evident/*` runtime dependency.
175
- This repo's own consumer (`infrastructure/evident-runner`) still consumes it as a
176
- `workspace:*` dependency for dogfooding.
190
+ Evident's own consumer still consumes it as a `workspace:*` dependency for dogfooding.
177
191
  - **AWS/ECS-specific.** Non-AWS clouds are an open question on the epic, not a supported
178
192
  path.
@@ -49854,7 +49854,8 @@ function parseDoorbell(rawBody) {
49854
49854
  occurred_at: occurredAt,
49855
49855
  microvm_id: microvmId,
49856
49856
  run_payload: runPayload,
49857
- shape
49857
+ shape,
49858
+ recreate_on_outdated_image: recreateOnOutdatedImage
49858
49859
  } = body;
49859
49860
  if (type !== SUSPEND_EVENT_TYPE && type !== SHAPES_EVENT_TYPE && !isWakeType(type)) {
49860
49861
  return { ok: false, reason: DOORBELL_REJECTION.unsupportedType };
@@ -49885,10 +49886,14 @@ function parseDoorbell(rawBody) {
49885
49886
  if (Buffer.byteLength(runHookPayload, "utf8") > RUN_HOOK_PAYLOAD_MAX_BYTES) {
49886
49887
  return { ok: false, reason: DOORBELL_REJECTION.runPayloadTooLarge };
49887
49888
  }
49888
- return {
49889
- ok: true,
49890
- doorbell: shape === void 0 ? { ...fields, type, runHookPayload } : { ...fields, type, shape, runHookPayload }
49891
- };
49889
+ const doorbell = { ...fields, type, runHookPayload };
49890
+ if (shape !== void 0) {
49891
+ doorbell.shape = shape;
49892
+ }
49893
+ if (recreateOnOutdatedImage === true) {
49894
+ doorbell.recreateOnOutdatedImage = true;
49895
+ }
49896
+ return { ok: true, doorbell };
49892
49897
  }
49893
49898
 
49894
49899
  // src/microvm/controller/throttle-retry.ts
@@ -49949,6 +49954,7 @@ function decide({
49949
49954
  runnerId,
49950
49955
  microvmId,
49951
49956
  startedAt,
49957
+ imageVersion,
49952
49958
  shapes,
49953
49959
  polled
49954
49960
  }) {
@@ -49966,6 +49972,7 @@ function decide({
49966
49972
  reason,
49967
49973
  ...microvmId === void 0 ? {} : { microvm_id: microvmId },
49968
49974
  ...startedAt === void 0 ? {} : { microvm_started_at: startedAt.toISOString() },
49975
+ ...imageVersion === void 0 ? {} : { image_version: imageVersion },
49969
49976
  ...shapes === void 0 ? {} : { shapes },
49970
49977
  ...polled === true ? { polled: true } : {}
49971
49978
  }),
@@ -50122,10 +50129,11 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
50122
50129
  runnerId: doorbell.runnerId,
50123
50130
  microvmId: doorbell.microvmId,
50124
50131
  polled,
50125
- startedAt: described.startedAt
50132
+ startedAt: described.startedAt,
50133
+ imageVersion: described.imageVersion
50126
50134
  });
50127
50135
  case "SUSPENDED":
50128
- if (await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm)) {
50136
+ if (doorbell.recreateOnOutdatedImage === true && await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm)) {
50129
50137
  return runMicrovm(
50130
50138
  doorbell,
50131
50139
  shape,
@@ -50143,7 +50151,8 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
50143
50151
  microvm,
50144
50152
  timing,
50145
50153
  polled,
50146
- described.startedAt
50154
+ described.startedAt,
50155
+ described.imageVersion
50147
50156
  );
50148
50157
  default:
50149
50158
  return runMicrovm(
@@ -50220,10 +50229,11 @@ async function runMicrovm(doorbell, shape, microvm, timing, polled, reason, stat
50220
50229
  runnerId: doorbell.runnerId,
50221
50230
  microvmId: started.microvmId,
50222
50231
  polled,
50223
- startedAt: started.startedAt
50232
+ startedAt: started.startedAt,
50233
+ imageVersion: started.imageVersion
50224
50234
  });
50225
50235
  }
50226
- async function resumeMicrovm(doorbell, microvmId, shape, microvm, timing, polled, startedAt) {
50236
+ async function resumeMicrovm(doorbell, microvmId, shape, microvm, timing, polled, startedAt, imageVersion) {
50227
50237
  try {
50228
50238
  await withThrottleRetry(() => microvm.resume(microvmId), timing);
50229
50239
  } catch (error3) {
@@ -50248,7 +50258,8 @@ async function resumeMicrovm(doorbell, microvmId, shape, microvm, timing, polled
50248
50258
  runnerId: doorbell.runnerId,
50249
50259
  microvmId,
50250
50260
  polled,
50251
- startedAt
50261
+ startedAt,
50262
+ imageVersion
50252
50263
  });
50253
50264
  }
50254
50265
  async function suspendMicrovm(doorbell, microvm, timing) {
@@ -50443,7 +50454,7 @@ function createRuntimeMicrovm(executionRoleArn) {
50443
50454
  return latestActiveImageVersion;
50444
50455
  },
50445
50456
  async run({ imageIdentifier, runHookPayload, clientToken }) {
50446
- const { microvmId, startedAt } = await microvms.send(
50457
+ const { microvmId, startedAt, imageVersion } = await microvms.send(
50447
50458
  new import_client_lambda_microvms.RunMicrovmCommand({
50448
50459
  imageIdentifier,
50449
50460
  executionRoleArn,
@@ -50476,7 +50487,7 @@ function createRuntimeMicrovm(executionRoleArn) {
50476
50487
  if (!microvmId) {
50477
50488
  throw new Error("RunMicrovm returned no microvmId");
50478
50489
  }
50479
- return { microvmId, startedAt };
50490
+ return { microvmId, startedAt, imageVersion };
50480
50491
  },
50481
50492
  async resume(microvmId) {
50482
50493
  await microvms.send(new import_client_lambda_microvms.ResumeMicrovmCommand({ microvmIdentifier: microvmId }));
@@ -17,7 +17,7 @@ export type EvidentScaleToZeroConstructProps = {
17
17
  * (`agents/<id>`) and is the single expected `agent_id` the waker checks.
18
18
  */
19
19
  evidentAgentId: string;
20
- /** GitHub `owner/repo` the agent clones and works on (e.g. `sroze/evident`). */
20
+ /** GitHub `owner/repo` the agent clones and works on (e.g. `my-org/my-repo`). */
21
21
  gitRepo: string;
22
22
  /** Branch to clone. Defaults to `main` when omitted. */
23
23
  gitBranch?: string;
@@ -168,9 +168,8 @@ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
168
168
  cluster,
169
169
  taskDefinition,
170
170
  serviceName,
171
- // Starts at 1; the runner self-scales to 0 on idle. A deploy resets this to
172
- // 1 (CFN), waking a sleeping agent that then re-naps (benign).
173
- desiredCount: 1,
171
+ // Deliberately omit DesiredCount: CloudFormation leaves the existing service's
172
+ // self-stop/waker count alone; either 1 or 0 would re-assert it on every deploy.
174
173
  enableExecuteCommand: true,
175
174
  assignPublicIp: true,
176
175
  vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/microvm/image-version-reporter/handler.ts
21
+ var handler_exports = {};
22
+ __export(handler_exports, {
23
+ handleImageVersionReport: () => handleImageVersionReport,
24
+ handler: () => handler
25
+ });
26
+ module.exports = __toCommonJS(handler_exports);
27
+ var import_node_crypto = require("node:crypto");
28
+ var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager");
29
+ var PHYSICAL_RESOURCE_ID = "evident-image-version-report";
30
+ var REPORT_TIMEOUT_MS = 5e3;
31
+ var secrets = new import_client_secrets_manager.SecretsManagerClient({});
32
+ async function fetchDoorbellSecret(secretArn, secretKey) {
33
+ const { SecretString } = await secrets.send(new import_client_secrets_manager.GetSecretValueCommand({ SecretId: secretArn }));
34
+ if (!SecretString) {
35
+ throw new Error(`doorbell secret ${secretArn} has no SecretString`);
36
+ }
37
+ const parsed = JSON.parse(SecretString);
38
+ const value = parsed !== null && typeof parsed === "object" ? parsed[secretKey] : void 0;
39
+ if (typeof value !== "string" || value === "") {
40
+ throw new Error(`doorbell secret ${secretArn} is missing a non-empty '${secretKey}' field`);
41
+ }
42
+ return value;
43
+ }
44
+ function requireEnv(name) {
45
+ const value = process.env[name];
46
+ if (!value) {
47
+ throw new Error(`missing required env var ${name}`);
48
+ }
49
+ return value;
50
+ }
51
+ function readImageVersions(value) {
52
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
53
+ throw new Error("imageVersions must be an object");
54
+ }
55
+ return Object.entries(value).map(([shape, imageVersion]) => {
56
+ if (typeof imageVersion !== "string") {
57
+ throw new Error(`imageVersions.${shape} must be a string`);
58
+ }
59
+ return { shape, image_version: imageVersion };
60
+ });
61
+ }
62
+ function response() {
63
+ return { PhysicalResourceId: PHYSICAL_RESOURCE_ID };
64
+ }
65
+ var runtimeDependencies = {
66
+ getSecret: fetchDoorbellSecret,
67
+ fetch: globalThis.fetch
68
+ };
69
+ async function handleImageVersionReport(event, getConfig, dependencies = runtimeDependencies) {
70
+ if (event.RequestType === "Delete") {
71
+ return response();
72
+ }
73
+ let runnerId = "unknown";
74
+ let versions = [];
75
+ let responseStatus;
76
+ try {
77
+ versions = readImageVersions(event.ResourceProperties?.imageVersions);
78
+ const config = getConfig();
79
+ runnerId = config.evidentRunnerId;
80
+ const secret = await dependencies.getSecret(config.doorbellSecretArn, config.doorbellSecretKey);
81
+ const body = JSON.stringify({
82
+ type: "runner.microvm_image_versions_reported",
83
+ versions
84
+ });
85
+ const signature = (0, import_node_crypto.createHmac)("sha256", secret).update(body).digest("hex");
86
+ const url = `${config.evidentApiUrl.replace(/\/+$/, "")}/v1/runners/${encodeURIComponent(
87
+ runnerId
88
+ )}/microvm-image-versions`;
89
+ const result = await dependencies.fetch(url, {
90
+ method: "POST",
91
+ headers: {
92
+ "content-type": "application/json",
93
+ "x-evident-signature": signature
94
+ },
95
+ body,
96
+ signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
97
+ });
98
+ responseStatus = result.status;
99
+ console.log("runner.microvm_image_versions_reported", {
100
+ runnerId,
101
+ versions,
102
+ status: result.status
103
+ });
104
+ if (!result.ok) {
105
+ throw new Error(`Evident returned HTTP ${result.status}`);
106
+ }
107
+ } catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ console.warn("MicroVM image version report failed", {
110
+ operation: event.RequestType,
111
+ runnerId,
112
+ versions,
113
+ ...responseStatus === void 0 ? {} : { status: responseStatus },
114
+ error: message
115
+ });
116
+ }
117
+ return response();
118
+ }
119
+ var handler = (event) => handleImageVersionReport(event, () => ({
120
+ doorbellSecretArn: requireEnv("DOORBELL_SECRET_ARN"),
121
+ doorbellSecretKey: requireEnv("DOORBELL_SECRET_KEY"),
122
+ evidentApiUrl: requireEnv("EVIDENT_API_URL"),
123
+ evidentRunnerId: requireEnv("EVIDENT_RUNNER_ID")
124
+ }));
125
+ // Annotate the CommonJS export names for ESM import in node:
126
+ 0 && (module.exports = {
127
+ handleImageVersionReport,
128
+ handler
129
+ });
@@ -55,6 +55,12 @@ export interface EvidentMicrovmConstructProps {
55
55
  readonly gitUserName?: string;
56
56
  /** Optional git email the `/run` hook configures for agent commits. */
57
57
  readonly gitUserEmail?: string;
58
+ /**
59
+ * Non-secret values baked into every VM launched from this image version,
60
+ * typically values a deployment's overlay needs at runtime. Every VM can
61
+ * read them, so this must never contain a secret.
62
+ */
63
+ readonly extraImageEnvironment?: Record<string, string>;
58
64
  /**
59
65
  * TCP port the image's hook server listens on, baked into both the image
60
66
  * (`HOOKS_PORT` env var) and the `MicrovmImage`'s `hooks.port` — a mismatch
@@ -69,6 +75,24 @@ export interface EvidentMicrovmConstructProps {
69
75
  * running a hook).
70
76
  */
71
77
  readonly hookTimeoutSeconds: number;
78
+ /**
79
+ * Base URL of Evident's own API. Together with `evidentRunnerId`, opts this
80
+ * deploy into reporting each shape's published image version to Evident
81
+ * (#1903) — a CDK custom resource, triggered only when a deploy actually
82
+ * changes a version. Omitting BOTH leaves the template byte-for-byte
83
+ * identical to not having this prop at all: an adopter with no Evident
84
+ * runner id configured has nothing to report against. Supplying only one
85
+ * throws at synth — see `evidentRunnerId`.
86
+ */
87
+ readonly evidentApiUrl?: string;
88
+ /**
89
+ * The pool runner id (public, non-secret UUID) to report against — see
90
+ * `evidentApiUrl`. Mirrors `EvidentScaleToZeroConstructProps.evidentAgentId`'s
91
+ * "public (non-secret) Evident agent UUID" precedent. Must be supplied
92
+ * together with `evidentApiUrl`, or neither at all — supplying exactly one
93
+ * throws at construction rather than silently disabling the reporter.
94
+ */
95
+ readonly evidentRunnerId?: string;
72
96
  }
73
97
  /**
74
98
  * The customer-account half of the per-session runner (#558): the MicroVM
@@ -41,6 +41,7 @@ const lambda = __importStar(require("aws-cdk-lib/aws-lambda"));
41
41
  const s3 = __importStar(require("aws-cdk-lib/aws-s3"));
42
42
  const lambda_microvm_cdk_1 = require("@evident-ai/lambda-microvm-cdk");
43
43
  const shapes_1 = require("./shapes");
44
+ const construct_1 = require("./image-version-reporter/construct");
44
45
  // Image hooks build the snapshot, so they get minutes where the runtime hooks
45
46
  // get AWS's 60 s.
46
47
  const IMAGE_HOOK_TIMEOUT_SECONDS = 600;
@@ -65,6 +66,15 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
65
66
  // Synth-time failure, before any construct exists.
66
67
  const shapes = (0, shapes_1.validateShapes)(props.shapes ?? shapes_1.MICROVM_SHAPES);
67
68
  const [defaultShape, ...additionalShapes] = shapes;
69
+ // D8's "both required together" is a synth-time contract, not just a
70
+ // README sentence: a caller supplying exactly one silently gets NO
71
+ // reporter and no warning (the `&&` check below just skips it), which
72
+ // reads as "configured" when it is not. Fail loudly instead.
73
+ if (Boolean(props.evidentApiUrl) !== Boolean(props.evidentRunnerId)) {
74
+ throw new Error('EvidentMicrovmConstruct: evidentApiUrl and evidentRunnerId must be supplied ' +
75
+ 'together or not at all — supplying only one silently disables the image ' +
76
+ 'version reporter rather than reporting a misconfiguration.');
77
+ }
68
78
  // The model credentials a runner restores at /run, and the litestream
69
79
  // session replica (it is `LITESTREAM_BUCKET` below), share one per-runner
70
80
  // prefix chosen at /run — synth can't name it, and lifecycle filters take
@@ -131,6 +141,7 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
131
141
  : {}),
132
142
  ...(props.gitUserName ? { GIT_USER_NAME: props.gitUserName } : {}),
133
143
  ...(props.gitUserEmail ? { GIT_USER_EMAIL: props.gitUserEmail } : {}),
144
+ ...props.extraImageEnvironment,
134
145
  };
135
146
  // ...and the hooks configuration every shape's image is built with.
136
147
  const imageHooks = {
@@ -249,6 +260,22 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
249
260
  // HMAC verification of the doorbell body IS the auth, as for the ECS waker.
250
261
  authType: lambda.FunctionUrlAuthType.NONE,
251
262
  });
263
+ // Opt-in only (D8): both values present is what creates the reporter, so an
264
+ // adopter who supplies neither gets a template with no additional Lambda and
265
+ // no custom resource — this branch must not run for them.
266
+ if (props.evidentApiUrl && props.evidentRunnerId) {
267
+ new construct_1.ImageVersionReporter(this, 'ImageVersionReporter', {
268
+ doorbellSecret: props.doorbellSecret,
269
+ evidentApiUrl: props.evidentApiUrl,
270
+ evidentRunnerId: props.evidentRunnerId,
271
+ imageVersions: Object.fromEntries(shapes.map((shape) => [shape.name, images.get(shape.name).latestActiveImageVersion])),
272
+ // `buildInputs` already covers every property that can cause AWS
273
+ // to publish a new version for THIS shape's image (source, base
274
+ // image, memory, hooks, environment, description, build role —
275
+ // see its docstring); no additional encoding needed here.
276
+ buildTriggers: Object.fromEntries(shapes.map((shape) => [shape.name, images.get(shape.name).buildInputs])),
277
+ });
278
+ }
252
279
  this.functionUrl = functionUrl.url;
253
280
  this.shapeCatalogueJson = shapeCatalogueJson;
254
281
  this.durableStateBucket = durableState;
@@ -40,6 +40,11 @@ export type WakeDoorbell = DoorbellFields & {
40
40
  * shape" — the controller resolves it against the shape catalogue.
41
41
  */
42
42
  shape?: string;
43
+ /**
44
+ * Set only when the body carries the literal `true`. When absent, the
45
+ * suspended VM is always resumed because recreating it destroys its filesystem.
46
+ */
47
+ recreateOnOutdatedImage?: boolean;
43
48
  };
44
49
  export type Doorbell = WakeDoorbell | (DoorbellFields & {
45
50
  type: typeof SUSPEND_EVENT_TYPE;
@@ -63,7 +63,7 @@ function parseDoorbell(rawBody) {
63
63
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
64
64
  return { ok: false, reason: exports.DOORBELL_REJECTION.invalidJson };
65
65
  }
66
- const { type, runner_id: runnerId, occurred_at: occurredAt, microvm_id: microvmId, run_payload: runPayload, shape, } = body;
66
+ const { type, runner_id: runnerId, occurred_at: occurredAt, microvm_id: microvmId, run_payload: runPayload, shape, recreate_on_outdated_image: recreateOnOutdatedImage, } = body;
67
67
  if (type !== SUSPEND_EVENT_TYPE && type !== exports.SHAPES_EVENT_TYPE && !isWakeType(type)) {
68
68
  return { ok: false, reason: exports.DOORBELL_REJECTION.unsupportedType };
69
69
  }
@@ -95,13 +95,14 @@ function parseDoorbell(rawBody) {
95
95
  if (Buffer.byteLength(runHookPayload, 'utf8') > constants_1.RUN_HOOK_PAYLOAD_MAX_BYTES) {
96
96
  return { ok: false, reason: exports.DOORBELL_REJECTION.runPayloadTooLarge };
97
97
  }
98
- // Omit `shape` entirely when absent, rather than setting it to `undefined`,
99
- // so a body with no shape parses to exactly the same object as before this
100
- // field existed.
101
- return {
102
- ok: true,
103
- doorbell: shape === undefined
104
- ? { ...fields, type, runHookPayload }
105
- : { ...fields, type, shape, runHookPayload },
106
- };
98
+ // Omit optional fields entirely when absent, rather than setting them to
99
+ // `undefined`, so older bodies keep the same parsed shape.
100
+ const doorbell = { ...fields, type, runHookPayload };
101
+ if (shape !== undefined) {
102
+ doorbell.shape = shape;
103
+ }
104
+ if (recreateOnOutdatedImage === true) {
105
+ doorbell.recreateOnOutdatedImage = true;
106
+ }
107
+ return { ok: true, doorbell };
107
108
  }
@@ -37,7 +37,7 @@ const LOG_ACTION = {
37
37
  rejected: 'reject',
38
38
  describe: 'describe',
39
39
  };
40
- function decide({ statusCode, action, reason, state, runnerId, microvmId, startedAt, shapes, polled, }) {
40
+ function decide({ statusCode, action, reason, state, runnerId, microvmId, startedAt, imageVersion, shapes, polled, }) {
41
41
  const line = `[doorbell] state=${state ?? 'none'} action=${LOG_ACTION[action]} ` +
42
42
  `microvm_id=${microvmId ?? '-'} runner_id=${runnerId ?? '-'} reason=${reason}` +
43
43
  (polled === undefined ? '' : ` polled=${polled}`);
@@ -56,6 +56,7 @@ function decide({ statusCode, action, reason, state, runnerId, microvmId, starte
56
56
  reason,
57
57
  ...(microvmId === undefined ? {} : { microvm_id: microvmId }),
58
58
  ...(startedAt === undefined ? {} : { microvm_started_at: startedAt.toISOString() }),
59
+ ...(imageVersion === undefined ? {} : { image_version: imageVersion }),
59
60
  ...(shapes === undefined ? {} : { shapes }),
60
61
  ...(polled === true ? { polled: true } : {}),
61
62
  }),
@@ -128,9 +129,8 @@ function compareImageVersions(a, b) {
128
129
  return 0;
129
130
  }
130
131
  /**
131
- * Whether the SUSPENDED VM must be thrown away and recreated because a newer
132
- * image has been published since it booted — a resume would otherwise keep
133
- * that VM on its old baked-in hooks and repo checkout for up to 8 hours.
132
+ * Whether an opted-in doorbell should throw away and recreate the SUSPENDED VM
133
+ * because a newer image has been published since it booted.
134
134
  *
135
135
  * FAIL-SAFE, one direction only: recreating destroys the VM's filesystem
136
136
  * (only the credentials and `opencode.db` in the object store survive), so
@@ -283,16 +283,18 @@ async function handleWakeDoorbell(doorbell, shape, microvm, timing) {
283
283
  microvmId: doorbell.microvmId,
284
284
  polled,
285
285
  startedAt: described.startedAt,
286
+ imageVersion: described.imageVersion,
286
287
  });
287
288
  case 'SUSPENDED':
288
- // A newer image has been published since this VM booted: resuming would
289
- // keep it on the old baked-in hooks and repo checkout for up to 8 h, so
290
- // take the same recreate path the arm below takes — which, passing no
291
- // `imageVersion`, boots the latest (handler.ts's `run`).
292
- if (await shouldRecreateForNewerImage(doorbell, shape, described.imageVersion, microvm)) {
289
+ // False/absent disables silent automatic roll-forward; an explicit restart
290
+ // request (#1906) can still set the same wire flag. Recreate loses VM-local
291
+ // filesystem state; durable provider credentials survive in object storage,
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))) {
293
295
  return runMicrovm(doorbell, shape, microvm, timing, polled, REASON.imageVersionOutdated, state);
294
296
  }
295
- return resumeMicrovm(doorbell, doorbell.microvmId, shape, microvm, timing, polled, described.startedAt);
297
+ return resumeMicrovm(doorbell, doorbell.microvmId, shape, microvm, timing, polled, described.startedAt, described.imageVersion);
296
298
  default:
297
299
  // TERMINATED, TERMINATING, not_found — gone or going, so a fresh VM either
298
300
  // way. This is what self-heals a stale `microvm_id`.
@@ -387,13 +389,13 @@ async function runMicrovm(doorbell, shape, microvm, timing, polled, reason, stat
387
389
  microvmId: started.microvmId,
388
390
  polled,
389
391
  startedAt: started.startedAt,
392
+ imageVersion: started.imageVersion,
390
393
  });
391
394
  }
392
395
  async function resumeMicrovm(doorbell, microvmId, shape, microvm, timing, polled,
393
- // Threaded in from the `describe()` result that led here — `ResumeMicrovm`
394
- // itself returns no `startedAt` (its response is empty), so this arm has no
395
- // timestamp of its own to report.
396
- startedAt) {
396
+ // Both values come from the `describe()` result that led here because
397
+ // `ResumeMicrovm` returns no lifecycle metadata of its own.
398
+ startedAt, imageVersion) {
397
399
  try {
398
400
  await (0, throttle_retry_1.withThrottleRetry)(() => microvm.resume(microvmId), timing);
399
401
  }
@@ -411,6 +413,7 @@ startedAt) {
411
413
  microvmId,
412
414
  polled,
413
415
  startedAt,
416
+ imageVersion,
414
417
  });
415
418
  }
416
419
  async function suspendMicrovm(doorbell, microvm, timing) {
@@ -69,6 +69,12 @@ export interface MicrovmClient {
69
69
  * none. Never synthesize one (e.g. `new Date()`) here or in any caller.
70
70
  */
71
71
  startedAt?: Date;
72
+ /**
73
+ * The image version this VM booted on, straight from `RunMicrovm`'s own
74
+ * `imageVersion`. Absent means AWS omitted it and must be read as UNKNOWN;
75
+ * see `MicrovmDescription.imageVersion` above.
76
+ */
77
+ imageVersion?: string;
72
78
  }>;
73
79
  resume(microvmId: string): Promise<void>;
74
80
  suspend(microvmId: string): Promise<void>;
@@ -14,6 +14,12 @@ export interface StageMicrovmImageContextOptions {
14
14
  readonly originUrl: string;
15
15
  /** Directory to write the build context to. Removed and recreated. */
16
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;
17
23
  /**
18
24
  * The published template to copy from. Defaults to the one inside this
19
25
  * package; named directly by the tests, which assert against a template they
@@ -23,11 +29,14 @@ export interface StageMicrovmImageContextOptions {
23
29
  }
24
30
  /**
25
31
  * Writes the build context AWS unpacks — the Dockerfile, the hook server, the
26
- * per-phase hook scripts and the repository — and returns its path. The result
27
- * is what `EvidentMicrovmConstruct`'s `imageSource` takes.
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.
28
35
  *
29
- * The first three come from this package's published `dist/`, so a consumer
30
- * needs no checkout of `sroze/evident` and no copy of the Dockerfile or hook
31
- * scripts (#1528). Only `repositoryPath` is theirs to supply.
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.
32
41
  */
33
42
  export declare function stageMicrovmImageContext(options: StageMicrovmImageContextOptions): string;