Docker: one self-contained image with the browsers above the lock copy

Fold the base/app split back into a single Dockerfile. The base tag was
never published, so a plain `docker build -t ouroboros-web .` failed on
every fresh machine, and a runtime `UV_PROJECT_ENVIRONMENT` let an agent's
`uv sync` in a task workspace rewrite the runtime venv. What the split was
for stays: the Playwright browsers pinned to the locked version are
installed from an ephemeral uvx tool environment into `/ms-playwright`
above `COPY pyproject.toml uv.lock` (every release rewrites the lock), and
the BuildKit cache mounts keep the uv and apt caches out of the layers
(4.82 GB -> 4.12 GB). The tag-only docker lanes stop forcing
`PLAYWRIGHT_BROWSERS_PATH=0`, which pointed at an empty package tree and
made them download browsers mid-test; the portable path-length test reads
the shipped browser path. The Russian Trusted CA files leave the image:
the next commit gives deployments an owner-side setting instead.
Docs: ARCHITECTURE §8 Docker, README.
This commit is contained in:
Ouroboros 2026-09-26 00:46:45 +03:00
parent e856ec9d15
commit 9637d194c2
12 changed files with 111 additions and 226 deletions

View file

@ -42,7 +42,6 @@ on:
- 'build_linux.sh'
- 'build_windows.ps1'
- 'Dockerfile'
- 'docker/**'
- 'scripts/**'
- 'devtools/**'
- 'packaging/**'
@ -533,9 +532,7 @@ jobs:
- uses: actions/checkout@v4
- name: Build Docker image
id: docker_build
run: |
docker build -f docker/Dockerfile.base -t ouroboros-base:local .
docker build -t ouroboros-web:test .
run: docker build -t ouroboros-web:test .
- uses: ./.github/actions/setup-python-env
id: setup_python
- name: Install UI smoke browser binaries
@ -554,7 +551,7 @@ jobs:
docker run --rm --entrypoint sh \
-e OUROBOROS_EXPECT_BROWSER_ENGINES=chromium,webkit \
ouroboros-web:test -c \
"PLAYWRIGHT_BROWSERS_PATH=0 python -m pytest tests/test_browser_tools_smoke.py -m browser -q --tb=short"
"python -m pytest tests/test_browser_tools_smoke.py -m browser -q --tb=short"
docker-portable-test:
if: |
@ -564,13 +561,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -f docker/Dockerfile.base -t ouroboros-base:local .
docker build -t ouroboros-web:test .
run: docker build -t ouroboros-web:test .
- name: Run portable detail tests in Docker
run: |
docker run --rm --entrypoint sh -e OUROBOROS_EXPECT_HEADLESS_SHELL=1 ouroboros-web:test -c \
"PLAYWRIGHT_BROWSERS_PATH=0 python -m playwright install --only-shell chromium && python -m pytest tests/ -m portable_detail -q --tb=short"
"python -m pytest tests/ -m portable_detail -q --tb=short"
# ──────────────────────────────────────────────────────────────────
# Scheduled: the KEYLESS system_e2e scenario lane (tests/system_e2e/).

View file

