diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5f6bfa9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.impeccable +.venv +__pycache__ +*.py[cod] +*.xls +*.xlsx +screenshots +tests +docs +design-system +流水模板 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0c76b66 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +APP_PORT=4173 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e9ea5ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src \ + APP_HOST=0.0.0.0 \ + APP_PORT=4173 + +WORKDIR /app + +RUN addgroup --system app && adduser --system --ingroup app app + +COPY requirements.txt ./ +RUN python -m pip install --no-cache-dir --requirement requirements.txt + +COPY server.py ./ +COPY src ./src +COPY web ./web + +USER app + +EXPOSE 4173 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 \ + CMD ["python", "-c", "import os, urllib.request; port = os.getenv('APP_PORT', '4173'); urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=2)"] + +CMD ["python", "server.py"] diff --git a/README.md b/README.md index e6ce8a6..68ae50a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,24 @@ python -m unittest discover -s tests -v python server.py ``` +## Docker 部署 + +Windows PowerShell 一键启动: + +```powershell +.\start-docker.ps1 +``` + +Linux 或 macOS 一键启动: + +```sh +./start-docker.sh +``` + +默认访问 `http://localhost:4173/`。如需更换宿主机端口,可运行 +`.\start-docker.ps1 -Port 8080`,或运行 `APP_PORT=8080 ./start-docker.sh`。 +停止服务使用 `docker compose down`,查看日志使用 `docker compose logs -f app`。 + 访问地址: - 登录入口:`http://127.0.0.1:4173/` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a4dcb83 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,17 @@ +services: + app: + build: + context: . + image: caiwuzongzhang:local + ports: + - "${APP_PORT:-4173}:4173" + environment: + APP_HOST: 0.0.0.0 + APP_PORT: 4173 + init: true + restart: unless-stopped + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true diff --git a/server.py b/server.py index aebc1b8..b37880a 100644 --- a/server.py +++ b/server.py @@ -106,9 +106,10 @@ class AppHandler(SimpleHTTPRequestHandler): def main() -> None: + host = os.environ.get("APP_HOST", "127.0.0.1") port = int(os.environ.get("APP_PORT", "4173")) - server = ThreadingHTTPServer(("127.0.0.1", port), AppHandler) - print(f"Serving on http://127.0.0.1:{port}") + server = ThreadingHTTPServer((host, port), AppHandler) + print(f"Serving on http://{host}:{port}") server.serve_forever() diff --git a/start-docker.ps1 b/start-docker.ps1 new file mode 100644 index 0000000..38d083e --- /dev/null +++ b/start-docker.ps1 @@ -0,0 +1,43 @@ +param( + [ValidateRange(1, 65535)] + [int]$Port = 4173 +) + +$ErrorActionPreference = "Stop" + +docker compose version | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "Docker Compose is not available." +} + +docker info | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "Docker is not running." +} + +$env:APP_PORT = $Port.ToString() +docker compose up --build --detach +if ($LASTEXITCODE -ne 0) { + throw "Docker Compose failed to start the application." +} + +$containerId = (docker compose ps --quiet app).Trim() +if (-not $containerId) { + throw "The application container was not created." +} + +for ($attempt = 0; $attempt -lt 60; $attempt++) { + $state = docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $containerId + if ($state -eq "healthy") { + Write-Host "Application is ready: http://localhost:$Port" + exit 0 + } + if ($state -eq "unhealthy" -or $state -eq "exited" -or $state -eq "dead") { + docker compose logs --no-color --tail 100 app + throw "The application container entered state: $state" + } + Start-Sleep -Seconds 1 +} + +docker compose logs --no-color --tail 100 app +throw "Timed out waiting for the application health check." diff --git a/start-docker.sh b/start-docker.sh new file mode 100755 index 0000000..2851ecf --- /dev/null +++ b/start-docker.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env sh +set -eu + +APP_PORT="${APP_PORT:-${1:-4173}}" +export APP_PORT + +case "$APP_PORT" in + ''|*[!0-9]*) + echo "APP_PORT must be an integer between 1 and 65535." >&2 + exit 1 + ;; +esac +if [ "$APP_PORT" -lt 1 ] || [ "$APP_PORT" -gt 65535 ]; then + echo "APP_PORT must be an integer between 1 and 65535." >&2 + exit 1 +fi + +docker compose version >/dev/null +docker info >/dev/null +docker compose up --build --detach + +container_id="$(docker compose ps --quiet app)" +if [ -z "$container_id" ]; then + echo "The application container was not created." >&2 + exit 1 +fi + +attempt=0 +while [ "$attempt" -lt 60 ]; do + state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" + case "$state" in + healthy) + echo "Application is ready: http://localhost:${APP_PORT}" + exit 0 + ;; + unhealthy|exited|dead) + docker compose logs --no-color --tail 100 app + echo "The application container entered state: $state" >&2 + exit 1 + ;; + esac + attempt=$((attempt + 1)) + sleep 1 +done + +docker compose logs --no-color --tail 100 app +echo "Timed out waiting for the application health check." >&2 +exit 1 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..9ed360d --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import os +from unittest import TestCase +from unittest.mock import patch + +import server + + +class ServerConfigurationTests(TestCase): + @patch.dict(os.environ, {"APP_HOST": "0.0.0.0", "APP_PORT": "8080"}) + @patch("server.ThreadingHTTPServer") + def test_main_uses_configured_host_and_port(self, http_server) -> None: + server.main() + + http_server.assert_called_once_with(("0.0.0.0", 8080), server.AppHandler) + http_server.return_value.serve_forever.assert_called_once_with()