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..bf7d69f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,6 +2,10 @@ 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. 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-reload.sh b/tests/workspace-reload.sh new file mode 100644 index 0000000..3fcd8d4 --- /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 wodby:wodby /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' 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"