@ -1,23 +1,46 @@
# syntax=docker/dockerfile:1
# Ouroboros — application image for web UI runtime
# Ouroboros — Docker image for web UI runtime
# Usage:
# docker build -f docker/Dockerfile.base -t ouroboros-base:local .
# docker build -t ouroboros-web .
# docker run --rm -p 8765:8765 ouroboros-web
# The RUN --mount caches need BuildKit, Docker's default builder.
ARG OUROBOROS_BASE_IMAGE=ouroboros-base:local
FROM ${OUROBOROS_BASE_IMAGE}
FROM ghcr.io/astral-sh/uv:0.12.1 AS uv
FROM python:3.10-slim
# Application environment
ENV APP_HOME=/app \
COPY --from=uv /uv /uvx /bin/
# Browsers first, dependencies second, sources last: every release rewrites
# pyproject.toml/uv.lock, so anything below the lock copy is rebuilt per
# release while the apt packages and the Chromium/WebKit downloads above it
# are reused. The Playwright pin must equal the locked version so the
# downloaded browser revisions match the venv's driver
# (tests/test_build_scripts.py::TestDockerfile). The installer runs from an
# ephemeral uvx tool environment, so the image carries one Playwright: the
# venv's. Browsers live in a shared path the runtime honors as-is
# (ouroboros/tools/browser.py) — not inside the package tree, which the
# per-release dependency layer would rebuild.
ARG PLAYWRIGHT_VERSION=1.62.0
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PROJECT_ENVIRONMENT=/opt/venv \
PATH="/opt/venv/bin:$PATH"
UV_COMPILE_BYTECODE=1
# System dependencies: git for the agent's own history and updates, plus
# every Chromium/WebKit native library from Playwright's authoritative list.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends git \
&& uvx --from "playwright==${PLAYWRIGHT_VERSION}" playwright install-deps chromium webkit \
&& uvx --from "playwright==${PLAYWRIGHT_VERSION}" playwright install chromium webkit
# Working directory
ENV APP_HOME=/app \
PATH="/app/.venv/bin:$PATH"
WORKDIR ${APP_HOME}
# Install locked project dependencies separately so source edits reuse this layer.
# Resolve only from the reviewed lock; the project itself is installed after
# the source copy so source edits reuse this layer.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --extra browser --no-install-project

View file

