From 758c1c9ab196aaf4c0ad8ebe8c02dcc8e92d7a8a Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 12:56:33 +0000 Subject: [PATCH 1/3] fix: enforce preloaded VRT images and verified reaper reuse Validate all images before resource creation. A pinned Testcontainers patch blocks pulls/auth and verifies existing Ryuk image IDs before connecting. Exercise real reapers and public compare/update targets in a minimal runner. BREAKING CHANGE: VRT images must be preloaded before test execution. --- .github/workflows/ci.yaml | 2 + CHANGELOG.md | 7 ++ README.md | 3 +- docs/api.md | 21 ++-- docs/component-vrt.md | 3 +- docs/getting-started.md | 2 +- docs/host-browsers.md | 3 +- docs/testcontainers-vrt.md | 24 +++- examples/react/BUILD.bazel | 23 ++++ patches/BUILD.bazel | 1 + patches/README.md | 17 +++ patches/testcontainers-12.1.0.patch | 60 ++++++++++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 3 + runtime/BUILD.bazel | 6 + runtime/container.ts | 12 ++ runtime/fixtures/docker-proxy.ts | 120 ++++++++++++++++++++ runtime/fixtures/preload-worker.ts | 35 ++++++ runtime/preload-browser.test.ts | 170 ++++++++++++---------------- runtime/preload-lifecycle.test.ts | 73 ++++++++++++ tests/preloaded-vrt.sh | 49 ++++++++ 21 files changed, 529 insertions(+), 112 deletions(-) create mode 100644 patches/BUILD.bazel create mode 100644 patches/README.md create mode 100644 patches/testcontainers-12.1.0.patch create mode 100644 runtime/fixtures/docker-proxy.ts create mode 100644 runtime/fixtures/preload-worker.ts create mode 100644 runtime/preload-lifecycle.test.ts create mode 100644 tests/preloaded-vrt.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 611eeb0..d61486e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,6 +88,8 @@ jobs: fi done bazelisk test //runtime:preload_browser_test --test_output=errors + - name: Verify VRT with only declared tools and Docker + run: bash tests/preloaded-vrt.sh - name: Verify generated capture bounds and pixels run: bazelisk test //runtime:capture_browser_test --test_output=errors - name: Run visual tests and container isolation coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 3638b07..8ba01d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Breaking compatibility: preload VRT images + +VRT no longer pulls images or authenticates to registries during execution. +Preload every image in the runtime manifest before testing or updating baselines. +Existing Ryuk containers must match the pinned image; unverifiable or mismatched +reapers fail without being stopped. See [setup](docs/api.md#vrt-image-manifest-and-ci-preloading). + ### Breaking compatibility: host browser execution `web_e2e_test` and `component_browser_test` now launch on the host. Provision a diff --git a/README.md b/README.md index 332f20f..1c0ce9b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ attributes; see [migration](docs/getting-started.md#migrating-from-100). ## Try it Install Bazelisk and provision host Chromium first (with the locked Playwright version). -Start Docker only for VRT. From this checkout: +Start Docker and [preload the pinned images](docs/api.md#vrt-image-manifest-and-ci-preloading) +for VRT. From this checkout: ```sh export PLAYWRIGHT_BROWSERS_PATH="$(pwd)/.playwright-browsers" diff --git a/docs/api.md b/docs/api.md index fd86e6a..7430380 100644 --- a/docs/api.md +++ b/docs/api.md @@ -303,9 +303,9 @@ done Then run the test using the **same daemon**, exact image references, and correct platforms, with an empty `DOCKER_CONFIG` and registry credentials unavailable. -Testcontainers uses already-present images before attempting registry authentication. +The runtime requires locally available images and never attempts registry authentication or pulls. The manifest is not an image archive; transfer/load workflows must preserve the -exact digest references. Missing images may still trigger a pull and fail. +exact digest references. Missing or uninspectable images fail with a preload error before resource creation. Supported discovery is a host runner's normal Docker discovery, or a containerized runner with an explicit TCP/HTTP(S) `DOCKER_HOST` (and optional TLS settings). @@ -322,15 +322,16 @@ browser infrastructure, not images launched by consumer fixtures or servers. ### Existing reapers on shared daemons -Testcontainers may reuse an already-running Ryuk container without checking its -image digest against this manifest. The pinned requirement governs **fresh reaper -creation**, not the identity of every reused reaper. The preload regression hides -existing reapers and therefore verifies fresh startup only. +Before connecting to an existing Ryuk, the runtime verifies that its actual +container image ID matches the locally resolved pinned Ryuk image. Labels and +requested image names are not sufficient. Mismatched or unverifiable identities +fail with an actionable error; the runtime never stops another invocation's +reaper. Use a dedicated daemon or coordinate cleanup with that reaper's owner. -For strict image identity today, use a dedicated fresh daemon with the manifest's -images preloaded and no running reaper from another invocation. Digest-checked -reuse or rejection of mismatched reapers on shared daemons is tracked in -[#20](https://github.com/perplexityai/rules_web_e2e/issues/20). +A pinned [dependency patch](../patches/README.md) enforces verification before +reuse and disables pulls/auth inside Testcontainers itself, including after +preflight. Tests cover real fresh and reused reapers, a different Ryuk image, +unverifiable identity, missing images, and public comparison/update failures. ## Host browser execution diff --git a/docs/component-vrt.md b/docs/component-vrt.md index f83606b..7aef33d 100644 --- a/docs/component-vrt.md +++ b/docs/component-vrt.md @@ -10,7 +10,8 @@ and the [API reference](api.md) for all supported attributes. ## Try the standalone example -Install Bazelisk and Docker, start a local Docker daemon, then: +Install Bazelisk and Docker, start a local Docker daemon, and +[preload the pinned images](api.md#vrt-image-manifest-and-ci-preloading), then: ```sh cd examples/react diff --git a/docs/getting-started.md b/docs/getting-started.md index 5a5ebac..5c1e852 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,7 +19,7 @@ flowchart LR ## Run the example Install Bazelisk and [provision host Chromium](host-browsers.md) for E2E/component -tests. Start Docker only for VRT; Linux amd64 is the validated screenshot platform. +tests. Start Docker and [preload images](api.md#vrt-image-manifest-and-ci-preloading) for VRT; Linux amd64 is the validated screenshot platform. ```sh cd examples/react diff --git a/docs/host-browsers.md b/docs/host-browsers.md index 00da2bf..41026f3 100644 --- a/docs/host-browsers.md +++ b/docs/host-browsers.md @@ -123,4 +123,5 @@ AGI can retain its ECR runtime image; FormatJS can supply a custom image with it fonts and rendering dependencies. This image is ignored by host interaction tests that share the runtime. Ryuk is still a separate helper requirement for VRT, exposed by `playwright_images`; constructing a browser image does not remove -that helper or the documented existing-reaper reuse limitation. +that helper. Preload all manifest images before execution; existing Ryuk image +identity must match the pin before reuse. diff --git a/docs/testcontainers-vrt.md b/docs/testcontainers-vrt.md index 6efaf4d..1e42985 100644 --- a/docs/testcontainers-vrt.md +++ b/docs/testcontainers-vrt.md @@ -6,6 +6,8 @@ control relay per invocation. Both use the same digest-pinned Playwright image and explicit `linux/amd64` platform. The runner verifies the browser's platform and the declared Playwright package version before running tests. Ryuk, the cleanup helper, uses a pinned image digest on the daemon-selected platform. +All images must be preloaded; execution never pulls images or authenticates to +registries. Existing Ryuk image identity is verified before reuse. ```mermaid flowchart LR @@ -58,7 +60,7 @@ state. Pixel tolerance should not hide uncontrolled inputs. This is reproducible local browser testing, not a fully sandboxed Bazel action. The host Node processes execute trusted consumer config, plugins, and tests; those can explicitly read host files or access the network. Docker discovery, -credentials, daemon/kernel behavior, image availability, and machine resources +daemon/kernel behavior, preloaded image availability, and machine resources remain external inputs. Tests therefore remain manual, local, and uncached. Remote Docker daemons are not supported by the loopback control binding. @@ -67,3 +69,23 @@ blocked unrelated host ports, and blocked direct public-network access. The standalone example checks committed screenshot baselines. The built-in server reads only compiled assets; dotenv loading and source transformation are absent during execution. Consumer builds remain responsible for their own environment and dependency discovery. + +## Enforced runtime dependency contract + +Beyond Bazel and standard OS facilities, Docker is the only additional installed +VRT prerequisite. Node, Playwright packages, and compiled application inputs come +from Bazel; Chromium and fonts come from the pinned browser image. Preload both +browser and Ryuk images using the [manifest](api.md#vrt-image-manifest-and-ci-preloading) +before testing. Image acquisition remains a caller-owned setup step. + +CI runs `tests/preloaded-vrt.sh` in a pinned OS-only Linux container with no +installed Node, Chromium, Docker CLI, or registry credentials. It executes built +public compare/update targets using their declared runfiles and a proxy that +rejects Docker pull/auth requests. Missing-image updates must preserve baselines; +completed and failed invocations must remove their browser/relay/network resources. +The envelope uses Linux host networking so Docker's loopback relay is reachable; +it is a regression environment, not a new consumer execution requirement. + +This verifies tool provisioning, not full hermeticity of consumer code. Host-side +Node code is still trusted and unsandboxed, and application clocks, randomness, +and opted-in external services remain consumer-controlled inputs. diff --git a/examples/react/BUILD.bazel b/examples/react/BUILD.bazel index c35a669..64bb3b5 100644 --- a/examples/react/BUILD.bazel +++ b/examples/react/BUILD.bazel @@ -247,3 +247,26 @@ visual_test( shell = ":app_shell", baseline_dir = "__isolation_screenshots__", ) + +js_test( + name = "preloaded_vrt_test", + copy_data_to_bin = False, + data = [ + "package.json", + ":native_visual_test", + ":native_visual_test.update", + "__native_screenshots__/saved.png", + "@rules_web_e2e//runtime:typecheck", + "@rules_web_e2e//runtime:images", + ], + entry_point = "@rules_web_e2e//runtime:preload_lifecycle_entry", + env = { + "PRELOAD_COMPARE": "$(rlocationpath :native_visual_test)", + "PRELOAD_UPDATE": "$(rlocationpath :native_visual_test.update)", + "PRELOAD_BASELINE": "$(rlocationpath __native_screenshots__/saved.png)", + "PRELOAD_IMAGES": "$(rlocationpath @rules_web_e2e//runtime:images)", + }, + env_inherit = ["DOCKER_HOST"], + tags = ["manual", "external", "no-sandbox", "no-remote", "no-cache"], + timeout = "long", +) diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel new file mode 100644 index 0000000..53e5842 --- /dev/null +++ b/patches/BUILD.bazel @@ -0,0 +1 @@ +exports_files(["testcontainers-12.1.0.patch"]) diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..7889124 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,17 @@ +# Testcontainers 12.1.0 execution guard + +The pnpm-locked patch adds opt-in `TESTCONTAINERS_PRELOADED_IMAGES_ONLY=true`. +`startBrowser` sets it for the rules' private runtime process: + +- Image startup inspects local image availability and returns without registry + authentication or pulls, including if an image disappears after preflight. +- Existing Ryuk containers are inspected before opening a cleanup connection. + Docker's actual container image ID must equal the locally resolved pinned image + ID. Cached reapers are checked too. Unverifiable or mismatched identities fail; + foreign reapers are never stopped by this check. + +This patches the pinned package rather than replacing its cleanup lifecycle or +monkey-patching shared client methods. Default Testcontainers behavior is unchanged +when the flag is absent. Revisit the patch on dependency upgrades; upstream support +for these policies can replace it. Real-daemon coverage lives in +`runtime/preload-browser.test.ts` and the React example's `preloaded_vrt_test`. diff --git a/patches/testcontainers-12.1.0.patch b/patches/testcontainers-12.1.0.patch new file mode 100644 index 0000000..f2716a0 --- /dev/null +++ b/patches/testcontainers-12.1.0.patch @@ -0,0 +1,60 @@ +--- a/build/container-runtime/clients/image/docker-image-client.js ++++ b/build/container-runtime/clients/image/docker-image-client.js +@@ -124,6 +124,14 @@ + } + async pull(imageName, opts) { + try { ++ if (process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY === "true") { ++ try { ++ await this.inspect(imageName); ++ } catch { ++ throw new Error(`Preload required image ${imageName.string} before running VRT; registry access is disabled`); ++ } ++ return; ++ } + if (!opts?.force && (await this.exists(imageName))) { + common_1.log.debug(`Image "${imageName.string}" already exists`); + return; +--- a/build/reaper/reaper.js ++++ b/build/reaper/reaper.js +@@ -25,6 +25,7 @@ + let sessionId; + async function getReaper(client) { + if (reaper) { ++ await verifyReaperImage(client, reaper.containerId); + return reaper; + } + const userId = (0, os_1.userInfo)().uid; +@@ -35,6 +36,7 @@ + return new DisabledReaper(sessionId, ""); + } + for (const reaperContainer of reaperContainers) { ++ await verifyReaperImage(client, reaperContainer.Id); + const existingSessionId = reaperContainer.Labels[labels_1.LABEL_TESTCONTAINERS_SESSION_ID] ?? new common_1.RandomUuid().nextUuid(); + try { + sessionId = existingSessionId; +@@ -52,6 +54,24 @@ + }); + reaper.addSession(sessionId); + return reaper; ++} ++// Verify before opening a cleanup connection; never remove a foreign reaper. ++async function verifyReaperImage(client, containerId) { ++ if (process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY !== "true") return; ++ const reference = getReaperImage(); ++ if (!/@sha256:[a-f0-9]{64}$/.test(reference)) { ++ throw new Error("Ryuk requires a digest-pinned image in preloaded-only mode"); ++ } ++ let expected, actual; ++ try { ++ expected = await client.image.inspect(container_runtime_1.ImageName.fromString(reference)); ++ actual = await client.container.inspect(client.container.getById(containerId)); ++ } catch { ++ throw new Error(`Cannot verify existing Ryuk ${containerId} against ${reference}; preload the image and use a dedicated daemon`); ++ } ++ if (!expected.Id || actual.Image !== expected.Id) { ++ throw new Error(`Existing Ryuk ${containerId} does not match ${reference}; use a dedicated daemon or ask its owner to stop it`); ++ } + } + async function findReaperContainers(client) { + const containers = await client.container.list(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d81c59..55df1a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + testcontainers@12.1.0: 47c55412b43f08e5d156bfef6de3e0f1ea734520500d9eed0487aa6e886453ad + importers: .: @@ -132,7 +135,7 @@ importers: version: 1.63.0 testcontainers: specifier: 12.1.0 - version: 12.1.0 + version: 12.1.0(patch_hash=47c55412b43f08e5d156bfef6de3e0f1ea734520500d9eed0487aa6e886453ad) typescript: specifier: 7.0.2 version: 7.0.2 @@ -2523,7 +2526,7 @@ snapshots: - bare-abort-controller - react-native-b4a - testcontainers@12.1.0: + testcontainers@12.1.0(patch_hash=47c55412b43f08e5d156bfef6de3e0f1ea734520500d9eed0487aa6e886453ad): dependencies: '@balena/dockerignore': 1.0.2 '@types/dockerode': 4.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba56e4c..bb85a08 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,3 +10,6 @@ minimumReleaseAgeExclude: - "playwright@1.63.0" - "playwright-core@1.63.0" - "@types/node@26.5.0" + +patchedDependencies: + testcontainers@12.1.0: patches/testcontainers-12.1.0.patch diff --git a/runtime/BUILD.bazel b/runtime/BUILD.bazel index bf7d4bc..3ef3c94 100644 --- a/runtime/BUILD.bazel +++ b/runtime/BUILD.bazel @@ -237,3 +237,9 @@ js_test( data = ["package.json", ":typecheck"], entry_point = "host-browser.test.js", ) + +filegroup( + name = "preload_lifecycle_entry", + srcs = ["preload-lifecycle.test.js"], + visibility = ["//visibility:public"], +) diff --git a/runtime/container.ts b/runtime/container.ts index 7542995..d3e2089 100644 --- a/runtime/container.ts +++ b/runtime/container.ts @@ -2,6 +2,7 @@ import { GenericContainer, getContainerRuntimeClient, getReaper, + ImageName, LABEL_TESTCONTAINERS_SESSION_ID, StartedNetwork, Wait, @@ -25,7 +26,18 @@ export async function startBrowser( fs.existsSync(path.join(os.homedir(), '.testcontainers.properties')) ) if (host) process.env.TESTCONTAINERS_HOST_OVERRIDE = host + // The pinned Testcontainers patch enforces this before auth, pulls, or reuse. + process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY = 'true' const client = await getContainerRuntimeClient() + for (const reference of [image, process.env.RYUK_CONTAINER_IMAGE || '']) { + if (!/@sha256:[a-f0-9]{64}$/.test(reference)) + throw new Error(`VRT requires a digest-pinned image: ${reference}`) + try { + await client.image.inspect(ImageName.fromString(reference)) + } catch { + throw new Error(`Preload required image ${reference} before running VRT; registry access is disabled`) + } + } const reaper = await getReaper(client) const name = `vrt-${randomUUID()}` const network = new StartedNetwork( diff --git a/runtime/fixtures/docker-proxy.ts b/runtime/fixtures/docker-proxy.ts new file mode 100644 index 0000000..a038296 --- /dev/null +++ b/runtime/fixtures/docker-proxy.ts @@ -0,0 +1,120 @@ +// Test-only Docker boundary: real containers, with registry operations denied. +import http from 'node:http' +import {once} from 'node:events' + +export async function dockerProxy() { + const daemon = process.env.DOCKER_HOST + const url = daemon && !daemon.startsWith('unix:') + ? new URL(daemon.replace(/^tcp:/, 'http:')) : undefined + if (url && url.protocol !== 'http:') throw new Error('Test proxy requires TCP or Unix Docker') + const upstream = url + ? {hostname: url.hostname, port: url.port} + : {socketPath: daemon?.replace(/^unix:\/\//, '') || '/var/run/docker.sock'} + const state = { + forbidden: 0, + reapers: new Set(), + missingImages: new Set(), + unverifiableContainers: new Set(), + containers: [] as {id: string; image: string}[], + networks: [] as string[], + } + const api = (route: string, method = 'GET', body?: unknown): Promise => new Promise((resolve, reject) => { + const request = http.request({...upstream, path: route, method, headers: {'Content-Type': 'application/json'}}, response => { + const chunks: Buffer[] = [] + response.on('data', chunk => chunks.push(chunk)) + response.on('end', () => { + const text = Buffer.concat(chunks).toString() + if (response.statusCode! >= 400) reject(new Error(`Docker ${response.statusCode}: ${text}`)) + else resolve(text ? JSON.parse(text) : undefined) + }) + }) + request.on('error', reject) + request.end(body === undefined ? undefined : JSON.stringify(body)) + }) + const server = http.createServer(async (request, response) => { + const route = request.url! + const pathname = decodeURIComponent(route.split('?')[0].replace(/^\/v[\d.]+/, '')) + if (/^\/(images\/create|auth)$/.test(pathname)) { + state.forbidden++ + response.writeHead(403).end('Registry access forbidden') + return + } + const image = pathname.match(/^\/images\/(.+)\/json$/)?.[1] + const container = pathname.match(/^\/containers\/(.+)\/json$/)?.[1] + if (image && state.missingImages.has(image)) { + response.writeHead(404, {'Content-Type': 'application/json'}).end(JSON.stringify({message: 'No such image'})) + return + } + if (container && state.unverifiableContainers.has(container)) { + response.writeHead(500).end('Image identity unavailable') + return + } + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + const body = Buffer.concat(chunks) + const forward = http.request({...upstream, path: route, method: request.method, headers: request.headers}, result => { + response.on('close', () => { result.destroy(); forward.destroy() }) + const inspectResponse = pathname === '/containers/json' || pathname === '/containers/create' || pathname === '/networks/create' + if (!inspectResponse) { + response.writeHead(result.statusCode!, result.headers) + result.pipe(response) + return + } + const parts: Buffer[] = [] + result.on('data', part => parts.push(part)) + result.on('end', () => { + let payload = JSON.parse(Buffer.concat(parts).toString()) + if (result.statusCode! < 300) { + if (pathname === '/containers/json') + payload = payload.filter((item: any) => item.Labels?.['org.testcontainers.ryuk'] !== 'true' || state.reapers.has(item.Id)) + if (pathname === '/containers/create') state.containers.push({id: payload.Id, image: JSON.parse(body.toString()).Image}) + if (pathname === '/networks/create') state.networks.push(payload.Id) + } + response.writeHead(result.statusCode!, {'Content-Type': 'application/json'}) + response.end(JSON.stringify(payload)) + }) + }) + forward.on('error', error => response.destroy(error)) + forward.end(body) + }) + server.on('upgrade', (request, socket, head) => { + const forward = http.request({...upstream, path: request.url, method: request.method, headers: request.headers}) + forward.on('upgrade', (response, remote, remoteHead) => { + socket.write(`HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n` + + Object.entries(response.headers).map(([key, value]) => `${key}: ${value}`).join('\r\n') + '\r\n\r\n') + if (head.length) remote.write(head) + if (remoteHead.length) socket.write(remoteHead) + socket.pipe(remote).pipe(socket) + socket.on('error', () => remote.destroy()) + remote.on('error', () => socket.destroy()) + socket.on('close', () => remote.destroy()) + remote.on('close', () => socket.destroy()) + }) + forward.on('error', () => socket.destroy()) + forward.end() + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing proxy address') + return { + ...state, api, host: `tcp://127.0.0.1:${address.port}`, + // Keep the counter live rather than copying its initial value. + get forbidden() { return state.forbidden }, + close() { server.closeAllConnections(); server.close() }, + } +} + +/** A different image running the real Ryuk binary, with its own cleanup lifetime. */ +export async function alternateReaper(proxy: Awaited>, original: string) { + const info = await proxy.api(`/containers/${original}/json`) + const image: string = (await proxy.api(`/commit?container=${original}&pause=false&changes=${encodeURIComponent('LABEL rules.web-e2e.test-alternate=true')}`, 'POST')).Id + const id: string = (await proxy.api('/containers/create', 'POST', { + Image: image, + Env: [...info.Config.Env.filter((entry: string) => !entry.startsWith('RYUK_CONNECTION_TIMEOUT=')), 'RYUK_CONNECTION_TIMEOUT=300s'], + Labels: {'org.testcontainers.ryuk': 'true', 'org.testcontainers.session-id': 'preload-alternate'}, + HostConfig: {Binds: info.HostConfig.Binds, PortBindings: {'8080/tcp': [{HostIp: '127.0.0.1', HostPort: '0'}]}}, + })).Id + await proxy.api(`/containers/${id}/start`, 'POST') + return {id, image} +} diff --git a/runtime/fixtures/preload-worker.ts b/runtime/fixtures/preload-worker.ts new file mode 100644 index 0000000..1f33303 --- /dev/null +++ b/runtime/fixtures/preload-worker.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs' +import path from 'node:path' +import {createRequire} from 'node:module' +import {chromium} from 'playwright' +import {startBrowser} from '../container.js' +import {GenericContainer} from 'testcontainers' +const require = createRequire(import.meta.url) +let stop: (() => Promise) | undefined +try { + if (process.env.DIRECT_IMAGE) { + process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY = 'true' + await new GenericContainer(process.env.DIRECT_IMAGE).start() + throw new Error('A missing image unexpectedly started') + } + const browser = await startBrowser(process.env.BROWSER_IMAGE!, + path.dirname(createRequire(require.resolve('playwright/package.json')).resolve('playwright-core/package.json'))) + stop = browser.stop + const client = await chromium.connect(browser.endpoint) + try { + const page = await client.newPage() + await page.setContent('

