@evident-ai/runner-cdk 3.4.1-dev.59c7df3 → 3.4.1-dev.7e9dfeb
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 +1 -0
- package/dist/controller-lambda/handler.js +24 -13
- package/dist/image-version-reporter-lambda/handler.js +129 -0
- package/dist/microvm/construct.d.ts +18 -0
- package/dist/microvm/construct.js +26 -0
- package/dist/microvm/controller/doorbell.d.ts +5 -0
- package/dist/microvm/controller/doorbell.js +11 -10
- package/dist/microvm/controller/handle-doorbell.js +17 -14
- package/dist/microvm/controller/microvm-client.d.ts +6 -0
- package/dist/microvm/image-version-reporter/construct.d.ts +35 -0
- package/dist/microvm/image-version-reporter/construct.js +91 -0
- package/dist/microvm/image-version-reporter/handler.d.ts +26 -0
- package/dist/microvm/image-version-reporter/handler.js +104 -0
- package/dist/microvm-image-context/Dockerfile +10 -0
- package/dist/microvm-image-context/hooks/common.sh +116 -168
- package/dist/microvm-image-context/hooks/resume +9 -5
- package/dist/microvm-image-context/hooks/run +6 -4
- package/dist/microvm-image-context/hooks/suspend +4 -5
- package/dist/microvm-image-context/hooks/terminate +5 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -165,6 +165,7 @@ and the agent installs on first use.
|
|
|
165
165
|
| `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
166
|
| `runnerOpencodeConfigPath` | OpenCode uses the baked project configuration. Relative paths resolve from the workspace; absolute paths resolve in the image. |
|
|
167
167
|
| `gitUserName` / `gitUserEmail` | The hook uses the Evident bot defaults for git identity. |
|
|
168
|
+
| `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. |
|
|
168
169
|
|
|
169
170
|
## Status / limitations
|
|
170
171
|
|
|
@@ -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
|
-
|
|
49889
|
-
|
|
49890
|
-
doorbell
|
|
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 }));
|
|
@@ -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
|
+
});
|
|
@@ -69,6 +69,24 @@ export interface EvidentMicrovmConstructProps {
|
|
|
69
69
|
* running a hook).
|
|
70
70
|
*/
|
|
71
71
|
readonly hookTimeoutSeconds: number;
|
|
72
|
+
/**
|
|
73
|
+
* Base URL of Evident's own API. Together with `evidentRunnerId`, opts this
|
|
74
|
+
* deploy into reporting each shape's published image version to Evident
|
|
75
|
+
* (#1903) — a CDK custom resource, triggered only when a deploy actually
|
|
76
|
+
* changes a version. Omitting BOTH leaves the template byte-for-byte
|
|
77
|
+
* identical to not having this prop at all: an adopter with no Evident
|
|
78
|
+
* runner id configured has nothing to report against. Supplying only one
|
|
79
|
+
* throws at synth — see `evidentRunnerId`.
|
|
80
|
+
*/
|
|
81
|
+
readonly evidentApiUrl?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The pool runner id (public, non-secret UUID) to report against — see
|
|
84
|
+
* `evidentApiUrl`. Mirrors `EvidentScaleToZeroConstructProps.evidentAgentId`'s
|
|
85
|
+
* "public (non-secret) Evident agent UUID" precedent. Must be supplied
|
|
86
|
+
* together with `evidentApiUrl`, or neither at all — supplying exactly one
|
|
87
|
+
* throws at construction rather than silently disabling the reporter.
|
|
88
|
+
*/
|
|
89
|
+
readonly evidentRunnerId?: string;
|
|
72
90
|
}
|
|
73
91
|
/**
|
|
74
92
|
* 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
|
|
@@ -249,6 +259,22 @@ class EvidentMicrovmConstruct extends constructs_1.Construct {
|
|
|
249
259
|
// HMAC verification of the doorbell body IS the auth, as for the ECS waker.
|
|
250
260
|
authType: lambda.FunctionUrlAuthType.NONE,
|
|
251
261
|
});
|
|
262
|
+
// Opt-in only (D8): both values present is what creates the reporter, so an
|
|
263
|
+
// adopter who supplies neither gets a template with no additional Lambda and
|
|
264
|
+
// no custom resource — this branch must not run for them.
|
|
265
|
+
if (props.evidentApiUrl && props.evidentRunnerId) {
|
|
266
|
+
new construct_1.ImageVersionReporter(this, 'ImageVersionReporter', {
|
|
267
|
+
doorbellSecret: props.doorbellSecret,
|
|
268
|
+
evidentApiUrl: props.evidentApiUrl,
|
|
269
|
+
evidentRunnerId: props.evidentRunnerId,
|
|
270
|
+
imageVersions: Object.fromEntries(shapes.map((shape) => [shape.name, images.get(shape.name).latestActiveImageVersion])),
|
|
271
|
+
// `buildInputs` already covers every property that can cause AWS
|
|
272
|
+
// to publish a new version for THIS shape's image (source, base
|
|
273
|
+
// image, memory, hooks, environment, description, build role —
|
|
274
|
+
// see its docstring); no additional encoding needed here.
|
|
275
|
+
buildTriggers: Object.fromEntries(shapes.map((shape) => [shape.name, images.get(shape.name).buildInputs])),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
252
278
|
this.functionUrl = functionUrl.url;
|
|
253
279
|
this.shapeCatalogueJson = shapeCatalogueJson;
|
|
254
280
|
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
|
|
99
|
-
// so
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
132
|
-
* image has been published since it booted
|
|
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
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
if (
|
|
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
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
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>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Construct } from 'constructs';
|
|
2
|
+
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
|
3
|
+
import type { MicrovmImageBuildInputs } from '@evident-ai/lambda-microvm-cdk';
|
|
4
|
+
export type ImageVersionReporterProps = {
|
|
5
|
+
/** The secret whose value is the same HMAC key the controller verifies every doorbell against. */
|
|
6
|
+
doorbellSecret: secretsmanager.ISecret;
|
|
7
|
+
/** Base URL of Evident's own API — non-secret. */
|
|
8
|
+
evidentApiUrl: string;
|
|
9
|
+
/** The pool runner id the report is filed against — non-secret, public UUID. */
|
|
10
|
+
evidentRunnerId: string;
|
|
11
|
+
/** Shape name -> the `Fn::GetAtt LatestActiveImageVersion` published this deploy. */
|
|
12
|
+
imageVersions: Record<string, string>;
|
|
13
|
+
/**
|
|
14
|
+
* Shape name -> {@link MicrovmImageBuildInputs}, a STRUCTURED object (never
|
|
15
|
+
* a delimiter-joined string — see its docstring for why flattening is
|
|
16
|
+
* unsafe) that changes whenever THIS deploy's build inputs change. Unlike
|
|
17
|
+
* `imageVersions`' `Fn::GetAtt` (a runtime attribute of the MicroVM image
|
|
18
|
+
* service, whose deploy-time propagation to a dependent resource this
|
|
19
|
+
* construct cannot itself guarantee), every field here is either a
|
|
20
|
+
* synth-time-computed literal or a plain `Ref`/`Fn::GetAtt` to a resource
|
|
21
|
+
* IN THIS STACK — CloudFormation diffs the full resolved property tree, so
|
|
22
|
+
* a change anywhere in this object is a certain, not merely likely,
|
|
23
|
+
* re-invocation trigger.
|
|
24
|
+
*/
|
|
25
|
+
buildTriggers: Record<string, MicrovmImageBuildInputs>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Deploy-time push: reports each shape's just-published MicroVM image version to
|
|
29
|
+
* Evident, once per deploy that actually changes it. `imageVersions` is the
|
|
30
|
+
* payload; `buildTriggers` is what guarantees re-invocation on exactly the
|
|
31
|
+
* deploy that matters, independent of `imageVersions`' own resolution timing.
|
|
32
|
+
*/
|
|
33
|
+
export declare class ImageVersionReporter extends Construct {
|
|
34
|
+
constructor(scope: Construct, id: string, props: ImageVersionReporterProps);
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ImageVersionReporter = void 0;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const cdk = __importStar(require("aws-cdk-lib"));
|
|
39
|
+
const constructs_1 = require("constructs");
|
|
40
|
+
const lambda = __importStar(require("aws-cdk-lib/aws-lambda"));
|
|
41
|
+
const cr = __importStar(require("aws-cdk-lib/custom-resources"));
|
|
42
|
+
/** The JSON field name inside the doorbell secret, same key the controller reads. */
|
|
43
|
+
const DOORBELL_SECRET_KEY = 'DOORBELL_SECRET';
|
|
44
|
+
/**
|
|
45
|
+
* Deploy-time push: reports each shape's just-published MicroVM image version to
|
|
46
|
+
* Evident, once per deploy that actually changes it. `imageVersions` is the
|
|
47
|
+
* payload; `buildTriggers` is what guarantees re-invocation on exactly the
|
|
48
|
+
* deploy that matters, independent of `imageVersions`' own resolution timing.
|
|
49
|
+
*/
|
|
50
|
+
class ImageVersionReporter extends constructs_1.Construct {
|
|
51
|
+
constructor(scope, id, props) {
|
|
52
|
+
super(scope, id);
|
|
53
|
+
const handler = new lambda.Function(this, 'Function', {
|
|
54
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
55
|
+
// Pre-bundled at PACKAGE build time, same reasoning as the waker's and the
|
|
56
|
+
// controller's own `Code.fromAsset` (see their comments) — resolved from
|
|
57
|
+
// the PACKAGE ROOT, two directories below this file either compiled
|
|
58
|
+
// (`dist/microvm/image-version-reporter`) or via ts-node
|
|
59
|
+
// (`src/microvm/image-version-reporter`).
|
|
60
|
+
code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', '..', 'dist', 'image-version-reporter-lambda')),
|
|
61
|
+
handler: 'handler.handler',
|
|
62
|
+
memorySize: 128,
|
|
63
|
+
// A deploy hook must not hold CloudFormation for its default 1 h — this is
|
|
64
|
+
// the hard stop; the handler's own internal REPORT_TIMEOUT_MS is well under it.
|
|
65
|
+
timeout: cdk.Duration.seconds(30),
|
|
66
|
+
environment: {
|
|
67
|
+
// ARN + field name only, never the value (mirrors the controller and waker).
|
|
68
|
+
DOORBELL_SECRET_ARN: props.doorbellSecret.secretArn,
|
|
69
|
+
DOORBELL_SECRET_KEY: DOORBELL_SECRET_KEY,
|
|
70
|
+
EVIDENT_API_URL: props.evidentApiUrl,
|
|
71
|
+
EVIDENT_RUNNER_ID: props.evidentRunnerId,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
props.doorbellSecret.grantRead(handler);
|
|
75
|
+
const provider = new cr.Provider(this, 'Provider', {
|
|
76
|
+
onEventHandler: handler,
|
|
77
|
+
});
|
|
78
|
+
new cdk.CustomResource(this, 'Resource', {
|
|
79
|
+
serviceToken: provider.serviceToken,
|
|
80
|
+
properties: {
|
|
81
|
+
imageVersions: props.imageVersions,
|
|
82
|
+
// Not read by the handler — its only job is to be a property value
|
|
83
|
+
// CloudFormation can see change (a structured object, so any field
|
|
84
|
+
// changing anywhere is a real property diff), so the resource is
|
|
85
|
+
// invoked on exactly the deploy that changes a build input.
|
|
86
|
+
buildTriggers: props.buildTriggers,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
exports.ImageVersionReporter = ImageVersionReporter;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type ImageVersionPair = {
|
|
2
|
+
shape: string;
|
|
3
|
+
image_version: string;
|
|
4
|
+
};
|
|
5
|
+
export type ReporterEvent = {
|
|
6
|
+
RequestType: 'Create' | 'Update' | 'Delete';
|
|
7
|
+
ResourceProperties?: {
|
|
8
|
+
imageVersions?: unknown;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export type ReporterConfig = {
|
|
12
|
+
doorbellSecretArn: string;
|
|
13
|
+
doorbellSecretKey: string;
|
|
14
|
+
evidentApiUrl: string;
|
|
15
|
+
evidentRunnerId: string;
|
|
16
|
+
};
|
|
17
|
+
type ReporterDependencies = {
|
|
18
|
+
getSecret: (secretArn: string, secretKey: string) => Promise<string>;
|
|
19
|
+
fetch: typeof fetch;
|
|
20
|
+
};
|
|
21
|
+
type ReporterResponse = {
|
|
22
|
+
PhysicalResourceId: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function handleImageVersionReport(event: ReporterEvent, getConfig: () => ReporterConfig, dependencies?: ReporterDependencies): Promise<ReporterResponse>;
|
|
25
|
+
export declare const handler: (event: ReporterEvent) => Promise<ReporterResponse>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handler = void 0;
|
|
4
|
+
exports.handleImageVersionReport = handleImageVersionReport;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const client_secrets_manager_1 = require("@aws-sdk/client-secrets-manager");
|
|
7
|
+
const PHYSICAL_RESOURCE_ID = 'evident-image-version-report';
|
|
8
|
+
const REPORT_TIMEOUT_MS = 5_000;
|
|
9
|
+
const secrets = new client_secrets_manager_1.SecretsManagerClient({});
|
|
10
|
+
async function fetchDoorbellSecret(secretArn, secretKey) {
|
|
11
|
+
const { SecretString } = await secrets.send(new client_secrets_manager_1.GetSecretValueCommand({ SecretId: secretArn }));
|
|
12
|
+
if (!SecretString) {
|
|
13
|
+
throw new Error(`doorbell secret ${secretArn} has no SecretString`);
|
|
14
|
+
}
|
|
15
|
+
const parsed = JSON.parse(SecretString);
|
|
16
|
+
const value = parsed !== null && typeof parsed === 'object'
|
|
17
|
+
? parsed[secretKey]
|
|
18
|
+
: undefined;
|
|
19
|
+
if (typeof value !== 'string' || value === '') {
|
|
20
|
+
throw new Error(`doorbell secret ${secretArn} is missing a non-empty '${secretKey}' field`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function requireEnv(name) {
|
|
25
|
+
const value = process.env[name];
|
|
26
|
+
if (!value) {
|
|
27
|
+
throw new Error(`missing required env var ${name}`);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function readImageVersions(value) {
|
|
32
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
33
|
+
throw new Error('imageVersions must be an object');
|
|
34
|
+
}
|
|
35
|
+
return Object.entries(value).map(([shape, imageVersion]) => {
|
|
36
|
+
if (typeof imageVersion !== 'string') {
|
|
37
|
+
throw new Error(`imageVersions.${shape} must be a string`);
|
|
38
|
+
}
|
|
39
|
+
return { shape, image_version: imageVersion };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function response() {
|
|
43
|
+
return { PhysicalResourceId: PHYSICAL_RESOURCE_ID };
|
|
44
|
+
}
|
|
45
|
+
const runtimeDependencies = {
|
|
46
|
+
getSecret: fetchDoorbellSecret,
|
|
47
|
+
fetch: globalThis.fetch,
|
|
48
|
+
};
|
|
49
|
+
async function handleImageVersionReport(event, getConfig, dependencies = runtimeDependencies) {
|
|
50
|
+
if (event.RequestType === 'Delete') {
|
|
51
|
+
return response();
|
|
52
|
+
}
|
|
53
|
+
let runnerId = 'unknown';
|
|
54
|
+
let versions = [];
|
|
55
|
+
let responseStatus;
|
|
56
|
+
try {
|
|
57
|
+
versions = readImageVersions(event.ResourceProperties?.imageVersions);
|
|
58
|
+
const config = getConfig();
|
|
59
|
+
runnerId = config.evidentRunnerId;
|
|
60
|
+
const secret = await dependencies.getSecret(config.doorbellSecretArn, config.doorbellSecretKey);
|
|
61
|
+
const body = JSON.stringify({
|
|
62
|
+
type: 'runner.microvm_image_versions_reported',
|
|
63
|
+
versions,
|
|
64
|
+
});
|
|
65
|
+
const signature = (0, node_crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
|
|
66
|
+
const url = `${config.evidentApiUrl.replace(/\/+$/, '')}/v1/runners/${encodeURIComponent(runnerId)}/microvm-image-versions`;
|
|
67
|
+
const result = await dependencies.fetch(url, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: {
|
|
70
|
+
'content-type': 'application/json',
|
|
71
|
+
'x-evident-signature': signature,
|
|
72
|
+
},
|
|
73
|
+
body,
|
|
74
|
+
signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
|
|
75
|
+
});
|
|
76
|
+
responseStatus = result.status;
|
|
77
|
+
console.log('runner.microvm_image_versions_reported', {
|
|
78
|
+
runnerId,
|
|
79
|
+
versions,
|
|
80
|
+
status: result.status,
|
|
81
|
+
});
|
|
82
|
+
if (!result.ok) {
|
|
83
|
+
throw new Error(`Evident returned HTTP ${result.status}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
console.warn('MicroVM image version report failed', {
|
|
89
|
+
operation: event.RequestType,
|
|
90
|
+
runnerId,
|
|
91
|
+
versions,
|
|
92
|
+
...(responseStatus === undefined ? {} : { status: responseStatus }),
|
|
93
|
+
error: message,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return response();
|
|
97
|
+
}
|
|
98
|
+
const handler = (event) => handleImageVersionReport(event, () => ({
|
|
99
|
+
doorbellSecretArn: requireEnv('DOORBELL_SECRET_ARN'),
|
|
100
|
+
doorbellSecretKey: requireEnv('DOORBELL_SECRET_KEY'),
|
|
101
|
+
evidentApiUrl: requireEnv('EVIDENT_API_URL'),
|
|
102
|
+
evidentRunnerId: requireEnv('EVIDENT_RUNNER_ID'),
|
|
103
|
+
}));
|
|
104
|
+
exports.handler = handler;
|
|
@@ -95,13 +95,23 @@ ARG OPENCODE_VERSION=1.18.3
|
|
|
95
95
|
ARG EVIDENT_CLI_VERSION=dev
|
|
96
96
|
ARG CLAUDE_CODE_VERSION=latest
|
|
97
97
|
ARG RUNNER_SYNCHRONISER_VERSION=dev
|
|
98
|
+
#
|
|
99
|
+
# @brave/brave-search-mcp-server is pinned (not a build arg — a single caller,
|
|
100
|
+
# opencode.evident.jsonc, needs one version) and baked here for the same reason
|
|
101
|
+
# as the ECS runner image: a cold `npx -y` install of it took ~38s in a fresh
|
|
102
|
+
# MicroVM, over OpenCode's ~30s MCP connect timeout, so the server silently
|
|
103
|
+
# failed to connect on every first turn after a boot. Baking it lets
|
|
104
|
+
# opencode.evident.jsonc invoke the installed `brave-search-mcp-server` binary
|
|
105
|
+
# directly instead of through `npx`.
|
|
98
106
|
RUN npm install -g \
|
|
99
107
|
"opencode-ai@${OPENCODE_VERSION}" \
|
|
100
108
|
"@evident-ai/cli@${EVIDENT_CLI_VERSION}" \
|
|
101
109
|
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
|
102
110
|
"@evident-ai/runner-synchroniser@${RUNNER_SYNCHRONISER_VERSION}" \
|
|
111
|
+
"@brave/brave-search-mcp-server@2.1.0" \
|
|
103
112
|
&& opencode --version \
|
|
104
113
|
&& evident --version \
|
|
114
|
+
&& brave-search-mcp-server --help >/dev/null \
|
|
105
115
|
&& npm cache clean --force
|
|
106
116
|
|
|
107
117
|
# Playwright chromium + OS deps for E2E, baked at build time to avoid a slow
|
|
@@ -21,11 +21,15 @@ OPENCODE_PORT="${OPENCODE_PORT:-4096}"
|
|
|
21
21
|
# /proc/<pid>/environ, which runs as that same uid. /terminate removes it.
|
|
22
22
|
# shellcheck disable=SC2034 # read by the scripts that source this file
|
|
23
23
|
CONTEXT_FILE="/dev/shm/evident-run-context"
|
|
24
|
+
|
|
25
|
+
# The runtime injects MICROVM_ID only into /run, never /resume. This tmpfs file
|
|
26
|
+
# survives suspend/resume so /resume can restore the id for every fresh CLI
|
|
27
|
+
# process to self-report and acknowledge a fulfilled recycle request (#1906).
|
|
28
|
+
# shellcheck disable=SC2034 # read by the scripts that source this file
|
|
29
|
+
MICROVM_ID_FILE="/dev/shm/evident-microvm-id"
|
|
24
30
|
TUNNEL_PID_FILE="/dev/shm/evident-tunnel.pid"
|
|
25
31
|
OPENCODE_PID_FILE="/dev/shm/evident-opencode.pid"
|
|
26
32
|
LITESTREAM_PID_FILE="/dev/shm/evident-litestream.pid"
|
|
27
|
-
CREDS_SYNC_PID_FILE="/dev/shm/evident-creds-sync.pid"
|
|
28
|
-
CREDS_SYNC_LAST_ERROR_FILE="/dev/shm/evident-creds-sync.last-error"
|
|
29
33
|
|
|
30
34
|
# Where the runner's credential store lives inside the durable-state bucket.
|
|
31
35
|
# The BUCKET is the same for every VM from an image version, so the stack bakes
|
|
@@ -47,24 +51,29 @@ LITESTREAM_CONFIG_FILE="/dev/shm/evident-litestream.yml"
|
|
|
47
51
|
# `/terminate` removes it with the rest of the per-VM state.
|
|
48
52
|
SESSION_DB_NO_REPLICATE_MARKER="/dev/shm/evident-session-db-no-replicate"
|
|
49
53
|
|
|
54
|
+
# Completion evidence for the CLI-owned credential flush. It lives in tmpfs,
|
|
55
|
+
# is removed before every handshake, and is also removed by /terminate.
|
|
56
|
+
CREDENTIAL_FLUSH_MARKER_FILE="/dev/shm/evident-credential-flush"
|
|
57
|
+
|
|
50
58
|
hook_name() { printf '%s' "${0##*/}"; }
|
|
51
59
|
log() { echo "[hook:$(hook_name)] $*"; }
|
|
52
60
|
warn() { echo "[hook:$(hook_name)] $*" >&2; }
|
|
53
61
|
error() { echo "[hook:$(hook_name)] ERROR: $*" >&2; }
|
|
54
62
|
|
|
55
|
-
# Returns the CLI's own exit code.
|
|
56
|
-
#
|
|
63
|
+
# Returns the CLI's own exit code. `restore` logs domain outcomes and returns 0;
|
|
64
|
+
# a non-zero status means the tool itself failed. `sync-once` returns 40 for
|
|
65
|
+
# `failed`, `hashFailed`, and `localInvalid` (credentials not persisted), and 0
|
|
66
|
+
# for every other outcome. The predicates answer "no" with 10, and
|
|
57
67
|
# `session-db-classify`'s three typed answers are 30 (fatal)/31 (replica
|
|
58
68
|
# unusable)/32 (retry), extended by `session-db-verify`'s 33 (integrity
|
|
59
69
|
# exhausted, replica separated and local disposed) / 34 (could not prove
|
|
60
|
-
# separation or disposal)
|
|
61
|
-
# comment for what each means, not restated here. Any OTHER non-zero status
|
|
70
|
+
# separation or disposal). Any status outside a command's contractual answers
|
|
62
71
|
# means the tool itself broke, which is the only case worth an ERROR here.
|
|
63
72
|
run_synchroniser() {
|
|
64
73
|
local rc=0
|
|
65
74
|
"${SYNCHRONISER}" "$@" || rc=$?
|
|
66
75
|
case "${rc}" in
|
|
67
|
-
0 | 10 | 30 | 31 | 32 | 33 | 34) ;;
|
|
76
|
+
0 | 10 | 30 | 31 | 32 | 33 | 34 | 40) ;;
|
|
68
77
|
*) error "synchroniser '$*' exited ${rc}; the '${SYNCHRONISER}' command is missing from PATH, corrupt, or it threw" ;;
|
|
69
78
|
esac
|
|
70
79
|
return "${rc}"
|
|
@@ -317,7 +326,6 @@ regenerate_machine_id() {
|
|
|
317
326
|
tunnel_is_running() { is_running "${TUNNEL_PID_FILE}"; }
|
|
318
327
|
opencode_is_running() { is_running "${OPENCODE_PID_FILE}"; }
|
|
319
328
|
litestream_is_running() { is_running "${LITESTREAM_PID_FILE}"; }
|
|
320
|
-
creds_sync_is_running() { is_running "${CREDS_SYNC_PID_FILE}"; }
|
|
321
329
|
|
|
322
330
|
# `jq -e` alone is not enough: its exit status reflects the LAST OUTPUT VALUE,
|
|
323
331
|
# and an interpolation of a missing field is still a non-empty string, so a
|
|
@@ -462,149 +470,6 @@ kill_litestream() {
|
|
|
462
470
|
}
|
|
463
471
|
# --- litestream replicate (end) ----------------------------------------------
|
|
464
472
|
|
|
465
|
-
# --- credential sync loop (#1868 WI-3, ECS parity) ---------------------------
|
|
466
|
-
#
|
|
467
|
-
# sync_credentials (above) covers the three boundary flushes /run's restore,
|
|
468
|
-
# /suspend and /terminate already call. What it does NOT cover is a VM that
|
|
469
|
-
# runs for a long time between those boundaries: a provider re-authenticated
|
|
470
|
-
# through the proxied UI hours into a run would sit unflushed until the next
|
|
471
|
-
# suspend/terminate, and a VM that dies without one (a crash, an OOM kill)
|
|
472
|
-
# loses everything since boot. runner/docker-images/fargate/entrypoint.sh's
|
|
473
|
-
# own sync_credentials_loop is the ECS side of the identical gap; this is the
|
|
474
|
-
# same fix, backgrounded like the other long-lived services so it
|
|
475
|
-
# outlives this hook process, `( … ) &` rather than `setsid`: a plain
|
|
476
|
-
# backgrounded subshell is reparented to init and keeps running once its
|
|
477
|
-
# parent hook script exits (verified: PPID=1, still alive, with no controlling
|
|
478
|
-
# terminal in this image to send it a stray SIGHUP), and it inherits every
|
|
479
|
-
# function this file defines, so it can call run_synchroniser directly with no
|
|
480
|
-
# re-exec.
|
|
481
|
-
|
|
482
|
-
# Bounded confirmation window `stop_credential_sync` polls after signalling the
|
|
483
|
-
# loop. The loop's current child is one fast `run_synchroniser sync-once` call,
|
|
484
|
-
# so this stays a short backstop rather than a graceful drain.
|
|
485
|
-
CREDS_SYNC_STOP_WAIT_SECONDS=2
|
|
486
|
-
|
|
487
|
-
# Best-effort per tick, exactly like sync_credentials above: a failed tick
|
|
488
|
-
# must never end the loop, or a single transient S3 error would silently
|
|
489
|
-
# disable sync for the rest of the VM's life.
|
|
490
|
-
start_credential_sync() {
|
|
491
|
-
if [ -z "${PERSISTENCE_BUCKET:-}" ]; then
|
|
492
|
-
warn "CREDS-SYNC-DISABLED: LITESTREAM_BUCKET/LITESTREAM_PREFIX are not both set; no interval credential sync this boot."
|
|
493
|
-
return 0
|
|
494
|
-
fi
|
|
495
|
-
|
|
496
|
-
if creds_sync_is_running; then
|
|
497
|
-
warn "credential sync loop already running (pid $(cat "${CREDS_SYNC_PID_FILE}")); reusing it"
|
|
498
|
-
return 0
|
|
499
|
-
fi
|
|
500
|
-
|
|
501
|
-
# This is the same environment input runner-synchroniser validates. The
|
|
502
|
-
# variable is normally absent, so that ordinary case uses the documented
|
|
503
|
-
# default without a warning; a present invalid value is named and rejected.
|
|
504
|
-
local interval="${CREDS_SYNC_INTERVAL:-60}"
|
|
505
|
-
if [ -n "${CREDS_SYNC_INTERVAL+x}" ] && [[ ! "${CREDS_SYNC_INTERVAL}" =~ ^[1-9][0-9]*$ ]]; then
|
|
506
|
-
warn "CREDS-SYNC-INTERVAL-INVALID: CREDS_SYNC_INTERVAL='${CREDS_SYNC_INTERVAL}' is not a positive integer; using 60s"
|
|
507
|
-
interval=60
|
|
508
|
-
fi
|
|
509
|
-
|
|
510
|
-
rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
511
|
-
|
|
512
|
-
(
|
|
513
|
-
# Releases the fds this subshell inherited from the hook process before
|
|
514
|
-
# settling in for the VM's whole remaining life: nothing here writes to
|
|
515
|
-
# them (every synchroniser call already redirects its own), so there is
|
|
516
|
-
# no reason to keep holding the hook's original stdout/stderr open. A
|
|
517
|
-
# long-lived process that instead inherited a pipe's write end (a test
|
|
518
|
-
# harness reading the hook's own output, for one) would keep that pipe
|
|
519
|
-
# from ever reporting EOF — testing-guide.mdc's own lesson, and the same
|
|
520
|
-
# reason the long-lived services never inherit stdio either. That
|
|
521
|
-
# redirect also means `warn`/`log`/`error` calls in here go nowhere, so a
|
|
522
|
-
# failed sync-once is instead recorded to CREDS_SYNC_LAST_ERROR_FILE and
|
|
523
|
-
# surfaced by stop_credential_sync, which DOES have live stdio.
|
|
524
|
-
exec >/dev/null 2>&1 </dev/null
|
|
525
|
-
|
|
526
|
-
# A TERM this subshell receives (from stop_credential_sync, below) only
|
|
527
|
-
# kills THIS wrapper by default — its currently-running child (`sleep`,
|
|
528
|
-
# or a `run_synchroniser sync-once` call) is a separate process that
|
|
529
|
-
# would otherwise be orphaned and keep running, free to upload STALE
|
|
530
|
-
# credentials to S3 after the boundary flush that /suspend and
|
|
531
|
-
# /terminate perform immediately following the stop. Tracking the
|
|
532
|
-
# current child explicitly and forwarding the signal closes that race.
|
|
533
|
-
creds_sync_child_pid=""
|
|
534
|
-
trap 'trap - TERM; [ -n "${creds_sync_child_pid}" ] && kill -TERM "${creds_sync_child_pid}" 2>/dev/null; exit 0' TERM
|
|
535
|
-
|
|
536
|
-
while true; do
|
|
537
|
-
sleep "${interval}" &
|
|
538
|
-
creds_sync_child_pid=$!
|
|
539
|
-
wait "${creds_sync_child_pid}" 2>/dev/null
|
|
540
|
-
creds_sync_child_pid=""
|
|
541
|
-
|
|
542
|
-
run_synchroniser sync-once claude &
|
|
543
|
-
creds_sync_child_pid=$!
|
|
544
|
-
wait "${creds_sync_child_pid}" 2>/dev/null || echo "claude" >"${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
545
|
-
creds_sync_child_pid=""
|
|
546
|
-
|
|
547
|
-
run_synchroniser sync-once opencode &
|
|
548
|
-
creds_sync_child_pid=$!
|
|
549
|
-
wait "${creds_sync_child_pid}" 2>/dev/null || echo "opencode" >"${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
550
|
-
creds_sync_child_pid=""
|
|
551
|
-
done
|
|
552
|
-
) &
|
|
553
|
-
|
|
554
|
-
echo $! >"${CREDS_SYNC_PID_FILE}"
|
|
555
|
-
log "CREDS-SYNC-STARTED: pid=$! interval=${interval}s"
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
# Signals the loop, then confirms (bounded — see CREDS_SYNC_STOP_WAIT_SECONDS)
|
|
559
|
-
# that it and its current child are actually gone before returning: /suspend
|
|
560
|
-
# and /terminate start their own boundary flush immediately after this call,
|
|
561
|
-
# and an orphaned in-flight sync-once surviving past that point can overwrite
|
|
562
|
-
# fresher credentials with stale ones. The TERM trap inside the loop (above)
|
|
563
|
-
# forwards the signal to its current child almost instantly — this poll is a
|
|
564
|
-
# defensive confirmation, not the primary mechanism, so it stays short; a
|
|
565
|
-
# SIGKILL backstop covers a child that ignores TERM entirely.
|
|
566
|
-
#
|
|
567
|
-
# The DIED branch is a liveness report, not a no-op: every recovery/no-op path
|
|
568
|
-
# must say what it found (development-workflow.mdc) — a stopped-before-called
|
|
569
|
-
# loop and a died-on-its-own loop are different facts an operator needs told
|
|
570
|
-
# apart, not the same "nothing to stop" line.
|
|
571
|
-
stop_credential_sync() {
|
|
572
|
-
if [ ! -s "${CREDS_SYNC_PID_FILE}" ]; then
|
|
573
|
-
log "CREDS-SYNC-NOT-RUNNING: no credential sync loop to stop"
|
|
574
|
-
return 0
|
|
575
|
-
fi
|
|
576
|
-
|
|
577
|
-
local pid
|
|
578
|
-
pid="$(cat "${CREDS_SYNC_PID_FILE}")"
|
|
579
|
-
if ! process_is_alive "${pid}"; then
|
|
580
|
-
rm -f "${CREDS_SYNC_PID_FILE}"
|
|
581
|
-
warn "CREDS-SYNC-DIED: credential sync loop (pid=${pid}) had already exited before this stop"
|
|
582
|
-
return 0
|
|
583
|
-
fi
|
|
584
|
-
|
|
585
|
-
kill -TERM "${pid}" 2>/dev/null || true
|
|
586
|
-
rm -f "${CREDS_SYNC_PID_FILE}"
|
|
587
|
-
|
|
588
|
-
local waited_ms=0
|
|
589
|
-
while process_is_alive "${pid}" && [ "${waited_ms}" -lt $((CREDS_SYNC_STOP_WAIT_SECONDS * 1000)) ]; do
|
|
590
|
-
sleep 0.1
|
|
591
|
-
waited_ms=$((waited_ms + 100))
|
|
592
|
-
done
|
|
593
|
-
|
|
594
|
-
if process_is_alive "${pid}"; then
|
|
595
|
-
kill -KILL "${pid}" 2>/dev/null || true
|
|
596
|
-
warn "CREDS-SYNC-STOP-TIMEOUT: pid=${pid} still alive after ${CREDS_SYNC_STOP_WAIT_SECONDS}s; sent SIGKILL"
|
|
597
|
-
fi
|
|
598
|
-
|
|
599
|
-
if [ -s "${CREDS_SYNC_LAST_ERROR_FILE}" ]; then
|
|
600
|
-
warn "CREDS-SYNC-HAD-FAILURES: sync-once failed at least once for: $(tr '\n' ' ' <"${CREDS_SYNC_LAST_ERROR_FILE}")"
|
|
601
|
-
rm -f "${CREDS_SYNC_LAST_ERROR_FILE}"
|
|
602
|
-
fi
|
|
603
|
-
|
|
604
|
-
log "CREDS-SYNC-STOPPED: pid=${pid}"
|
|
605
|
-
}
|
|
606
|
-
# --- credential sync loop (end) -----------------------------------------------
|
|
607
|
-
|
|
608
473
|
# --- flush_session_db (#812 WI-4) -------------------------------------------
|
|
609
474
|
#
|
|
610
475
|
# The checked, synchronous flush /suspend and /terminate need before they
|
|
@@ -681,11 +546,14 @@ flush_session_db() {
|
|
|
681
546
|
# suspend it (#732). Sized from the measured cost of guessing wrong rather than
|
|
682
547
|
# the saving: suspend reaches SUSPENDED in ~7 s and a resume is RUNNING in
|
|
683
548
|
# ~0.6 s with the tunnel back ~2 s later (README, "Measured, end to end"), so
|
|
684
|
-
# napping a VM whose user comes straight back costs ~10 s — against
|
|
685
|
-
#
|
|
686
|
-
#
|
|
687
|
-
#
|
|
688
|
-
#
|
|
549
|
+
# napping a VM whose user comes straight back costs ~10 s — against the
|
|
550
|
+
# legacy conservative baseline input of ~$0.30/h, AWS can burst a loaded VM
|
|
551
|
+
# to a 16 GB / 8 vCPU peak (~$1.06/h at sustained full load—a worst-case
|
|
552
|
+
# ceiling, not an expectation); that is cheap enough that the balance
|
|
553
|
+
# sits far nearer the floor than the ceiling. Not AT the floor, though: the
|
|
554
|
+
# CLI's idle detector needs 2 clear poll cycles (`run.ts`'s `idlePolls >= 2`,
|
|
555
|
+
# ≥4 s of real time), so a value near that would spend more time
|
|
556
|
+
# suspending/resuming than idle.
|
|
689
557
|
# ECS's waker uses 900 s instead only because *its* cold start is far slower
|
|
690
558
|
# than this VM's ~2 s resume — not evidence this default should match it.
|
|
691
559
|
#
|
|
@@ -697,7 +565,9 @@ flush_session_db() {
|
|
|
697
565
|
# runner/docker-images/fargate/entrypoint.sh uses for the ECS runner's own
|
|
698
566
|
# idle-timeout flag, so an operator who knows one knows the other. A
|
|
699
567
|
# non-numeric override must never silently DROP the flag — that degrades to
|
|
700
|
-
# an always-on VM burning ~$
|
|
568
|
+
# an always-on VM burning ~$7.17/day at baseline, or up to ~$25.49/day at
|
|
569
|
+
# sustained full peak as load increases (a worst-case ceiling, not an
|
|
570
|
+
# expectation), exactly the bug this closes — so it
|
|
701
571
|
# warns and falls back to the default instead.
|
|
702
572
|
#
|
|
703
573
|
# Deliberately NOT in the /run payload yet: doing so would touch the doorbell
|
|
@@ -765,6 +635,7 @@ start_tunnel() {
|
|
|
765
635
|
--litestream-config "${LITESTREAM_CONFIG_FILE}" \
|
|
766
636
|
--litestream-pid-file "${LITESTREAM_PID_FILE}" \
|
|
767
637
|
--session-db-no-replicate-marker "${SESSION_DB_NO_REPLICATE_MARKER}" \
|
|
638
|
+
--credential-sync-marker "${CREDENTIAL_FLUSH_MARKER_FILE}" \
|
|
768
639
|
"${credential_flags[@]}" \
|
|
769
640
|
"${session_db_flags[@]}" \
|
|
770
641
|
"${opencode_config_flags[@]}" \
|
|
@@ -779,19 +650,17 @@ start_tunnel() {
|
|
|
779
650
|
# whole seconds: 25 s in-flight drain (SHUTDOWN_DRAIN_TIMEOUT_MS,
|
|
780
651
|
# apps/cli/src/commands/run.ts) + 2 s offline POST (notifyAgentDisconnected,
|
|
781
652
|
# apps/cli/src/commands/agent-lookup.ts) + 5 s telemetry flush
|
|
782
|
-
# (TELEMETRY_SHUTDOWN_TIMEOUT_MS, run.ts)
|
|
783
|
-
#
|
|
784
|
-
#
|
|
785
|
-
#
|
|
653
|
+
# (TELEMETRY_SHUTDOWN_TIMEOUT_MS, run.ts) + 8.5 s pre-drain credential flush
|
|
654
|
+
# + 8.5 s post-drain credential flush. Each phase is bounded there, so this is
|
|
655
|
+
# a ceiling rather than a typical cost — an idle suspend finishes in a couple
|
|
656
|
+
# of seconds. This is the ONE place the hand-maintained budget is written down;
|
|
657
|
+
# the CLI points back here when its bounds change.
|
|
786
658
|
#
|
|
787
|
-
# DOCUMENTATION ONLY — nothing is derived from this
|
|
788
|
-
#
|
|
789
|
-
#
|
|
790
|
-
# backstop chosen on its own merits and the two numbers are unrelated. Nothing
|
|
791
|
-
# cross-checks the 32 against apps/cli either: it is a hand-maintained sum of the
|
|
792
|
-
# three bounds cited above, so if one of them moves, update it here.
|
|
659
|
+
# DOCUMENTATION ONLY — nothing is derived from this value. It is the hand-maintained
|
|
660
|
+
# sum of the five bounded phases above (25 + 2 + 5 + 8.5 + 8.5); update it here if
|
|
661
|
+
# any of those bounds changes.
|
|
793
662
|
# shellcheck disable=SC2034 # documentation; deliberately read by nothing
|
|
794
|
-
CLI_SHUTDOWN_CEILING_SECONDS=
|
|
663
|
+
CLI_SHUTDOWN_CEILING_SECONDS=49
|
|
795
664
|
|
|
796
665
|
# How long stop_tunnel waits for that shutdown before the SIGKILL backstop.
|
|
797
666
|
# Since #718 this binds ONLY for a CLI that is still draining: one that has
|
|
@@ -867,6 +736,85 @@ stop_tunnel() {
|
|
|
867
736
|
log "tunnel stopped ${outcome}"
|
|
868
737
|
}
|
|
869
738
|
|
|
739
|
+
# The CLI owns the interval loop and its boundary flush. Two seconds of slack
|
|
740
|
+
# over the CLI's 8s flush deadline keeps this marker handshake inside the 55s
|
|
741
|
+
# hook ceiling while leaving the fallback flush and tunnel stop budget intact.
|
|
742
|
+
#
|
|
743
|
+
# This bounds only the CLI's pre-drain flush, which always runs first and
|
|
744
|
+
# unconditionally (run.ts's cleanup(), before the channel-work drain) — not
|
|
745
|
+
# the CLI's post-drain second pass, which can take up to
|
|
746
|
+
# SHUTDOWN_DRAIN_TIMEOUT_MS longer than this wait covers. A drain that
|
|
747
|
+
# consumes the whole window loses the SECOND pass, not the credential
|
|
748
|
+
# guarantee itself: the pre-drain flush already persisted everything on disk
|
|
749
|
+
# at signal time, exactly what the old bash `sync_credentials` guaranteed in
|
|
750
|
+
# one synchronous call — so the worst case here is no worse than before this
|
|
751
|
+
# handshake existed, never a fresh data-loss window. See
|
|
752
|
+
# docs/decisions/0063-microvm-boot-orchestration-in-cli.md's two-phase-flush
|
|
753
|
+
# section for the full reasoning.
|
|
754
|
+
CREDENTIAL_FLUSH_WAIT_SECONDS="${EVIDENT_CREDENTIAL_FLUSH_WAIT_SECONDS:-10}"
|
|
755
|
+
|
|
756
|
+
# Remove the previous answer, signal the same CLI that stop_tunnel handles, and
|
|
757
|
+
# wait for either its marker or its death. A fallback sync runs only after the
|
|
758
|
+
# CLI is known to be absent, never alongside a live CLI that may still write.
|
|
759
|
+
stop_runner_and_flush_credentials() {
|
|
760
|
+
rm -f "${CREDENTIAL_FLUSH_MARKER_FILE}"
|
|
761
|
+
|
|
762
|
+
if ! tunnel_is_running; then
|
|
763
|
+
warn "CREDS-FLUSH-NO-RUNNER: no live CLI at handshake entry"
|
|
764
|
+
stop_tunnel
|
|
765
|
+
sync_credentials
|
|
766
|
+
return 0
|
|
767
|
+
fi
|
|
768
|
+
|
|
769
|
+
local pid
|
|
770
|
+
pid="$(cat "${TUNNEL_PID_FILE}")"
|
|
771
|
+
kill -TERM "${pid}" 2>/dev/null || true
|
|
772
|
+
|
|
773
|
+
local waited_ms=0 marker_found=false runner_exited=false
|
|
774
|
+
while [ "${waited_ms}" -lt $((CREDENTIAL_FLUSH_WAIT_SECONDS * 1000)) ]; do
|
|
775
|
+
if [ -s "${CREDENTIAL_FLUSH_MARKER_FILE}" ]; then
|
|
776
|
+
marker_found=true
|
|
777
|
+
break
|
|
778
|
+
fi
|
|
779
|
+
if ! process_is_alive "${pid}"; then
|
|
780
|
+
runner_exited=true
|
|
781
|
+
break
|
|
782
|
+
fi
|
|
783
|
+
sleep 0.1
|
|
784
|
+
waited_ms=$((waited_ms + 100))
|
|
785
|
+
done
|
|
786
|
+
|
|
787
|
+
# The CLI can publish the marker and exit inside one poll tick. A final
|
|
788
|
+
# marker check after a death break preserves that answer instead of falling
|
|
789
|
+
# through to the fallback.
|
|
790
|
+
if [ "${runner_exited}" = true ] && [ -s "${CREDENTIAL_FLUSH_MARKER_FILE}" ]; then
|
|
791
|
+
marker_found=true
|
|
792
|
+
runner_exited=false
|
|
793
|
+
fi
|
|
794
|
+
|
|
795
|
+
if [ "${marker_found}" = true ]; then
|
|
796
|
+
local failures
|
|
797
|
+
failures="$(grep -Ev '^(claude|opencode)=ok$' "${CREDENTIAL_FLUSH_MARKER_FILE}" || true)"
|
|
798
|
+
if [ -n "${failures}" ]; then
|
|
799
|
+
warn "CREDS-FLUSH-HAD-FAILURES: ${failures//$'\n'/ }"
|
|
800
|
+
else
|
|
801
|
+
log "CREDS-FLUSH-OK"
|
|
802
|
+
fi
|
|
803
|
+
stop_tunnel
|
|
804
|
+
return 0
|
|
805
|
+
fi
|
|
806
|
+
|
|
807
|
+
if [ "${runner_exited}" = true ]; then
|
|
808
|
+
warn "CREDS-FLUSH-RUNNER-EXITED: CLI exited without a completion marker"
|
|
809
|
+
stop_tunnel
|
|
810
|
+
sync_credentials
|
|
811
|
+
return 0
|
|
812
|
+
fi
|
|
813
|
+
|
|
814
|
+
warn "CREDS-FLUSH-TIMEOUT: live CLI did not write a completion marker within ${CREDENTIAL_FLUSH_WAIT_SECONDS}s"
|
|
815
|
+
stop_tunnel
|
|
816
|
+
}
|
|
817
|
+
|
|
870
818
|
# --- check_runner_key (#1172) ------------------------------------------------
|
|
871
819
|
#
|
|
872
820
|
# Answers exactly one question before opencode/the tunnel start spending this
|
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
#
|
|
3
3
|
# Re-dial with the identity /run left behind. There is no step that could fetch
|
|
4
4
|
# a fresh runner key, so the one from /run is what resumes. The CLI delegated by
|
|
5
|
-
# start_tunnel owns the session-DB replicator's
|
|
6
|
-
#
|
|
7
|
-
# a resumed VM that never restarted it here would never sync credentials again
|
|
8
|
-
# for the rest of its life.
|
|
5
|
+
# start_tunnel owns the credential loop and the session-DB replicator's
|
|
6
|
+
# post-resume start.
|
|
9
7
|
set -euo pipefail
|
|
10
8
|
|
|
11
9
|
# shellcheck source=./common.sh
|
|
@@ -39,12 +37,18 @@ fi
|
|
|
39
37
|
read -r runner_key
|
|
40
38
|
} <"${CONTEXT_FILE}"
|
|
41
39
|
|
|
40
|
+
# Restore the id so the resumed CLI can self-report and acknowledge any
|
|
41
|
+
# outstanding recycle request (#1906). Older images may not have this file.
|
|
42
|
+
if [ -s "${MICROVM_ID_FILE}" ]; then
|
|
43
|
+
MICROVM_ID="$(cat "${MICROVM_ID_FILE}")"
|
|
44
|
+
export MICROVM_ID
|
|
45
|
+
fi
|
|
46
|
+
|
|
42
47
|
# Tolerant, never fatal: a resume that fails costs the user their whole
|
|
43
48
|
# session, so a broken durable-state config degrades to "no session-DB
|
|
44
49
|
# replication" rather than a failed resume. The CLI's own guards handle the
|
|
45
50
|
# rest — this needs no logic of its own.
|
|
46
51
|
load_state_config || warn "could not resolve durable-state config; the session DB will not resume replicating"
|
|
47
|
-
start_credential_sync
|
|
48
52
|
|
|
49
53
|
# Diagnostic-only, unlike /run's gate: a failed resume costs the user their
|
|
50
54
|
# whole session, so this never exits — it only converts a silent "resumed but
|
|
@@ -28,7 +28,6 @@ cleanup() {
|
|
|
28
28
|
stop_tunnel || warn "stop_tunnel failed while cleaning up"
|
|
29
29
|
stop_opencode || warn "stop_opencode failed while cleaning up"
|
|
30
30
|
kill_litestream || warn "kill_litestream failed while cleaning up"
|
|
31
|
-
stop_credential_sync || warn "stop_credential_sync failed while cleaning up"
|
|
32
31
|
}
|
|
33
32
|
trap cleanup EXIT
|
|
34
33
|
|
|
@@ -73,9 +72,12 @@ load_state_config || exit 1
|
|
|
73
72
|
# it warns and this VM still boots.
|
|
74
73
|
check_runner_key "${runner_key}" "${api_url}" || exit 1
|
|
75
74
|
|
|
76
|
-
# 6 — the
|
|
77
|
-
#
|
|
78
|
-
|
|
75
|
+
# 6 — preserve the VM id across suspend/resume. The runtime always supplies it
|
|
76
|
+
# for a /run hook, and the subshell keeps the persisted value protected.
|
|
77
|
+
(
|
|
78
|
+
umask 077
|
|
79
|
+
printf '%s\n' "${MICROVM_ID}" >"${MICROVM_ID_FILE}"
|
|
80
|
+
)
|
|
79
81
|
|
|
80
82
|
# 7 — the first per-VM identity on the wire. The subshell's umask makes the file
|
|
81
83
|
# unreadable to anyone else from the moment it exists, before the key is in it.
|
|
@@ -8,11 +8,10 @@ set -euo pipefail
|
|
|
8
8
|
# shellcheck source=./common.sh
|
|
9
9
|
source "$(dirname "$0")/common.sh"
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
stop_tunnel
|
|
11
|
+
# load_state_config exports PERSISTENCE_BUCKET, which flush_session_db below
|
|
12
|
+
# requires; the CLI flush completes, or is proven absent, before teardown continues.
|
|
13
|
+
load_state_config || warn "could not resolve durable-state config; neither the credential flush nor the session-DB flush below can run"
|
|
14
|
+
stop_runner_and_flush_credentials
|
|
16
15
|
|
|
17
16
|
# opencode is deliberately NOT stopped here (#812 WI-4) — it is inside the
|
|
18
17
|
# snapshot and must resume with it — so a turn still in flight may not be
|
|
@@ -8,11 +8,10 @@ set -uo pipefail
|
|
|
8
8
|
# shellcheck source=./common.sh
|
|
9
9
|
source "$(dirname "$0")/common.sh"
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
stop_tunnel
|
|
11
|
+
# load_state_config exports PERSISTENCE_BUCKET, which flush_session_db below
|
|
12
|
+
# requires; the CLI flush completes, or is proven absent, before teardown continues.
|
|
13
|
+
load_state_config || warn "could not resolve durable-state config; neither the credential flush nor the session-DB flush below can run"
|
|
14
|
+
stop_runner_and_flush_credentials
|
|
16
15
|
|
|
17
16
|
# Writers stopped BEFORE litestream's final sync (#812 WI-4, the
|
|
18
17
|
# ordered-shutdown invariant, plan §6): otherwise litestream could snapshot
|
|
@@ -32,6 +31,6 @@ flush_session_db
|
|
|
32
31
|
# this hook runs under `set -u` (above), so an unbound one aborts the script
|
|
33
32
|
# AT THIS LINE, leaving the runner key sitting in CONTEXT_FILE on a VM that is
|
|
34
33
|
# being torn down. That is the one thing this line exists to prevent.
|
|
35
|
-
rm -f "${CONTEXT_FILE}" "${SESSION_DB_NO_REPLICATE_MARKER}"
|
|
34
|
+
rm -f "${CONTEXT_FILE}" "${SESSION_DB_NO_REPLICATE_MARKER}" "${CREDENTIAL_FLUSH_MARKER_FILE}"
|
|
36
35
|
|
|
37
36
|
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evident-ai/runner-cdk",
|
|
3
|
-
"version": "3.4.1-dev.
|
|
3
|
+
"version": "3.4.1-dev.7e9dfeb",
|
|
4
4
|
"description": "Reusable CDK constructs for an Evident agent runner: a single scale-to-zero Fargate runner (task + service + per-agent self-stop role + waker Lambda), or a per-session AWS Lambda MicroVM that boots on demand and suspends between messages. Instantiate once per agent from your own stack.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|