Skip to content
Open
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
71 changes: 71 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Git
.git
.gitignore
.gitattributes

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
*.egg-info/
dist/
build/
*.egg

# IDE
.vscode/
.idea/
*.swp
*.swo
*~

# OS
.DS_Store
Thumbs.db

# Docker (don't copy docker files into the image)
Dockerfile
docker-compose*.yml
.dockerignore

# Database lock files
*.db
*.sqlite
*.sqlite3
.db_initialized

# Logs
logs/
*.log

# Test files
.coverage
htmlcov/
.pytest_cache/
.tox/

# CI/CD
.github/
.travis.yml
.circleci/

# Temporary files
tmp/
temp/
*.tmp

# Secrets and credentials (prevent accidental inclusion)
*.pem
*.key
service-account.json
gcp-key.json
secret_key
secret_csrf
config.py
.env
3 changes: 2 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
*.bat text eol=crlf
*.bat text eol=crlf
*.sh text eol=lf
82 changes: 82 additions & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Running the Sample Platform with Docker

A two-container development stack: MySQL 8 and the Flask application served by
Gunicorn. It gives contributors a working platform without installing MySQL,
Python, or the native libraries the app depends on.

## Prerequisites

- Docker Engine 24+ with the Compose plugin (`docker compose`).

## Quick start (empty database)

```sh
cp env.example .env # then edit the passwords
docker compose up --build
```

On first start the database initialises, the app waits for it, builds the
schema, loads the bundled fixture set (categories, samples, regression tests)
and serves on <http://localhost:5000>. Later starts only apply migrations
newer than the database, and never re-seed.

Create an administrator so you can sign in:

```sh
docker compose exec backend \
python install/init_db.py "$SQLALCHEMY_DATABASE_URI" admin admin@example.com admin
```

## Starting from a database dump

To develop against real data, load a `mysqldump` on the database's **first**
start by dropping it into the init directory. Create `docker-compose.override.yml`:

```yaml
services:
db:
volumes:
- /absolute/path/to/dump.sql:/docker-entrypoint-initdb.d/01-dump.sql:ro
```

Then `docker compose up --build`. MySQL imports the dump before the app starts;
the app applies any migrations newer than the dump on top of it. The import
only runs while the `db_data` volume is empty — `docker compose down -v` first
to reload a different dump.

## Live reload

The image is self-contained (code is copied in, not mounted). For an
edit-refresh loop, mount the package you are working on and enable Gunicorn's
reloader in the override file:

```yaml
services:
backend:
environment:
GUNICORN_RELOAD: "1"
volumes:
- ./mod_sample:/app/mod_sample
- ./templates:/app/templates
```

## Common commands

| Task | Command |
|---|---|
| Start | `docker compose up --build` |
| Stop | `docker compose down` |
| Reset the database | `docker compose down -v` |
| App logs | `docker compose logs -f backend` |
| A shell in the app | `docker compose exec backend bash` |
| A MySQL shell | `docker compose exec db mysql -u root -p` |

## Notes

- The database port is not published to the host. Reach MySQL through
`docker compose exec`, or add a `ports` mapping in an override if you need a
local client.
- The image generates throwaway secret keys and GCP credentials at build time
so the app can boot offline. They are not suitable for production.
- Storage falls back to the local `/repository` volume; no GCS bucket is
required for development.
72 changes: 72 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
FROM python:3.12-slim-bookworm

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
FLASK_APP=run.py

# git for the build commit run.py reads, libmagic for upload sniffing,
# mediainfo for sample metadata. No compiler: everything has a wheel.
RUN apt-get update && apt-get install -y --no-install-recommends \
git libmagic1 mediainfo \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Dependencies first so application edits don't invalidate this layer.
# Pinned, and wheels only, so nothing runs a setup script during the build.
# cryptography is used by the credential generator further down.
COPY requirements.txt .
RUN pip install --only-binary :all: --upgrade \
pip==26.2 setuptools==83.0.0 wheel==0.47.0 \
&& pip install --only-binary :all: \
cryptography==50.0.0 gunicorn==26.0.0 \
&& pip install --only-binary :all: -r requirements.txt

Check warning on line 24 in Dockerfile

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=CCExtractor_sample-platform&issues=AZ_C02Y_1vMCp7Sk6LDA&open=AZ_C02Y_1vMCp7Sk6LDA&pullRequest=1023

# Listed by name instead of "COPY . ." so a secret_key, config.py or
# service-account.json left in a working tree can't end up in the image.
COPY run.py config_parser.py database.py decorators.py exceptions.py \
log_configuration.py mailer.py utility.py ./
COPY mod_api/ ./mod_api/
COPY mod_auth/ ./mod_auth/
COPY mod_ci/ ./mod_ci/
COPY mod_customized/ ./mod_customized/
COPY mod_health/ ./mod_health/
COPY mod_home/ ./mod_home/
COPY mod_regression/ ./mod_regression/
COPY mod_sample/ ./mod_sample/
COPY mod_test/ ./mod_test/
COPY mod_upload/ ./mod_upload/
COPY install/ ./install/
COPY migrations/ ./migrations/
COPY static/ ./static/
COPY templates/ ./templates/

# The app reads its settings from config.py; this variant takes them from
# the environment.
COPY config.docker.py config.py

# Done once here instead of on every start: unprivileged user, secret keys,
# throwaway GCP credentials, the repository tree (named volumes inherit this
# layout on first mount) and a git repo so run.py can resolve a commit.
RUN useradd --create-home --uid 1001 appuser \
&& python install/generate_dev_credentials.py \
&& head -c 32 /dev/urandom > secret_key \
&& head -c 32 /dev/urandom > secret_csrf \
&& mkdir -p logs \
/repository/ci-tests /repository/unsafe-ccextractor /repository/TempFiles \
/repository/LogFiles /repository/TestResults /repository/TestFiles/media \
/repository/QueuedFiles /repository/TestData/ci-linux \
/repository/TestData/ci-windows /repository/vm_data \
&& git init -q . \
&& git -c user.email=dev@local -c user.name=docker add -A \
&& git -c user.email=dev@local -c user.name=docker commit -qm "container image" \
&& chown -R appuser:appuser /app /repository

COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
&& chmod +x /usr/local/bin/docker-entrypoint.sh

USER appuser
EXPOSE 5000
ENTRYPOINT ["docker-entrypoint.sh"]
60 changes: 60 additions & 0 deletions config.docker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Container configuration for the Sample Platform.

