Fix release smoke workspace identity on rootless Docker

The exact rehearsal passed its test suites and image build but its browser
could not enter the private bind mount. A host UID is remapped to an unrelated
subordinate identity inside rootless Docker. Use the daemon owner's container
identity for rootless Docker and the host UID/GID for rootful Docker.

Execute the locked Playwright CLI directly and probe it during integration
preparation, before expensive suites, so missing dependencies or inaccessible
mounts fail early without fetching an unqualified CLI version.
This commit is contained in:
rcourtman
2026-09-07 08:29:42 +01:00
parent a5cbbaf1d4
commit 8b73085e82
3 changed files with 106 additions and 17 deletions
@@ -1041,6 +1041,14 @@ artifact-selection behaviour.
status on that port, and hand Playwright the same base URL, because a
worker may also host long-running Pulse instances on `7655` and `17655`
and a port collision fails the smoke only after every other stage passed.
Browser smoke must preserve private workspace ownership across the Docker
user-namespace boundary. On rootless Docker, container `0:0` maps to the
daemon owner; on rootful Docker, the browser uses the worker's host UID/GID.
The worker must read the daemon's security options, reject unreadable or
malformed identity information, and probe the mounted, lockfile-installed
Playwright CLI before the expensive suites and image build. It must not
download a replacement CLI or broaden checkout permissions to hide a mount
identity failure.
Race-instrumented Go builds must place `GOTMPDIR` under the worker's
persistent run directory and remove that bounded scratch directory on exit;
a small WSL `/tmp` tmpfs must not turn release qualification into a false
+37 -17
View File
@@ -96,6 +96,23 @@ if [ "$(node -p "process.versions.node.split('.')[0]")" != "24" ]; then
exit 3
fi
playwright_container_user() {
local security_options
security_options="$(docker info --format '{{json .SecurityOptions}}')" || return
python3 - "$security_options" "$(id -u):$(id -g)" <<'PY'
import json
import sys
options = json.loads(sys.argv[1])
if not isinstance(options, list) or not all(isinstance(value, str) for value in options):
raise SystemExit("Docker security options must be a JSON list of strings.")
# Container root maps to the daemon owner on rootless Docker. Reusing that
# owner's host UID instead selects an unrelated subordinate host identity.
print("0:0" if "name=rootless" in options else sys.argv[2])
PY
}
PLAYWRIGHT_CONTAINER_USER="$(playwright_container_user)"
mkdir -p \
"$CACHE_DIR/go-build" \
"$CACHE_DIR/go-mod" \
@@ -204,11 +221,29 @@ run_backend() {
fi
}
run_playwright() {
docker run --rm \
--network host \
--ipc host \
--user "$PLAYWRIGHT_CONTAINER_USER" \
--env CI=true \
--env HOME=/tmp \
--env "PULSE_E2E_DIAGNOSTIC=${PULSE_E2E_DIAGNOSTIC:-}" \
--env "PLAYWRIGHT_BASE_URL=${PULSE_E2E_BASE_URL}" \
--volume "$REPOSITORY_DIR/tests/integration:/work" \
--workdir /work \
"$PLAYWRIGHT_IMAGE" \
node /work/node_modules/@playwright/test/cli.js "$@"
}
run_integration_prep() {
phase integration-dependencies npm --prefix tests/integration ci
PLAYWRIGHT_VERSION="$(node -p "require('./tests/integration/node_modules/@playwright/test/package.json').version")"
PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
phase playwright-image docker pull "$PLAYWRIGHT_IMAGE"
# Check the real mount and locked CLI before the expensive test suites and
# image build. Never let npx download a different browser test version.
phase playwright-runtime run_playwright --version
phase mock-github-image docker build --tag pulse-mock-github:test tests/integration/mock-github-server
}
@@ -257,21 +292,6 @@ else
--tag pulse:test \
.
fi
run_playwright() {
docker run --rm \
--network host \
--ipc host \
--user "$(id -u):$(id -g)" \
--env CI=true \
--env HOME=/tmp \
--env "PULSE_E2E_DIAGNOSTIC=${PULSE_E2E_DIAGNOSTIC:-}" \
--env "PLAYWRIGHT_BASE_URL=${PULSE_E2E_BASE_URL}" \
--volume "$REPOSITORY_DIR/tests/integration:/work" \
--workdir /work \
"$PLAYWRIGHT_IMAGE" \
npx playwright test "$@"
}
run_rehearsal_smoke() {
cd tests/integration
export MOCK_CHECKSUM_ERROR=false
@@ -281,7 +301,7 @@ run_rehearsal_smoke() {
export PULSE_E2E_DIAGNOSTIC=1
docker compose -f docker-compose.test.yml up -d --wait
timeout 60 sh -c 'until curl -fsS ${PULSE_E2E_BASE_URL}/api/health >/dev/null; do sleep 2; done'
run_playwright tests/00-diagnostic.spec.ts --project=chromium --reporter=list
run_playwright test tests/00-diagnostic.spec.ts --project=chromium --reporter=list
local status
status="$(curl -s -o "$RUN_DIR/update-status.json" -w '%{http_code}' ${PULSE_E2E_BASE_URL}/api/updates/status || true)"
case "$status" in
@@ -306,7 +326,7 @@ run_release_smoke() {
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-mock-github | grep -q healthy; do sleep 2; done'
timeout 60 sh -c 'until docker inspect --format="{{json .State.Health.Status}}" pulse-test-server | grep -q healthy; do sleep 2; done'
timeout 60 sh -c 'until curl -fsS ${PULSE_E2E_BASE_URL}/api/health >/dev/null; do sleep 2; done'
run_playwright tests/95-release-smoke.spec.ts --project=chromium --reporter=list
run_playwright test tests/95-release-smoke.spec.ts --project=chromium --reporter=list
docker compose -f docker-compose.test.yml down -v
}
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import json
import pathlib
import re
import subprocess
@@ -281,6 +282,66 @@ class ReleasePreflightTest(unittest.TestCase):
self.assertLess(block.index("done\n"), block.index("run_frontend_tests\n"))
self.assertLess(block.index("run_frontend_tests\n"), block.index("run_backend\n"))
def test_browser_mount_identity_and_locked_cli_follow_the_docker_daemon(self) -> None:
worker = (ROOT / "scripts/release-preflight-worker.sh").read_text()
functions = []
for name in ("playwright_container_user", "run_playwright"):
match = re.search(rf"^{name}\(\) \{{\n.*?^\}}", worker, re.MULTILINE | re.DOTALL)
self.assertIsNotNone(match, name)
functions.append(match.group(0))
for options, info_rc, valid, expected_user in (
(["name=seccomp,profile=builtin", "name=rootless"], 0, True, "0:0"),
(["name=seccomp,profile=builtin"], 0, True, f"{os.getuid()}:{os.getgid()}"),
([], 42, False, None),
({"rootless": True}, 0, False, None),
(None, 0, False, None),
):
with self.subTest(options=options, info_rc=info_rc), tempfile.TemporaryDirectory() as raw:
temp = pathlib.Path(raw)
docker = temp / "docker"
docker.write_text(
f"#!{sys.executable}\n"
"import json,os,pathlib,sys\n"
"if sys.argv[1] == 'info':\n"
" print(os.environ['TEST_SECURITY_OPTIONS'])\n"
" raise SystemExit(int(os.environ['TEST_INFO_RC']))\n"
"pathlib.Path(os.environ['TEST_CONTAINER_STARTED']).touch()\n"
"print(json.dumps(sys.argv[1:]))\n"
)
docker.chmod(0o755)
started = temp / "started"
script = "set -euo pipefail\n" + "\n".join(functions) + "\n"
script += 'PLAYWRIGHT_CONTAINER_USER="$(playwright_container_user)"\n'
script += "run_playwright --version\n"
script += "run_playwright test tests/00-diagnostic.spec.ts --project=chromium\n"
result = subprocess.run(
["bash", "-c", script], capture_output=True, text=True, check=False,
env={**os.environ, "PATH": f"{temp}:{os.environ['PATH']}",
"TEST_SECURITY_OPTIONS": json.dumps(options), "TEST_INFO_RC": str(info_rc),
"TEST_CONTAINER_STARTED": str(started),
"REPOSITORY_DIR": str(temp / "private workspace"),
"PLAYWRIGHT_IMAGE": "locked-playwright-image",
"PULSE_E2E_BASE_URL": "http://127.0.0.1:27655"},
)
self.assertEqual(valid, result.returncode == 0, result.stderr)
self.assertEqual(valid, started.exists())
if not valid:
continue
calls = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(2, len(calls))
for args in calls:
self.assertEqual(expected_user, args[args.index("--user") + 1])
self.assertEqual(f"{temp}/private workspace/tests/integration:/work",
args[args.index("--volume") + 1])
self.assertIn("node", args)
self.assertIn("/work/node_modules/@playwright/test/cli.js", args)
self.assertNotIn("npx", args)
self.assertEqual("--version", calls[0][-1])
self.assertEqual(["test", "tests/00-diagnostic.spec.ts", "--project=chromium"], calls[1][-3:])
prep = worker[worker.index("run_integration_prep() {"):worker.index("# Static frontend checks")]
self.assertLess(prep.index("phase playwright-image"), prep.index("phase playwright-runtime"))
self.assertIn("phase playwright-runtime run_playwright --version", prep)
def test_api_shard_plan_is_deterministic_complete_and_disjoint(self) -> None:
test_names = [
f"TestReleaseCase{index:04d}"