2026-06-09 16:06:02 +02:00
# ppy: the single shared image every DevPlace container instance runs.
# Build once with `make ppy`. Context is devplacepy/services/containers/files.
FROM python:3.13-slim-bookworm AS base
ENV PYTHONUNBUFFERED = 1 \
PYTHONDONTWRITEBYTECODE = 1 \
PIP_NO_CACHE_DIR = 1 \
PIP_DISABLE_PIP_VERSION_CHECK = 1 \
2026-06-14 16:46:36 +02:00
PIP_DEFAULT_TIMEOUT = 120 \
PIP_RETRIES = 10 \
2026-06-09 16:06:02 +02:00
DEBIAN_FRONTEND = noninteractive \
PLAYWRIGHT_BROWSERS_PATH = /opt/playwright
RUN apt-get update && apt-get install -y --no-install-recommends \
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
git openssh-client curl wget vim ack ca-certificates build-essential libpq-dev \
2026-06-11 14:06:17 +02:00
tmux apache2-utils procps htop iftop iotop netcat-openbsd zip unzip \
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
rsync jq fakeroot xz-utils pkg-config \
2026-08-07 10:53:08 +02:00
binutils gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libedit-dev \
libncurses-dev libpython3-dev libsqlite3-0 libsqlite3-dev uuid-dev \
libxml2-dev libz3-dev tzdata zlib1g-dev \
2026-06-09 16:06:02 +02:00
&& rm -rf /var/lib/apt/lists/*
2026-08-07 10:53:08 +02:00
ENV RUSTUP_HOME = /opt/rust/rustup \
CARGO_HOME = /opt/rust/cargo \
CHOOSENIM_HOME = /opt/nim/choosenim \
CHOOSENIM_DIR = /opt/nim/toolchains \
NIMBLE_DIR = /opt/nim/nimble \
SWIFTLY_HOME_DIR = /opt/swift/swiftly \
SWIFTLY_BIN_DIR = /opt/swift/bin \
SWIFT_HOME = /opt/swift/toolchain
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal \
&& rm -rf " $CARGO_HOME /registry " " $CARGO_HOME /git " \
&& " $CARGO_HOME /bin/rustc " --version
RUN curl -fsSL https://nim-lang.org/choosenim/init.sh | sh -s -- -y || true; \
rm -rf " $CHOOSENIM_HOME /downloads " ; \
" $NIMBLE_DIR /bin/nim " --version | head -1
RUN set -eu; \
curl -fsSL " https://download.swift.org/swiftly/linux/swiftly- $( uname -m) .tar.gz " -o /tmp/swiftly.tar.gz; \
mkdir -p /tmp/swiftly-unpack; \
tar -xzf /tmp/swiftly.tar.gz -C /tmp/swiftly-unpack; \
/tmp/swiftly-unpack/swiftly init --assume-yes --skip-install --no-modify-profile; \
" $SWIFTLY_BIN_DIR /swiftly " install latest --assume-yes; \
toolchain = " $( find /root/.local/share/swiftly/toolchains -mindepth 1 -maxdepth 1 -type d | head -1) " ; \
[ -n " $toolchain " ] || { echo "swift toolchain not found after install" ; exit 1; } ; \
mv " $toolchain " /opt/swift/toolchain; \
rm -rf /tmp/swiftly.tar.gz /tmp/swiftly-unpack /root/.local/share/swiftly \
" $SWIFTLY_HOME_DIR " " $SWIFTLY_BIN_DIR " ; \
/opt/swift/toolchain/usr/bin/swift --version
2026-06-09 16:06:02 +02:00
FROM base AS deps
RUN pip install \
pip setuptools wheel packaging \
requests urllib3 certifi idna charset-normalizer httpx aiohttp \
pydantic typing-extensions python-dotenv aiofiles \
python-dateutil six pytz \
pyyaml toml \
loguru structlog tenacity rich tqdm click typer
RUN pip install \
numpy pandas matplotlib plotly \
sqlalchemy psycopg2-binary redis dataset
RUN pip install \
flask django fastapi starlette "uvicorn[standard]" gunicorn \
2026-06-10 09:21:05 +02:00
h2 celery apscheduler watchfiles \
2026-06-09 16:06:02 +02:00
websocket-client websockets \
beautifulsoup4 lxml scrapy
RUN pip install \
cryptography pycryptodome pillow \
pytest ruff black isort flake8 mypy \
sphinx mkdocs poetry pipenv hatch \
reflex
RUN pip install playwright && playwright install --with-deps chromium
FROM deps AS runtime
Make dev workspaces serve a working browser IDE end to end
The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.
- Add a VS Code button to the project action row and a Workspace item to
the overflow menu, gated by can_open_workspace plus a running instance
(viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
assert it in the build smoke test, so an image that cannot run the
editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
pronounceable secret, minted once at the ensure_editor_password choke
point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
certificate from a new WorkspaceService phase against the molohttp admin
API, then notify the owner with the live URL and the password. Only
pending rows are retried, so a broken host cannot burn the ACME failure
rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
proxied websockets, so code-server scopes its session cookie to the
public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
ARG CODE_SERVER_VERSION = 4 .131.0
RUN set -eu; \
case " $( dpkg --print-architecture) " in \
amd64) arch = amd64 ; ; \
arm64) arch = arm64 ; ; \
*) echo " unsupported architecture: $( dpkg --print-architecture) " ; exit 1 ; ; \
esac ; \
curl -fsSL -o /tmp/code-server.tar.gz \
" https://github.com/coder/code-server/releases/download/v ${ CODE_SERVER_VERSION } /code-server- ${ CODE_SERVER_VERSION } -linux- ${ arch } .tar.gz " ; \
mkdir -p /usr/local/lib/code-server; \
tar -xzf /tmp/code-server.tar.gz -C /usr/local/lib/code-server --strip-components= 1; \
rm -f /tmp/code-server.tar.gz; \
ln -sf /usr/local/lib/code-server/bin/code-server /usr/local/bin/code-server
2026-08-10 00:23:20 +02:00
COPY vscode/devplace-workspace /usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace
COPY vscode/branding/favicon.ico /usr/local/lib/code-server/src/browser/media/favicon.ico
COPY vscode/branding/favicon.svg /usr/local/lib/code-server/src/browser/media/favicon.svg
COPY vscode/branding/favicon-dark-support.svg /usr/local/lib/code-server/src/browser/media/favicon-dark-support.svg
COPY vscode/branding/pwa-icon-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-192.png
COPY vscode/branding/pwa-icon-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-512.png
COPY vscode/branding/pwa-icon-maskable-192.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-192.png
COPY vscode/branding/pwa-icon-maskable-512.png /usr/local/lib/code-server/src/browser/media/pwa-icon-maskable-512.png
COPY vscode/branding/devplace-login.css /tmp/devplace-login.css
COPY vscode/product.patch.json /tmp/product.patch.json
RUN set -eu; \
cat /tmp/devplace-login.css >> /usr/local/lib/code-server/src/browser/pages/login.css; \
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
2026-08-11 20:03:15 +02:00
python3 -c "import json,pathlib; p=pathlib.Path('/usr/local/lib/code-server/lib/vscode/product.json'); d=json.loads(p.read_text()); patch=json.loads(pathlib.Path('/tmp/product.patch.json').read_text()); d.update({k: ({**d[k], **v} if isinstance(v, dict) and isinstance(d.get(k), dict) else v) for k, v in patch.items()}); p.write_text(json.dumps(d, indent=2))" ; \
2026-08-10 00:23:20 +02:00
rm -f /tmp/devplace-login.css /tmp/product.patch.json
2026-06-09 16:06:02 +02:00
COPY sudo /usr/local/bin/sudo
2026-06-11 14:06:17 +02:00
COPY aptroot /usr/local/bin/aptroot
2026-06-09 16:06:02 +02:00
COPY pagent /usr/bin/pagent.py
2026-06-14 16:46:36 +02:00
COPY bot.py /usr/bin/botje.py
COPY d.py /usr/bin/d.py
2026-06-16 20:38:33 +02:00
COPY dpc /usr/bin/dpc
2026-06-09 16:06:02 +02:00
COPY .vimrc /home/pravda/.vimrc
RUN set -eu; \
2026-06-16 20:38:33 +02:00
chmod 0755 /usr/local/bin/sudo /usr/local/bin/aptroot /usr/bin/pagent.py /usr/bin/botje.py /usr/bin/d.py /usr/bin/dpc; \
2026-06-09 16:06:02 +02:00
ln -sf /usr/local/bin/sudo /usr/bin/sudo || true; \
2026-06-11 14:06:17 +02:00
for t in apt apt-get dpkg; do ln -sf /usr/local/bin/aptroot " /usr/local/bin/ $t " ; done ; \
2026-06-09 16:06:02 +02:00
if getent passwd 1000 >/dev/null 2>& 1; then \
existing = " $( getent passwd 1000 | cut -d: -f1) " ; \
if [ " $existing " != "pravda" ] ; then userdel " $existing " 2>/dev/null || deluser " $existing " 2>/dev/null || true; fi ; \
fi ; \
if ! getent group pravda >/dev/null 2>& 1; then groupadd --gid 1000 pravda 2>/dev/null || addgroup -g 1000 pravda 2>/dev/null || true; fi ; \
if ! id pravda >/dev/null 2>& 1; then \
useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash pravda 2>/dev/null \
|| adduser -u 1000 -G pravda -D -s /bin/sh pravda 2>/dev/null || true; \
fi ; \
mkdir -p /app /home/pravda/.local/bin; \
2026-06-11 14:06:17 +02:00
for d in /app /home/pravda /usr/local/lib /usr/local/bin /usr/local/share /usr/lib/python3 /opt \
/usr/lib /usr/bin /usr/sbin /usr/share /usr/include /etc /var/lib /var/cache /var/log /srv; do \
2026-06-09 16:06:02 +02:00
[ -e " $d " ] && chown -R pravda:pravda " $d " 2>/dev/null || true; \
done
USER pravda
2026-08-07 10:53:08 +02:00
ENV PATH = /home/pravda/.local/bin:/opt/rust/cargo/bin:/opt/nim/nimble/bin:/opt/swift/toolchain/usr/bin:$PATH \
DEVPLACE_TOOLCHAINS = python,rust,nim,swift
2026-06-09 16:06:02 +02:00
WORKDIR /app
2026-08-07 10:53:08 +02:00
RUN printf '%s\n' \
'export RUSTUP_HOME=/opt/rust/rustup' \
'export CARGO_HOME=/opt/rust/cargo' \
'export NIMBLE_DIR=/opt/nim/nimble' \
'export SWIFT_HOME=/opt/swift/toolchain' \
'export DEVPLACE_TOOLCHAINS=python,rust,nim,swift' \
'export PATH=/home/pravda/.local/bin:/opt/rust/cargo/bin:/opt/nim/nimble/bin:/opt/swift/toolchain/usr/bin:$PATH' \
> /etc/profile.d/devplace-toolchains.sh \
&& chmod 0644 /etc/profile.d/devplace-toolchains.sh
RUN set -eu; \
for tool in "python --version" "rustc --version" "cargo --version" \
Make dev workspaces serve a working browser IDE end to end
The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.
- Add a VS Code button to the project action row and a Workspace item to
the overflow menu, gated by can_open_workspace plus a running instance
(viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
assert it in the build smoke test, so an image that cannot run the
editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
pronounceable secret, minted once at the ensure_editor_password choke
point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
certificate from a new WorkspaceService phase against the molohttp admin
API, then notify the owner with the live URL and the password. Only
pending rows are retried, so a broken host cannot burn the ACME failure
rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
proxied websockets, so code-server scopes its session cookie to the
public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
"nim --version" "nimble --version" "swift --version" \
"code-server --version" ; do \
2026-08-07 10:53:08 +02:00
$tool > /tmp/toolcheck 2>& 1 || { echo " TOOLCHAIN FAILED: $tool " ; cat /tmp/toolcheck; exit 1; } ; \
head -1 /tmp/toolcheck; \
done ; \
rm -f /tmp/toolcheck; \
for b in /usr/local/bin/sudo /usr/local/bin/aptroot /usr/bin/pagent.py \
Make dev workspaces serve a working browser IDE end to end
The workspace feature shipped its routes, agent tools and docs, but the
editor was never reachable: the project page had no entry point, the ppy
image had no code-server binary, no certificate was ever requested for a
tunnel, and both nginx and the proxy dropped what the editor needs.
- Add a VS Code button to the project action row and a Workspace item to
the overflow menu, gated by can_open_workspace plus a running instance
(viewer_can_workspace and workspace_editor_url on ProjectDetailOut).
- Install a pinned code-server in ppy.Dockerfile before USER pravda and
assert it in the build smoke test, so an image that cannot run the
editor no longer builds green.
- Run the editor with --auth password and a per workspace 8 character
pronounceable secret, minted once at the ensure_editor_password choke
point and injected as PASSWORD. Keep it off WorkspaceViewOut, which the
admin listing shares.
- Publish the editor tunnel when a workspace is created and issue its
certificate from a new WorkspaceService phase against the molohttp admin
API, then notify the owner with the live URL and the password. Only
pending rows are retried, so a broken host cannot burn the ACME failure
rate limit. Renewal stays molohttp's job.
- Forward the original Host on proxied requests and the client cookie on
proxied websockets, so code-server scopes its session cookie to the
public hostname and authenticates the workbench socket.
- Recreate a container stuck in the created state instead of retrying
docker start forever against an image it can no longer run.
- Return a JSON string from WorkspaceController.dispatch; raw dicts landed
in a tool message and aborted the turn at the model endpoint.
- Let the nginx catch-all carry websocket upgrades, keeping upstream
keepalive, so tunnelled apps and the editor both connect.
2026-08-07 13:46:40 +02:00
/usr/bin/botje.py /usr/bin/d.py /usr/bin/dpc /usr/local/bin/code-server; do \
2026-08-07 10:53:08 +02:00
[ -x " $b " ] || { echo " missing or not executable: $b " ; exit 1; } ; \
done ; \
[ -f /home/pravda/.vimrc ] || { echo "missing /home/pravda/.vimrc" ; exit 1; }
2026-08-10 00:23:20 +02:00
RUN set -eu; \
ext = /usr/local/lib/code-server/lib/vscode/extensions/devplace-workspace; \
[ -f " $ext /package.json " ] || { echo "missing the DevPlace extension" ; exit 1; } ; \
[ -f " $ext /extension.js " ] || { echo "missing the DevPlace extension entry point" ; exit 1; } ; \
for theme in devplace-dark devplace-light; do \
python3 -c " import json,sys; json.load(open(' $ext /themes/ $theme .json')) " \
|| { echo " invalid theme: $theme " ; exit 1; } ; \
done ; \
python3 -c " import json; d=json.load(open(' $ext /package.json')); assert d['contributes']['configurationDefaults']['security.workspace.trust.enabled'] is False, 'trust default lost' " ; \
[ -f /usr/local/lib/code-server/src/browser/media/favicon.svg ] || { echo "missing the DevPlace favicon" ; exit 1; } ; \
python3 -c "import json; d=json.load(open('/usr/local/lib/code-server/lib/vscode/product.json')); assert d['nameShort']=='DevPlace', d['nameShort']; assert d['nameLong']=='DevPlace Workspace', d['nameLong']" ; \
grep -q 'devplace-login-theme' /usr/local/lib/code-server/src/browser/pages/login.css \
|| { echo "the DevPlace login stylesheet was not applied" ; exit 1; } ; \
for flag in --app-name --disable-workspace-trust --disable-getting-started-override --welcome-text; do \
code-server --help 2>& 1 | grep -q -- " $flag " \
|| { echo " code-server no longer supports $flag " ; exit 1; } ; \
done ; \
echo "DevPlace branding verified"
2026-06-09 16:06:02 +02:00
CMD [ "sleep" , "infinity" ]