From 554253e48d1e90f795c438ddd5f2cbd183e95ce5 Mon Sep 17 00:00:00 2001 From: Rain Mark Date: Thu, 26 Mar 2026 13:30:35 +0800 Subject: [PATCH] docker deploy --- .dockerignore | 22 +++ Dockerfile | 39 +++++ README.md | 8 +- deploy/caddy/Caddyfile | 13 -- deploy/env/hw.env.example | 6 +- deploy/systemd/hw.service | 16 -- docker/entrypoint.sh | 34 ++++ docs/development-setup.md | 4 +- docs/production-deployment.md | 211 +++++++++++++------------ docs/production-ops.md | 175 ++++++++++---------- docs/requirements.md | 3 +- docs/technical-design.md | 4 +- docs/todo-progress.md | 8 +- scripts/deploy-container.sh | 81 ++++++++++ scripts/deploy-systemd.sh | 55 ------- scripts/install-linux-container.sh | 101 ++++++++++++ scripts/install-linux-systemd.sh | 55 ------- scripts/verify-container-deployment.sh | 72 +++++++++ scripts/verify-deployment.sh | 34 ---- 19 files changed, 565 insertions(+), 376 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 deploy/caddy/Caddyfile delete mode 100644 deploy/systemd/hw.service create mode 100644 docker/entrypoint.sh create mode 100644 scripts/deploy-container.sh delete mode 100644 scripts/deploy-systemd.sh create mode 100644 scripts/install-linux-container.sh delete mode 100644 scripts/install-linux-systemd.sh create mode 100644 scripts/verify-container-deployment.sh delete mode 100644 scripts/verify-deployment.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fef0459 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.gitignore +.sisyphus/ +.venv/ +.pytest_cache/ +__pycache__/ +*.py[cod] +node_modules/ +instance/ +app/static/css/ +playwright-report/ +test-results/ +ui-audit-home-dark.png +ui-audit-home-default.png +ui-audit-home-sidebar-collapsed.png +ui-polish-desktop-collapsed.png +ui-polish-desktop-default.png +ui-polish-mobile-final-check.png +ui-polish-mobile-hidden-current.png +tests/ +docs/ +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc2a05c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-bookworm-slim AS assets + +WORKDIR /opt/hw + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY app/static/src ./app/static/src +COPY app/templates ./app/templates +RUN mkdir -p ./app/static/css && npm run build:css + +FROM python:3.13-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /opt/hw + +RUN groupadd --system --gid 10001 hw \ + && useradd --system --uid 10001 --gid 10001 --create-home --home-dir /opt/hw hw + +COPY requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +COPY . . +COPY --from=assets /opt/hw/app/static/css/main.css /opt/hw/app/static/css/main.css + +RUN chmod +x /opt/hw/docker/entrypoint.sh \ + && mkdir -p /opt/hw/instance/csv_previews \ + && chown -R hw:hw /opt/hw + +USER hw + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD python -c "import json, urllib.request; payload = json.load(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)); raise SystemExit(0 if payload.get('status') == 'ok' else 1)" + +ENTRYPOINT ["/opt/hw/docker/entrypoint.sh"] diff --git a/README.md b/README.md index fa12e00..c30ec50 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ - 压力测试数据生成命令 `flask seed-stress`(默认 1000 户、平均每户 4 人、`STRESS-` 前缀) - Python + Playwright 端到端测试(管理员搜索编辑、长辈录入页保存、管理端新增户、家庭成员 CRUD、账号管理提交链路、审计日志筛选与详情、CSV 导入导出闭环、认证与权限边界) - 当前最新一次本地验证结果: - - `.venv/bin/python -m pytest tests` → `102 passed` + - `.venv/bin/python -m pytest tests` → `107 passed` - `.venv/bin/python -m pytest tests/e2e -q` → 请以本地最近一次执行结果为准(需先安装 Playwright Chromium) - - `bash -n scripts/generate-secrets.sh scripts/install-linux-systemd.sh scripts/deploy-systemd.sh scripts/verify-deployment.sh` → 通过 -- 已补齐 Linux 单机生产部署交付:`ProductionConfig`、Gunicorn、systemd 服务模板、Caddy 反向代理模板、环境变量示例、部署/校验脚本、生产部署与运维文档 + - `bash -n scripts/generate-secrets.sh scripts/install-linux-container.sh scripts/deploy-container.sh scripts/verify-container-deployment.sh` → 通过 +- 已补齐 Linux 单机生产容器化部署交付:`ProductionConfig`、Python 3.13 容器镜像、Docker 入口脚本、环境变量示例、安装/部署/校验脚本、生产部署与运维文档 ### 本地最快验证路径 @@ -101,7 +101,7 @@ npm run watch:css ### 当前版本状态说明 - 当前版本以“核心可用版 + 生产部署交付”作为阶段性基线,功能范围暂不继续扩展 -- 已交付 Linux 单机生产部署方案:Caddy + systemd + Gunicorn,并补齐部署脚本与运维文档 +- 已交付 Linux 单机生产部署方案:Python 3.13 容器 + Gunicorn,并补齐部署脚本与运维文档 - 如未来恢复功能迭代,可优先考虑桌席安排、更细粒度权限 / CSRF 加固,以及更丰富的统计分析能力 ### 当前内部版本已知限制 diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile deleted file mode 100644 index 499adb9..0000000 --- a/deploy/caddy/Caddyfile +++ /dev/null @@ -1,13 +0,0 @@ -example.com { - encode zstd gzip - - log { - output stderr - } - - reverse_proxy 127.0.0.1:8000 { - header_up X-Forwarded-For {remote_host} - header_up X-Forwarded-Proto {scheme} - header_up Host {host} - } -} diff --git a/deploy/env/hw.env.example b/deploy/env/hw.env.example index da3f16b..4885499 100644 --- a/deploy/env/hw.env.example +++ b/deploy/env/hw.env.example @@ -1,5 +1,5 @@ # HappyWedding production environment variables -# Copy this file to /etc/hw/hw.env and fill in real values. +# Copy this file to /opt/hw.conf.d/hw.env and fill in real values. HW_CONFIG=production HW_SECRET_KEY=replace-with-a-long-random-secret @@ -7,5 +7,9 @@ HW_SECRET_KEY=replace-with-a-long-random-secret # Recommended production database example (MySQL) DATABASE_URL=mysql+pymysql://hw_user:replace-password@127.0.0.1:3306/hw +# Optional container runtime tuning +# HW_GUNICORN_WORKERS=2 +# HW_GUNICORN_PORT=8000 + # SQLite is only suitable for lightweight or temporary environments. # DATABASE_URL=sqlite:////opt/hw/instance/happywedding.db diff --git a/deploy/systemd/hw.service b/deploy/systemd/hw.service deleted file mode 100644 index c99fcbf..0000000 --- a/deploy/systemd/hw.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=HappyWedding Gunicorn Service -After=network.target - -[Service] -Type=simple -User=hw -Group=hw -WorkingDirectory=/opt/hw -EnvironmentFile=/etc/hw/hw.env -ExecStart=/opt/hw/.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 run:app -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..ee2035d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +APP_DIR="${APP_DIR:-/opt/hw}" +CONFIG_DIR="${CONFIG_DIR:-/opt/hw.conf.d}" +ENV_FILE="${ENV_FILE:-${CONFIG_DIR}/hw.env}" +INSTANCE_DIR="${INSTANCE_DIR:-${APP_DIR}/instance}" +SEED_MARKER_FILE="${SEED_MARKER_FILE:-${INSTANCE_DIR}/.seed-all-complete}" + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck disable=SC1090 + . "$ENV_FILE" + set +a +fi + +export FLASK_APP="${FLASK_APP:-run.py}" +export HW_CONFIG="${HW_CONFIG:-production}" +export PYTHONPATH="${PYTHONPATH:-${APP_DIR}}" + +mkdir -p "$INSTANCE_DIR/csv_previews" + +python -m flask db upgrade + +if [ "${RUN_SEED_ALL:-0}" = "1" ] && [ ! -f "$SEED_MARKER_FILE" ]; then + python -m flask seed-all + : > "$SEED_MARKER_FILE" +fi + +if [ "$#" -eq 0 ]; then + set -- gunicorn --workers "${HW_GUNICORN_WORKERS:-2}" --bind "0.0.0.0:${HW_GUNICORN_PORT:-8000}" run:app +fi + +exec "$@" diff --git a/docs/development-setup.md b/docs/development-setup.md index 89c29bd..b0b9acc 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -412,7 +412,7 @@ FLASK_APP=run.py .venv/bin/flask seed-stress --household-count 500 --members-per - `ProductionConfig` 已设置 `PREFERRED_URL_SCHEME=https` - `ProductionConfig.validate()` 会强制检查 `HW_SECRET_KEY` 与 `DATABASE_URL` - `ProductionConfig` 已包含基础数据库连接池参数(`pool_pre_ping`、`pool_recycle`) -- 正式生产部署文档与 systemd / Caddy 模板见 `docs/production-deployment.md` +- 正式生产部署文档与容器化部署脚本见 `docs/production-deployment.md` 仍建议后续按正式上线需要继续补充: @@ -450,7 +450,7 @@ FLASK_APP=run.py .venv/bin/flask seed-stress --household-count 500 --members-per ## 12. 当前阶段说明 -当前仓库已经完成核心可用版开发,并补齐 Linux 单机生产部署文档、systemd / Caddy 模板与配套脚本。按照当前约定,功能需求先暂停扩展,本阶段以部署落地、文档同步与必要修复为主。 +当前仓库已经完成核心可用版开发,并补齐 Linux 单机生产容器化部署文档与配套脚本。按照当前约定,功能需求先暂停扩展,本阶段以部署落地、文档同步与必要修复为主。 如未来恢复功能迭代,可优先参考以下方向: diff --git a/docs/production-deployment.md b/docs/production-deployment.md index eb1bfc9..7e52be8 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -1,47 +1,55 @@ -# HappyWedding 生产部署说明(Linux + systemd + Caddy) +# HappyWedding 生产部署说明(Linux + Docker 容器) -本文档说明如何在 Linux 单机上以 **Caddy + systemd + Gunicorn** 的方式部署 HappyWedding(`hw`)。 +本文档说明如何在 Linux 单机上以 **Python 3.13 容器 + Gunicorn** 的方式部署 HappyWedding(`hw`)。 ## 1. 部署拓扑 部署拓扑固定为: ```text -Internet - -> Caddy (80/443) - -> Gunicorn (127.0.0.1:8000) +Internet / 内网入口 + -> 宿主机端口(默认 8000,可自行前置 nginx / Caddy / LB) + -> Docker container + -> Gunicorn (0.0.0.0:8000) -> Flask app (run:app) ``` 约定: -- Caddy 负责 HTTPS、反向代理与对外暴露 -- Gunicorn 仅监听本机 `127.0.0.1:8000` -- systemd 负责托管 Gunicorn 进程 +- 不再使用 systemd 托管应用进程 +- 应用通过 Docker 容器运行,使用 Docker 的 `--restart unless-stopped` 负责常驻 - Flask 通过环境变量进入 `production` 配置 +- 容器启动时会自动执行数据库迁移 +- 容器镜像内已包含 Python 运行时依赖与编译后的 CSS 产物 ## 2. 目录约定 -本文档和配套脚本统一使用以下目录: +本方案统一使用以下目录: - 应用目录:`/opt/hw` -- 环境变量目录:`/etc/hw` -- systemd unit:`/etc/systemd/system/hw.service` -- 环境文件:`/etc/hw/hw.env` +- 配置目录:`/opt/hw.conf.d` +- 环境文件:`/opt/hw.conf.d/hw.env` - 可写实例目录:`/opt/hw/instance` - CSV 预览目录:`/opt/hw/instance/csv_previews` +说明: + +- `/opt/hw` 同时作为宿主机部署目录和容器内工作目录 +- `/opt/hw.conf.d` 同时作为宿主机配置目录和容器内只读挂载目录 +- `/opt/hw/instance` 会挂载进容器,保留 SQLite、CSV 预览等运行时数据 + ## 3. 服务器前置要求 -请先在主机上准备: +请先在目标主机上准备: -1. Linux systemd 环境 -2. Python 3 -3. Caddy -4. 数据库(推荐 MySQL) -5. 可访问仓库代码的方式(git clone / rsync / scp 等) +1. Linux 主机 +2. Docker Engine + - 若主机是 Debian / Ubuntu,可使用 `scripts/install-linux-container.sh` 自动安装 + - 正式部署前请确认 Docker daemon 已启动 +3. 数据库(推荐 MySQL) +4. 可访问仓库代码的方式(git clone / rsync / scp 等) -> 当前脚本**不负责安装 MySQL 或 Caddy 包本身**,它们应由你按主机环境单独安装。 +> 当前脚本不负责安装 MySQL。本方案默认应用运行时只依赖 Docker,不再依赖宿主机 Python / Node 运行环境。 ## 4. 生产配置项 @@ -61,6 +69,10 @@ HappyWedding 当前依赖以下关键环境变量: HW_CONFIG=production HW_SECRET_KEY=replace-with-a-long-random-secret DATABASE_URL=mysql+pymysql://hw_user:replace-password@127.0.0.1:3306/hw + +# 可选:Gunicorn worker 数量 +# HW_GUNICORN_WORKERS=2 +# HW_GUNICORN_PORT=8000 ``` ### 4.1 生成随机密钥 @@ -71,15 +83,15 @@ DATABASE_URL=mysql+pymysql://hw_user:replace-password@127.0.0.1:3306/hw bash scripts/generate-secrets.sh ``` -把输出内容填入 `/etc/hw/hw.env` 的 `HW_SECRET_KEY=`。 +把输出内容填入 `/opt/hw.conf.d/hw.env` 的 `HW_SECRET_KEY=`。 ### 4.2 数据库说明 - 正式生产建议使用 MySQL - SQLite 仅适合轻量或临时环境 -- 首次上线前至少执行一次: +- 容器启动时会自动执行: - `flask db upgrade` -- 是否执行 `flask seed-all` 取决于你的数据库是否已初始化基础管理员和选项数据 +- 是否执行 `flask seed-all` 取决于你是否在首次部署时显式开启 `RUN_SEED_ALL=1` ## 5. 首次部署步骤 @@ -103,25 +115,25 @@ sudo git clone /opt/hw 在仓库根目录执行: ```bash -sudo bash scripts/install-linux-systemd.sh +sudo bash scripts/install-linux-container.sh ``` 该脚本会: -1. 创建系统用户 `hw`(如不存在) -2. 创建 `/opt/hw`、`/etc/hw`、`/opt/hw/instance`、`/opt/hw/instance/csv_previews` -3. 创建虚拟环境 `/opt/hw/.venv` -4. 安装 / 更新 pip -5. 为后续 Tailwind CLI 编译准备 Node.js / npm 运行环境(需主机预先安装) -6. 安装 systemd 模板到 `/etc/systemd/system/hw.service` -7. 若 `/etc/hw/hw.env` 不存在,则从模板创建一份 +1. 检查 Docker 是否可用 +2. 若主机为 apt 系发行版且未安装 Docker,则自动安装 Docker +3. 创建 `/opt/hw` +4. 创建 `/opt/hw.conf.d` +5. 创建 `/opt/hw/instance` +6. 创建 `/opt/hw/instance/csv_previews` +7. 若 `/opt/hw.conf.d/hw.env` 不存在,则从模板初始化一份 ### 5.3 编辑环境变量文件 打开并修改: ```bash -sudo vi /etc/hw/hw.env +sudo vi /opt/hw.conf.d/hw.env ``` 至少填好: @@ -135,137 +147,128 @@ sudo vi /etc/hw/hw.env 执行: ```bash -sudo bash scripts/deploy-systemd.sh -``` - -如果你确定这是一个全新数据库,需要同时初始化基础数据: - -```bash -sudo RUN_SEED_ALL=1 bash scripts/deploy-systemd.sh +sudo bash scripts/deploy-container.sh ``` 该脚本会: -1. 使用 `/etc/hw/hw.env` 加载环境变量 -2. 安装 `requirements.txt` 依赖 -3. 安装 npm 依赖(如 `node_modules` 不存在或有变更) -4. 执行 `npm run build:css` 生成 `app/static/css/main.css` -5. 执行 `flask db upgrade` -6. 可选执行 `flask seed-all` -7. `systemctl daemon-reload` -8. `systemctl restart hw` -9. `systemctl enable hw` - -## 6. 配置 systemd - -仓库提供的模板文件是: - -- `deploy/systemd/hw.service` - -核心行为: +1. 读取 `/opt/hw.conf.d/hw.env` +2. 在 `/opt/hw` 下构建 Python 3.13 容器镜像 +3. 停止并删除旧容器(如存在) +4. 挂载: + - `/opt/hw/instance -> /opt/hw/instance` + - `/opt/hw.conf.d -> /opt/hw.conf.d:ro` +5. 启动容器,并使用 `--restart unless-stopped` +6. 容器启动时自动执行 `flask db upgrade` +7. 启动 Gunicorn 对外监听 `0.0.0.0:8000` -- 以 `hw` 用户运行 -- 工作目录固定为 `/opt/hw` -- 使用 `/etc/hw/hw.env` 作为环境变量文件 -- 通过 Gunicorn 启动: +如果你确认这是一个全新空库,需要同时初始化基础数据: ```bash -/opt/hw/.venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 run:app +sudo RUN_SEED_ALL=1 bash scripts/deploy-container.sh ``` -如果你需要调整 worker 数量,可以修改 `/etc/systemd/system/hw.service` 中的 `ExecStart=`。 +> `seed-all` 会在首次成功执行后写入 `/opt/hw/instance/.seed-all-complete` 标记,避免每次重启容器都重复灌种子数据。 -## 7. 配置 Caddy +## 6. 镜像与运行时行为 -仓库提供的模板文件是: +当前镜像交付包含: -- `deploy/caddy/Caddyfile` +- Python 3.13 运行时 +- `requirements.txt` 的 Python 依赖 +- Node 构建阶段生成的 `app/static/css/main.css` +- 入口脚本 `docker/entrypoint.sh` -建议把模板复制到你的 Caddy 配置位置,例如: +入口脚本行为: -```bash -sudo cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile -``` - -然后把 `example.com` 改成你的真实域名。 - -模板行为: - -1. 开启 `encode zstd gzip` -2. 反代到 `127.0.0.1:8000` -3. 透传 `Host`、`X-Forwarded-For`、`X-Forwarded-Proto` -4. `/health` 会直接由 Flask 响应 - -重载 Caddy: +1. 加载 `/opt/hw.conf.d/hw.env`(若存在) +2. 设置: + - `FLASK_APP=run.py` + - `HW_CONFIG=production` +3. 确保 `/opt/hw/instance/csv_previews` 存在 +4. 执行 `python -m flask db upgrade` +5. 如 `RUN_SEED_ALL=1` 且尚未打过标记,则执行 `python -m flask seed-all` +6. 启动: ```bash -sudo systemctl reload caddy +gunicorn --workers ${HW_GUNICORN_WORKERS:-2} --bind 0.0.0.0:${HW_GUNICORN_PORT:-8000} run:app ``` -## 8. 部署后验证 +## 7. 部署后校验 仓库提供脚本: -- `scripts/verify-deployment.sh` +- `scripts/verify-container-deployment.sh` 默认检查: -1. systemd 服务 `hw` 是否 active -2. `127.0.0.1:8000` 是否有监听 -3. `http://127.0.0.1/health` 是否返回 `status=ok` +1. Docker 容器 `hw` 是否运行中 +2. Docker health 状态是否 healthy(如镜像含 healthcheck) +3. 宿主机 `8000` 端口是否有监听 +4. `http://127.0.0.1:8000/health` 是否返回 `status=ok` 执行: ```bash -sudo bash scripts/verify-deployment.sh +sudo bash scripts/verify-container-deployment.sh ``` -如果你的健康检查地址不是默认值,可以覆盖: +如需覆盖端口或健康检查地址: ```bash -sudo HEALTHCHECK_URL="https://your-domain/health" bash scripts/verify-deployment.sh +sudo HOST_PORT=18000 HEALTHCHECK_URL="http://127.0.0.1:18000/health" bash scripts/verify-container-deployment.sh ``` -## 9. 常用管理命令 +## 8. 常用管理命令 + +查看容器状态: + +```bash +docker ps --filter name=hw +``` -查看服务状态: +查看容器日志: ```bash -sudo systemctl status hw --no-pager +docker logs -f hw ``` 重启应用: ```bash -sudo systemctl restart hw +docker restart hw ``` -查看应用日志: +停止应用: ```bash -sudo journalctl -u hw -f +docker stop hw ``` -查看 Caddy 日志: +重新部署最新代码: ```bash -sudo journalctl -u caddy -f +cd /opt/hw +sudo bash scripts/deploy-container.sh +sudo bash scripts/verify-container-deployment.sh ``` -## 10. 已知限制与注意事项 +## 9. 已知限制与注意事项 1. 当前系统正式发布前仍建议补充 CSRF 防护 -2. 当前前端样式依赖 Tailwind CLI 编译产物,生产环境需要保证部署流程执行过 `npm install` 与 `npm run build:css` +2. 当前前端样式依赖 Tailwind CLI 编译产物,但该产物已在镜像构建阶段自动生成 3. 当前生产配置会强制要求设置 `HW_SECRET_KEY` 与 `DATABASE_URL`,未设置时应用会直接启动失败 4. 如果使用 SQLite,请确保 `/opt/hw/instance` 可写 5. CSV 导入预览依赖 `/opt/hw/instance/csv_previews`,目录权限必须正常 +6. 如你需要对外提供 HTTPS,可自行在容器前增加 nginx / Caddy / LB;当前交付只负责应用容器本身 -## 11. 本轮交付文件索引 +## 10. 本轮交付文件索引 -- `deploy/systemd/hw.service` -- `deploy/caddy/Caddyfile` +- `Dockerfile` +- `.dockerignore` +- `docker/entrypoint.sh` - `deploy/env/hw.env.example` - `scripts/generate-secrets.sh` -- `scripts/install-linux-systemd.sh` -- `scripts/deploy-systemd.sh` -- `scripts/verify-deployment.sh` +- `scripts/install-linux-container.sh` +- `scripts/deploy-container.sh` +- `scripts/verify-container-deployment.sh` diff --git a/docs/production-ops.md b/docs/production-ops.md index aa4b1d5..0c56882 100644 --- a/docs/production-ops.md +++ b/docs/production-ops.md @@ -1,54 +1,65 @@ -# HappyWedding 生产运维说明 +# HappyWedding 生产运维说明(Docker 容器) -本文档对应 HappyWedding(`hw`)在 Linux 主机上以 **Caddy + systemd + Gunicorn** 方式运行时的日常运维操作。 +本文档对应 HappyWedding(`hw`)在 Linux 主机上以 **Python 3.13 容器 + Gunicorn** 方式运行时的日常运维操作。 ## 1. 服务组成 线上服务主要由以下部分组成: -1. `caddy` - - 对外提供 HTTP/HTTPS - - 反向代理到 `127.0.0.1:8000` +1. `hw` 容器 + - 由 Docker 运行 + - 容器内启动 Gunicorn + - Gunicorn 加载 `run:app` + - 容器启动时自动执行数据库迁移 -2. `hw.service` - - 由 systemd 托管 - - 启动 Gunicorn - - Gunicorn 载入 `run:app` +> 运维前提:Docker CLI 可用且 Docker daemon 已启动。 -3. 数据库 +2. 数据库 - 推荐 MySQL - - 连接串由 `/etc/hw/hw.env` 中的 `DATABASE_URL` 控制 + - 连接串由 `/opt/hw.conf.d/hw.env` 中的 `DATABASE_URL` 控制 + +3. 宿主持久化目录 + - `/opt/hw/instance` + - `/opt/hw/instance/csv_previews` + +如需 HTTPS 或域名接入,可在容器前自行挂 nginx / Caddy / 负载均衡;本文档只覆盖应用容器本身。 ## 2. 日常查看命令 -### 2.1 查看应用服务状态 +### 2.1 查看容器状态 ```bash -sudo systemctl status hw --no-pager +docker ps --filter name=hw ``` -### 2.2 查看应用日志 +### 2.2 查看容器日志 ```bash -sudo journalctl -u hw -f +docker logs -f hw ``` -### 2.3 查看 Caddy 日志 +查看最近 200 行: ```bash -sudo journalctl -u caddy -f +docker logs --tail 200 hw ``` -### 2.4 查看健康检查 +### 2.3 查看健康检查 ```bash -curl -fsS http://127.0.0.1/health +docker inspect --format '{{json .State.Health}}' hw ``` -如果走域名验证: +### 2.4 查看健康检查接口 ```bash -curl -fsS https://your-domain/health +curl -fsS http://127.0.0.1:8000/health +``` + +如你发布到了其他端口: + +```bash +curl -fsS http://127.0.0.1:/health ``` ## 3. 常规发布流程 @@ -57,15 +68,15 @@ curl -fsS https://your-domain/health ```bash cd /opt/hw -sudo bash scripts/deploy-systemd.sh -sudo bash scripts/verify-deployment.sh +sudo bash scripts/deploy-container.sh +sudo bash scripts/verify-container-deployment.sh ``` 建议的发布后检查: -1. `systemctl status hw --no-pager` -2. `journalctl -u hw -n 100 --no-pager` -3. `curl -fsS https://your-domain/health` +1. `docker ps --filter name=hw` +2. `docker logs --tail 100 hw` +3. `curl -fsS http://127.0.0.1:8000/health` 4. 打开浏览器访问首页和登录页 ## 4. 首次初始化基础数据 @@ -74,33 +85,39 @@ sudo bash scripts/verify-deployment.sh ```bash cd /opt/hw -sudo RUN_SEED_ALL=1 bash scripts/deploy-systemd.sh +sudo RUN_SEED_ALL=1 bash scripts/deploy-container.sh ``` -> 已有生产数据时,不要重复无脑执行 `seed-all`,虽然项目当前多数 seed 命令偏幂等,但生产环境仍应谨慎。 +> 已有生产数据时,不要重复无脑执行 `seed-all`。当前脚本会通过 `/opt/hw/instance/.seed-all-complete` 做一次性标记,但生产环境仍应谨慎操作。 ## 5. 修改环境变量后的操作 -如果你修改了 `/etc/hw/hw.env`,需要重启服务让其生效: +如果你修改了 `/opt/hw.conf.d/hw.env`,需要重新部署容器让其生效: ```bash -sudo systemctl restart hw -sudo bash /opt/hw/scripts/verify-deployment.sh +cd /opt/hw +sudo bash scripts/deploy-container.sh +sudo bash scripts/verify-container-deployment.sh ``` -## 6. Caddy 配置变更 +## 6. 重启 / 停止 / 删除容器 -修改 Caddy 配置后建议: +重启: ```bash -sudo systemctl reload caddy +docker restart hw ``` -若怀疑配置有问题,可先查看: +停止: ```bash -sudo systemctl status caddy --no-pager -sudo journalctl -u caddy -n 100 --no-pager +docker stop hw +``` + +删除(下次需重新部署): + +```bash +docker rm -f hw ``` ## 7. 回滚建议 @@ -108,12 +125,13 @@ sudo journalctl -u caddy -n 100 --no-pager 当前仓库未内置自动回滚脚本,建议按以下原则手工回滚: 1. 保留最近一个稳定版本代码目录或 git tag -2. 回滚代码到稳定版本 +2. 回滚 `/opt/hw` 代码到稳定版本 3. 重新执行: ```bash -sudo bash scripts/deploy-systemd.sh -sudo bash scripts/verify-deployment.sh +cd /opt/hw +sudo bash scripts/deploy-container.sh +sudo bash scripts/verify-container-deployment.sh ``` 如果本次发布包含数据库迁移: @@ -134,49 +152,56 @@ mysqldump --single-transaction --quick --default-character-set=utf8mb4 -u ### 8.2 SQLite(仅轻量环境) -停止服务后备份: +停止容器后备份: ```bash -sudo systemctl stop hw +docker stop hw cp /opt/hw/instance/happywedding.db /opt/hw/instance/happywedding.db.bak -sudo systemctl start hw +docker start hw ``` ## 9. 常见故障排查 -### 9.1 `hw.service` 启动失败 +### 9.1 容器启动失败 先看: ```bash -sudo systemctl status hw --no-pager -sudo journalctl -u hw -n 200 --no-pager +docker ps -a --filter name=hw +docker logs --tail 200 hw ``` 优先检查: -1. `/etc/hw/hw.env` 是否存在 +1. `/opt/hw.conf.d/hw.env` 是否存在 2. `HW_SECRET_KEY` 是否仍是占位值 3. `DATABASE_URL` 是否为空或格式错误 -4. `/opt/hw/.venv` 是否存在 -5. Gunicorn 是否已安装到虚拟环境中 +4. `/opt/hw/instance` 是否可写 +5. 数据库是否可连通 -### 9.2 验证脚本失败:端口未监听 +### 9.2 校验脚本失败:端口未监听 -说明 Gunicorn 没有在 `127.0.0.1:8000` 正常起来。检查: +说明容器没有在预期端口正常起来。检查: ```bash -sudo journalctl -u hw -n 200 --no-pager +docker logs --tail 200 hw +ss -tln | grep ':8000 ' +``` + +如果宿主机没有 `ss`(例如部分 macOS 环境),可改用: + +```bash +lsof -nP -iTCP:8000 -sTCP:LISTEN ``` ### 9.3 `/health` 不通 可能原因: -1. `hw.service` 没起来 -2. Caddy 没 reload 成功 -3. 域名未正确指向主机 -4. 防火墙未放行 80/443 +1. 容器未正常运行 +2. 容器内 Gunicorn 未起来 +3. 宿主机端口映射不正确 +4. 你实际发布的不是默认端口 8000 ### 9.4 CSV 相关功能异常 @@ -185,43 +210,25 @@ sudo journalctl -u hw -n 200 --no-pager - `/opt/hw/instance` - `/opt/hw/instance/csv_previews` -它们需要对运行用户 `hw` 可写。 +它们需要对容器运行用户可写。 ## 10. 安全提醒 1. 生产环境不要使用默认 `HW_SECRET_KEY` -2. `/etc/hw/hw.env` 应限制读取权限 -3. Gunicorn 仅监听本机,不要直接暴露到公网 +2. `/opt/hw.conf.d/hw.env` 应限制读取权限 +3. 如需对外暴露,请在容器前增加反向代理或防火墙规则,不建议裸暴露管理后台 4. 当前项目仍建议在正式发布前补充 CSRF -5. 如果主机是内网 / 离线环境,需要确保发布包中已经包含编译后的 `app/static/css/main.css`,或确保部署流程可在主机上执行 `npm run build:css` - -## 10.1 前端样式构建提醒 - -当前生产样式不再依赖 CDN,而是依赖 Tailwind CLI 编译产物。 - -如发布包含样式改动,建议在 `/opt/hw` 下额外确认: - -```bash -cd /opt/hw -npm install -npm run build:css -``` - -然后再执行: - -```bash -sudo bash scripts/deploy-systemd.sh -``` +5. 如果宿主机是内网 / 离线环境,需要确保部署时 Docker 能成功构建镜像,或提前准备好基础镜像与依赖访问能力 ## 11. 建议保留的最小操作清单 建议把以下命令记录到你的运维笔记中: ```bash -sudo systemctl status hw --no-pager -sudo systemctl restart hw -sudo journalctl -u hw -f -sudo journalctl -u caddy -f -sudo bash /opt/hw/scripts/verify-deployment.sh -curl -fsS https://your-domain/health +docker ps --filter name=hw +docker logs -f hw +docker restart hw +sudo bash /opt/hw/scripts/deploy-container.sh +sudo bash /opt/hw/scripts/verify-container-deployment.sh +curl -fsS http://127.0.0.1:8000/health ``` diff --git a/docs/requirements.md b/docs/requirements.md index 0d47a2b..3e2f361 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -457,11 +457,10 @@ - CSV 导入导出(模板下载、导出全部、导出当前筛选、上传预览、校验、冲突处理、确认导入) - light / dark 双主题切换 - 压力测试数据生成功能(`seed-stress`) -- Linux 单机生产部署交付(`ProductionConfig`、Gunicorn、systemd 服务模板、Caddy 反向代理模板、环境变量示例、部署脚本、校验脚本、生产部署与运维文档) +- Linux 单机生产部署交付(`ProductionConfig`、Python 3.13 容器镜像、Docker 入口脚本、环境变量示例、安装/部署/校验脚本、生产部署与运维文档) ### 当前阶段边界 - 当前版本已形成“核心可用版 + 生产部署交付”的阶段性基线 - 按当前约定,功能需求先暂停扩展,优先保证部署落地、文档同步与必要缺陷修复 - 如未来恢复功能迭代,可优先考虑更丰富统计报表分析、桌席安排、历史往来追溯增强、数据快照回滚、更细权限控制与更智能的还礼参考 - diff --git a/docs/technical-design.md b/docs/technical-design.md index 0850e93..744e05b 100644 --- a/docs/technical-design.md +++ b/docs/technical-design.md @@ -1039,8 +1039,8 @@ hw/ - 中文 / 拼音 / 首字母搜索 - CSV 模板下载、导出全部、导出当前筛选、上传预览、校验、冲突处理、确认导入与导入审计日志 - Python + Playwright E2E 基础设施与核心流程测试(管理员编辑、长辈录入、管理端新增户、家庭成员 CRUD、账号管理提交链路、审计日志筛选与详情、CSV 导入导出、认证与权限边界) -- Linux 单机生产部署闭环:`ProductionConfig`、Gunicorn 依赖、systemd 服务模板、Caddy 反向代理模板、环境变量示例、部署脚本、校验脚本、生产部署文档与运维文档 -- 当前最新一次本地全量测试:`.venv/bin/python -m pytest tests` → `102 passed` +- Linux 单机生产部署闭环:`ProductionConfig`、Python 3.13 容器镜像、Docker 入口脚本、环境变量示例、安装/部署/校验脚本、生产部署文档与运维文档 +- 当前最新一次本地全量测试:`.venv/bin/python -m pytest tests` → `107 passed` ### 当前阶段边界 diff --git a/docs/todo-progress.md b/docs/todo-progress.md index 221b6a5..c814480 100644 --- a/docs/todo-progress.md +++ b/docs/todo-progress.md @@ -105,16 +105,16 @@ - [x] 独立统计与报表页 - [x] light / dark 双主题切换 - [x] Python + Playwright 端到端测试基础设施与主要操作闭环补齐 -- [x] Linux 单机生产部署交付:`ProductionConfig`、Gunicorn、systemd 模板、Caddy 模板、环境变量示例、部署脚本、校验脚本、生产文档与运维文档 +- [x] Linux 单机生产部署交付:`ProductionConfig`、Python 3.13 容器镜像、Docker 入口脚本、环境变量示例、安装/部署/校验脚本、生产文档与运维文档 ## 3. 当前重点任务建议 -当前核心可用版本已经形成闭环,范围包括:登录与权限、一键开发启动与演示 seed、压力测试 seed-stress、后台左侧边栏、管理首页只读总览、中文 / 拼音 / 首字母搜索、组合筛选、排序、筛选统计、管理首页与长辈页动态刷新、户级单条编辑、管理端新增一户、管理端家庭成员 CRUD、管理端礼金明细 CRUD 与自动汇总回写、长辈专用搜索与 bottom-sheet 有限字段编辑、独立统计与报表页、账号管理、审计日志、CSV 导入导出、light / dark 双主题、Python + Playwright 端到端测试,以及 Linux 单机生产部署文档与脚本交付。 +当前核心可用版本已经形成闭环,范围包括:登录与权限、一键开发启动与演示 seed、压力测试 seed-stress、后台左侧边栏、管理首页只读总览、中文 / 拼音 / 首字母搜索、组合筛选、排序、筛选统计、管理首页与长辈页动态刷新、户级单条编辑、管理端新增一户、管理端家庭成员 CRUD、管理端礼金明细 CRUD 与自动汇总回写、长辈专用搜索与 bottom-sheet 有限字段编辑、独立统计与报表页、账号管理、审计日志、CSV 导入导出、light / dark 双主题、Python + Playwright 端到端测试,以及 Linux 单机生产容器化部署文档与脚本交付。 当前阶段说明: - 功能需求先暂停扩展,当前以部署落地、文档同步、运维校验和必要缺陷修复为主 -- 最新一次本地全量测试结果:`.venv/bin/python -m pytest tests` → `102 passed` +- 最新一次本地全量测试结果:`.venv/bin/python -m pytest tests` → `107 passed` - 如未来恢复功能迭代,可参考桌席安排、CSRF / 安全加固、统计报表增强、历史往来能力等方向 ## 4. 风险与注意事项 @@ -161,7 +161,7 @@ MySQL 与 SQLite 存在差异,开发时应减少特性耦合。 - 已完成页面样式基线从 Skeleton CSS 切换到 Tailwind CSS,并进一步迁移到 Tailwind CLI 编译模式 - 已将审计服务扩展到长辈页与管理端业务保存场景(`update_household_entry` / `update_household`) - 已完成管理员侧礼金明细闭环:支持明细新增、编辑、软删除、户级礼金自动汇总回写与审计日志 -- 已完成 Linux 单机生产部署文档、systemd / Caddy 模板与配套脚本交付 +- 已完成 Linux 单机生产容器化部署文档与配套脚本交付 - 当前进入部署与文档同步阶段,功能需求暂不继续扩展 - 已完成管理端家庭成员管理闭环:在户级编辑页支持成员列表展示、成员新增/编辑/删除,以及对应审计日志与测试覆盖 - 已完成管理首页搜索与统计增强:支持中文 / 拼音 / 首字母搜索、组合筛选、排序、筛选结果计数与摘要统计 diff --git a/scripts/deploy-container.sh b/scripts/deploy-container.sh new file mode 100644 index 0000000..0a0eebb --- /dev/null +++ b/scripts/deploy-container.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/hw}" +CONFIG_DIR="${CONFIG_DIR:-/opt/hw.conf.d}" +ENV_FILE="${ENV_FILE:-$CONFIG_DIR/hw.env}" +CONTAINER_NAME="${CONTAINER_NAME:-hw}" +IMAGE_NAME="${IMAGE_NAME:-hw}" +IMAGE_TAG="${IMAGE_TAG:-py313}" +APP_PORT="${APP_PORT:-8000}" +RUN_SEED_ALL="${RUN_SEED_ALL:-0}" +HOST_PORT="${HOST_PORT:-8000}" +ALLOW_NON_ROOT="${ALLOW_NON_ROOT:-0}" +CONTAINER_UID="${CONTAINER_UID:-10001}" +CONTAINER_GID="${CONTAINER_GID:-10001}" + +require_root() { + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + return + fi + if [[ "${EUID}" -ne 0 ]]; then + printf 'Please run this script as root.\n' >&2 + exit 1 + fi +} + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + printf 'Required file not found: %s\n' "$path" >&2 + exit 1 + fi +} + +require_docker_daemon() { + if ! docker info >/dev/null 2>&1; then + printf 'Docker daemon is not available. Please start Docker and re-run this script.\n' >&2 + exit 1 + fi +} + +main() { + require_root + require_file "$ENV_FILE" + require_file "$APP_DIR/Dockerfile" + require_file "$APP_DIR/run.py" + require_docker_daemon + + install -d -m 755 "$APP_DIR/instance" + install -d -m 755 "$APP_DIR/instance/csv_previews" + + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + chown -R "$CONTAINER_UID:$CONTAINER_GID" "$APP_DIR/instance" >/dev/null 2>&1 || true + else + chown -R "$CONTAINER_UID:$CONTAINER_GID" "$APP_DIR/instance" + fi + + docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" "$APP_DIR" + + if docker ps -a --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then + docker rm -f "$CONTAINER_NAME" >/dev/null + fi + + docker run -d \ + --name "$CONTAINER_NAME" \ + --env-file "$ENV_FILE" \ + -e RUN_SEED_ALL="$RUN_SEED_ALL" \ + -e HW_GUNICORN_PORT="$APP_PORT" \ + -p "$HOST_PORT:$APP_PORT" \ + -v "$APP_DIR/instance:/opt/hw/instance" \ + -v "$CONFIG_DIR:/opt/hw.conf.d:ro" \ + --restart unless-stopped \ + "${IMAGE_NAME}:${IMAGE_TAG}" + + printf 'Container deployment completed.\n' + printf 'Container: %s\n' "$CONTAINER_NAME" + printf 'Image: %s:%s\n' "$IMAGE_NAME" "$IMAGE_TAG" + printf 'Published port: %s -> %s\n' "$HOST_PORT" "$APP_PORT" +} + +main "$@" diff --git a/scripts/deploy-systemd.sh b/scripts/deploy-systemd.sh deleted file mode 100644 index 25b0956..0000000 --- a/scripts/deploy-systemd.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -APP_DIR="${APP_DIR:-/opt/hw}" -ENV_FILE="${ENV_FILE:-/etc/hw/hw.env}" -SERVICE_NAME="${SERVICE_NAME:-hw}" -RUN_SEED_ALL="${RUN_SEED_ALL:-0}" - -require_root() { - if [[ "${EUID}" -ne 0 ]]; then - printf 'Please run this script as root.\n' >&2 - exit 1 - fi -} - -require_file() { - local path="$1" - if [[ ! -f "$path" ]]; then - printf 'Required file not found: %s\n' "$path" >&2 - exit 1 - fi -} - -main() { - require_root - require_file "$ENV_FILE" - require_file "$APP_DIR/requirements.txt" - require_file "$APP_DIR/run.py" - - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a - - "$APP_DIR/.venv/bin/python" -m pip install --upgrade pip - "$APP_DIR/.venv/bin/python" -m pip install -r "$APP_DIR/requirements.txt" - - export FLASK_APP=run.py - export HW_CONFIG="${HW_CONFIG:-production}" - - (cd "$APP_DIR" && "$APP_DIR/.venv/bin/python" -m flask db upgrade) - - if [[ "$RUN_SEED_ALL" == "1" ]]; then - (cd "$APP_DIR" && "$APP_DIR/.venv/bin/python" -m flask seed-all) - fi - - systemctl daemon-reload - systemctl restart "$SERVICE_NAME" - systemctl enable "$SERVICE_NAME" - - printf 'Deployment completed.\n' - printf 'Use: systemctl status %s --no-pager\n' "$SERVICE_NAME" -} - -main "$@" diff --git a/scripts/install-linux-container.sh b/scripts/install-linux-container.sh new file mode 100644 index 0000000..b0b178f --- /dev/null +++ b/scripts/install-linux-container.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/hw}" +CONFIG_DIR="${CONFIG_DIR:-/opt/hw.conf.d}" +DEFAULT_HOST_USER="${SUDO_USER:-$(id -un)}" +APP_USER="${APP_USER:-$DEFAULT_HOST_USER}" +APP_GROUP="${APP_GROUP:-$(id -gn "$APP_USER" 2>/dev/null || id -gn)}" +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_TEMPLATE_SOURCE="${ENV_TEMPLATE_SOURCE:-$PROJECT_ROOT/deploy/env/hw.env.example}" +ENV_FILE_NAME="${ENV_FILE_NAME:-hw.env}" +DOCKER_INSTALL_SCRIPT_URL="https://get.docker.com" +ALLOW_NON_ROOT="${ALLOW_NON_ROOT:-0}" +CONTAINER_UID="${CONTAINER_UID:-10001}" +CONTAINER_GID="${CONTAINER_GID:-10001}" + +require_root() { + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + return + fi + if [[ "${EUID}" -ne 0 ]]; then + printf 'Please run this script as root.\n' >&2 + exit 1 + fi +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +require_command() { + if ! command_exists "$1"; then + printf 'Required command not found: %s\n' "$1" >&2 + exit 1 + fi +} + +install_docker_if_missing() { + if command_exists docker; then + return + fi + + if command_exists apt-get; then + printf 'Docker not found. Installing Docker via %s ...\n' "$DOCKER_INSTALL_SCRIPT_URL" + curl -fsSL "$DOCKER_INSTALL_SCRIPT_URL" | sh + return + fi + + printf 'Docker is not installed and automatic installation is only supported on apt-based hosts.\n' >&2 + printf 'Please install Docker manually, then re-run this script.\n' >&2 + exit 1 +} + +main() { + require_root + require_command curl + install_docker_if_missing + + install -d -m 755 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR" + install -d -m 755 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR/instance" + install -d -m 755 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR/instance/csv_previews" + + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + install -d -m 750 "$CONFIG_DIR" + else + install -d -m 750 -o root -g "$APP_GROUP" "$CONFIG_DIR" + fi + + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + chown -R "$CONTAINER_UID:$CONTAINER_GID" "$APP_DIR/instance" >/dev/null 2>&1 || true + else + chown -R "$CONTAINER_UID:$CONTAINER_GID" "$APP_DIR/instance" + fi + + if [[ ! -f "$CONFIG_DIR/$ENV_FILE_NAME" ]]; then + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + install -m 640 "$ENV_TEMPLATE_SOURCE" "$CONFIG_DIR/$ENV_FILE_NAME" + else + install -m 640 -o root -g "$APP_GROUP" "$ENV_TEMPLATE_SOURCE" "$CONFIG_DIR/$ENV_FILE_NAME" + fi + printf 'Created %s from template. Please edit it before deployment.\n' "$CONFIG_DIR/$ENV_FILE_NAME" + else + printf 'Keeping existing %s\n' "$CONFIG_DIR/$ENV_FILE_NAME" + fi + + if [[ "$ALLOW_NON_ROOT" != "1" ]] && id -u "$APP_USER" >/dev/null 2>&1; then + usermod -aG docker "$APP_USER" >/dev/null 2>&1 || true + fi + + printf '\nContainer deployment prerequisites are ready.\n' + printf 'Next steps:\n' + printf '1. Sync project files into %s\n' "$APP_DIR" + printf '2. Edit %s/%s with real production values\n' "$CONFIG_DIR" "$ENV_FILE_NAME" + if [[ "$ALLOW_NON_ROOT" == "1" ]]; then + printf '3. Run ALLOW_NON_ROOT=1 bash scripts/deploy-container.sh\n' + else + printf '3. Run sudo bash scripts/deploy-container.sh\n' + fi +} + +main "$@" diff --git a/scripts/install-linux-systemd.sh b/scripts/install-linux-systemd.sh deleted file mode 100644 index 351e672..0000000 --- a/scripts/install-linux-systemd.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -APP_USER="${APP_USER:-hw}" -APP_GROUP="${APP_GROUP:-$APP_USER}" -APP_DIR="${APP_DIR:-/opt/hw}" -ENV_DIR="${ENV_DIR:-/etc/hw}" -SERVICE_NAME="${SERVICE_NAME:-hw}" -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -require_root() { - if [[ "${EUID}" -ne 0 ]]; then - printf 'Please run this script as root.\n' >&2 - exit 1 - fi -} - -ensure_user() { - if ! id -u "$APP_USER" >/dev/null 2>&1; then - useradd --system --create-home --shell /usr/sbin/nologin "$APP_USER" - fi -} - -main() { - require_root - ensure_user - - install -d -m 755 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR" - install -d -m 755 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR/instance" - install -d -m 755 -o "$APP_USER" -g "$APP_GROUP" "$APP_DIR/instance/csv_previews" - install -d -m 750 -o root -g "$APP_GROUP" "$ENV_DIR" - - if [[ ! -d "$APP_DIR/.venv" ]]; then - python3 -m venv "$APP_DIR/.venv" - fi - - "$APP_DIR/.venv/bin/python" -m pip install --upgrade pip - - install -m 644 "$PROJECT_ROOT/deploy/systemd/hw.service" "/etc/systemd/system/${SERVICE_NAME}.service" - - if [[ ! -f "$ENV_DIR/hw.env" ]]; then - install -m 640 -o root -g "$APP_GROUP" "$PROJECT_ROOT/deploy/env/hw.env.example" "$ENV_DIR/hw.env" - printf 'Created %s/hw.env from template. Please edit it before starting the service.\n' "$ENV_DIR" - else - printf 'Keeping existing %s/hw.env\n' "$ENV_DIR" - fi - - printf '\nNext steps:\n' - printf '1. Sync project files into %s\n' "$APP_DIR" - printf '2. Edit %s/hw.env with real production values\n' "$ENV_DIR" - printf '3. Copy deploy/caddy/Caddyfile into your Caddy config and replace the domain\n' - printf '4. Run scripts/deploy-systemd.sh as root\n' -} - -main "$@" diff --git a/scripts/verify-container-deployment.sh b/scripts/verify-container-deployment.sh new file mode 100644 index 0000000..4a056a0 --- /dev/null +++ b/scripts/verify-container-deployment.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER_NAME="${CONTAINER_NAME:-hw}" +HOST_PORT="${HOST_PORT:-${APP_PORT:-8000}}" +HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://127.0.0.1:${HOST_PORT}/health}" +ALLOW_NON_ROOT="${ALLOW_NON_ROOT:-0}" + +fail() { + printf 'Verification failed: %s\n' "$1" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +require_docker_daemon() { + if ! docker info >/dev/null 2>&1; then + fail "docker daemon is not available" + fi +} + +port_is_listening() { + local port="$1" + + if command_exists ss; then + ss -tln | grep -q ":${port} " + return + fi + + if command_exists lsof; then + lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 + return + fi + + if command_exists netstat; then + netstat -an | grep -E -q "[\.:]${port}[[:space:]].*LISTEN" + return + fi + + fail "no supported port inspection command found (need one of: ss, lsof, netstat)" +} + +main() { + require_docker_daemon + if ! docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then + fail "container '$CONTAINER_NAME' is not running" + fi + + health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CONTAINER_NAME")" + if [[ "$health_status" != "healthy" && "$health_status" != "none" ]]; then + fail "container health status is '$health_status'" + fi + + if ! port_is_listening "$HOST_PORT"; then + fail "nothing is listening on TCP port ${HOST_PORT}" + fi + + health_payload="$(curl --fail --silent --show-error --max-time 10 "$HEALTHCHECK_URL")" || fail "healthcheck request failed: $HEALTHCHECK_URL" + + if ! printf '%s' "$health_payload" | grep -q '"status":"ok"\|"status": "ok"'; then + fail "healthcheck response does not contain status=ok" + fi + + printf 'Verification succeeded.\n' + printf 'Container: %s\n' "$CONTAINER_NAME" + printf 'Port: %s\n' "$HOST_PORT" + printf 'Healthcheck: %s\n' "$HEALTHCHECK_URL" +} + +main "$@" diff --git a/scripts/verify-deployment.sh b/scripts/verify-deployment.sh deleted file mode 100644 index de4c74b..0000000 --- a/scripts/verify-deployment.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SERVICE_NAME="${SERVICE_NAME:-hw}" -APP_PORT="${APP_PORT:-8000}" -HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://127.0.0.1/health}" - -fail() { - printf 'Verification failed: %s\n' "$1" >&2 - exit 1 -} - -main() { - if ! systemctl is-active --quiet "$SERVICE_NAME"; then - fail "systemd service '$SERVICE_NAME' is not active" - fi - - if ! ss -tln | grep -q ":${APP_PORT} "; then - fail "nothing is listening on TCP port ${APP_PORT}" - fi - - health_payload="$(curl --fail --silent --show-error --max-time 10 "$HEALTHCHECK_URL")" || fail "healthcheck request failed: $HEALTHCHECK_URL" - - if ! printf '%s' "$health_payload" | grep -q '"status":"ok"\|"status": "ok"'; then - fail "healthcheck response does not contain status=ok" - fi - - printf 'Verification succeeded.\n' - printf 'Service: %s\n' "$SERVICE_NAME" - printf 'Port: %s\n' "$APP_PORT" - printf 'Healthcheck: %s\n' "$HEALTHCHECK_URL" -} - -main "$@"