Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
{ \
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions bin/workspace-path
Original file line number Diff line number Diff line change
@@ -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"
22 changes: 22 additions & 0 deletions bin/workspace-python
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions tests/workspace-contract.sh
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/workspace-reload.sh
Original file line number Diff line number Diff line change
@@ -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'
49 changes: 49 additions & 0 deletions tests/workspace-runtime.sh
Original file line number Diff line number Diff line change
@@ -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'
2 changes: 2 additions & 0 deletions workspace-profile.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Preserve image tool paths when the developer home is mounted.
export PATH="/home/wodby/.local/bin:/usr/local/bin:$PATH"
Loading