diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 89e7110d7..acdbbb0ed 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/scripts/release-preflight-worker.sh b/scripts/release-preflight-worker.sh index ed9166e4f..07b9a1e96 100755 --- a/scripts/release-preflight-worker.sh +++ b/scripts/release-preflight-worker.sh @@ -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 } diff --git a/scripts/release_control/internal/release_preflight_test.py b/scripts/release_control/internal/release_preflight_test.py index 0c2cc4950..3a3de052e 100755 --- a/scripts/release_control/internal/release_preflight_test.py +++ b/scripts/release_control/internal/release_preflight_test.py @@ -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}"