Preloaded browser

') + if (await page.locator('h1').textContent() !== 'Preloaded browser') throw new Error('Browser did not render') + } finally { await client.close() } + await stop() + stop = undefined + process.send?.({ready: true}) + // Keep the original Ryuk connection alive while other workers exercise reuse. + await new Promise(resolve => process.once('message', () => resolve())) +} catch (error) { + process.send?.({error: String(error)}) + process.exitCode = 1 +} finally { + await stop?.() + process.disconnect?.() +} diff --git a/runtime/preload-browser.test.ts b/runtime/preload-browser.test.ts index f69c752..2c1c141 100644 --- a/runtime/preload-browser.test.ts +++ b/runtime/preload-browser.test.ts @@ -1,109 +1,89 @@ -// An API proxy forbids pulls/auth and hides existing reapers: cached containers -// cannot mask a missing helper image in this real-daemon regression. import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import http from 'node:http' +import {spawn, type ChildProcess} from 'node:child_process' import {once} from 'node:events' -import {createRequire} from 'node:module' -import {chromium} from 'playwright' -import {startBrowser} from './container.js' +import {fileURLToPath} from 'node:url' +import {dockerProxy, alternateReaper} from './fixtures/docker-proxy.js' +import {testEnvironment} from './isolation.js' -const require = createRequire(import.meta.url) -const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) as { - images: {image: string; platform: string | null; roles: string[]}[] -} -const browserImage = manifest.images.find(image => image.roles.includes('browser'))! -const reaperImage = manifest.images.find(image => image.roles.includes('reaper'))! -const daemon = process.env.DOCKER_HOST -const daemonUrl = daemon && !daemon.startsWith('unix:') - ? new URL(daemon.replace(/^tcp:/, 'http:')) : undefined -if (daemonUrl) assert.equal(daemonUrl.protocol, 'http:', 'test proxy supports TCP or Unix daemons') -const upstream = daemonUrl - ? {hostname: daemonUrl.hostname, port: daemonUrl.port} - : {socketPath: daemon?.replace(/^unix:\/\//, '') || '/var/run/docker.sock'} -let forbidden = 0 -const createdImages: string[] = [] -const proxy = http.createServer((request, response) => { - const route = request.url! - if (/\/(images\/create|auth)(\?|$)/.test(route)) { - forbidden++ - response.writeHead(403).end('Registry access forbidden by preload regression') - return - } - const forward = http.request({...upstream, path: route, method: request.method, headers: request.headers}, result => { - response.on('close', () => { result.destroy(); forward.destroy() }) - if (/\/containers\/json(\?|$)/.test(route)) { - const chunks: Buffer[] = [] - result.on('data', chunk => chunks.push(chunk)) - result.on('end', () => { - const containers = JSON.parse(Buffer.concat(chunks).toString()) as {Labels: Record}[] - response.setHeader('Content-Type', 'application/json') - response.end(JSON.stringify(containers.filter(container => - container.Labels?.['org.testcontainers.ryuk'] !== 'true'))) - }) - } else { - response.writeHead(result.statusCode!, result.headers) - result.pipe(response) - } - }) - forward.on('error', error => response.destroy(error)) - if (/\/containers\/create(\?|$)/.test(route)) { - const chunks: Buffer[] = [] - request.on('data', chunk => chunks.push(chunk)) - request.on('end', () => { - const body = Buffer.concat(chunks) - createdImages.push(JSON.parse(body.toString()).Image) - forward.end(body) - }) - } else request.pipe(forward) -}) -proxy.on('upgrade', (request, socket, head) => { - const forward = http.request({...upstream, path: request.url, method: request.method, headers: request.headers}) - forward.on('upgrade', (response, remote, remoteHead) => { - socket.write(`HTTP/1.1 ${response.statusCode} ${response.statusMessage}\r\n` + - Object.entries(response.headers).map(([key, value]) => `${key}: ${value}`).join('\r\n') + '\r\n\r\n') - if (head.length) remote.write(head) - if (remoteHead.length) socket.write(remoteHead) - socket.pipe(remote).pipe(socket) - socket.on('error', () => remote.destroy()) - remote.on('error', () => socket.destroy()) - }) - forward.on('error', () => socket.destroy()) - forward.end() -}) -proxy.listen(0, '127.0.0.1') -await once(proxy, 'listening') -const address = proxy.address() -assert(address && typeof address !== 'string') +const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) +const browserImage = manifest.images.find((item: any) => item.roles.includes('browser')).image as string +const reaperImage = manifest.images.find((item: any) => item.roles.includes('reaper')).image as string +const proxy = await dockerProxy() const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'preload-browser-')) -// No registry credentials or credential helpers in the test process. -process.env.DOCKER_CONFIG = temp fs.writeFileSync(path.join(temp, 'config.json'), '{}') -process.env.DOCKER_HOST = `tcp://127.0.0.1:${address.port}` -for (const key of Object.keys(process.env)) - if (key.startsWith('TESTCONTAINERS_') || key.startsWith('RYUK_')) delete process.env[key] -process.env.RYUK_CONTAINER_IMAGE = reaperImage.image -let stop: (() => Promise) | undefined +const workers: ChildProcess[] = [] +const run = async (expected?: RegExp, directImage?: string) => { + const child = spawn(fs.realpathSync(process.env.JS_BINARY__NODE_BINARY || process.execPath), + [fileURLToPath(new URL('./fixtures/preload-worker.js', import.meta.url))], { + env: {...testEnvironment({}, [], temp), PATH: '/usr/bin:/bin', + DOCKER_CONFIG: temp, DOCKER_HOST: proxy.host, + BROWSER_IMAGE: browserImage, RYUK_CONTAINER_IMAGE: reaperImage, DIRECT_IMAGE: directImage}, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + workers.push(child) + const [message] = await Promise.race([ + once(child, 'message'), + once(child, 'exit').then(([code]) => { throw new Error(`Worker exited before result: ${code}`) }), + ]) + if (expected) { + assert.match(message.error, expected) + const [code] = await once(child, 'exit') + assert.equal(code, 1) + } else assert.equal(message.ready, true, message.error) + return child +} +const absent = async (route: string) => assert.rejects(proxy.api(route), /Docker 404/) +let alternate: string | undefined +let alternateImage: string | undefined try { - const container = await startBrowser(browserImage.image, - path.dirname(createRequire(require.resolve('playwright/package.json')).resolve('playwright-core/package.json')), - browserImage.platform!) - stop = container.stop - const browser = await chromium.connect(container.endpoint) - try { - const page = await browser.newPage() - await page.setContent('

