From 62eee7017f0329501e1191d574f3ad1fb43ab5cd Mon Sep 17 00:00:00 2001 From: Chingis S Date: Wed, 23 Sep 2026 20:09:58 +0400 Subject: [PATCH 1/3] Add development workspace runtime contract Separate runtime configuration from developer identity and application initialization. Add explicit preparation/startup, shared dependency storage, contract labeling and CI coverage. --- Dockerfile | 5 +++- Makefile | 1 + README.md | 26 ++++++++++++++++++++ bin/workspace-path | 13 ++++++++++ bin/workspace-python | 22 +++++++++++++++++ docker-entrypoint.sh | 9 +++++++ tests/run.sh | 2 ++ tests/workspace-contract.sh | 9 +++++++ tests/workspace-runtime.sh | 49 +++++++++++++++++++++++++++++++++++++ workspace-profile.sh | 2 ++ 10 files changed, 137 insertions(+), 1 deletion(-) create mode 100755 bin/workspace-path create mode 100755 bin/workspace-python create mode 100644 tests/workspace-contract.sh create mode 100644 tests/workspace-runtime.sh create mode 100644 workspace-profile.sh diff --git a/Dockerfile b/Dockerfile index b44aeb4..6576369 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ FROM ${BASE_IMAGE} LABEL com.wodby.ci.cache="uv" ARG PYTHON_DEV +LABEL com.wodby.workspace.contract="${PYTHON_DEV:+1}" ARG WODBY_USER_ID=1000 ARG WODBY_GROUP_ID=1000 @@ -128,7 +129,7 @@ RUN set -xe; \ cp /home/wodby/.shrc /home/wodby/.bashrc; \ cp /home/wodby/.shrc /home/wodby/.bash_profile; \ \ - curl -LsSf https://astral.sh/uv/install.sh | su-exec wodby sh; \ + curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh; \ \ # Configure sudoers \ { \ @@ -186,3 +187,5 @@ COPY bin /usr/local/bin/ ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["/etc/init.d/gunicorn"] + +COPY workspace-profile.sh /etc/profile.d/workspace.sh diff --git a/Makefile b/Makefile index 4a4d57f..c35f161 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,7 @@ buildx-imagetools-create: .PHONY: buildx-imagetools-create test: + cd ./tests && IMAGE=$(REPO):$(TAG) bash ./workspace-contract.sh ifneq ($(PYTHON_DEV),) cd ./tests && IMAGE=$(REPO):$(TAG) ./run.sh else diff --git a/README.md b/README.md index cc465ab..cdd89dd 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,29 @@ image. A version without a pin fails before the build starts. When adding a supported base version or variant, add its image index digest to `base-images.mk`. For a custom build, override `BASE_IMAGE` with a complete `repository:tag@sha256:...` reference. + +### Development workspace contract + +Development variants declare `com.wodby.workspace.contract=1`. Configuration-only +startup (`/docker-entrypoint.sh --configure-runtime`) does not rewrite developer +SSH/Git settings, initialize shared storage, or run application hooks. Normal +startup retains its existing behavior. `WODBY_WORKSPACE=1` selects the workspace +startup command. Login-shell tools remain available when the developer home is mounted. + +`workspace-python prepare` runs `uv sync --locked` when `uv.lock` exists, or installs +`requirements.txt` into a virtual environment. `workspace-python start` activates it +and runs Gunicorn with its polling reloader against `GUNICORN_APP`. Override +`WORKSPACE_PYTHON_COMMAND` for another server; `HOST` and `PORT` default to +`0.0.0.0` and `8080`. Uvicorn commands receive `WATCHFILES_FORCE_POLLING=true` unless +explicitly configured otherwise. Custom commands must implement their own reload +behavior. Dependency changes require preparation again. + +Dependencies/build output use `.wodby-workspace/` in the shared checkout, excluded +through `.git/info/exclude` without editing `.gitignore`. A tracked directory or +symlink at that reserved path is refused. The runner's private home is not required +by application pods. Package lifecycle scripts remain application-owned and may +modify files; review Git changes after preparation. + +CI checks labels for all image variants and runs configuration, developer-state, +reserved-path and runtime tests for development variants. Publish a new image +revision before enabling this contract in a consuming service. diff --git a/bin/workspace-path b/bin/workspace-path new file mode 100755 index 0000000..618c22a --- /dev/null +++ b/bin/workspace-path @@ -0,0 +1,13 @@ +#!/bin/sh +# Reserve dependency/build storage without changing tracked ignore rules. +set -eu +cd "${APP_ROOT:-/usr/src/app}" +root=$(git rev-parse --show-toplevel) +[ "$(pwd -P)" = "$root" ] || { echo 'Workspace commands require the checkout root' >&2; exit 1; } +[ ! -L .wodby-workspace ] || { echo '.wodby-workspace must not be a symlink' >&2; exit 1; } +[ -z "$(git ls-files -- .wodby-workspace)" ] || { echo '.wodby-workspace is reserved; remove tracked files there' >&2; exit 1; } +exclude=$(git rev-parse --git-path info/exclude) +mkdir -p "$(dirname "$exclude")" +grep -qxF '/.wodby-workspace/' "$exclude" 2>/dev/null || printf '\n/.wodby-workspace/\n' >> "$exclude" +mkdir -p .wodby-workspace +printf '%s/.wodby-workspace\n' "$root" diff --git a/bin/workspace-python b/bin/workspace-python new file mode 100755 index 0000000..b9dea27 --- /dev/null +++ b/bin/workspace-python @@ -0,0 +1,22 @@ +#!/bin/sh +# Dependencies live on the shared checkout; the runner home stays private. +set -eu +cd "${APP_ROOT:-/usr/src/app}" +state=$(workspace-path) +export VIRTUAL_ENV="$state/venv" UV_PROJECT_ENVIRONMENT="$state/venv" PIP_USER=0 +export PATH="$VIRTUAL_ENV/bin:$PATH" +export HOST="${HOST:-0.0.0.0}" PORT="${PORT:-8080}" +export WATCHFILES_FORCE_POLLING="${WATCHFILES_FORCE_POLLING:-true}" +case "${1:-}" in + prepare) + if [ -f uv.lock ]; then uv sync --locked + elif [ -f requirements.txt ]; then + python -m venv "$VIRTUAL_ENV" + "$VIRTUAL_ENV/bin/python" -m pip install -r requirements.txt + else echo 'Provide uv.lock with pyproject.toml or requirements.txt' >&2; exit 1; fi ;; + start) + [ -x "$VIRTUAL_ENV/bin/python" ] || { echo 'Run workspace preparation first' >&2; exit 1; } + if [ -n "${WORKSPACE_PYTHON_COMMAND:-}" ]; then exec sh -ec "$WORKSPACE_PYTHON_COMMAND"; fi + exec python -m gunicorn --bind "$HOST:$PORT" --reload --reload-engine poll "${GUNICORN_APP:-myapp.wsgi:application}" ;; + *) echo 'Usage: workspace-python prepare|start' >&2; exit 1 ;; +esac diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 90d3241..c7ecf33 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -41,6 +41,15 @@ process_templates() { _gotpl "gunicorn.py.tmpl" "/usr/local/etc/gunicorn/config.py" } +# Configuration-only startup never changes SSH/Git state or runs application hooks. +if [[ "${1:-}" == --configure-runtime ]]; then + _gotpl "gunicorn.py.tmpl" "/usr/local/etc/gunicorn/config.py" + exit 0 +fi +if [[ "${WODBY_WORKSPACE:-}" == 1 ]]; then + exec workspace-python start +fi + sudo init_container init_git diff --git a/tests/run.sh b/tests/run.sh index 4eb352e..d6398e8 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,6 +2,8 @@ set -e +docker run --rm --network none --entrypoint /bin/bash -v "$PWD/workspace-runtime.sh:/tmp/workspace-runtime.sh:ro" "${IMAGE}" /tmp/workspace-runtime.sh + # Validate the development tool contract before application integration tests. docker run --rm --network none --entrypoint /bin/sh -v "$PWD/development-tools.sh:/tmp/development-tools.sh:ro" "${IMAGE}" /tmp/development-tools.sh diff --git a/tests/workspace-contract.sh b/tests/workspace-contract.sh new file mode 100644 index 0000000..26cd640 --- /dev/null +++ b/tests/workspace-contract.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Ensure ordinary images do not advertise the development-only contract. +set -euo pipefail +label=$(docker image inspect --format '{{index .Config.Labels "com.wodby.workspace.contract"}}' "$IMAGE") +if docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGE" | grep -Eq '^PYTHON_DEV=.+$'; then + test "$label" = 1 +else + test -z "$label" +fi diff --git a/tests/workspace-runtime.sh b/tests/workspace-runtime.sh new file mode 100644 index 0000000..45e5501 --- /dev/null +++ b/tests/workspace-runtime.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Run inside the image with the normal entrypoint bypassed. +set -euo pipefail +fixture=$(mktemp -d) +fixture=$(cd "$fixture" && pwd -P) +trap 'rm -rf "$fixture"' EXIT +mkdir -p "$fixture/repo" "$fixture/home/.ssh" "$fixture/tools" +export APP_ROOT="$fixture/repo" HOME="$fixture/home" +printf 'Host saved\n' > "$HOME/.ssh/config" +printf '[user]\n name = Developer\n' > "$HOME/.gitconfig" +cp "$HOME/.ssh/config" "$fixture/ssh.expected" +cp "$HOME/.gitconfig" "$fixture/git.expected" +# Configuration succeeds repeatedly without running storage/init or identity setup. +/docker-entrypoint.sh --configure-runtime +/docker-entrypoint.sh --configure-runtime +cmp "$HOME/.ssh/config" "$fixture/ssh.expected" +cmp "$HOME/.gitconfig" "$fixture/git.expected" +test "$(bash -lc 'command -v uv')" = /usr/local/bin/uv +cd "$APP_ROOT" +git init -q +printf 'tracked\n' > source.txt +git add source.txt +workspace-path > "$fixture/path" +test "$(cat "$fixture/path")" = "$APP_ROOT/.wodby-workspace" +test -z "$(git ls-files --others --exclude-standard)" +# A tracked collision is refused, even when local exclude rules hide new files. +printf collision > .wodby-workspace/collision +git add -f .wodby-workspace/collision +if workspace-path; then echo 'accepted tracked runtime storage' >&2; exit 1; fi +git rm -q --cached .wodby-workspace/collision +rm .wodby-workspace/collision +export PATH="$fixture/tools:$PATH" +# Locked uv preparation keeps the lock unchanged and targets shared storage. +printf lock > uv.lock +cat > "$fixture/tools/uv" <<'SH' +#!/bin/sh +[ "$*" = 'sync --locked' ] +[ "$UV_PROJECT_ENVIRONMENT" = "$APP_ROOT/.wodby-workspace/venv" ] +mkdir -p "$UV_PROJECT_ENVIRONMENT/bin" +printf '#!/bin/sh\nexit 0\n' > "$UV_PROJECT_ENVIRONMENT/bin/python" +chmod +x "$UV_PROJECT_ENVIRONMENT/bin/python" +SH +chmod +x "$fixture/tools/uv" +workspace-python prepare +test "$(cat uv.lock)" = lock +WORKSPACE_PYTHON_COMMAND='test "$PIP_USER" = 0; test "$WATCHFILES_FORCE_POLLING" = true; test "$PORT" = 8080' WODBY_WORKSPACE=1 /docker-entrypoint.sh ignored +cmp "$HOME/.ssh/config" "$fixture/ssh.expected" +cmp "$HOME/.gitconfig" "$fixture/git.expected" +echo 'Workspace runtime checks passed' diff --git a/workspace-profile.sh b/workspace-profile.sh new file mode 100644 index 0000000..0a0fca8 --- /dev/null +++ b/workspace-profile.sh @@ -0,0 +1,2 @@ +# Preserve image tool paths when the developer home is mounted. +export PATH="/home/wodby/.local/bin:/usr/local/bin:$PATH" From e35446b8210f5df0f4273e400cbae8d80322859a Mon Sep 17 00:00:00 2001 From: Chingis S Date: Wed, 23 Sep 2026 20:13:16 +0400 Subject: [PATCH 2/3] Exercise locked workspace preparation and shared-volume reload Run a real uv and Gunicorn smoke test during development-image CI. Verify that an edit from another container appears in HTTP responses and preparation preserves the lockfile. --- tests/run.sh | 2 ++ tests/workspace-reload.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/workspace-reload.sh diff --git a/tests/run.sh b/tests/run.sh index d6398e8..bf7d69f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,6 +2,8 @@ set -e +bash "$PWD/workspace-reload.sh" + docker run --rm --network none --entrypoint /bin/bash -v "$PWD/workspace-runtime.sh:/tmp/workspace-runtime.sh:ro" "${IMAGE}" /tmp/workspace-runtime.sh # Validate the development tool contract before application integration tests. diff --git a/tests/workspace-reload.sh b/tests/workspace-reload.sh new file mode 100644 index 0000000..7ac15ad --- /dev/null +++ b/tests/workspace-reload.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail +image="${IMAGE:?Set IMAGE to the candidate development image}" +volume=workspace-python-reload-$$ +server=workspace-python-reload-$$ +cleanup() { docker rm -f "$server" >/dev/null 2>&1 || true; docker volume rm "$volume" >/dev/null; } +trap cleanup EXIT +docker volume create "$volume" >/dev/null +docker run --rm --user 0 --entrypoint sh -v "$volume:/fixture" "$image" -ec 'chown 1000:1000 /fixture' +docker run --rm --entrypoint sh -e APP_ROOT=/fixture -v "$volume:/fixture" "$image" -ec ' +cd /fixture +git init -q +printf "[project]\nname = \"workspace-test\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\ndependencies = [\"gunicorn==23.0.0\"]\n" > pyproject.toml +uv lock +cp uv.lock /tmp/expected.lock +workspace-python prepare +cmp uv.lock /tmp/expected.lock +printf "def app(environ, start_response):\n start_response(\"200 OK\", [(\"Content-Type\", \"text/plain\")])\n return [b\"before\"]\n" > app.py +' +docker run -d --name "$server" --network none -e WODBY_WORKSPACE=1 -e GUNICORN_APP=app:app -e APP_ROOT=/fixture -v "$volume:/fixture" "$image" >/dev/null +before=0 +for i in $(seq 1 30); do if docker exec "$server" curl -fs http://localhost:8080/ | grep -q before; then before=1;break;fi;sleep 1;done +[ "$before" = 1 ] || { docker logs "$server";exit 1; } +docker run --rm --network none --entrypoint sh -v "$volume:/fixture" "$image" -ec "sed -i 's/before/after-edit/' /fixture/app.py" +after=0 +for i in $(seq 1 30); do if docker exec "$server" curl -fs http://localhost:8080/ | grep -q after-edit; then after=1;break;fi;sleep 1;done +[ "$after" = 1 ] || { docker logs "$server";exit 1; } +echo 'Locked uv preparation and second-container Gunicorn polling reload passed' From dc051604fe14914868a975f90aa3a4cd76837438 Mon Sep 17 00:00:00 2001 From: Chingis S Date: Wed, 23 Sep 2026 20:19:34 +0400 Subject: [PATCH 3/3] Use the image user for workspace reload fixtures Resolve fixture ownership through the wodby account so the same reload test works for development images using UID 1000 and macOS variants using UID 501. Verified the real reload test with both IDs. --- tests/workspace-reload.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/workspace-reload.sh b/tests/workspace-reload.sh index 7ac15ad..3fcd8d4 100644 --- a/tests/workspace-reload.sh +++ b/tests/workspace-reload.sh @@ -6,7 +6,7 @@ server=workspace-python-reload-$$ cleanup() { docker rm -f "$server" >/dev/null 2>&1 || true; docker volume rm "$volume" >/dev/null; } trap cleanup EXIT docker volume create "$volume" >/dev/null -docker run --rm --user 0 --entrypoint sh -v "$volume:/fixture" "$image" -ec 'chown 1000:1000 /fixture' +docker run --rm --user 0 --entrypoint sh -v "$volume:/fixture" "$image" -ec 'chown wodby:wodby /fixture' docker run --rm --entrypoint sh -e APP_ROOT=/fixture -v "$volume:/fixture" "$image" -ec ' cd /fixture git init -q