diff --git a/.dockerignore b/.dockerignore index a34eb26..e782ebe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,5 +10,4 @@ __pycache__ data downloads docs -tests *.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..743243e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.12-slim-bookworm AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN groupadd --gid 10001 app \ + && useradd --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir --requirement requirements.txt + +FROM base AS test + +COPY requirements-dev.txt pyproject.toml ./ +RUN pip install --no-cache-dir --requirement requirements-dev.txt + +COPY --chown=app:app app ./app +COPY --chown=app:app tests ./tests + +USER app + +CMD ["pytest"] + +FROM base AS runtime + +COPY --chown=app:app app ./app + +USER app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] diff --git a/README.md b/README.md index 6aa2323..4dc028e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,41 @@ Each stage should produce a small, working, reviewable commit. ## Local development -Local setup instructions will be added with the first application container. +The current milestone is a minimal FastAPI application running in a hardened +local container. Docker publishes it only on the PC's loopback interface, so it +is not exposed to other devices on the LAN. + +Build and start it: + +```bash +docker compose up --build -d +``` + +Check its status and logs: + +```bash +docker compose ps +docker compose logs --tail=50 web +``` + +Open or request the health endpoint: + +```bash +curl --fail http://127.0.0.1:8000/health +``` + +Run the automated tests in an ephemeral container: + +```bash +docker compose run --rm --build test +``` + +Stop the application: + +```bash +docker compose down +``` + Runtime data, downloaded media, local environment files, and secrets must not be committed to Git. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f0055d8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Media Ingest application package.""" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..eacd174 --- /dev/null +++ b/app/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +app = FastAPI( + title="Media Ingest", + description="Private broadcast media-ingest service.", + version="0.1.0", +) + + +@app.get("/", tags=["system"]) +def root() -> dict[str, str]: + return {"name": "Media Ingest", "status": "running"} + + +@app.get("/health", tags=["system"]) +def health() -> dict[str, str]: + """Report whether the application process can serve requests.""" + return {"status": "healthy"} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bf4aa9c --- /dev/null +++ b/compose.yaml @@ -0,0 +1,45 @@ +services: + web: + build: + context: . + target: runtime + image: media-ingest:local + init: true + ports: + - "127.0.0.1:8000:8000" + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2) + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + + test: + profiles: + - test + build: + context: . + target: test + image: media-ingest-test:local + init: true + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5eb8c0a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.pytest.ini_options] +addopts = "-ra" +cache_dir = "/tmp/pytest-cache" +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..20f46de --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +httpx2==2.7.0 +pytest==9.1.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..db66b80 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.139.2 +uvicorn==0.51.0 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..559f9a7 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,19 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health_returns_healthy() -> None: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_root_identifies_application() -> None: + response = client.get("/") + + assert response.status_code == 200 + assert response.json() == {"name": "Media Ingest", "status": "running"}