@ -346,7 +346,6 @@ uv export --locked --no-dev --extra browser --no-emit-project --no-hashes --no-a
### Docker
```bash
docker build -f docker/Dockerfile.base -t ouroboros-base:local .
docker build -t ouroboros-web .
docker run --rm -p 8765:8765 \
-e OUROBOROS_NETWORK_PASSWORD='choose-a-password' \
@ -355,10 +354,9 @@ docker run --rm -p 8765:8765 \
ouroboros-web
```
The base image contains only runtime prerequisites: Playwright, Chromium/WebKit,
their system libraries, Git, and trusted certificates from `docker/certs`.
The application image owns the project environment and locked dependencies. Docker runs
the web runtime, not the native desktop shell; use [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for network and container policy.
Docker runs the web runtime, not the native desktop shell. The image bundles Chromium and WebKit for the locked
Playwright version and needs Docker's default BuildKit builder; use [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for network
and container policy, including how to trust an extra CA (`OUROBOROS_EXTRA_CA_BUNDLE`).
### Release tag prerequisite

View file

@ -1,28 +0,0 @@
# syntax=docker/dockerfile:1
# Ouroboros runtime-dependency base. Rebuild when this file or docker/certs changes:
# docker build -f docker/Dockerfile.base -t ouroboros-base:local .
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.1
ARG PYTHON_IMAGE=python:3.10-slim
FROM ${UV_IMAGE} AS uv
FROM ${PYTHON_IMAGE}
ARG DEBIAN_FRONTEND=noninteractive
ARG PLAYWRIGHT_VERSION=1.62.0
COPY --from=uv /uv /uvx /bin/
COPY docker/certs/ /usr/local/share/ca-certificates/
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
UV_SYSTEM_CERTS=true
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& update-ca-certificates \
&& uv pip install --system "playwright==${PLAYWRIGHT_VERSION}" \
&& python3 -m playwright install-deps chromium webkit \
&& python3 -m playwright install chromium webkit

View file

@ -1,4 +0,0 @@
**
!docker/
!docker/certs/
!docker/certs/*.crt

View file

@ -1,33 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
-----END CERTIFICATE-----

View file

@ -1,41 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi
IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE
wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br
HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3
S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn
vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp
BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ
vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa
L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN
3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+
qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R
Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ
2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB
Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM
XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH
AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y
b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu
eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv
aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw
gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv
b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1
L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry
LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF
AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v
Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D
anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9
cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33
Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s
Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH
h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k
F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F
E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe
GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p
ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU=
-----END CERTIFICATE-----

View file

@ -153,7 +153,7 @@ or Intent/Scope checklists are.
| 6 | New tool added? | `get_tools()` exports it, its schema description says WHEN to choose it (each profile receives its visible schema set every round, so the schema is the SSOT of the per-tool contract; `prompts/SYSTEM.md` is the cross-tool selection policy and mentions a tool only when the change alters that policy, never as a catalog entry; mechanism documentation lives in ARCHITECTURE/DEVELOPMENT), the handler signature matches the declared schema, and (if it mutates repo state) it is routed through the reviewed commit path rather than ad-hoc `run_command`. Also add an explicit entry in `ouroboros/safety.py::TOOL_POLICY` (`POLICY_SKIP` for trusted built-ins, `POLICY_CHECK` for opaque or outward-facing ones) — the `test_tool_policy_covers_all_builtin_tools` invariant will fail otherwise, and without an entry the tool falls through to `DEFAULT_POLICY = check` and pays a light-model LLM call per invocation. |
| 7 | Tests green before first `commit_reviewed`? | Run `pytest -x` on the narrowest relevant target(s) you can name before the first `preflight_review` / `commit_reviewed` attempt. Size gates no longer block locally: they live in the official-CI-only `size_ratchet` pytest lane (manifest exactness plus the pairwise base-vs-tip shrink-only transition), and local surfaces (`check_worktree_readiness`, `codebase_health`) surface the same `validate_size_ratchet` findings as "official CI will enforce" warnings. When a size warning appears — or a new `.py` file lands under `ouroboros/` or `supervisor/` — run `pytest tests/ -m size_ratchet` and `scripts/regenerate_size_ratchet.py` locally to preview and fix what official CI would reject. A red test suite before the first commit attempt has caused repeated $2-5 blocked-review cycles. |
| 8 | Adding a `README.md` version row? | BIBLE.md P9 hard cap: ≤ 2 major, ≤ 5 minor, ≤ 5 patch visible entries. Categories are mutually exclusive: major = `X.0.0` (minor=0, patch=0); minor = `X.Y.0` (patch=0, Y≠0); patch = all other `X.Y.Z` (Z≠0). Count existing rows in the category you are adding to. Easy check: `run_command(["python", "-c", "import sys; from ouroboros.tools.release_sync import check_history_limit; warns=check_history_limit(open('README.md').read()); print(warns or 'OK')"])` — if it prints warnings, trim the oldest row in the over-limit category **in the same edit** before committing. |
| 9 | Changing any of `build.sh`, `build_linux.sh`, `build_windows.ps1`, `Dockerfile`, `docker/Dockerfile.base`, or `ouroboros/tools/browser.py`? | Cross-surface doc sync is mandatory. Check ALL of: `README.md` Install section (Linux native-lib caveat), `README.md` Build section (per-platform instructions), `docs/ARCHITECTURE.md` browser tools paragraph, WebKit/mobile verification notes, and inline comments in the touched build script. Any one of these being stale has blocked review twice. Verify before staging. |
| 9 | Changing any of `build.sh`, `build_linux.sh`, `build_windows.ps1`, `Dockerfile`, or `ouroboros/tools/browser.py`? | Cross-surface doc sync is mandatory. Check ALL of: `README.md` Install section (Linux native-lib caveat), `README.md` Build section (per-platform instructions), `docs/ARCHITECTURE.md` browser tools paragraph, WebKit/mobile verification notes, and inline comments in the touched build script. Any one of these being stale has blocked review twice. Verify before staging. |
| 10 | Changing `ouroboros/tools/commit_gate.py`? | Coupled surfaces that MUST be updated atomically in the same commit: (a) `claude_advisory_review.py::get_tools()` tool description for `preflight_review` and `review_status`; (b) `claude_advisory_review.py::_next_step_guidance()` strings; (c) `docs/DEVELOPMENT.md` Review & Commit Protocol section; (d) the `prompts/SYSTEM.md` Self-Modification section IF the commit-gate rule it states changed. Missing any one has blocked review. |
| 11 | Changing VERSION + pyproject.toml? | Ordering matters: (1) write `VERSION`, `pyproject.toml`, `uv.lock`, `web/package.json` and `web/modules/api_types.js` first; (2) then write the `README.md` badge + changelog row + download links, both install pages' download links, and the `docs/ARCHITECTURE.md` header; (3) then run `pytest`. Never interleave — updating README before VERSION means `test_version_in_readme` will catch a stale badge. |
| 12 | Writing or editing any JS file under `web/modules/`? | New or changed static inline visual properties are blocked: inspect the diff for added/changed `style=""` markup and `.style.<property>` assignments, and use CSS classes/tokens plus `classList`/`hidden` instead. Unchanged legacy hits are debt, not a blocker. A dynamic measured value may update a narrowly named CSS custom property when that is the actual runtime data flow. |

View file

@ -60,4 +60,6 @@ Docker runs the web and server runtime without PyWebView. The image binds `0.0.0
The root `.dockerignore` filters `COPY . .`: secrets, host virtualenvs, `node_modules`, caches and runtime/review/operator state stay out of image layers; `.git`, `tests/` and sources stay in, because CI runs pytest inside the image.
The image is one self-contained `Dockerfile` built with Docker's default BuildKit builder: `python:3.10-slim` with the pinned `uv`, then git plus Playwright's native libraries and the Chromium/WebKit binaries for the Playwright version `uv.lock` pins, installed from an ephemeral `uvx` tool environment into `/ms-playwright` (the `PLAYWRIGHT_BROWSERS_PATH` the runtime honors as-is), then `uv sync --locked` into `/app/.venv`, then the sources. Browsers and apt packages sit above the lock copy because every release rewrites `pyproject.toml`/`uv.lock`, and `RUN --mount=type=cache` keeps the uv and apt caches out of the layers. `tests/test_build_scripts.py::TestDockerfile` pins the version match, the order, the shared browser path and the absence of a runtime `UV_PROJECT_ENVIRONMENT` (a baked one would let an agent's `uv sync` in a task workspace rewrite the runtime venv); the tag-only `docker-ui-smoke` and `docker-portable-test` lanes exercise the shipped browsers without a download. Trust anchors are not baked into the image: a deployment that needs an extra CA sets `OUROBOROS_EXTRA_CA_BUNDLE` (§7; `docs/DEPLOYMENT.md`).
---

View file

@ -544,104 +544,60 @@ class TestDockerignore:
class TestDockerfile:
"""The Docker base must bundle Playwright Chromium/WebKit for child images."""
"""One self-contained Dockerfile ships Chromium/WebKit for the locked Playwright.
def test_application_image_uses_dependency_base(self):
The pins below are the contract the tag-only ``docker-ui-smoke`` and
``docker-portable-test`` lanes rely on: browsers land in the shared
``/ms-playwright`` the runtime honors as-is, they are downloaded ABOVE the
lock copy (every release rewrites ``pyproject.toml``/``uv.lock``), and CI
exercises the image's own browsers instead of re-downloading them.
"""
def test_build_is_self_contained(self):
"""``docker build -t ouroboros-web .`` must not depend on a locally built base tag."""
src = _read("Dockerfile")
assert "ARG OUROBOROS_BASE_IMAGE=ouroboros-base:local" in src
assert "FROM ${OUROBOROS_BASE_IMAGE}" in src
assert "FROM python:3.10-slim" in src
assert "FROM ${" not in src, "the image must not start from an unpublished local tag"
def test_application_owns_project_environment(self):
app = _read("Dockerfile")
base = _read("docker/Dockerfile.base")
assert "UV_PROJECT_ENVIRONMENT=/opt/venv" in app
assert 'PATH="/opt/venv/bin:$PATH"' in app
assert "uv sync --locked --no-dev --extra browser --no-install-project" in app
assert "UV_PROJECT_ENVIRONMENT" not in base
assert "uv sync" not in base
def test_base_installs_project_certificates(self):
src = _read("docker/Dockerfile.base")
assert "COPY docker/certs/ /usr/local/share/ca-certificates/" in src
assert "update-ca-certificates" in src
def test_playwright_install_chromium_present(self):
src = _read("docker/Dockerfile.base")
assert "playwright install chromium webkit" in src, (
"Dockerfile must call 'playwright install chromium webkit' to bundle the browsers"
)
def test_playwright_uses_shared_browser_path(self):
src = _read("docker/Dockerfile.base")
assert "PLAYWRIGHT_BROWSERS_PATH=/ms-playwright" in src
def test_base_playwright_version_matches_lock(self):
src = _read("docker/Dockerfile.base")
def test_playwright_pin_matches_lock(self):
src = _read("Dockerfile")
match = re.search(r'\[\[package\]\]\nname = "playwright"\nversion = "([^"]+)"', _read("uv.lock"))
assert match is not None
assert f"PLAYWRIGHT_VERSION={match.group(1)}" in src
def test_playwright_install_deps_present(self):
"""Dockerfile must use 'playwright install-deps chromium webkit' (the authoritative
Playwright dependency resolver) rather than a hand-curated apt library list.
This ensures all runtime native libs required by Chromium/WebKit are present."""
src = _read("docker/Dockerfile.base")
assert "playwright install-deps chromium webkit" in src, (
"Dockerfile must call 'playwright install-deps chromium webkit' to install all "
"native system libraries required by Chromium/WebKit via Playwright's authoritative "
"dependency resolver"
assert match is not None, "uv.lock must lock playwright"
assert f"PLAYWRIGHT_VERSION={match.group(1)}" in src, (
"Dockerfile must pin the browser installer to the locked Playwright version"
)
def test_install_deps_before_install_chromium(self):
"""Native system dependencies must be installed BEFORE the Chromium binary
is downloaded, so the binary can find its runtime libraries on first launch."""
src = _read("docker/Dockerfile.base")
def test_shared_browser_path_is_the_image_environment(self):
src = _read("Dockerfile")
assert "PLAYWRIGHT_BROWSERS_PATH=/ms-playwright" in src
assert "PLAYWRIGHT_BROWSERS_PATH=0" not in src, (
"browsers must not live in the package tree: the dependency layer is rebuilt on every release"
)
def test_browsers_install_before_the_lock_copy(self):
src = _read("Dockerfile")
deps_pos = src.find("playwright install-deps chromium webkit")
src.find("playwright install chromium webkit")
# binary_pos must not match the install-deps line itself
# find the standalone 'playwright install chromium webkit' (not install-deps)
import re as _re
binary_match = _re.search(r"(?<!install-deps )playwright install chromium webkit", src)
assert deps_pos != -1, "playwright install-deps chromium webkit not found in Dockerfile"
assert binary_match is not None, "standalone playwright install chromium webkit not found in Dockerfile"
assert deps_pos < binary_match.start(), (
"playwright install-deps must appear BEFORE playwright install chromium webkit in Dockerfile"
install_pos = src.find("playwright install chromium webkit")
lock_pos = src.find("COPY pyproject.toml uv.lock")
assert deps_pos != -1 and install_pos != -1 and lock_pos != -1
assert deps_pos < install_pos < lock_pos, (
"install-deps, then the browser download, then the lock copy — otherwise a release "
f"bump re-downloads the browsers (deps {deps_pos}, install {install_pos}, lock {lock_pos})"
)
def test_playwright_package_before_playwright_install_deps(self):
"""uv pip must install Playwright BEFORE playwright install-deps — the
playwright Python package must be importable when install-deps runs."""
src = _read("docker/Dockerfile.base")
install_pos = src.find("uv pip install --system")
deps_pos = src.find("playwright install-deps chromium webkit")
assert install_pos != -1, "Playwright package install not found in Dockerfile"
assert deps_pos != -1, "playwright install-deps chromium webkit not found in Dockerfile"
assert install_pos < deps_pos, (
"Playwright package must be installed before playwright install-deps "
f"(install at char {install_pos}, install-deps at {deps_pos})"
)
def test_playwright_package_before_all_playwright_invocations(self):
"""uv pip must install Playwright before every ``python3 -m playwright`` invocation
in the Dockerfile — both ``install-deps`` and ``install chromium webkit``.
Otherwise the module invocation raises ModuleNotFoundError."""
src = _read("docker/Dockerfile.base")
install_pos = src.find("uv pip install --system")
assert install_pos != -1, "Playwright package install not found in Dockerfile"
import re as _re
playwright_invocations = [
m.start() for m in _re.finditer(r"python3 -m playwright", src)
]
assert playwright_invocations, "No 'python3 -m playwright' invocations found in Dockerfile"
earliest_playwright = min(playwright_invocations)
assert install_pos < earliest_playwright, (
"Playwright package install must appear before its first module invocation "
f"(install at char {install_pos}, earliest playwright at {earliest_playwright}). "
f"Found {len(playwright_invocations)} playwright invocation(s) at positions: "
f"{playwright_invocations}"
def test_no_runtime_uv_project_environment(self):
"""A baked ``UV_PROJECT_ENVIRONMENT`` makes an agent's ``uv sync`` in a task
workspace rewrite Ouroboros's own venv."""
assert "UV_PROJECT_ENVIRONMENT" not in _read("Dockerfile")
def test_ci_docker_lanes_use_the_shipped_browsers(self):
"""The release-gate container commands must not redirect Playwright away from the image."""
ci = _read(".github/workflows/ci.yml")
assert "docker build -t ouroboros-web:test ." in ci
assert "PLAYWRIGHT_BROWSERS_PATH=0" not in ci, (
"a PLAYWRIGHT_BROWSERS_PATH=0 prefix hides /ms-playwright and makes the lane download at test time"
)
assert "playwright install --only-shell" not in ci, "the portable lane measures the shipped image"
@pytest.mark.parametrize(

View file

@ -175,7 +175,10 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = {
# 20560 -> 20800 (PR #1255; measured 20768): the Docker subsection maps the new root
# .dockerignore (what it keeps out of image layers and why .git/tests/ must stay in),
# a config BIBLE P6 requires on the map.
"docs/architecture/08-git-branching-ci-and-build.md": 20800,
# 20800 -> 22000 (PR #1300; measured 21921): the Docker subsection maps the single-Dockerfile layout
# (browsers above the lock copy, the shared browser path, cache mounts, the CI lanes that exercise
# them) and points at the extra-CA setting; the base sat 16 bytes under the previous budget.
"docs/architecture/08-git-branching-ci-and-build.md": 22000,
# 12405 -> 14400 (issue #1142): the ordinary-close paragraph gains the mechanism the chapter had
# no text for — graceful stop signals the server PID only, the server half (stop event at the
# signal, bounded uvicorn drain) is self-sufficient against an old group-SIGTERM launcher.

View file

@ -9,17 +9,31 @@ import pytest
pytestmark = pytest.mark.portable_detail
def test_bundled_playwright_headless_shell_paths_stay_short():
def _bundled_browsers_root() -> pathlib.Path:
"""Where the shipped artifact keeps its Playwright browsers.
Packaged builds install into the Playwright package tree
(``PLAYWRIGHT_BROWSERS_PATH=0``); the Docker image hands the runtime a
shared directory through the same variable. Either way the test measures
the artifact as shipped, never a download it made itself.
"""
configured = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "")
if configured and configured != "0":
return pathlib.Path(configured)
playwright = pytest.importorskip("playwright", reason="Playwright is not installed")
root = pathlib.Path(playwright.__file__).parent / "driver" / "package" / ".local-browsers"
return pathlib.Path(playwright.__file__).parent / "driver" / "package" / ".local-browsers"
def test_bundled_playwright_headless_shell_paths_stay_short():
root = _bundled_browsers_root()
if not root.is_dir():
if os.environ.get("OUROBOROS_EXPECT_HEADLESS_SHELL") == "1":
pytest.fail("Expected Playwright local browser bundle in this CI lane")
pytest.fail(f"Expected Playwright browser bundle at {root} in this CI lane")
pytest.skip("Playwright local browser bundle not present")
shells = sorted(root.glob("chromium_headless_shell-*"))
if not shells:
if os.environ.get("OUROBOROS_EXPECT_HEADLESS_SHELL") == "1":
pytest.fail("Expected Playwright headless-shell bundle in this CI lane")
pytest.fail(f"Expected Playwright headless-shell bundle under {root} in this CI lane")
pytest.skip("Playwright headless-shell bundle not present")
too_long = []
for shell in shells: