diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a4b405 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +docker/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f34fad6..170e373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ on: - '**.cfg' - '**.ini' - 'requirements.d/*' + - 'docker/**' + - '**/Dockerfile' - '!docs/**' pull_request: branches: [ master ] @@ -22,6 +24,8 @@ on: - '**.cfg' - '**.ini' - 'requirements.d/*' + - 'docker/**' + - '**/Dockerfile' - '!docs/**' jobs: @@ -31,9 +35,9 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: python-version: '3.12' - name: Lint with flake8 @@ -41,6 +45,48 @@ jobs: pip install flake8-pyproject flake8 flake8 src scripts + hadolint: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v7 + - name: Lint Dockerfile with Hadolint + uses: hadolint/hadolint-action@v3.4.0 + with: + dockerfile: docker/Dockerfile + failure-threshold: warning + + docker-image-test: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v7 + - name: Build Docker image + run: | + docker build -f docker/Dockerfile -t bepasty-ci:${{ github.sha }} . + - name: Smoke test Python package in image + run: | + docker run --rm bepasty-ci:${{ github.sha }} /app/env/bin/python -c "import bepasty.app; print('bepasty import ok')" + - name: Container HTTP smoke test + run: | + set -euo pipefail + cid=$(docker run -d \ + -e BEPASTY_SITENAME="CI Test Instance" \ + -e BEPASTY_SECRET_KEY="ci-test-secret-key-change-me" \ + -p 8000:8000 \ + bepasty-ci:${{ github.sha }}) + trap 'docker logs "$cid" || true; docker rm -f "$cid" || true' EXIT + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8000/ >/dev/null; then + exit 0 + fi + sleep 1 + done + echo "Container did not become ready on :8000 in time" + exit 1 + pytest: needs: lint @@ -71,12 +117,12 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 with: # Fetching only the latest commit is not enough for setuptools-scm; fetch full history fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -88,7 +134,7 @@ jobs: run: | tox --skip-missing-interpreters - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v7 env: OS: ${{ runner.os }} python: ${{ matrix.python-version }} diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml new file mode 100644 index 0000000..5e0a88b --- /dev/null +++ b/.github/workflows/dockerhub.yml @@ -0,0 +1,37 @@ +name: Publish Docker image + +on: + release: + types: [published] + push: + branches: + - master + +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v7 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v6 + with: + images: duchenpaul/bepasty + + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: . + file: ./docker/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 2f2b87c..f6fdb1d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ coverage.xml docs/build/ .idea +.vscode/ diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..b1b72b8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.14-slim AS builder + +# hadolint ignore=DL3008 +RUN true \ + && mkdir /app \ + && apt-get update \ + && apt-get install --no-install-recommends -y git \ + && python -m venv /app/env \ + && /app/env/bin/pip install wheel gunicorn + +COPY . /usr/local/src/bepasty +RUN /app/env/bin/pip install /usr/local/src/bepasty + +# --- + +FROM python:3.14-slim + +RUN true \ + && mkdir /app \ + && adduser -u 17357 --system --home /app/data --disabled-login --disabled-password paste + +COPY docker/init.sh /usr/local/bin/init.sh +COPY --from=builder /app/env /app/env + +ENV WORKERS=4 +ENV LISTEN=0.0.0.0:8000 +ENV BEPASTY_CONFIG=/etc/bepasty.conf +COPY docker/autoconfig.py /etc/bepasty.conf + +# hadolint ignore=DL3066 +USER paste +CMD ["/usr/local/bin/init.sh"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..37a54ed --- /dev/null +++ b/docker/README.md @@ -0,0 +1,51 @@ +# bepasty Docker image + +A Docker image that provides very basic configuration via environment variables. + +## Notes + +* For a more advanced configuration mount a custom configuration to + `/etc/bepasty.conf`. +* All data will be owned by UID 17357, to change this you can use the + `--user ` option from `docker run`. + +## Quickstart + +```sh +# build from the repository root +docker build -f docker/Dockerfile -t bepasty . + +# create datadir +mkdir data +sudo chown 17357:0 data + +# run +docker run -it \ + -p 8000:8000 \ + -v $PWD/data:/app/data \ + -e BEPASTY_SECRET_KEY=$(openssl rand -hex 16) \ + -e BEPASTY_SITENAME=localhost \ + bepasty + +# visit http://localhost:8000 +``` + +## Docker image environment variables + +* `WORKERS`: Number of Gunicorn workers (default: `4`) +* `LISTEN`: IP and address to listen on (default: `0.0.0.0:8000`) +* `BEPASTY_CONFIG`: Path to bepasty configuration (default: `/etc/bepasty.conf`) + +## Bepasty environment variables + +These match the bepasty options described in the [bepasty quickstart configuration docs][1]. + +* `BEPASTY_SITENAME` (**required**) +* `BEPASTY_APP_BASE_PATH` +* `BEPASTY_APP_STORAGE_FILESYSTEM_DIRECTORY` +* `BEPASTY_DEFAULT_PERMISSIONS` +* `BEPASTY_SECRET_KEY` +* `BEPASTY_MAX_ALLOWED_FILE_SIZE` +* `BEPASTY_MAX_BODY_SIZE` + +[1]: https://bepasty-server.readthedocs.io/en/latest/quickstart.html#configuring-bepasty diff --git a/docker/autoconfig.py b/docker/autoconfig.py new file mode 100644 index 0000000..d6631e3 --- /dev/null +++ b/docker/autoconfig.py @@ -0,0 +1,42 @@ +import os +import sys + + +def error_exit(message): + print("\n\n%s" % message) + sys.exit(1) + + +SITENAME = os.environ.get("BEPASTY_SITENAME") +if SITENAME is None: + error_exit("Environment variable BEPASTY_SITENAME must be set.") + +SECRET_KEY = os.environ.get("BEPASTY_SECRET_KEY") +if SECRET_KEY is None: + error_exit("Environment variable BEPASTY_SECRET_KEY must be set.") + +APP_BASE_PATH = os.environ.get("BEPASTY_APP_BASE_PATH") + +# Keep defaults in sync with bepasty by only setting values that are +# explicitly provided via environment variables. +storage_filesystem_directory = os.environ.get("BEPASTY_STORAGE_FILESYSTEM_DIRECTORY") +if storage_filesystem_directory is not None: + STORAGE_FILESYSTEM_DIRECTORY = storage_filesystem_directory + +default_permissions = os.environ.get("BEPASTY_DEFAULT_PERMISSIONS") +if default_permissions is not None: + DEFAULT_PERMISSIONS = default_permissions + +max_allowed_file_size = os.environ.get("BEPASTY_MAX_ALLOWED_FILE_SIZE") +if max_allowed_file_size is not None: + try: + MAX_ALLOWED_FILE_SIZE = int(max_allowed_file_size) + except ValueError as err: + error_exit("Invalid BEPASTY_MAX_ALLOWED_FILE_SIZE: %s" % str(err)) + +max_body_size = os.environ.get("BEPASTY_MAX_BODY_SIZE") +if max_body_size is not None: + try: + MAX_BODY_SIZE = int(max_body_size) + except ValueError as err: + error_exit("Invalid BEPASTY_MAX_BODY_SIZE: %s" % str(err)) diff --git a/docker/goss.yaml b/docker/goss.yaml new file mode 100644 index 0000000..6b5581b --- /dev/null +++ b/docker/goss.yaml @@ -0,0 +1,11 @@ +command: + app-import: + exec: /app/env/bin/python -c "import bepasty.app; print('bepasty import ok')" + exit-status: 0 + stdout: + - /bepasty import ok/ + +http: + http://127.0.0.1:8000/: + status: 200 + timeout: 10000 diff --git a/docker/init.sh b/docker/init.sh new file mode 100755 index 0000000..7a6a326 --- /dev/null +++ b/docker/init.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +if [ ! -f "$BEPASTY_CONFIG" ]; then + echo "Please please mount a configuration file to '$BEPASTY_CONFIG'." + exit 1 +fi + +if ! python "$BEPASTY_CONFIG"; then + exit 1 +fi + +exec /app/env/bin/gunicorn -b "$LISTEN" --workers="$WORKERS" bepasty.wsgi:application