Skip to content

Commit 2b5aec9

Browse files
committed
Keep an image entrypoint the dev-folder mount would hide available to the run
Apify's Playwright base images start through ./xvfb-entrypoint.sh inside the working directory. A dev-folder run bind-mounts the local folder over that directory, so unless the folder happens to carry the same file the engine refuses to start the container ("executable file not found"). The driver now inspects the image's command: a working-directory-relative entrypoint (or Cmd) the dev folder does not provide is read out of the image and placed in the container before start, and the run starts through that copy. Absolute and PATH-resolved commands are untouched, as is anything the dev folder provides itself. Covered by unit tests and a new e2e case with a busybox Actor whose entrypoint lives in its working directory. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZhuUP13NByepkP4n3sg3P
1 parent 4c265cc commit 2b5aec9

6 files changed

Lines changed: 310 additions & 4 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,10 @@ apify call --input '{"maxPages":3}' # picks up the new dist/, no rebuild
123123

124124
Node doesn't hot-reload a running process, so a local recompile is picked up by the **next** run's
125125
container start, not by any run already in progress. `node_modules` inside the container still comes
126-
from the built image - an anonymous volume preserves it underneath the bind mount - so a new dependency
127-
in `package.json` still needs a real `apify push`/build; only source edits skip it. Clear the
126+
from the built image - a per-run volume preserves it underneath the bind mount - so a new dependency
127+
in `package.json` still needs a real `apify push`/build; only source edits skip it. An entrypoint script
128+
the image keeps in its working directory (Apify's Playwright images start through an Xvfb wrapper there)
129+
stays available too, unless your folder carries its own copy. Clear the
128130
registration with an empty body (`--body '""'`) to go back to running purely from the built image. Full
129131
mechanics: `requirements/actor-driver.md`'s "Bind mount volumes with Actor source code";
130132
endpoint/console details: `requirements/api.md`'s `/actor-runtime/*` section and

requirements/actor-driver.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
else unverifiable reports a generic "could not verify".
5353
- **Registration has no build-first precondition** - it requires no build of the Actor to exist,
5454
succeeded or otherwise.
55+
- An entrypoint the image keeps inside its working directory (Apify's Playwright base images start
56+
through an Xvfb script there) stays available to the run even though the mount covers that directory,
57+
unless the dev folder provides its own copy; the run log says so.
5558
- The working directory the mount covers is recorded **per build**, never on the Actor
5659
(`storage.md`); the mount a run applies always uses the one from _that run's own resolved build_,
5760
never any other build the Actor happens to have.

src/driver/docker-driver.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,46 @@ function devNodeModulesVolumeName(runId: string): string {
224224
return `${DEV_NODE_MODULES_VOLUME_PREFIX}${runId}`;
225225
}
226226

227+
/** A command token the engine resolves against the working directory rather than `PATH`: `./x.sh`,
228+
* `bin/x` - relative, with a slash. A bare `x.sh` goes through `PATH`, an absolute path is unaffected
229+
* by what is mounted over the working directory. */
230+
function isWorkingDirectoryRelative(token: string): boolean {
231+
return !token.startsWith('/') && token.includes('/');
232+
}
233+
234+
function readStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
235+
return new Promise((resolve, reject) => {
236+
const chunks: Buffer[] = [];
237+
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
238+
stream.once('error', reject);
239+
stream.once('end', () => resolve(Buffer.concat(chunks)));
240+
});
241+
}
242+
243+
/** Re-packs the single-file archive `getArchive` returns so its entries land under `directory` when
244+
* extracted at `/`, with an explicit directory entry so no engine has to invent the parent. */
245+
async function repackUnderDirectory(archive: Buffer, directory: string): Promise<Buffer> {
246+
const dir = directory.replace(/^\/+/, '');
247+
const pack = tar.pack();
248+
const extract = tar.extract();
249+
const packed = readStream(pack);
250+
pack.entry({ name: dir, type: 'directory', mode: 0o755 });
251+
await new Promise<void>((resolve, reject) => {
252+
extract.on('entry', (header, content, next) => {
253+
const entry = pack.entry({ ...header, name: `${dir}/${header.name}` }, (error) => {
254+
if (error) reject(error);
255+
else next();
256+
});
257+
content.pipe(entry);
258+
});
259+
extract.once('error', reject);
260+
extract.once('finish', resolve);
261+
extract.end(archive);
262+
});
263+
pack.finalize();
264+
return packed;
265+
}
266+
227267
/** A container's address on `network`, else on whatever network it does have. */
228268
function containerAddress(info: Docker.ContainerInspectInfo, network: string | undefined): string | undefined {
229269
const networks = info.NetworkSettings?.Networks ?? {};
@@ -252,6 +292,17 @@ const X11_SOCKET_DIR = '/tmp/.X11-unix';
252292
* follows. Named, not anonymous, because Podman 3.x refuses a volume mount without a source ("must set
253293
* source volume"). Removed with the run, and swept by this prefix after a restart. */
254294
const DEV_NODE_MODULES_VOLUME_PREFIX = 'actor-runtime-node-modules-';
295+
/** Where a `devMount` run keeps the image's own copy of an entrypoint file the bind mount would hide
296+
* (`preserveHiddenEntrypoint`). */
297+
const PRESERVED_ENTRYPOINT_DIR = '/apify-runtime-entrypoint';
298+
299+
/** The image command a `devMount` run starts through instead of its own, plus the tar that puts the
300+
* preserved file in place before the container starts. */
301+
interface PreservedEntrypoint {
302+
field: 'Entrypoint' | 'Cmd';
303+
command: string[];
304+
tar: Buffer;
305+
}
255306
/** Reachable only on `apify-local`; never published on the host. */
256307
const BROWSER_VIEWER_VNC_PORT = 5900;
257308
const BROWSER_VIEWER_MEMORY_BYTES = 256 * 1024 * 1024;
@@ -1017,12 +1068,14 @@ export class DockerDriver implements Driver {
10171068
// bind whose source vanished since registration, but Podman's Docker-compatible API auto-creates the
10181069
// missing source instead - which would silently start the run against an empty directory, exactly
10191070
// what `actor-driver.md` forbids ("fail visibly - never silently mount an empty directory").
1071+
let preservedEntrypoint: PreservedEntrypoint | undefined;
10201072
if (ctx.devMount) {
10211073
await this.assertDevFolderStillPresent(ctx.devMount.localDevFolder);
10221074
onLog(
10231075
`Mounting local dev folder ${ctx.devMount.localDevFolder} over the image's working directory ` +
1024-
`${ctx.devMount.imageWorkingDirectory} (node_modules preserved via an anonymous volume).\n`,
1076+
`${ctx.devMount.imageWorkingDirectory} (node_modules preserved via a per-run volume).\n`,
10251077
);
1078+
preservedEntrypoint = await this.preserveHiddenEntrypoint(ctx.imageId, ctx.devMount, onLog);
10261079
}
10271080

10281081
// Loaded and logged before `createContainer` so a missing payload fails the run before any
@@ -1055,6 +1108,7 @@ export class DockerDriver implements Driver {
10551108
Image: ctx.imageId,
10561109
Env: env,
10571110
Labels: { [RUN_LABEL]: ctx.runId },
1111+
...(preservedEntrypoint ? { [preservedEntrypoint.field]: preservedEntrypoint.command } : {}),
10581112
...(ctx.debug ? { ExposedPorts: { [`${ctx.debug.port}/tcp`]: {} } } : {}),
10591113
HostConfig: {
10601114
...(await this.actorNetworkHostConfig(onActorNetwork)),
@@ -1094,6 +1148,7 @@ export class DockerDriver implements Driver {
10941148

10951149
try {
10961150
// Inside the try so a failed upload still reaches the finally below and removes the container.
1151+
if (preservedEntrypoint) await container.putArchive(preservedEntrypoint.tar, { path: '/' });
10971152
if (debugPayload) {
10981153
await container.putArchive(debugPayload.tar, { path: '/' });
10991154
}
@@ -1117,6 +1172,7 @@ export class DockerDriver implements Driver {
11171172
this.runContainers.set(ctx.runId, retry);
11181173
container = retry;
11191174
try {
1175+
if (preservedEntrypoint) await retry.putArchive(preservedEntrypoint.tar, { path: '/' });
11201176
if (debugPayload) await retry.putArchive(debugPayload.tar, { path: '/' });
11211177
await retry.start();
11221178
} catch (retryError) {
@@ -1245,6 +1301,75 @@ export class DockerDriver implements Driver {
12451301
);
12461302
}
12471303

1304+
/**
1305+
* A `devMount` run starts through the image's own `Entrypoint` (or `Cmd`); when that names a file
1306+
* inside the working directory - Apify's Playwright base images start through `./xvfb-entrypoint.sh`
1307+
* there - the bind mount hides it unless the dev folder happens to carry the same file, and the engine
1308+
* refuses to start ("executable file not found"). Unless the dev folder provides it, the file is
1309+
* taken from the image and the run starts through that copy, at a path no mount covers. Anything
1310+
* `PATH`-resolved or absolute is left alone: the mount cannot hide it.
1311+
*/
1312+
private async preserveHiddenEntrypoint(
1313+
imageId: string,
1314+
devMount: DevFolderMount,
1315+
onLog: (chunk: string) => void,
1316+
): Promise<PreservedEntrypoint | undefined> {
1317+
const info = await this.docker.getImage(imageId).inspect();
1318+
const entrypointRaw = info.Config?.Entrypoint;
1319+
const entrypoint = Array.isArray(entrypointRaw) ? entrypointRaw : entrypointRaw ? [entrypointRaw] : [];
1320+
const field: PreservedEntrypoint['field'] = entrypoint.length > 0 ? 'Entrypoint' : 'Cmd';
1321+
const command = field === 'Entrypoint' ? entrypoint : (info.Config?.Cmd ?? []);
1322+
const first = command[0];
1323+
if (!first || !isWorkingDirectoryRelative(first)) return undefined;
1324+
if (await this.devFolderHasEntry(devMount.localDevFolder, first)) return undefined;
1325+
1326+
const inImage = path.posix.resolve(devMount.imageWorkingDirectory, first);
1327+
const archive = await this.extractFromImage(imageId, inImage);
1328+
const tarball = await repackUnderDirectory(archive, PRESERVED_ENTRYPOINT_DIR);
1329+
onLog(
1330+
`The image starts through ${first} in its working directory, which the dev folder does not contain; ` +
1331+
`using the image's own copy of it.\n`,
1332+
);
1333+
return {
1334+
field,
1335+
command: [`${PRESERVED_ENTRYPOINT_DIR}/${path.posix.basename(inImage)}`, ...command.slice(1)],
1336+
tar: tarball,
1337+
};
1338+
}
1339+
1340+
/** Whether `relativePath` exists inside the registered dev folder on the host - through the same probe
1341+
* container `probeDevFolder` uses, so the answer is the engine's own. Public for tests. */
1342+
async devFolderHasEntry(localDevFolder: string, relativePath: string): Promise<boolean> {
1343+
const container = await this.docker.createContainer({
1344+
Image: await this.ensureProbeImage(),
1345+
Labels: { [PROBE_LABEL]: 'true' },
1346+
HostConfig: {
1347+
Mounts: [{ Type: 'bind', Source: PROBE_MOUNT_SOURCE, Target: PROBE_MOUNT_TARGET, ReadOnly: true }],
1348+
},
1349+
});
1350+
try {
1351+
const outcome = await statInProbe(
1352+
container,
1353+
path.posix.join(PROBE_MOUNT_TARGET, localDevFolder, relativePath),
1354+
);
1355+
return outcome.ok;
1356+
} finally {
1357+
await container.remove().catch((error: Error) => {
1358+
console.warn(`Could not remove dev-folder probe container ${container.id}: ${error.message}`);
1359+
});
1360+
}
1361+
}
1362+
1363+
/** The archive of one path from an image, read through a container created (never started) from it. */
1364+
private async extractFromImage(imageId: string, pathInImage: string): Promise<Buffer> {
1365+
const container = await this.docker.createContainer({ Image: imageId, Labels: { [PROBE_LABEL]: 'true' } });
1366+
try {
1367+
return await readStream((await container.getArchive({ path: pathInImage })) as NodeJS.ReadableStream);
1368+
} finally {
1369+
await container.remove().catch(() => undefined);
1370+
}
1371+
}
1372+
12481373
/** The two `HostConfig.Mounts` entries for a `devMount` run: a read-write bind for the dev folder
12491374
* itself (`Mounts`, not `Binds` - a `Mounts`-type bind errors on a missing source instead of silently
12501375
* auto-creating one), plus a fresh per-run volume over `node_modules` - the engine creates it at

test/e2e/dev-folder-bind-mount.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* Requires a reachable Docker daemon and fails loudly, never skips, mirroring `actor-dev-loop.test.ts`.
1515
*/
1616
import { execFileSync } from 'node:child_process';
17-
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
17+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1818
import { tmpdir } from 'node:os';
1919
import { fileURLToPath } from 'node:url';
2020
import { dirname, join } from 'node:path';
@@ -298,6 +298,63 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r
298298
5 * 60 * 1000,
299299
);
300300

301+
it(
302+
"an entrypoint the image keeps inside its working directory (Apify's Playwright images start through one) stays available under the mount when the dev folder lacks it",
303+
() => {
304+
const env = apifyEnv(isolatedApifyHome);
305+
// A tiny Actor of its own: busybox, an entrypoint script at ./entry.sh in the working directory
306+
// (what `apify/actor-*-playwright*` images do with their Xvfb wrapper), and a dev folder that has
307+
// no such file - mounting it over /app would hide the script.
308+
const entryActorDir = mkdtempSync(join(tmpdir(), 'actor-runtime-e2e-devfolder-entry-'));
309+
const devFolder = mkdtempSync(join(tmpdir(), 'actor-runtime-e2e-devfolder-entry-src-'));
310+
try {
311+
mkdirSync(join(entryActorDir, '.actor'));
312+
writeFileSync(
313+
join(entryActorDir, '.actor', 'actor.json'),
314+
JSON.stringify({
315+
actorSpecification: 1,
316+
name: 'devfolder-entrypoint',
317+
version: '0.0',
318+
buildTag: 'latest',
319+
}),
320+
);
321+
writeFileSync(
322+
join(entryActorDir, 'entry.sh'),
323+
'#!/bin/sh\necho "entry.sh from the image: $0"\nexec "$@"\n',
324+
);
325+
writeFileSync(
326+
join(entryActorDir, 'Dockerfile'),
327+
[
328+
'FROM docker.io/library/busybox',
329+
'WORKDIR /app',
330+
'COPY entry.sh ./entry.sh',
331+
'RUN chmod 755 ./entry.sh',
332+
'ENTRYPOINT ["./entry.sh"]',
333+
'CMD ["sh", "-c", "ls /app; echo run-body-done"]',
334+
'',
335+
].join('\n'),
336+
);
337+
writeFileSync(join(devFolder, 'only-in-dev-folder.txt'), 'x');
338+
339+
const push = JSON.parse(apify(['push', '--json'], { cwd: entryActorDir, env })) as PushResult;
340+
expect(push.build.status).toBe('SUCCEEDED');
341+
registerDevFolder(push.actor.id, devFolder, env);
342+
343+
// `apify call` streams the run log; a FAILED run makes it exit non-zero, which throws here.
344+
const output = apifyAllOutput(['call'], { cwd: entryActorDir, env });
345+
expect(output).toContain('starts through ./entry.sh in its working directory');
346+
expect(output).toContain('entry.sh from the image: /apify-runtime-entrypoint/entry.sh');
347+
// The dev folder, not the image's /app, is what the run sees in the working directory.
348+
expect(output).toContain('only-in-dev-folder.txt');
349+
expect(output).toContain('run-body-done');
350+
} finally {
351+
rmSync(entryActorDir, { recursive: true, force: true });
352+
rmSync(devFolder, { recursive: true, force: true });
353+
}
354+
},
355+
5 * 60 * 1000,
356+
);
357+
301358
it(
302359
'anonymous node_modules volumes do not accumulate across runs ({ v: true } cleanup)',
303360
async () => {

test/unit/docker-driver.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,110 @@ describe('DockerDriver.probeDevFolder (actor-driver.md: "A host-side existence-a
10091009
});
10101010
});
10111011

1012+
describe('DockerDriver.startRun - an image entrypoint the dev-folder mount would hide (actor-driver.md: "stays available to the run")', () => {
1013+
const devMountRun = {
1014+
runId: 'run-entry',
1015+
imageId: 'fake-image',
1016+
env: {},
1017+
memoryMbytes: 128,
1018+
timeoutSecs: 60,
1019+
devMount: { localDevFolder: '/host/src', imageWorkingDirectory: '/usr/src/app' },
1020+
};
1021+
1022+
function scriptArchive(name: string, content: string): NodeJS.ReadableStream {
1023+
const pack = tar.pack();
1024+
pack.entry({ name, mode: 0o755 }, content);
1025+
pack.finalize();
1026+
return pack as unknown as NodeJS.ReadableStream;
1027+
}
1028+
1029+
async function entryNamesOf(archive: Buffer): Promise<Array<{ name: string; type?: string; content: string }>> {
1030+
const entries: Array<{ name: string; type?: string; content: string }> = [];
1031+
const extract = tar.extract();
1032+
await new Promise<void>((resolve, reject) => {
1033+
extract.on('entry', (header, stream, next) => {
1034+
const chunks: Buffer[] = [];
1035+
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
1036+
stream.on('end', () => {
1037+
entries.push({ name: header.name, type: header.type, content: Buffer.concat(chunks).toString() });
1038+
next();
1039+
});
1040+
stream.resume();
1041+
});
1042+
extract.once('finish', resolve);
1043+
extract.once('error', reject);
1044+
extract.end(archive);
1045+
});
1046+
return entries;
1047+
}
1048+
1049+
it('a relative entrypoint inside the working directory that the dev folder lacks: the file comes out of the image, lands in the container before start, and the run starts through that copy - the run log says so', async () => {
1050+
const stub = stubDockerForRun();
1051+
stub.imageInspect.mockResolvedValue({
1052+
Config: {
1053+
Entrypoint: ['./xvfb-entrypoint.sh'],
1054+
Cmd: ['python', '-m', 'my_actor'],
1055+
WorkingDir: '/usr/src/app',
1056+
},
1057+
});
1058+
stub.container.getArchive.mockResolvedValue(scriptArchive('xvfb-entrypoint.sh', '#!/bin/sh\nexec "$@"\n'));
1059+
const driver = new DockerDriver(stub.docker);
1060+
driver.available = true;
1061+
allowDevMountRecheck(driver);
1062+
const hasEntry = vi.spyOn(driver, 'devFolderHasEntry').mockResolvedValue(false);
1063+
const logged: string[] = [];
1064+
1065+
const outcomePromise = driver.startRun(devMountRun, (chunk) => logged.push(chunk));
1066+
await new Promise((resolve) => setImmediate(resolve));
1067+
await new Promise((resolve) => setImmediate(resolve));
1068+
stub.triggerContainerExit(0);
1069+
stub.endLogStream();
1070+
await outcomePromise;
1071+
1072+
expect(hasEntry).toHaveBeenCalledWith('/host/src', './xvfb-entrypoint.sh');
1073+
expect(stub.container.getArchive).toHaveBeenCalledWith({ path: '/usr/src/app/xvfb-entrypoint.sh' });
1074+
// Two containers: the throwaway one the file is read from, then the run's own.
1075+
expect(stub.createContainer).toHaveBeenCalledTimes(2);
1076+
const runOptions = stub.createContainer.mock.calls[1]![0];
1077+
expect(runOptions.Entrypoint).toEqual(['/apify-runtime-entrypoint/xvfb-entrypoint.sh']);
1078+
expect(runOptions.Cmd).toBeUndefined();
1079+
const [archive, putOptions] = stub.container.putArchive.mock.calls[0]!;
1080+
expect(putOptions).toEqual({ path: '/' });
1081+
expect(await entryNamesOf(archive as Buffer)).toEqual([
1082+
{ name: 'apify-runtime-entrypoint', type: 'directory', content: '' },
1083+
{ name: 'apify-runtime-entrypoint/xvfb-entrypoint.sh', type: 'file', content: '#!/bin/sh\nexec "$@"\n' },
1084+
]);
1085+
expect(logged.join('')).toContain('starts through ./xvfb-entrypoint.sh in its working directory');
1086+
});
1087+
1088+
it('the dev folder providing the file itself, an absolute entrypoint, or a PATH-resolved one: nothing is preserved and the image command stands', async () => {
1089+
for (const [config, devFolderHasIt] of [
1090+
[{ Entrypoint: ['./xvfb-entrypoint.sh'], WorkingDir: '/usr/src/app' }, true],
1091+
[{ Entrypoint: ['/usr/local/bin/xvfb-run', 'node', 'main.js'], WorkingDir: '/usr/src/app' }, false],
1092+
[{ Cmd: ['npm', 'start'], WorkingDir: '/usr/src/app' }, false],
1093+
] as Array<[Record<string, unknown>, boolean]>) {
1094+
const stub = stubDockerForRun();
1095+
stub.imageInspect.mockResolvedValue({ Config: config });
1096+
const driver = new DockerDriver(stub.docker);
1097+
driver.available = true;
1098+
allowDevMountRecheck(driver);
1099+
vi.spyOn(driver, 'devFolderHasEntry').mockResolvedValue(devFolderHasIt);
1100+
1101+
const outcomePromise = driver.startRun(devMountRun, () => {});
1102+
await new Promise((resolve) => setImmediate(resolve));
1103+
await new Promise((resolve) => setImmediate(resolve));
1104+
stub.triggerContainerExit(0);
1105+
stub.endLogStream();
1106+
await outcomePromise;
1107+
1108+
expect(stub.container.getArchive).not.toHaveBeenCalled();
1109+
expect(stub.createContainer).toHaveBeenCalledTimes(1);
1110+
expect(stub.createContainer.mock.calls[0]![0].Entrypoint).toBeUndefined();
1111+
expect(stub.container.putArchive).not.toHaveBeenCalled();
1112+
}
1113+
});
1114+
});
1115+
10121116
/** Lets a `startRun` test with a `devMount` get past the run-start dev-folder re-check
10131117
* (`assertDevFolderStillPresent`) when that check is not what the test is about. */
10141118
function allowDevMountRecheck(driver: DockerDriver): void {

0 commit comments

Comments
 (0)