diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 82747bc..730aa83 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -71,12 +71,15 @@ jobs: - name: Build production image run: docker build --target runtime --tag engin-console:ci . - - name: Assert runtime excludes training assets + - name: Assert production image contract run: | docker run --rm \ --entrypoint sh \ engin-console:ci \ - -c "test ! -e val.csv && + -c "test \"\$(id -u)\" = 10001 && + test \"\$(id -g)\" = 10001 && + test ! -w /app && + test ! -e val.csv && test ! -e train.csv && test ! -e final_pipeline.py && test ! -e severity_benchmark.py && @@ -87,8 +90,22 @@ jobs: run: | docker run -d \ --name engin-ci-app \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,size=64m \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --pids-limit 256 \ + --memory 1g \ + --cpus 2 \ engin-console:ci + - name: Verify runtime isolation + run: | + test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' engin-ci-app)" = true + test "$(docker inspect --format '{{.HostConfig.PidsLimit}}' engin-ci-app)" = 256 + test "$(docker inspect --format '{{.HostConfig.Memory}}' engin-ci-app)" = 1073741824 + test "$(docker inspect --format '{{.HostConfig.NanoCpus}}' engin-ci-app)" = 2000000000 + - name: Verify Streamlit health run: | set -eu diff --git a/.streamlit/config.toml b/.streamlit/config.toml index f9c76f6..14b624f 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -10,7 +10,11 @@ font = "sans-serif" showErrorDetails = "none" toolbarMode = "minimal" +[browser] +gatherUsageStats = false + [server] headless = true maxUploadSize = 10 runOnSave = false +fileWatcherType = "none" diff --git a/Dockerfile b/Dockerfile index 34f35f1..9d163b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17 +ARG APP_UID=10001 +ARG APP_GID=10001 FROM ${PYTHON_IMAGE} AS base ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -15,6 +17,21 @@ COPY . . FROM base AS runtime +ARG APP_UID +ARG APP_GID + +RUN groupadd --gid "${APP_GID}" engin \ + && useradd --uid "${APP_UID}" \ + --gid "${APP_GID}" \ + --create-home \ + --home-dir /home/engin \ + --shell /usr/sbin/nologin \ + engin + +ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + XDG_CACHE_HOME=/tmp/.cache \ + MPLCONFIGDIR=/tmp/matplotlib + COPY app.py ./ COPY engin/ ./engin/ COPY assets/ ./assets/ @@ -22,9 +39,13 @@ COPY .streamlit/ ./.streamlit/ COPY artifacts/ ./artifacts/ COPY test.csv predictions.csv prediction_diagnostics.csv ./ +USER ${APP_UID}:${APP_GID} + EXPOSE 8501 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" +STOPSIGNAL SIGTERM + CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"] diff --git a/README.md b/README.md index 5f687c3..4965f07 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,15 @@ Aplikacja otworzy się pod `http://localhost:8501`. Od razu uruchamia bezpieczne Alternatywnie przez Docker: ```bash -docker build -t engin-console . -docker run --rm -p 8501:8501 engin-console +docker build --target runtime -t engin-console . +docker run --rm -p 127.0.0.1:8501:8501 engin-console ``` +Zalecane uruchomienie produkcyjne korzysta z `compose.prod.yaml`: kontener działa +jako UID/GID `10001`, z systemem plików tylko do odczytu, bez Linux capabilities +oraz z limitami CPU, pamięci i procesów. Pełna procedura startu, aktualizacji i +rollbacku znajduje się w `docs/PRODUCTION_RUNBOOK.md`. + ## Co zawiera produkt - mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra, @@ -161,6 +166,8 @@ Oczekiwana odpowiedź: `ok`. - Model jest trenowany poza aplikacją i ładowany raz z wersjonowanego artefaktu do cache procesu. - Finalny obraz runtime nie zawiera `val.csv`, `train.csv` ani skryptów treningowych. +- Proces aplikacji działa jako użytkownik bez uprawnień roota; produkcyjny Compose + dodatkowo wymusza read-only root filesystem, usuwa capabilities i ustawia limity zasobów. - Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo. - Nieprawidłowe dane nie docierają do modelu. - Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie. diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..85160dc --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,31 @@ +services: + engin: + build: + context: . + target: runtime + image: "engin-console:${ENGIN_IMAGE_TAG:-local}" + restart: unless-stopped + init: true + user: "10001:10001" + ports: + - "${ENGIN_BIND_ADDRESS:-127.0.0.1}:${ENGIN_PORT:-8501}:8501" + environment: + STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false" + XDG_CACHE_HOME: /tmp/.cache + MPLCONFIGDIR: /tmp/matplotlib + read_only: true + tmpfs: + - "/tmp:rw,noexec,nosuid,size=64m,mode=1777" + security_opt: + - "no-new-privileges:true" + cap_drop: + - ALL + pids_limit: 256 + mem_limit: 1g + cpus: 2.0 + stop_grace_period: 30s + logging: + driver: local + options: + max-size: "10m" + max-file: "3" diff --git a/docs/PRODUCTION_RUNBOOK.md b/docs/PRODUCTION_RUNBOOK.md new file mode 100644 index 0000000..bccc1f1 --- /dev/null +++ b/docs/PRODUCTION_RUNBOOK.md @@ -0,0 +1,103 @@ +# ENGIN — production runbook + +## Kontrakt wdrożenia + +- Docker Engine i Docker Compose v2. +- Aplikacja działa jako UID/GID `10001:10001`. +- Port jest domyślnie dostępny tylko na `127.0.0.1:8501`. +- Root filesystem kontenera jest tylko do odczytu; zapisywalny jest wyłącznie + tymczasowy `/tmp` o rozmiarze 64 MiB. +- Kontener nie ma Linux capabilities i nie może uzyskać nowych uprawnień. +- Limity: 2 CPU, 1 GiB RAM i 256 procesów. +- Przesłane CSV nie są zapisywane na dysku ani w wolumenie. + +## Pierwszy start + +Wykonuj polecenia z katalogu repozytorium na czystym, zatwierdzonym commicie: + +```bash +export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)" + +docker compose -f compose.prod.yaml config --quiet +docker compose -f compose.prod.yaml build +docker compose -f compose.prod.yaml up -d --no-build +docker compose -f compose.prod.yaml ps + +curl --fail http://127.0.0.1:8501/_stcore/health +``` + +Oczekiwana odpowiedź healthchecku: `ok`. + +## Kontrola ograniczeń + +```bash +CONTAINER_ID="$(docker compose -f compose.prod.yaml ps -q engin)" + +docker inspect --format \ + 'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} pids={{.HostConfig.PidsLimit}} memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}' \ + "$CONTAINER_ID" +``` + +Oczekiwane wartości: + +```text +user=10001:10001 readonly=true pids=256 memory=1073741824 nanocpus=2000000000 +``` + +## Logi i diagnostyka + +```bash +docker compose -f compose.prod.yaml logs --tail 200 engin +docker compose -f compose.prod.yaml ps +``` + +Logi są rotowane przez sterownik `local`: maksymalnie trzy pliki po 10 MiB. + +## Aktualizacja + +Najpierw zachowaj SHA aktualnie działającego obrazu, następnie zbuduj nową, +jednoznacznie otagowaną wersję: + +```bash +docker compose -f compose.prod.yaml ps +git pull --ff-only + +export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)" +docker compose -f compose.prod.yaml build +docker compose -f compose.prod.yaml up -d --no-build +curl --fail http://127.0.0.1:8501/_stcore/health +``` + +Nie usuwaj poprzedniego obrazu przed zakończeniem smoke testu. + +## Rollback + +Jeżeli smoke test nowej wersji nie przejdzie, uruchom poprzedni lokalny tag bez +ponownego budowania: + +```bash +ENGIN_IMAGE_TAG= \ + docker compose -f compose.prod.yaml up -d --no-build + +curl --fail http://127.0.0.1:8501/_stcore/health +``` + +Jeżeli obrazu o wskazanym tagu już nie ma, przełącz repo na zatwierdzony commit, +odbuduj obraz i ponownie wykonaj smoke test. Nie używaj niezaufanego artefaktu +modelu jako skrótu do rollbacku. + +## Udostępnienie poza hostem + +Domyślne wiązanie `127.0.0.1` jest celowe. Dostęp sieciowy włączaj dopiero za +reverse proxy z TLS i regułami firewalla. W takim środowisku można ustawić: + +```bash +ENGIN_BIND_ADDRESS=0.0.0.0 \ + docker compose -f compose.prod.yaml up -d --no-build +``` + +## Zatrzymanie + +```bash +docker compose -f compose.prod.yaml down +```