Copied to ``config.py`` inside the image at build time. Every value is read
from the environment (see ``env.example``) with local-development defaults,
so the repository never carries a real secret. Override every placeholder
before pointing this at anything but a throwaway database.
"""
import os


def _int(name: str, default: int) -> int:
try:
return int(os.environ[name])
except (KeyError, ValueError):
return default


APPLICATION_ROOT = None
CSRF_ENABLED = True

DATABASE_URI = os.environ.get(
"SQLALCHEMY_DATABASE_URI",
"mysql+pymysql://sample_platform:sample_platform@db/sample_platform?charset=utf8mb4",
)
SERVER_NAME = os.environ.get("SERVER_NAME", "localhost:5000")
SESSION_COOKIE_PATH = "/"

INSTALL_FOLDER = os.environ.get("INSTALL_FOLDER", "/app")
SAMPLE_REPOSITORY = os.environ.get("SAMPLE_REPOSITORY", "/repository")

HMAC_KEY = os.environ.get("HMAC_KEY", "dev-hmac-key")
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
GITHUB_OWNER = os.environ.get("GITHUB_OWNER", "CCExtractor")
GITHUB_REPOSITORY = os.environ.get("GITHUB_REPOSITORY", "ccextractor")
GITHUB_CI_KEY = os.environ.get("GITHUB_CI_KEY", "")
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_KEY = os.environ.get("GITHUB_CLIENT_KEY", "")

EMAIL_DOMAIN = os.environ.get("EMAIL_DOMAIN", "")
EMAIL_API_KEY = os.environ.get("EMAIL_API_KEY", "")

FTP_PORT = _int("FTP_PORT", 21)
MAX_CONTENT_LENGTH = 512 * 1024 * 1024
MIN_PWD_LEN = 10
MAX_PWD_LEN = 500

# GCP / Cloud Storage. The build generates a throwaway service account so the
# storage client can initialise offline; file serving falls back to local disk.
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
SERVICE_ACCOUNT_FILE = os.environ.get("SERVICE_ACCOUNT_FILE", "service-account.json")
ZONE = "us-west4-b"
PROJECT_NAME = "ccextractor-sampleplatform"
MACHINE_TYPE = f"zones/{ZONE}/machineTypes/n1-standard-1"
WINDOWS_INSTANCE_PROJECT_NAME = "windows-cloud"
WINDOWS_INSTANCE_FAMILY_NAME = "windows-2019"
LINUX_INSTANCE_PROJECT_NAME = "ubuntu-os-cloud"
LINUX_INSTANCE_FAMILY_NAME = "ubuntu-minimal-2404-lts-amd64"
GCP_INSTANCE_MAX_RUNTIME = 120
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", "sample-platform-dev")
GCS_SIGNED_URL_EXPIRY_LIMIT = 720
50 changes: 50 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
services:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}
MYSQL_DATABASE: ${MYSQL_DATABASE:-sample_platform}
MYSQL_USER: ${MYSQL_USER:-sample_platform}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 5s
retries: 20
networks:
- sample_platform

backend:
build: .
depends_on:
db:
condition: service_healthy
environment:
SQLALCHEMY_DATABASE_URI: mysql+pymysql://${MYSQL_USER:-sample_platform}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE:-sample_platform}?charset=utf8mb4
DB_HOST: db
DB_PORT: "3306"
DB_USER: ${MYSQL_USER:-sample_platform}
DB_PASSWORD: ${MYSQL_PASSWORD}
SERVER_NAME: ${SERVER_NAME:-localhost:5000}
HMAC_KEY: ${HMAC_KEY:-dev-hmac-key}
GCS_BUCKET_NAME: ${GCS_BUCKET_NAME:-sample-platform-dev}
GITHUB_OWNER: ${GITHUB_OWNER:-CCExtractor}
GITHUB_REPOSITORY: ${GITHUB_REPOSITORY:-ccextractor}
DEBUG: ${DEBUG:-True}
ports:
- "${APP_PORT:-5000}:5000"
volumes:
- repository_data:/repository
networks:
- sample_platform
restart: unless-stopped

networks:
sample_platform:

volumes:
db_data:
repository_data:
21 changes: 21 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
set -e

# /app is a git repo created at build time; run.py reads a build commit from
# it. safe.directory guards against ownership mismatches under a bind mount.
git config --global --add safe.directory /app 2>/dev/null || true

python install/wait_for_db.py

# Creates the schema on an empty database, applies pending migrations on an
# existing one. See install/init_schema.py for why it is not just an upgrade.
python install/init_schema.py

exec gunicorn \
--workers "${GUNICORN_WORKERS:-3}" \
--bind 0.0.0.0:5000 \
--timeout 120 \
--access-logfile - \
--error-logfile - \
${GUNICORN_RELOAD:+--reload} \
run:app
19 changes: 19 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Sample Platform — Docker environment. Copy to .env and edit before use.

# MySQL. The root password stays with the database container and is never
# passed to the application, which connects only as the user below.
MYSQL_ROOT_PASSWORD=change-me-root
MYSQL_DATABASE=sample_platform
MYSQL_USER=sample_platform
MYSQL_PASSWORD=change-me-app

# Host port for the web app (the container always listens on 5000).
APP_PORT=5000

# Application settings.
SERVER_NAME=localhost:5000
HMAC_KEY=change-me
GCS_BUCKET_NAME=sample-platform-dev
GITHUB_OWNER=CCExtractor
GITHUB_REPOSITORY=ccextractor
DEBUG=True
Loading
Loading