@evident-ai/runner-cdk 3.4.1-dev.59c7df3 → 3.4.1-dev.6f7da9c

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.
@@ -66,8 +66,8 @@ function isShallowRepository(repositoryPath) {
66
66
  }
67
67
  }
68
68
  /**
69
- * Clones the caller's repository into the build context, so the Dockerfile can
70
- * ship the workspace already installed.
69
+ * Clones the caller's repository into the build context for the Dockerfile to
70
+ * bake as a checked-out but uninstalled workspace.
71
71
  *
72
72
  * `file://` rather than a plain path: a path triggers git's local-clone
73
73
  * optimisation, which copies the whole object store — every branch and worktree
@@ -102,21 +102,37 @@ function stageRepository(repositoryPath, destination, originUrl) {
102
102
  (0, node_fs_1.rmSync)(path.join(destination, '.git', 'index'), { force: true });
103
103
  (0, node_fs_1.rmSync)(path.join(destination, '.git', 'logs'), { recursive: true, force: true });
104
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`);
105
+ }
106
+ function stageScriptDirectory(source, destination) {
107
+ (0, node_fs_1.mkdirSync)(destination);
108
+ for (const entry of (0, node_fs_1.readdirSync)(source, { withFileTypes: true })) {
109
+ const sourcePath = path.join(source, entry.name);
110
+ const staged = path.join(destination, entry.name);
111
+ if (entry.isDirectory()) {
112
+ stageScriptDirectory(sourcePath, staged);
113
+ continue;
114
+ }
115
+ (0, node_fs_1.copyFileSync)(sourcePath, staged);
116
+ // Set modes here rather than inheriting them from the template, so they stay
117
+ // correct after an npm pack, zip, CI-cache or hand-copy round trip. The image
118
+ // executes extensionless scripts and sources `.sh` files.
119
+ (0, node_fs_1.chmodSync)(staged, entry.name.endsWith('.sh') ? 0o644 : 0o755);
107
120
  }
108
121
  }
109
122
  /**
110
123
  * 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.
124
+ * per-phase hook scripts, the deployment overlay and the repository — and
125
+ * returns its path. The result is what `EvidentMicrovmConstruct`'s `imageSource`
126
+ * takes.
113
127
  *
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.
128
+ * The Dockerfile, hook server and phase hooks come from this package's
129
+ * published `dist/`, so a consumer needs no checkout of the source repository and no
130
+ * copy of those files (#1528). A caller may supply an overlay; the default is
131
+ * deterministic no-op scripts. `repositoryPath` is the only required caller
132
+ * input.
117
133
  */