Preloaded browser

') - assert.equal(await page.locator('h1').textContent(), 'Preloaded browser') - } finally { - await browser.close() + // Every declared image is required before any resource is created. + for (const image of [browserImage, reaperImage]) { + proxy.missingImages.add(image) + await run(/Preload required image/) + // Exercise the patched pull path too, independently of runner preflight. + await run(/Preload required image/, image) + assert.equal(proxy.containers.length, 0) + assert.equal(proxy.networks.length, 0) + proxy.missingImages.clear() } - assert.equal(forbidden, 0, 'no image pull or registry auth may be attempted') - assert.deepEqual(createdImages.sort(), [reaperImage.image, browserImage.image, browserImage.image].sort()) + await run() + const original = proxy.containers.find(item => item.image === reaperImage)!.id + proxy.reapers.add(original) + await run() + assert.equal(proxy.containers.filter(item => item.image === reaperImage).length, 1, 'must reuse the real matching reaper') + for (const {id, image} of proxy.containers) + if (image === browserImage) await absent(`/containers/${id}/json`) + for (const id of proxy.networks) await absent(`/networks/${id}`) + + const created = proxy.containers.length + proxy.unverifiableContainers.add(original) + await run(/Cannot verify existing Ryuk/) + proxy.unverifiableContainers.clear() + assert.equal(proxy.containers.length, created) + assert.equal((await proxy.api(`/containers/${original}/json`)).State.Running, true) + + // Commit a different image of the actual Ryuk executable, not a label-only fake. + const modified = await alternateReaper(proxy, original) + alternate = modified.id + alternateImage = modified.image + proxy.reapers.clear() + proxy.reapers.add(alternate!) + await run(/Existing Ryuk .* does not match/) + assert.equal(proxy.containers.length, created) + assert.equal((await proxy.api(`/containers/${alternate}/json`)).State.Running, true) + assert.equal((await proxy.api(`/containers/${original}/json`)).State.Running, true) + assert.equal(proxy.forbidden, 0, 'no pull/auth attempt, including all failure cases') } finally { - await stop?.() - proxy.closeAllConnections() + for (const child of workers) if (child.connected) child.send('stop') + await Promise.all(workers.map(child => child.exitCode === null ? once(child, 'exit') : undefined)) + // Only this test's resources; never remove an unrelated daemon's reaper. + for (const id of [...proxy.containers.map(item => item.id), ...(alternate ? [alternate] : [])]) + await proxy.api(`/containers/${id}?force=true&v=true`, 'DELETE').catch(() => {}) + for (const id of proxy.networks) await proxy.api(`/networks/${id}`, 'DELETE').catch(() => {}) + if (alternateImage) await proxy.api(`/images/${alternateImage}`, 'DELETE').catch(() => {}) proxy.close() fs.rmSync(temp, {recursive: true, force: true}) } diff --git a/runtime/preload-lifecycle.test.ts b/runtime/preload-lifecycle.test.ts new file mode 100644 index 0000000..2d840da --- /dev/null +++ b/runtime/preload-lifecycle.test.ts @@ -0,0 +1,73 @@ +// Run public compare/update executables with declared Node and a registry-denying daemon. +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import {spawn} from 'node:child_process' +import {once} from 'node:events' +import {dockerProxy, alternateReaper} from './fixtures/docker-proxy.js' + +const runfiles = process.env.RUNFILES_DIR || process.env.JS_BINARY__RUNFILES! +const resolve = (name: string) => path.join(runfiles, process.env[name]!) +const manifest = JSON.parse(fs.readFileSync(resolve('PRELOAD_IMAGES'), 'utf8')) +const browser = manifest.images.find((item: any) => item.roles.includes('browser')).image as string +const reaper = manifest.images.find((item: any) => item.roles.includes('reaper')).image as string +const expected = fs.readFileSync(resolve('PRELOAD_BASELINE')) +const proxy = await dockerProxy() +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'preload-lifecycle-')) +fs.writeFileSync(path.join(temp, 'config.json'), '{}') +// The launchers resolve Node through runfiles, not PATH. Preserve only Bazel plumbing. +const plumbing = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^(RUNFILES|TEST_|BAZEL|JS_BINARY__)/.test(key))) +const run = async (update: boolean, failure?: RegExp) => { + const child = spawn(resolve(update ? 'PRELOAD_UPDATE' : 'PRELOAD_COMPARE'), [], { + env: {...plumbing, RUNFILES_DIR: runfiles, RUNFILES_MANIFEST_FILE: path.join(runfiles, 'MANIFEST'), PATH: process.env.PATH, HOME: temp, DOCKER_CONFIG: temp, + DOCKER_HOST: proxy.host, BUILD_WORKSPACE_DIRECTORY: temp}, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + for (const stream of [child.stdout, child.stderr]) stream!.on('data', data => { output += data }) + const [code] = await once(child, 'exit') + if (!failure) assert.equal(code, 0, output) + else { + assert.notEqual(code, 0, output) + assert.match(output, failure) + } + // Browser and relay must be gone after compare, update, and failed startup. + for (const {id, image} of proxy.containers) + if (image === browser) await assert.rejects(proxy.api(`/containers/${id}/json`), /Docker 404/) + for (const id of proxy.networks) await assert.rejects(proxy.api(`/networks/${id}`), /Docker 404/) +} +let alternate: {id: string; image: string} | undefined +try { + await run(false) + alternate = await alternateReaper(proxy, proxy.containers.find(item => item.image === reaper)!.id) + await run(true) + const updated = path.join(temp, '__native_screenshots__', 'saved.png') + assert.deepEqual(fs.readFileSync(updated), expected, 'update must reproduce the committed Linux baseline') + await run(false) + for (const image of [browser, reaper]) { + proxy.missingImages.add(image) + await run(true, /Preload required image/) + assert.deepEqual(fs.readFileSync(updated), expected, 'failed update must preserve baselines') + proxy.missingImages.clear() + } + proxy.reapers.add(alternate.id) + await run(true, /Existing Ryuk .* does not match/) + assert.deepEqual(fs.readFileSync(updated), expected, 'mismatched Ryuk must not modify baselines') + proxy.unverifiableContainers.add(alternate.id) + await run(true, /Cannot verify existing Ryuk/) + assert.deepEqual(fs.readFileSync(updated), expected, 'unverifiable Ryuk must not modify baselines') + assert.equal((await proxy.api(`/containers/${alternate.id}/json`)).State.Running, true, 'foreign reaper must not be stopped') + assert.deepEqual(fs.readFileSync(resolve('PRELOAD_BASELINE')), expected, 'source baseline must remain unchanged') + assert.equal(proxy.forbidden, 0, 'compare, update and failures must not attempt pulls/auth') +} finally { + for (const {id} of proxy.containers) + await proxy.api(`/containers/${id}?force=true&v=true`, 'DELETE').catch(() => {}) + for (const id of proxy.networks) await proxy.api(`/networks/${id}`, 'DELETE').catch(() => {}) + if (alternate) { + await proxy.api(`/containers/${alternate.id}?force=true&v=true`, 'DELETE') + await proxy.api(`/images/${alternate.image}`, 'DELETE') + } + proxy.close() + fs.rmSync(temp, {recursive: true, force: true}) +} diff --git a/tests/preloaded-vrt.sh b/tests/preloaded-vrt.sh new file mode 100644 index 0000000..a79af3f --- /dev/null +++ b/tests/preloaded-vrt.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build with Bazel, then execute the public VRT lifecycle in an OS-only runner. +set -euo pipefail +vrt_root=$(cd "$(dirname "$0")/.." && pwd) +vrt_bazel=${VRT_BAZEL:-bazelisk} +cd "$vrt_root/examples/react" +"$vrt_bazel" build //:preloaded_vrt_test +vrt_output=$("$vrt_bazel" info output_base) +vrt_install=$("$vrt_bazel" info install_base) +vrt_bin=$("$vrt_bazel" info bazel-bin) +vrt_launcher="$vrt_bin/preloaded_vrt_test_/preloaded_vrt_test" +vrt_docker_args=() +# Bazel 9 may symlink declared external repositories into its shared content cache. +# Mount only those resolved repository directories, not the developer's HOME. +declare -A vrt_mounted=() +while IFS= read -r -d '' vrt_link; do + vrt_dependency=$(readlink -f "$vrt_link") + [[ -d "$vrt_dependency" ]] || continue + case "$vrt_dependency" in "$vrt_root"|"$vrt_root"/*|"$vrt_output"/*|"$vrt_install"/*) continue ;; esac + [[ -z "${vrt_mounted[$vrt_dependency]:-}" ]] || continue + vrt_mounted[$vrt_dependency]=1 + vrt_docker_args+=(--mount "type=bind,source=$vrt_dependency,target=$vrt_dependency,readonly") +done < <(find "$vrt_output/external" -maxdepth 1 -type l -print0) +if [[ -n "${DOCKER_HOST:-}" && "$DOCKER_HOST" != unix:* ]]; then + vrt_docker_args+=(--env DOCKER_HOST) +else + vrt_socket=${DOCKER_HOST:-} + vrt_socket=${vrt_socket#unix://} + vrt_socket=${vrt_socket:-/var/run/docker.sock} + vrt_docker_args+=(--mount "type=bind,source=$vrt_socket,target=/var/run/docker.sock" --env DOCKER_HOST=unix:///var/run/docker.sock) +fi +# This image is the test envelope, not a rules-managed runtime image. It contains +# neither Node nor Chromium. Linux host networking keeps relay loopback reachable. +docker run --rm --init --platform linux/amd64 --network host \ + --mount "type=bind,source=$vrt_root,target=$vrt_root,readonly" \ + --mount "type=bind,source=$vrt_output,target=$vrt_output" \ + --mount "type=bind,source=$vrt_install,target=$vrt_install,readonly" \ + --workdir "$vrt_root/examples/react" \ + "${vrt_docker_args[@]}" \ + ubuntu:24.04@sha256:224a1869083a311ef3f13648a154ba79832fbef6364d31493642ca03082da254 \ + bash -c ' + set -eu + for program in node nodejs chromium chromium-browser google-chrome docker; do + if command -v "$program"; then echo "Unexpected host tool: $program" >&2; exit 1; fi + done + mkdir -p /tmp/home /tmp/test /tmp/outputs + export BAZEL_BINDIR=. HOME=/tmp/home TEST_TMPDIR=/tmp/test TEST_UNDECLARED_OUTPUTS_DIR=/tmp/outputs + exec "$1" + ' bash "$vrt_launcher" From 18b8cbb1dc6b95cfb9d20bf983de0c570091e126 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 12:58:57 +0000 Subject: [PATCH 2/3] fix: use pnpm-compatible dependency patch headers --- patches/testcontainers-12.1.0.patch | 2 ++ pnpm-lock.yaml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/patches/testcontainers-12.1.0.patch b/patches/testcontainers-12.1.0.patch index f2716a0..eb4fd87 100644 --- a/patches/testcontainers-12.1.0.patch +++ b/patches/testcontainers-12.1.0.patch @@ -1,3 +1,4 @@ +diff --git a/build/container-runtime/clients/image/docker-image-client.js b/build/container-runtime/clients/image/docker-image-client.js --- a/build/container-runtime/clients/image/docker-image-client.js +++ b/build/container-runtime/clients/image/docker-image-client.js @@ -124,6 +124,14 @@ @@ -15,6 +16,7 @@ if (!opts?.force && (await this.exists(imageName))) { common_1.log.debug(`Image "${imageName.string}" already exists`); return; +diff --git a/build/reaper/reaper.js b/build/reaper/reaper.js --- a/build/reaper/reaper.js +++ b/build/reaper/reaper.js @@ -25,6 +25,7 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55df1a2..2557a11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,7 +106,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - testcontainers@12.1.0: 47c55412b43f08e5d156bfef6de3e0f1ea734520500d9eed0487aa6e886453ad + testcontainers@12.1.0: 2fc419ead69b6a365fb305107e2da0adc0e5c548654bc1a1ab6368fea9e86bd7 importers: @@ -135,7 +135,7 @@ importers: version: 1.63.0 testcontainers: specifier: 12.1.0 - version: 12.1.0(patch_hash=47c55412b43f08e5d156bfef6de3e0f1ea734520500d9eed0487aa6e886453ad) + version: 12.1.0(patch_hash=2fc419ead69b6a365fb305107e2da0adc0e5c548654bc1a1ab6368fea9e86bd7) typescript: specifier: 7.0.2 version: 7.0.2 @@ -2526,7 +2526,7 @@ snapshots: - bare-abort-controller - react-native-b4a - testcontainers@12.1.0(patch_hash=47c55412b43f08e5d156bfef6de3e0f1ea734520500d9eed0487aa6e886453ad): + testcontainers@12.1.0(patch_hash=2fc419ead69b6a365fb305107e2da0adc0e5c548654bc1a1ab6368fea9e86bd7): dependencies: '@balena/dockerignore': 1.0.2 '@types/dockerode': 4.0.1 From 30b4f75c8d7efa5c927c2ec481a5e24d0814c6d8 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Sun, 13 Sep 2026 13:48:53 +0000 Subject: [PATCH 3/3] fix: reuse upstream never-pull Testcontainers patch --- patches/README.md | 22 ++++++---- patches/testcontainers-12.1.0.patch | 62 +++++++++++++++++++++++++---- pnpm-lock.yaml | 6 +-- runtime/container.ts | 1 + runtime/fixtures/preload-worker.ts | 1 + runtime/preload-browser.test.ts | 2 +- 6 files changed, 75 insertions(+), 19 deletions(-) diff --git a/patches/README.md b/patches/README.md index 7889124..84ad72d 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,17 +1,23 @@ # Testcontainers 12.1.0 execution guard -The pnpm-locked patch adds opt-in `TESTCONTAINERS_PRELOADED_IMAGES_ONLY=true`. -`startBrowser` sets it for the rules' private runtime process: +The pnpm-locked patch combines two independent changes, enabled by `startBrowser` +in the rules' private runtime process: -- Image startup inspects local image availability and returns without registry - authentication or pulls, including if an image disappears after preflight. -- Existing Ryuk containers are inspected before opening a cleanup connection. +- `TESTCONTAINERS_PULL_POLICY=never` uses the implementation submitted in + [testcontainers-node#1457](https://github.com/testcontainers/testcontainers-node/pull/1457) + (commit `42c7675`). The image-client and utility hunks are the exact compiled + output of that upstream commit (`npm run build -w testcontainers`). Image startup + inspects local availability and returns without registry authentication or pulls, + including if an image disappears after preflight. Both pull paths are covered. +- `TESTCONTAINERS_PRELOADED_IMAGES_ONLY=true` retains our separate Ryuk identity + check. Existing containers are inspected before opening a cleanup connection. Docker's actual container image ID must equal the locally resolved pinned image ID. Cached reapers are checked too. Unverifiable or mismatched identities fail; foreign reapers are never stopped by this check. This patches the pinned package rather than replacing its cleanup lifecycle or monkey-patching shared client methods. Default Testcontainers behavior is unchanged -when the flag is absent. Revisit the patch on dependency upgrades; upstream support -for these policies can replace it. Real-daemon coverage lives in -`runtime/preload-browser.test.ts` and the React example's `preloaded_vrt_test`. +when the flags are absent. Replace the upstream hunks with a released dependency +once available; retain the Ryuk check until upstream supports identity verification. +Real-daemon coverage lives in `runtime/preload-browser.test.ts` and the React +example's `preloaded_vrt_test`. diff --git a/patches/testcontainers-12.1.0.patch b/patches/testcontainers-12.1.0.patch index eb4fd87..6f88e39 100644 --- a/patches/testcontainers-12.1.0.patch +++ b/patches/testcontainers-12.1.0.patch @@ -1,21 +1,69 @@ diff --git a/build/container-runtime/clients/image/docker-image-client.js b/build/container-runtime/clients/image/docker-image-client.js --- a/build/container-runtime/clients/image/docker-image-client.js +++ b/build/container-runtime/clients/image/docker-image-client.js -@@ -124,6 +124,14 @@ +@@ -12,6 +12,7 @@ + const tar_fs_1 = __importDefault(require("tar-fs")); + const common_1 = require("../../../common"); + const get_auth_config_1 = require("../../auth/get-auth-config"); ++const use_local_image_1 = require("../../utils/use-local-image"); + class DockerImageClient { + dockerode; + indexServerAddress; +@@ -124,6 +125,9 @@ } async pull(imageName, opts) { try { -+ if (process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY === "true") { -+ try { -+ await this.inspect(imageName); -+ } catch { -+ throw new Error(`Preload required image ${imageName.string} before running VRT; registry access is disabled`); -+ } ++ if (await (0, use_local_image_1.useLocalImage)(this.dockerode, imageName)) { + return; + } if (!opts?.force && (await this.exists(imageName))) { common_1.log.debug(`Image "${imageName.string}" already exists`); return; +diff --git a/build/container-runtime/utils/pull-image.js b/build/container-runtime/utils/pull-image.js +--- a/build/container-runtime/utils/pull-image.js ++++ b/build/container-runtime/utils/pull-image.js +@@ -8,8 +8,12 @@ + const common_1 = require("../../common"); + const get_auth_config_1 = require("../auth/get-auth-config"); + const image_exists_1 = require("./image-exists"); ++const use_local_image_1 = require("./use-local-image"); + const pullImage = async (dockerode, indexServerAddress, options) => { + try { ++ if (await (0, use_local_image_1.useLocalImage)(dockerode, options.imageName)) { ++ return; ++ } + if (!options.force && (await (0, image_exists_1.imageExists)(dockerode, options.imageName))) { + common_1.log.debug(`Not pulling image "${options.imageName.string}" as it already exists`); + return; +diff --git a/build/container-runtime/utils/use-local-image.js b/build/container-runtime/utils/use-local-image.js +new file mode 100644 +--- /dev/null ++++ b/build/container-runtime/utils/use-local-image.js +@@ -0,0 +1,16 @@ ++"use strict"; ++Object.defineProperty(exports, "__esModule", { value: true }); ++exports.useLocalImage = useLocalImage; ++async function useLocalImage(dockerode, imageName) { ++ if (process.env.TESTCONTAINERS_PULL_POLICY !== "never") { ++ return false; ++ } ++ // Bypass the existence cache: an image may have been removed since the last check. ++ try { ++ await dockerode.getImage(imageName.string).inspect(); ++ } ++ catch (cause) { ++ throw new Error(`Cannot use local image "${imageName.string}" with TESTCONTAINERS_PULL_POLICY=never; preload it before starting containers`, { cause }); ++ } ++ return true; ++} +diff --git a/build/container-runtime/utils/use-local-image.d.ts b/build/container-runtime/utils/use-local-image.d.ts +new file mode 100644 +--- /dev/null ++++ b/build/container-runtime/utils/use-local-image.d.ts +@@ -0,0 +1,3 @@ ++import Dockerode from "dockerode"; ++import { ImageName } from "../image-name"; ++export declare function useLocalImage(dockerode: Dockerode, imageName: ImageName): Promise; diff --git a/build/reaper/reaper.js b/build/reaper/reaper.js --- a/build/reaper/reaper.js +++ b/build/reaper/reaper.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2557a11..441cfcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,7 +106,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - testcontainers@12.1.0: 2fc419ead69b6a365fb305107e2da0adc0e5c548654bc1a1ab6368fea9e86bd7 + testcontainers@12.1.0: e2b4c82137307780e2455c242a21ffc05fa282e1ec0b13c2bd63b8d39fdec30e importers: @@ -135,7 +135,7 @@ importers: version: 1.63.0 testcontainers: specifier: 12.1.0 - version: 12.1.0(patch_hash=2fc419ead69b6a365fb305107e2da0adc0e5c548654bc1a1ab6368fea9e86bd7) + version: 12.1.0(patch_hash=e2b4c82137307780e2455c242a21ffc05fa282e1ec0b13c2bd63b8d39fdec30e) typescript: specifier: 7.0.2 version: 7.0.2 @@ -2526,7 +2526,7 @@ snapshots: - bare-abort-controller - react-native-b4a - testcontainers@12.1.0(patch_hash=2fc419ead69b6a365fb305107e2da0adc0e5c548654bc1a1ab6368fea9e86bd7): + testcontainers@12.1.0(patch_hash=e2b4c82137307780e2455c242a21ffc05fa282e1ec0b13c2bd63b8d39fdec30e): dependencies: '@balena/dockerignore': 1.0.2 '@types/dockerode': 4.0.1 diff --git a/runtime/container.ts b/runtime/container.ts index d3e2089..b1fa6b5 100644 --- a/runtime/container.ts +++ b/runtime/container.ts @@ -27,6 +27,7 @@ export async function startBrowser( ) if (host) process.env.TESTCONTAINERS_HOST_OVERRIDE = host // The pinned Testcontainers patch enforces this before auth, pulls, or reuse. + process.env.TESTCONTAINERS_PULL_POLICY = 'never' process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY = 'true' const client = await getContainerRuntimeClient() for (const reference of [image, process.env.RYUK_CONTAINER_IMAGE || '']) { diff --git a/runtime/fixtures/preload-worker.ts b/runtime/fixtures/preload-worker.ts index 1f33303..a7a7e0d 100644 --- a/runtime/fixtures/preload-worker.ts +++ b/runtime/fixtures/preload-worker.ts @@ -8,6 +8,7 @@ const require = createRequire(import.meta.url) let stop: (() => Promise) | undefined try { if (process.env.DIRECT_IMAGE) { + process.env.TESTCONTAINERS_PULL_POLICY = 'never' process.env.TESTCONTAINERS_PRELOADED_IMAGES_ONLY = 'true' await new GenericContainer(process.env.DIRECT_IMAGE).start() throw new Error('A missing image unexpectedly started') diff --git a/runtime/preload-browser.test.ts b/runtime/preload-browser.test.ts index 2c1c141..1e5e134 100644 --- a/runtime/preload-browser.test.ts +++ b/runtime/preload-browser.test.ts @@ -44,7 +44,7 @@ try { proxy.missingImages.add(image) await run(/Preload required image/) // Exercise the patched pull path too, independently of runner preflight. - await run(/Preload required image/, image) + await run(/TESTCONTAINERS_PULL_POLICY=never/, image) assert.equal(proxy.containers.length, 0) assert.equal(proxy.networks.length, 0) proxy.missingImages.clear()