@evident-ai/runner-cdk 0.1.1-dev.da70cd4 → 3.4.1-dev.0ef5061
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 +78 -20
- package/dist/controller-lambda/handler.js +50529 -0
- package/dist/evident-scale-to-zero-construct.d.ts +16 -4
- package/dist/evident-scale-to-zero-construct.js +19 -14
- package/dist/image-version-reporter-lambda/handler.js +129 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -1
- package/dist/microvm/constants.d.ts +7 -0
- package/dist/microvm/constants.js +34 -0
- package/dist/microvm/construct.d.ts +105 -0
- package/dist/microvm/construct.js +283 -0
- package/dist/microvm/controller/doorbell.d.ts +73 -0
- package/dist/microvm/controller/doorbell.js +107 -0
- package/dist/microvm/controller/handle-doorbell.d.ts +27 -0
- package/dist/microvm/controller/handle-doorbell.js +483 -0
- package/dist/microvm/controller/microvm-client.d.ts +81 -0
- package/dist/microvm/controller/microvm-client.js +7 -0
- package/dist/microvm/controller/shape-catalogue.d.ts +64 -0
- package/dist/microvm/controller/shape-catalogue.js +108 -0
- package/dist/microvm/controller/throttle-retry.d.ts +11 -0
- package/dist/microvm/controller/throttle-retry.js +27 -0
- package/dist/microvm/image/stage-context.d.ts +33 -0
- package/dist/microvm/image/stage-context.js +148 -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/shapes.d.ts +72 -0
- package/dist/microvm/shapes.js +93 -0
- package/dist/microvm-image-context/Dockerfile +227 -0
- package/dist/microvm-image-context/hook-server.js +286 -0
- package/dist/microvm-image-context/hooks/common.sh +957 -0
- package/dist/microvm-image-context/hooks/resume +78 -0
- package/dist/microvm-image-context/hooks/run +94 -0
- package/dist/microvm-image-context/hooks/suspend +22 -0
- package/dist/microvm-image-context/hooks/terminate +37 -0
- package/dist/waker/construct.js +1 -1
- package/package.json +15 -7
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Parses the `MICROVM_SHAPES` environment variable (a JSON array, written by
|
|
4
|
+
* CloudFormation from `aws/runner-cdk/src/microvm/shapes.ts` at
|
|
5
|
+
* deploy time — see D6/D7 in the plan) into a `ShapeCatalogue` the pure
|
|
6
|
+
* decision core can query, without that core ever touching `process.env`
|
|
7
|
+
* itself.
|
|
8
|
+
*
|
|
9
|
+
* A malformed catalogue means a broken deploy: `parseShapeCatalogue` throws
|
|
10
|
+
* once, loudly, at cold start rather than handing back a half-parsed
|
|
11
|
+
* catalogue that degrades per-request.
|
|
12
|
+
*
|
|
13
|
+
* `resolve()` never falls back to the default shape for a *named* request —
|
|
14
|
+
* only the absence of a name (`undefined`) means "give me the default". An
|
|
15
|
+
* unrecognised name returns `undefined` so the caller can reject loudly
|
|
16
|
+
* (AC-4) instead of silently launching the default shape a doorbell did not
|
|
17
|
+
* ask for.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.parseShapeCatalogue = parseShapeCatalogue;
|
|
21
|
+
function nonBlankString(value) {
|
|
22
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
23
|
+
}
|
|
24
|
+
function parseShapeEntry(entry, index) {
|
|
25
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
26
|
+
throw new Error(`MICROVM_SHAPES[${index}] is not an object.`);
|
|
27
|
+
}
|
|
28
|
+
const { name, title, description, memory_mib: memoryMiB, image_arn: imageArn, is_default: isDefault, } = entry;
|
|
29
|
+
if (!nonBlankString(name)) {
|
|
30
|
+
throw new Error(`MICROVM_SHAPES[${index}] is missing a non-blank "name".`);
|
|
31
|
+
}
|
|
32
|
+
if (!nonBlankString(title)) {
|
|
33
|
+
throw new Error(`Shape "${name}" is missing a non-blank "title".`);
|
|
34
|
+
}
|
|
35
|
+
if (!nonBlankString(description)) {
|
|
36
|
+
throw new Error(`Shape "${name}" is missing a non-blank "description".`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof memoryMiB !== 'number' || !Number.isFinite(memoryMiB)) {
|
|
39
|
+
throw new Error(`Shape "${name}" has a non-number "memory_mib".`);
|
|
40
|
+
}
|
|
41
|
+
if (!nonBlankString(imageArn)) {
|
|
42
|
+
throw new Error(`Shape "${name}" is missing a non-blank "image_arn".`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof isDefault !== 'boolean') {
|
|
45
|
+
throw new Error(`Shape "${name}" has a non-boolean "is_default".`);
|
|
46
|
+
}
|
|
47
|
+
return { name, title, description, memoryMiB, imageArn, isDefault };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parses and validates the `MICROVM_SHAPES` catalogue. Mirrors
|
|
51
|
+
* `validateShapes`'s (`src/shapes.ts`) message vocabulary so an operator
|
|
52
|
+
* reading a controller cold-start failure recognises it as the same rule
|
|
53
|
+
* they already met at synth time.
|
|
54
|
+
*
|
|
55
|
+
* Throws on: invalid JSON, a non-array, an empty array, an entry missing a
|
|
56
|
+
* non-blank `name`/`title`/`description`/`image_arn`, a non-number
|
|
57
|
+
* `memory_mib`, a non-boolean `is_default`, a duplicate `name`, or
|
|
58
|
+
* zero/multiple `is_default: true` entries.
|
|
59
|
+
*/
|
|
60
|
+
function parseShapeCatalogue(raw) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error('MICROVM_SHAPES is not valid JSON.');
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(parsed)) {
|
|
69
|
+
throw new Error('MICROVM_SHAPES must be a JSON array.');
|
|
70
|
+
}
|
|
71
|
+
if (parsed.length === 0) {
|
|
72
|
+
throw new Error('MICROVM_SHAPES must not be empty.');
|
|
73
|
+
}
|
|
74
|
+
const shapes = parsed.map((entry, index) => parseShapeEntry(entry, index));
|
|
75
|
+
const seenNames = new Set();
|
|
76
|
+
for (const shape of shapes) {
|
|
77
|
+
if (seenNames.has(shape.name)) {
|
|
78
|
+
throw new Error(`Duplicate MicroVM shape name "${shape.name}" in MICROVM_SHAPES — shape names must be unique.`);
|
|
79
|
+
}
|
|
80
|
+
seenNames.add(shape.name);
|
|
81
|
+
}
|
|
82
|
+
const defaults = shapes.filter((shape) => shape.isDefault);
|
|
83
|
+
if (defaults.length === 0) {
|
|
84
|
+
throw new Error('MICROVM_SHAPES must mark exactly one shape is_default: true — none does.');
|
|
85
|
+
}
|
|
86
|
+
if (defaults.length > 1) {
|
|
87
|
+
throw new Error('MICROVM_SHAPES must mark exactly one shape is_default: true — ' +
|
|
88
|
+
`found ${defaults.length}: ${defaults.map((shape) => shape.name).join(', ')}.`);
|
|
89
|
+
}
|
|
90
|
+
const defaultShape = defaults[0];
|
|
91
|
+
return {
|
|
92
|
+
resolve(name) {
|
|
93
|
+
if (name === undefined) {
|
|
94
|
+
return defaultShape;
|
|
95
|
+
}
|
|
96
|
+
return shapes.find((shape) => shape.name === name);
|
|
97
|
+
},
|
|
98
|
+
advertise() {
|
|
99
|
+
return shapes.map((shape) => ({
|
|
100
|
+
name: shape.name,
|
|
101
|
+
title: shape.title,
|
|
102
|
+
description: shape.description,
|
|
103
|
+
memory_mib: shape.memoryMiB,
|
|
104
|
+
is_default: shape.isDefault,
|
|
105
|
+
}));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry for the mutating MicroVM operations. `RunMicrovm`/`ResumeMicrovm` are 5/s
|
|
3
|
+
* and `SuspendMicrovm` is 2/s burst 2, so a doorbell burst throttles in normal
|
|
4
|
+
* operation. Full jitter over `[300, 900] ms` — worst case 1.2 s, which has to
|
|
5
|
+
* coexist with the `SUSPENDING` poll (≤ 2 s) inside the Lambda's 8 s timeout.
|
|
6
|
+
*/
|
|
7
|
+
export type Timing = {
|
|
8
|
+
sleep: (ms: number) => Promise<void>;
|
|
9
|
+
random: () => number;
|
|
10
|
+
};
|
|
11
|
+
export declare function withThrottleRetry<T>(operation: () => Promise<T>, { sleep, random }: Timing): Promise<T>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Retry for the mutating MicroVM operations. `RunMicrovm`/`ResumeMicrovm` are 5/s
|
|
4
|
+
* and `SuspendMicrovm` is 2/s burst 2, so a doorbell burst throttles in normal
|
|
5
|
+
* operation. Full jitter over `[300, 900] ms` — worst case 1.2 s, which has to
|
|
6
|
+
* coexist with the `SUSPENDING` poll (≤ 2 s) inside the Lambda's 8 s timeout.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.withThrottleRetry = withThrottleRetry;
|
|
10
|
+
const BACKOFF_MS = [300, 900];
|
|
11
|
+
function isThrottling(error) {
|
|
12
|
+
const name = error?.name;
|
|
13
|
+
return name === 'ThrottlingException' || name === 'TooManyRequestsException';
|
|
14
|
+
}
|
|
15
|
+
async function withThrottleRetry(operation, { sleep, random }) {
|
|
16
|
+
for (let attempt = 0;; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
return await operation();
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (attempt >= BACKOFF_MS.length || !isThrottling(error)) {
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
await sleep(Math.floor(random() * BACKOFF_MS[attempt]));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface StageMicrovmImageContextOptions {
|
|
2
|
+
/**
|
|
3
|
+
* The repository baked into the image as the agent's workspace. This is the
|
|
4
|
+
* CONSUMER's repository, not Evident's — the image is a dev box for whatever
|
|
5
|
+
* codebase the agent works on. Must be a full (non-shallow) clone.
|
|
6
|
+
*/
|
|
7
|
+
readonly repositoryPath: string;
|
|
8
|
+
/**
|
|
9
|
+
* `origin` of the baked repository, as the VM will see it. Must be a
|
|
10
|
+
* credential-free URL (typically `https://github.com/<owner>/<repo>.git`):
|
|
11
|
+
* this string ships inside the shared snapshot, so a token in it would be
|
|
12
|
+
* baked into every VM. The credential arrives per-VM instead.
|
|
13
|
+
*/
|
|
14
|
+
readonly originUrl: string;
|
|
15
|
+
/** Directory to write the build context to. Removed and recreated. */
|
|
16
|
+
readonly destination: string;
|
|
17
|
+
/**
|
|
18
|
+
* The published template to copy from. Defaults to the one inside this
|
|
19
|
+
* package; named directly by the tests, which assert against a template they
|
|
20
|
+
* built themselves rather than whichever `dist/` happens to be present.
|
|
21
|
+
*/
|
|
22
|
+
readonly templateDir?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 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.
|
|
28
|
+
*
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
export declare function stageMicrovmImageContext(options: StageMicrovmImageContextOptions): string;
|
|
@@ -0,0 +1,148 @@
|
|
|
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.stageMicrovmImageContext = stageMicrovmImageContext;
|
|
37
|
+
const node_child_process_1 = require("node:child_process");
|
|
38
|
+
const node_fs_1 = require("node:fs");
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
/**
|
|
41
|
+
* The image build context template published inside this package.
|
|
42
|
+
*
|
|
43
|
+
* Resolved from the PACKAGE ROOT, THREE directories above `__dirname` — either
|
|
44
|
+
* compiled (`dist/microvm/image`) or via ts-node (`src/microvm/image`) — the
|
|
45
|
+
* same way `microvm/construct.ts` resolves the controller Lambda asset, but one
|
|
46
|
+
* `..` deeper because this file sits a directory below it. It is
|
|
47
|
+
* written by `scripts/build.ts` at PACKAGE build time, not at stage time, so a
|
|
48
|
+
* consumer needs no esbuild and no checkout of this repo (#1528).
|
|
49
|
+
*/
|
|
50
|
+
const TEMPLATE_DIR = path.resolve(__dirname, '..', '..', '..', 'dist', 'microvm-image-context');
|
|
51
|
+
function git(args, cwd) {
|
|
52
|
+
return (0, node_child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `git rev-parse --is-shallow-repository` throws an opaque `execFileSync`
|
|
56
|
+
* error (e.g. "not a git repository") when `repositoryPath` isn't usable at
|
|
57
|
+
* all, so that failure is rethrown naming the path.
|
|
58
|
+
*/
|
|
59
|
+
function isShallowRepository(repositoryPath) {
|
|
60
|
+
try {
|
|
61
|
+
return git(['rev-parse', '--is-shallow-repository'], repositoryPath) === 'true';
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
throw new Error(`${repositoryPath} is not a usable git repository: ` +
|
|
65
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Clones the caller's repository into the build context, so the Dockerfile can
|
|
70
|
+
* ship the workspace already installed.
|
|
71
|
+
*
|
|
72
|
+
* `file://` rather than a plain path: a path triggers git's local-clone
|
|
73
|
+
* optimisation, which copies the whole object store — every branch and worktree
|
|
74
|
+
* ref on the staging machine (50 MB here). The file transport negotiates like a
|
|
75
|
+
* network fetch and packs only what the checked-out branch reaches (9 MB), with
|
|
76
|
+
* one branch and one remote-tracking ref. Ignored files, `node_modules` first
|
|
77
|
+
* among them, are not part of a clone at all.
|
|
78
|
+
*/
|
|
79
|
+
function stageRepository(repositoryPath, destination, originUrl) {
|
|
80
|
+
if (isShallowRepository(repositoryPath)) {
|
|
81
|
+
throw new Error(`refusing to stage a shallow checkout (${repositoryPath}): the agent branches, ` +
|
|
82
|
+
'commits and opens PRs, so the image needs real history. Run `git fetch --unshallow`.');
|
|
83
|
+
}
|
|
84
|
+
// Absolute on both sides: `file://` needs an absolute path to mean anything,
|
|
85
|
+
// and the clone's cwd is the destination's parent (the only directory both
|
|
86
|
+
// are guaranteed to resolve against).
|
|
87
|
+
git(['clone', '--quiet', '--single-branch', `file://${repositoryPath}`, destination], path.dirname(destination));
|
|
88
|
+
// The clone points `origin` at this machine's filesystem; the VM's origin is
|
|
89
|
+
// the caller's own remote over HTTPS, with no credential in it.
|
|
90
|
+
git(['remote', 'set-url', 'origin', originUrl], destination);
|
|
91
|
+
// The asset hash of the resulting image must be a pure function of the commit,
|
|
92
|
+
// or every deploy publishes a new `AWS::Lambda::MicrovmImage` version and wedges
|
|
93
|
+
// the stack at its per-image version ceiling. Two things break that purity:
|
|
94
|
+
// - `pack-objects`' delta search is multithreaded (`pack.threads` defaults to
|
|
95
|
+
// the CPU count) and its output varies run to run; `-f`/`-F`
|
|
96
|
+
// (`--no-reuse-delta`/`--no-reuse-object`) additionally stop it from reusing
|
|
97
|
+
// whatever packing the source checkout happened to have. All three together
|
|
98
|
+
// make the pack a pure function of the object set, independent of the source.
|
|
99
|
+
// - `.git/index` carries stat data (inode/mtime/size) and `.git/logs/**` carries
|
|
100
|
+
// wall-clock reflog timestamps; neither is byte-stable for a fixed commit.
|
|
101
|
+
git(['-c', 'pack.threads=1', 'repack', '-adfqF'], destination);
|
|
102
|
+
(0, node_fs_1.rmSync)(path.join(destination, '.git', 'index'), { force: true });
|
|
103
|
+
(0, node_fs_1.rmSync)(path.join(destination, '.git', 'logs'), { recursive: true, force: true });
|
|
104
|
+
console.log(`[stage] repo ${git(['rev-parse', 'HEAD'], destination)}`);
|
|
105
|
+
if (!(0, node_fs_1.existsSync)(path.join(destination, 'pnpm-lock.yaml'))) {
|
|
106
|
+
console.log(`[stage] ${repositoryPath} has no pnpm-lock.yaml — the image build will skip dependency installation`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Writes the build context AWS unpacks — the Dockerfile, the hook server, the
|
|
111
|
+
* per-phase hook scripts and the repository — and returns its path. The result
|
|
112
|
+
* is what `EvidentMicrovmConstruct`'s `imageSource` takes.
|
|
113
|
+
*
|
|
114
|
+
* The first three come from this package's published `dist/`, so a consumer
|
|
115
|
+
* needs no checkout of `sroze/evident` and no copy of the Dockerfile or hook
|
|
116
|
+
* scripts (#1528). Only `repositoryPath` is theirs to supply.
|
|
117
|
+
*/
|
|
118
|
+
function stageMicrovmImageContext(options) {
|
|
119
|
+
const { originUrl, templateDir = TEMPLATE_DIR } = options;
|
|
120
|
+
// `file://` and the clone below only mean anything against absolute paths,
|
|
121
|
+
// and a caller may reasonably pass either.
|
|
122
|
+
const repositoryPath = path.resolve(options.repositoryPath);
|
|
123
|
+
const destination = path.resolve(options.destination);
|
|
124
|
+
if (!(0, node_fs_1.existsSync)(templateDir)) {
|
|
125
|
+
throw new Error(`the MicroVM image context template is missing from ${templateDir}. It is written by ` +
|
|
126
|
+
"this package's build (`pnpm --filter @evident-ai/runner-cdk build`) and ships in the " +
|
|
127
|
+
'published tarball, so an installed copy should always have it.');
|
|
128
|
+
}
|
|
129
|
+
(0, node_fs_1.rmSync)(destination, { recursive: true, force: true });
|
|
130
|
+
(0, node_fs_1.mkdirSync)(destination, { recursive: true });
|
|
131
|
+
(0, node_fs_1.copyFileSync)(path.join(templateDir, 'Dockerfile'), path.join(destination, 'Dockerfile'));
|
|
132
|
+
(0, node_fs_1.copyFileSync)(path.join(templateDir, 'hook-server.js'), path.join(destination, 'hook-server.js'));
|
|
133
|
+
const hooksSource = path.join(templateDir, 'hooks');
|
|
134
|
+
const hooksStage = path.join(destination, 'hooks');
|
|
135
|
+
(0, node_fs_1.mkdirSync)(hooksStage);
|
|
136
|
+
for (const entry of (0, node_fs_1.readdirSync)(hooksSource)) {
|
|
137
|
+
const staged = path.join(hooksStage, entry);
|
|
138
|
+
(0, node_fs_1.copyFileSync)(path.join(hooksSource, entry), staged);
|
|
139
|
+
// The runtime only runs a hook it can execute. Set here rather than
|
|
140
|
+
// inherited from the template, so the staged mode is a property of THIS
|
|
141
|
+
// function rather than of however the template reached disk — `npm pack`
|
|
142
|
+
// does carry the bit, but a zip-based vendoring, a CI cache restore or a
|
|
143
|
+
// hand-copied directory need not.
|
|
144
|
+
(0, node_fs_1.chmodSync)(staged, entry.endsWith('.sh') ? 0o644 : 0o755);
|
|
145
|
+
}
|
|
146
|
+
stageRepository(repositoryPath, path.join(destination, 'repo'), originUrl);
|
|
147
|
+
return destination;
|
|
148
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type MicrovmMemoryMiB } from '@evident-ai/lambda-microvm-cdk';
|
|
2
|
+
/**
|
|
3
|
+
* An operator-authored MicroVM shape: a named memory footprint the controller
|
|
4
|
+
* can launch a session on. A shape's size is fixed on its image (#723) —
|
|
5
|
+
* there is no launch-time resource dial — so "a bigger machine" means "a
|
|
6
|
+
* different shape", each backed by its own `MicrovmImage`.
|
|
7
|
+
*
|
|
8
|
+
* `constructId` and `imageName` are carried **explicitly** rather than
|
|
9
|
+
* derived from `name`, because for the default shape they are frozen
|
|
10
|
+
* deployment identity (see the comment on `MICROVM_SHAPES` below): deriving
|
|
11
|
+
* them would let an innocent rename of `name` silently rebuild the live
|
|
12
|
+
* image. Every other shape is free to name these however is convenient.
|
|
13
|
+
*/
|
|
14
|
+
export interface MicrovmShape {
|
|
15
|
+
/** The stable id a doorbell asks for; crosses the wire to Evident. */
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** Operator-authored, for Evident's future shape picker. */
|
|
18
|
+
readonly title: string;
|
|
19
|
+
/**
|
|
20
|
+
* Operator-authored; also becomes the image's CloudFormation
|
|
21
|
+
* `Description`.
|
|
22
|
+
*/
|
|
23
|
+
readonly description: string;
|
|
24
|
+
readonly memoryMiB: MicrovmMemoryMiB;
|
|
25
|
+
/**
|
|
26
|
+
* Exactly one shape must set this, and — see the ordering contract below —
|
|
27
|
+
* it must be the first entry in `MICROVM_SHAPES`.
|
|
28
|
+
*/
|
|
29
|
+
readonly isDefault: boolean;
|
|
30
|
+
/** The `MicrovmImage` construct id within the stack. */
|
|
31
|
+
readonly constructId: string;
|
|
32
|
+
/** The account-unique `imageName` passed to `MicrovmImage`. */
|
|
33
|
+
readonly imageName: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The committed shape list. Ordering contract (load-bearing, not cosmetic):
|
|
37
|
+
* the list is ordered, the default shape MUST be the first entry, and the
|
|
38
|
+
* stack constructs shapes in list order. That is because every non-default
|
|
39
|
+
* shape's image is built with the default shape's own execution role (D3 in
|
|
40
|
+
* the plan) — so the default has to exist before any other shape can borrow
|
|
41
|
+
* its role. Reordering this list to put the default anywhere but index 0 is
|
|
42
|
+
* a synth-time error (`validateShapes`), not a cosmetic change. The same
|
|
43
|
+
* order is what `advertise()` (controller-side) hands Evident's future
|
|
44
|
+
* picker, so default-first is also the picker's ordering — a small price for
|
|
45
|
+
* making the construction-order contract enforceable rather than a
|
|
46
|
+
* convention.
|
|
47
|
+
*
|
|
48
|
+
* The default entry's `constructId` ("Image") and `imageName`
|
|
49
|
+
* ("evident-microvm") are FROZEN: they are today's live, hand-deployed
|
|
50
|
+
* image's identity. Changing either destroys and rebuilds it (~4m45s,
|
|
51
|
+
* measured) plus a window with no image. Its `description` is copied
|
|
52
|
+
* verbatim from the image that is live today so the deploy changes nothing
|
|
53
|
+
* about it.
|
|
54
|
+
*
|
|
55
|
+
* "Frozen" means unchanged under OUR OWN synth (no `microvm:*` context set —
|
|
56
|
+
* `main.ts` passes none). `imageName` MAY carry a customer's
|
|
57
|
+
* `microvm:namePrefix` (`deployment.ts`'s `resolveShapeCatalogue`) — that is
|
|
58
|
+
* account-scoped naming, not a change to this module. `constructId` stays
|
|
59
|
+
* frozen unconditionally, with no override anywhere: it is the
|
|
60
|
+
* CloudFormation logical-id path, and prefixing it is the resource
|
|
61
|
+
* replacement this whole file's FROZEN warning exists to prevent.
|
|
62
|
+
*/
|
|
63
|
+
export declare const MICROVM_SHAPES: readonly MicrovmShape[];
|
|
64
|
+
/**
|
|
65
|
+
* Validates a shape list at synth time. Deliberately lean
|
|
66
|
+
* (`code-simplicity.mdc`): this is a hand-edited constant list in a typed
|
|
67
|
+
* file, so it only checks what `tsc` cannot already catch — CDK itself
|
|
68
|
+
* rejects a duplicate `constructId`, so that one needs no check here, and no
|
|
69
|
+
* byte-budget cap is applied (the image ARNs are unresolved tokens at synth,
|
|
70
|
+
* so a rendered size is not measurable anyway).
|
|
71
|
+
*/
|
|
72
|
+
export declare function validateShapes(shapes: readonly MicrovmShape[]): readonly MicrovmShape[];
|