118
134
  function stageMicrovmImageContext(options) {
119
- const { originUrl, templateDir = TEMPLATE_DIR } = options;
135
+ const { originUrl, overlayDir, templateDir = TEMPLATE_DIR } = options;
120
136
  // `file://` and the clone below only mean anything against absolute paths,
121
137
  // and a caller may reasonably pass either.
122
138
  const repositoryPath = path.resolve(options.repositoryPath);
@@ -132,16 +148,27 @@ function stageMicrovmImageContext(options) {
132
148
  (0, node_fs_1.copyFileSync)(path.join(templateDir, 'hook-server.js'), path.join(destination, 'hook-server.js'));
133
149
  const hooksSource = path.join(templateDir, 'hooks');
134
150
  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);
151
+ stageScriptDirectory(hooksSource, hooksStage);
152
+ const overlayStage = path.join(destination, 'overlay');
153
+ if (overlayDir === undefined) {
154
+ (0, node_fs_1.mkdirSync)(overlayStage);
155
+ for (const name of ['setup-root', 'setup-workspace']) {
156
+ const staged = path.join(overlayStage, name);
157
+ (0, node_fs_1.writeFileSync)(staged, '#!/usr/bin/env bash\n# No overlay supplied.\n', { mode: 0o755 });
158
+ (0, node_fs_1.chmodSync)(staged, 0o755);
159
+ }
160
+ }
161
+ else {
162
+ const resolvedOverlayDir = path.resolve(overlayDir);
163
+ if (!(0, node_fs_1.existsSync)(resolvedOverlayDir)) {
164
+ throw new Error(`overlay directory does not exist: ${resolvedOverlayDir}`);
165
+ }
166
+ for (const required of ['setup-root', 'setup-workspace']) {
167
+ if (!(0, node_fs_1.existsSync)(path.join(resolvedOverlayDir, required))) {
168
+ throw new Error(`overlay directory ${resolvedOverlayDir} is missing required script ${required}`);
169
+ }
170
+ }
171
+ stageScriptDirectory(resolvedOverlayDir, overlayStage);
145
172
  }
146
173
  stageRepository(repositoryPath, path.join(destination, 'repo'), originUrl);
147
174
  return destination;
@@ -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;
@@ -1,7 +1,9 @@
1
- # Evident per-session MicroVM image (#558, epic #556)
1
+ # Generic MicroVM runner image
2
2
  #
3
- # Same installed footprint as the ECS runner image, built for linux/arm64
4
- # (Lambda MicroVMs run on Graviton only) and with ONE process: the hook server.
3
+ # Built for linux/arm64 (Lambda MicroVMs run on Graviton only) and with ONE
4
+ # process: the hook server. `overlay/setup-root` and `overlay/setup-workspace`
5
+ # are the extension point for deployment-specific packages, dependency
6
+ # installation and build warm-up.
5
7
  #
6
8
  # THE BOOT SPLIT — the rule that governs everything below. AWS snapshots this
7
9
  # image ONCE at build and every MicroVM resumes from that same snapshot, so
@@ -9,7 +11,8 @@
9
11
  # this image version. Therefore this image must NOT start the tunnel
10
12
  # (`evident run`), clone the repo, or carry any credential, machine ID or
11
13
  # generated secret. Identity arrives per-VM in the `/run` hook payload.
12
- # `src/image/dockerfile.test.ts` fails the build if this regresses.
14
+ # `aws/runner-cdk/src/microvm/image/dockerfile.test.ts` fails the build if this
15
+ # regresses.
13
16
  #
14
17
  # No `--platform` on FROM: AWS builds this natively on Graviton. Build it
15
18
  # locally with `docker build --platform linux/arm64`.
@@ -27,9 +30,8 @@ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
27
30
 
28
31
  # System tooling + GitHub CLI + AWS CLI, in a single layer.
29
32
  # - awscli (v2): required — durable-state persistence uses `aws s3`.
30
- # - procps, postgresql(+contrib), direnv: required at RUNTIME so the VM is a
31
- # full dev box for this repo (local Postgres on 5433 as the non-root runner,
32
- # no sudo). Listed explicitly so apt keeps them.
33
+ # - procps: required at RUNTIME for process inspection and debugging. Listed
34
+ # explicitly so apt keeps it.
33
35
  # - gnupg, unzip: BUILD-ONLY (gh apt key / AWS CLI bundle); purged in this layer.
34
36
  #
35
37
  # Purge build-only packages BY NAME — do NOT use `apt-get autoremove`, which
@@ -38,7 +40,6 @@ RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
38
40
  RUN apt-get update \
39
41
  && apt-get install -y --no-install-recommends \
40
42
  ca-certificates curl git jq less procps psmisc ripgrep \
41
- postgresql postgresql-contrib direnv \
42
43
  gnupg unzip \
43
44
  # --- GitHub CLI (`gh`) from the official apt repo ---
44
45
  && mkdir -p -m 755 /etc/apt/keyrings \
@@ -54,10 +55,6 @@ RUN apt-get update \
54
55
  && unzip -q /tmp/awscliv2.zip -d /tmp \
55
56
  && /tmp/aws/install \
56
57
  && aws --version \
57
- # --- Put the PostgreSQL server binaries on PATH ---
58
- # On Debian initdb/pg_ctl/postgres/psql live under /usr/lib/postgresql/
59
- # <ver>/bin, not on PATH; symlink them into /usr/local/bin.
60
- && ln -s /usr/lib/postgresql/*/bin/* /usr/local/bin/ 2>/dev/null || true \
61
58
  # --- Drop the build-only packages (BY NAME, no autoremove) + caches ---
62
59
  && apt-get purge -y gnupg unzip \
63
60
  && rm -rf /tmp/aws /tmp/awscliv2.zip /var/lib/apt/lists/*
@@ -87,7 +84,7 @@ RUN curl -fsSL -o /tmp/litestream.tar.gz \
87
84
  # floating `opencode-ai@latest` install was implicated in unbounded runner
88
85
  # memory growth / OOM kills. Keep it in lockstep with the ECS runner image's.
89
86
  # EVIDENT_CLI_VERSION is DELIBERATELY the floating `dev` tag, matching the ECS
90
- # runner image (infrastructure/evident-runner/src/base-image.ts). This is a
87
+ # runner image. This is a
91
88
  # development environment: it should track the head of the CLI so changes are
92
89
  # testable without a version bump in every PR that touches a hook.
93
90
  # RUNNER_SYNCHRONISER_VERSION floats on `dev` for the identical reason.
@@ -104,27 +101,10 @@ RUN npm install -g \
104
101
  && evident --version \
105
102
  && npm cache clean --force
106
103
 
107
- # Playwright chromium + OS deps for E2E, baked at build time to avoid a slow
108
- # first-boot download.
109
- #
110
- # CRITICAL: this version MUST match the Playwright version the repo resolves for
111
- # `@playwright/test` (pnpm-lock.yaml → 1.58.0). The browser build is coupled to
112
- # the package version; if it drifts, a boot-time `playwright install`
113
- # re-downloads a different chromium. Bump in lockstep with @playwright/test.
114
- #
115
- # Browsers go to a SHARED PLAYWRIGHT_BROWSERS_PATH readable by the runtime
116
- # runner user (uid 10001), not root's ~/.cache.
117
- ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
118
- RUN npx playwright@1.58.0 install --with-deps chromium \
119
- && chown -R 10001:10001 /opt/ms-playwright \
120
- # Assert the browsers landed in the shared path (guard against a silent failure).
121
- && test -n "$(ls -A /opt/ms-playwright 2>/dev/null)" \
122
- || { echo "FATAL: Playwright browsers dir /opt/ms-playwright missing or empty after install" >&2; exit 1; }
123
-
124
104
  # Assert every REQUIRED runtime binary survived every layer above (fail loud).
125
105
  # Placed after the last install so it covers the apt purge, the litestream
126
106
  # extraction and the global npm installs in one pass.
127
- RUN for bin in ps pgrep pkill curl aws gh git jq rg psql pg_ctl initdb direnv litestream runner-synchroniser opencode evident timeout; do \
107
+ RUN for bin in ps pgrep pkill curl aws gh git jq rg litestream runner-synchroniser opencode evident timeout; do \
128
108
  command -v "$bin" >/dev/null 2>&1 || { echo "FATAL: required binary '$bin' missing from image" >&2; exit 1; }; \
129
109
  done \
130
110
  && curl -fsS -o /dev/null https://cli.github.com \
@@ -150,8 +130,8 @@ COPY hook-server.js /usr/local/lib/evident/hook-server.js
150
130
  COPY hooks /etc/evident/hooks
151
131
 
152
132
  # Workspace: the repository the agent works on, baked in. A resumed VM must be
153
- # useful in milliseconds, and a clone + install costs minutes — so both happen
154
- # here, and the image is deliberately large in exchange.
133
+ # useful in milliseconds, while a clone costs minutes — so the checkout happens
134
+ # here; dependency installation and build warm-up belong to the deployment overlay.
155
135
  #
156
136
  # This does not weaken the boot split: source, git history and node_modules are
157
137
  # neither secret nor per-VM-unique, so sharing them across every VM from this
@@ -162,8 +142,6 @@ COPY hooks /etc/evident/hooks
162
142
  # per-VM, so the agent is what brings this checkout up to date.
163
143
  ENV WORKSPACE=/workspace
164
144
  ENV HOME=/home/runner
165
- # Runner-owned PGDATA parent for the local dev Postgres cluster (initdb +
166
- # pg_ctl at boot, no sudo).
167
145
  #
168
146
  # The machine-id files are created EMPTY and runner-owned so that /run can
169
147
  # actually rewrite them: the hooks run as uid 10001, and neither `/etc` nor
@@ -171,10 +149,18 @@ ENV HOME=/home/runner
171
149
  # permanent no-op and every VM from this snapshot shares one machine id. Empty
172
150
  # is the correct unset state — it is the absence of an identity, so nothing
173
151
  # per-VM-unique enters the shared snapshot.
174
- RUN mkdir -p /var/lib/runner-pg /var/lib/dbus /home/runner/.local/state/evident \
152
+ RUN mkdir -p /var/lib/dbus /home/runner/.local/state/evident \
175
153
  && install -o runner -g runner -m 0644 /dev/null /etc/machine-id \
176
154
  && install -o runner -g runner -m 0644 /dev/null /var/lib/dbus/machine-id \
177
- && chown -R runner:runner /var/lib/runner-pg /home/runner
155
+ && chown -R runner:runner /home/runner
156
+
157
+ # Deployment additions are always present because staging writes no-op defaults
158
+ # when no overlay is supplied.
159
+ COPY overlay /etc/evident/overlay
160
+ # HOME is already /home/runner, so root-warmed npx/npm state must be runner-owned
161
+ # or the runner's first npm/pnpm call would fail with EACCES.
162
+ RUN /etc/evident/overlay/setup-root \
163
+ && chown -R runner:runner /home/runner
178
164
  COPY --chown=10001:10001 repo ${WORKSPACE}
179
165
  WORKDIR ${WORKSPACE}
180
166
  USER runner
@@ -185,36 +171,13 @@ USER runner
185
171
  # `runner` (not root) so the rebuilt index is runner-owned and writable later.
186
172
  RUN git reset --quiet
187
173
 
188
- # Dependencies and the shared builds everything else depends on. As the runner
189
- # user, so node_modules is writable by the agent's own later installs.
190
- #
191
- # ONE layer on purpose: pnpm hard-links node_modules into its content-addressed
192
- # store (which it puts at ${WORKSPACE}/.pnpm-store, gitignored), and hard links
193
- # only survive within a single layer — split in two and the image carries both
194
- # copies. `--frozen-lockfile` because the committed lockfile is the whole reason
195
- # this is bakeable at all: installing against anything else would bake a
196
- # dependency set nobody committed.
197
- #
198
- # Guarded on a committed `pnpm-lock.yaml` so `microvm:repositoryPath` can point
199
- # at a repository that is not a pnpm workspace at all: it is baked uninstalled
200
- # instead of failing the build, and the agent installs on first use.
174
+ # Deployment-specific dependency installation and build work belongs here. This runs
175
+ # as `runner` in `${WORKSPACE}`, so everything it writes is agent-writable.
201
176
  #
202
- # Nothing environment-specific is built: `vite build` inlines VITE_* at build
203
- # time and the migrations need a running Postgres, so the app builds and the dev
204
- # database stay per-VM (see entrypoint.sh's pre-warm in evident-runner).
205
- RUN if [ -f pnpm-lock.yaml ]; then \
206
- pnpm install --frozen-lockfile \
207
- && pnpm run build --filter='./packages/*' \
208
- --filter='./aws/runner-cdk' \
209
- --filter='./aws/lambda-microvm-cdk' \
210
- --filter='./aws/lambda-microvm-runtime'; \
211
- else \
212
- echo "[workspace-prep] no pnpm-lock.yaml in the baked repository — skipping the dependency install and the workspace build; the agent installs on first use."; \
213
- fi
177
+ # Keep the overlay's install and build in one layer: pnpm hard-links `node_modules`
178
+ # into its content-addressed store, and hard links only survive within a single layer.
179
+ RUN /etc/evident/overlay/setup-workspace
214
180
 
215
- # Data dir for the local dev Postgres cluster; listens on 5433 to match this
216
- # repo's .envrc DSN postgres://postgres:postgres@localhost:5433/evident.
217
- ENV PGDATA=/var/lib/runner-pg/data
218
181
  # Port AWS calls the MicroVM hooks on.
219
182
  ENV HOOKS_PORT=8080
220
183
  # OpenCode loopback port the CLI tunnels to (matches `evident run` default).