mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user