From 49a5452141fb3cac810c3f6b6c50d4f5a76cfdf2 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Tue, 21 Apr 2026 22:39:47 +0200 Subject: [PATCH] Add local-network deployment automation and tighten runtime defaults This commit adds the first complete local-network deployment path for the project. It normalizes the runtime contract around a fixed container listener on 0.0.0.0:10000, binds the published compose port to 127.0.0.1, and keeps the image/build workflow aligned with the released container image. It also introduces an installation script, an nginx reverse-proxy template, and a safer SQLite backup flow based on sqlite3 .backup with retention and optional rclone upload support. Deployment-oriented configuration has been consolidated into .env.example, repository-local .env files are now ignored, and the deployment scripts are executable. In addition, the frontend mixed-content issue is fixed by switching the stylesheet reference to a root-relative static path, with tests updated to cover the regression. README guidance has been expanded to document the new install, nginx, backup, and restore conventions. --- .env.example | 34 +++-- .gitignore | 1 + Dockerfile | 4 +- README.md | 149 +++++++++++++++++---- app/templates/base.html | 2 +- docker-compose.yml | 6 +- scripts/backup_db.sh | 82 ++++++++++-- scripts/deploy.sh | 28 ++-- scripts/install.sh | 137 +++++++++++++++++++ scripts/nginx/moving-helper.nginx.template | 29 ++++ tests/test_app.py | 8 ++ 11 files changed, 414 insertions(+), 66 deletions(-) create mode 100755 scripts/install.sh create mode 100644 scripts/nginx/moving-helper.nginx.template diff --git a/.env.example b/.env.example index 75a54ce..f5843df 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,32 @@ -# Runtime -HOST=0.0.0.0 -PORT=10000 +# This file is sourced by shell scripts. Keep values shell-compatible. -# In Docker, keep the database inside the mounted /app/data directory. +# Local TLS domain used by nginx and your own certificate files. +HOST_DOMAIN=moving-helper.lan + +# Certificate directory prepared by the user. +# Place fullchain.pem and privkey.key in this directory. +# If you use acme.sh, this is typically /etc/acme.sh/$HOST_DOMAIN +SSL_PATH=/etc/acme.sh/$HOST_DOMAIN + +# Deployment target directory used by the install script. +APP_DIR=$HOME/.local/share/moving-helper + +# Backup destination directory used by the deployed backup script. +BACKUP_DIR=$HOME/.local/backup/moving-helper + +# Optional rclone remote target, for example: remote:folder/moving-helper +BACKUP_REMOTE= + +# Host port published by docker compose. The container always listens on 10000. +APP_PORT=10000 + +# Database location inside the container. DATABASE_URL=sqlite:////app/data/app.db -# Host-side persistent data directory +# Host-side persistent data directory. +# Relative paths are resolved from APP_DIR after installation. DATA_DIR=./data -# Container user mapping -UID=1000 -GID=1000 +# Optional compose project name. +COMPOSE_PROJECT_NAME=moving-helper diff --git a/.gitignore b/.gitignore index 95fe68b..04a9445 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ .pytest_cache/ *.pyc +.env data/*.db # macOS generated files diff --git a/Dockerfile b/Dockerfile index fedbe86..9c0cd1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,6 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ - HOST=0.0.0.0 \ - PORT=10000 \ DATABASE_URL=sqlite:////app/data/app.db WORKDIR /app @@ -18,4 +16,4 @@ RUN mkdir -p /app/data EXPOSE 10000 -CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-10000}"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "10000"] diff --git a/README.md b/README.md index c8cd197..9d9e139 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,9 @@ Box ├── scripts │ ├── backup_db.sh │ └── deploy.sh +│ ├── install.sh +│ └── nginx +│ └── moving-helper.nginx.template ├── tests ├── .dockerignore ├── .env.example @@ -152,12 +155,15 @@ Box 项目通过环境变量支持以下部署时真正需要关心的配置: -- `HOST` -- `PORT` -- `DATABASE_URL` +- `HOST_DOMAIN` +- `SSL_PATH` +- `APP_DIR` +- `BACKUP_DIR` +- `BACKUP_REMOTE` +- `APP_PORT` - `DATA_DIR` -- `UID` -- `GID` +- `DATABASE_URL` +- `COMPOSE_PROJECT_NAME` 推荐从示例文件开始: @@ -168,20 +174,25 @@ cp .env.example .env 默认值如下: ```env -HOST=0.0.0.0 -PORT=10000 +HOST_DOMAIN=moving-helper.lan +SSL_PATH=/etc/acme.sh/$HOST_DOMAIN +APP_DIR=$HOME/.local/share/moving-helper +BACKUP_DIR=$HOME/.local/backup/moving-helper +BACKUP_REMOTE= +APP_PORT=10000 DATABASE_URL=sqlite:////app/data/app.db DATA_DIR=./data -UID=1000 -GID=1000 +COMPOSE_PROJECT_NAME=moving-helper ``` 说明: -- 本地开发默认数据库仍然是 `./data/app.db` -- Docker 内建议继续使用 `sqlite:////app/data/app.db` -- `DATA_DIR` 控制宿主机上的持久化目录 -- `UID/GID` 用来让容器内文件权限更贴近宿主机用户 +- 容器内应用固定监听 `0.0.0.0:10000` +- `APP_PORT` 只控制宿主机暴露端口,nginx 默认反代到这个端口 +- `APP_DIR` 是安装脚本复制 compose、`.env`、备份脚本等运行资产的目标目录 +- `DATA_DIR` 默认为相对路径 `./data`,安装后会相对于 `APP_DIR` 解析 +- `SSL_PATH` 由用户自行准备证书目录,安装脚本不会签发证书 +- `.env` 会被 shell 脚本直接 `source`,请保持 shell 兼容写法 ## 本地开发模式 @@ -227,6 +238,13 @@ Docker / Compose 是这个项目面向长期运行环境的方式。 - `image`:固定指向 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest` - `build`:用于本地开发时从当前代码构建镜像 +当前部署约定已经收敛为: + +- 容器内应用固定监听 `0.0.0.0:10000` +- compose 固定使用 `user: 1000:1000` +- 宿主机仅在 `127.0.0.1:${APP_PORT}` 暴露后端端口 +- SQLite 固定写入容器内 `/app/data/app.db` + ### 首次准备 ```bash @@ -271,10 +289,10 @@ http://localhost:10000 当前 `docker-compose.yml` 保持尽量简单: -- 默认镜像地址来自 `REGISTRY_HOST / IMAGE_NAME / IMAGE_TAG` -- 默认暴露 `10000` 端口 +- 固定镜像地址为 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest` +- 宿主机默认仅在 `127.0.0.1:10000` 暴露容器 `10000` - `restart: unless-stopped` -- 容器用户来自 `UID:GID` +- 容器固定使用 `1000:1000` - 宿主机 `DATA_DIR` 挂载到容器内 `/app/data` - SQLite 默认写入 `/app/data/app.db` @@ -285,7 +303,33 @@ http://localhost:10000 ## 自动化部署 -这个项目没有复杂 CI/CD,只提供一个适合家用项目的轻量部署脚本: +这个项目现在额外提供一个面向本地网络环境的最小安装脚本: + +```bash +sh scripts/install.sh +``` + +安装脚本会执行: + +1. 检查项目根目录下是否存在 `.env` +2. 读取 `.env` +3. 把 `docker-compose.yml`、`.env` 和渲染后的 `backup_db.sh` 复制到 `APP_DIR` +4. 用 `HOST_DOMAIN`、`SSL_PATH`、`APP_PORT` 渲染 nginx 配置 +5. 写入 `/etc/nginx/sites-available/moving-helper-nginx` +6. 创建到 `/etc/nginx/sites-enabled/` 的符号链接 +7. 执行 `nginx -t` 并 reload nginx +8. 在 `APP_DIR` 下执行 `docker compose pull` 和 `docker compose up -d` +9. 为当前用户写入每日 `02:10` 的 backup cron + +其中以下步骤需要 root 或 sudo: + +- 写入 nginx 配置 +- 执行 `nginx -t` +- reload nginx + +如果 `.env` 不存在,脚本会直接退出,不会继续做任何安装动作。 + +如果你只想在仓库目录里做一次手动更新,也保留了一个轻量部署脚本: ```bash ./scripts/deploy.sh @@ -296,7 +340,8 @@ http://localhost:10000 1. 检查 `.env` 2. 准备数据目录 3. 如果当前目录是 git 仓库,执行 `git pull --ff-only` -4. 执行 `docker compose up -d --build` +4. 执行 `docker compose pull web` +5. 执行 `docker compose up -d` 5. 输出容器状态 6. 输出最近日志 @@ -305,7 +350,8 @@ http://localhost:10000 如果你不想自动拉代码,也可以直接手动运行: ```bash -docker compose up -d --build +docker compose pull +docker compose up -d ``` ## 数据持久化 @@ -335,32 +381,83 @@ ${DATA_DIR:-./data} ## 备份与恢复 -### 最简单的备份方式 +### 备份机制 -直接复制 SQLite 文件即可: +安装脚本会把渲染后的备份脚本安装到: + +- `APP_DIR/backup_db.sh` + +并为当前用户创建一条 cron: + +- `10 2 * * *` + +备份行为如下: + +- 目标目录是 `BACKUP_DIR` +- 备份文件名带时间戳,例如 `app-20260421-021000.db` +- 最多保留 5 个本地备份 +- 如果 `BACKUP_REMOTE` 非空,会在本地备份完成后调用 `rclone copyto` + +SQLite 一致性策略: + +- 备份脚本优先使用 `sqlite3` 的 `.backup` +- 不停容器 +- 不直接 `cp` 正在写入的数据库文件 + +这样可以在应用仍然运行时生成事务一致的快照,避免简单文件复制带来的损坏风险。 + +### 手动执行备份 + +如果你想手动触发一次备份: ```bash -cp data/app.db backups/app.db +sh "$APP_DIR/backup_db.sh" ``` -或者使用附带脚本: +如果当前还没有执行安装脚本,也可以在仓库内手动准备 `.env` 后运行: ```bash ./scripts/backup_db.sh ``` -它会在 `backups/` 目录下生成一个带时间戳的副本。 +前提是先通过安装脚本把它渲染并部署到 `APP_DIR`,因为仓库内版本本身是带占位符的模板。 -### 最简单的恢复方式 +### 恢复大致步骤 停止容器后,把备份文件覆盖回去: ```bash +cd "$APP_DIR" docker compose stop -cp backups/app-YYYYMMDD-HHMMSS.db data/app.db +cp "$BACKUP_DIR/app-YYYYMMDD-HHMMSS.db" "${DATA_DIR:-./data}/app.db" docker compose up -d ``` +如果 `DATA_DIR` 是相对路径,记得在 `APP_DIR` 下执行这些命令。 + +### nginx 与证书约定 + +仓库提供的 nginx 模板位于: + +- `scripts/nginx/moving-helper.nginx.template` + +安装脚本会把它渲染成 Debian / Ubuntu 风格的站点配置: + +- `/etc/nginx/sites-available/moving-helper-nginx` +- `/etc/nginx/sites-enabled/moving-helper-nginx` + +模板约定: + +- 80 端口强制跳转到 443 +- 443 默认启用 SSL +- 反代到仅绑定在本机回环地址上的 `127.0.0.1:${APP_PORT}` +- `client_max_body_size 0` + +证书文件需要由用户自己准备在 `SSL_PATH` 下,当前模板默认引用: + +- `fullchain.pem` +- `privkey.key` + ## 常见排查 ### 1. 查看容器日志 diff --git a/app/templates/base.html b/app/templates/base.html index 6d6b31d..0d18852 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,7 +4,7 @@ {{ page_title or "搬家助手" }} - +
diff --git a/docker-compose.yml b/docker-compose.yml index 81cc0a5..71c35e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,10 @@ services: image: "code.wanderingbadger.dev/tliu93/2026-moving-helper:latest" build: context: . - user: "${UID:-1000}:${GID:-1000}" + user: "1000:1000" ports: - - "${PORT:-10000}:${PORT:-10000}" + - "127.0.0.1:${APP_PORT:-10000}:10000" environment: - HOST: ${HOST:-0.0.0.0} - PORT: ${PORT:-10000} DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db} volumes: - ${DATA_DIR:-./data}:/app/data diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh index 63087cd..02e3036 100755 --- a/scripts/backup_db.sh +++ b/scripts/backup_db.sh @@ -1,27 +1,83 @@ #!/usr/bin/env sh set -eu -PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -cd "$PROJECT_ROOT" +APP_DIR="__APP_DIR__" +DEFAULT_BACKUP_DIR="__BACKUP_DIR__" +ENV_FILE="$APP_DIR/.env" -if [ ! -f ".env" ] && [ -f ".env.example" ]; then - echo "未找到 .env,先从 .env.example 复制一份:" - echo " cp .env.example .env" +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +resolve_path() { + case "$1" in + /*) printf '%s\n' "$1" ;; + *) printf '%s/%s\n' "$APP_DIR" "$1" ;; + esac +} + +if [ ! -f "$ENV_FILE" ]; then + echo "Deployed .env file not found: $ENV_FILE" >&2 exit 1 fi -DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true) -DATA_DIR=${DATA_DIR_VALUE:-./data} -DB_PATH="$DATA_DIR/app.db" +set -a +. "$ENV_FILE" +set +a + +require_command sqlite3 + +if [ -n "${BACKUP_REMOTE:-}" ]; then + require_command rclone +fi + +BACKUP_DIR=${BACKUP_DIR:-$DEFAULT_BACKUP_DIR} +DATA_DIR=${DATA_DIR:-./data} +DB_PATH="$(resolve_path "$DATA_DIR")/app.db" if [ ! -f "$DB_PATH" ]; then - echo "未找到数据库文件:$DB_PATH" + echo "Database file not found: $DB_PATH" >&2 exit 1 fi -mkdir -p backups +mkdir -p "$BACKUP_DIR" TIMESTAMP=$(date +"%Y%m%d-%H%M%S") -DESTINATION="backups/app-$TIMESTAMP.db" +TMP_BACKUP="$BACKUP_DIR/.app-$TIMESTAMP.db.tmp" +FINAL_BACKUP="$BACKUP_DIR/app-$TIMESTAMP.db" -cp "$DB_PATH" "$DESTINATION" -echo "备份已创建:$DESTINATION" +cleanup() { + rm -f "$TMP_BACKUP" +} + +trap cleanup EXIT INT TERM + +# Prefer sqlite3 .backup so the snapshot stays transactionally consistent without +# stopping the running container or racing with SQLite writes. +sqlite3 "$DB_PATH" < container:10000" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..1fb3305 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +SOURCE_ENV="$PROJECT_ROOT/.env" +COMPOSE_SOURCE="$PROJECT_ROOT/docker-compose.yml" +BACKUP_TEMPLATE="$SCRIPT_DIR/backup_db.sh" +NGINX_TEMPLATE="$SCRIPT_DIR/nginx/moving-helper.nginx.template" +NGINX_SITE_NAME="moving-helper-nginx" +CRON_MARKER="# moving-helper-backup" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +run_as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + echo "This step requires root privileges, but sudo is not available: $*" >&2 + exit 1 + fi +} + +escape_sed_replacement() { + printf '%s' "$1" | sed 's/[|&]/\\&/g' +} + +render_template() { + src=$1 + dst=$2 + host_domain_escaped=$(escape_sed_replacement "$HOST_DOMAIN") + ssl_path_escaped=$(escape_sed_replacement "$SSL_PATH") + app_port_escaped=$(escape_sed_replacement "$APP_PORT") + app_dir_escaped=$(escape_sed_replacement "$APP_DIR") + backup_dir_escaped=$(escape_sed_replacement "$BACKUP_DIR") + + sed \ + -e "s|__HOST_DOMAIN__|$host_domain_escaped|g" \ + -e "s|__SSL_PATH__|$ssl_path_escaped|g" \ + -e "s|__APP_PORT__|$app_port_escaped|g" \ + -e "s|__APP_DIR__|$app_dir_escaped|g" \ + -e "s|__BACKUP_DIR__|$backup_dir_escaped|g" \ + "$src" > "$dst" +} + +if [ ! -f "$SOURCE_ENV" ]; then + echo "Missing $SOURCE_ENV" >&2 + echo "Create it first: cp .env.example .env" >&2 + exit 1 +fi + +require_command docker +require_command crontab + +if ! docker compose version >/dev/null 2>&1; then + echo "The docker compose plugin is not available in the current environment" >&2 + exit 1 +fi + +set -a +. "$SOURCE_ENV" +set +a + +HOST_DOMAIN=${HOST_DOMAIN:-} +SSL_PATH=${SSL_PATH:-} +APP_DIR=${APP_DIR:-$HOME/.local/share/moving-helper} +BACKUP_DIR=${BACKUP_DIR:-$HOME/.local/backup/moving-helper} +APP_PORT=${APP_PORT:-10000} +DATA_DIR=${DATA_DIR:-./data} + +if [ -z "$HOST_DOMAIN" ]; then + echo "HOST_DOMAIN is not configured" >&2 + exit 1 +fi + +if [ -z "$SSL_PATH" ]; then + echo "SSL_PATH is not configured" >&2 + exit 1 +fi + +mkdir -p "$APP_DIR" "$BACKUP_DIR" "$APP_DIR/logs" + +case "$DATA_DIR" in + /*) mkdir -p "$DATA_DIR" ;; + *) mkdir -p "$APP_DIR/$DATA_DIR" ;; +esac + +cp "$COMPOSE_SOURCE" "$APP_DIR/docker-compose.yml" +cp "$SOURCE_ENV" "$APP_DIR/.env" + +rendered_backup=$(mktemp) +rendered_nginx=$(mktemp) +trap 'rm -f "$rendered_backup" "$rendered_nginx"' EXIT INT TERM + +render_template "$BACKUP_TEMPLATE" "$rendered_backup" +install -m 0755 "$rendered_backup" "$APP_DIR/backup_db.sh" + +render_template "$NGINX_TEMPLATE" "$rendered_nginx" +run_as_root install -d /etc/nginx/sites-available /etc/nginx/sites-enabled +run_as_root install -m 0644 "$rendered_nginx" "/etc/nginx/sites-available/$NGINX_SITE_NAME" +run_as_root ln -sfn "/etc/nginx/sites-available/$NGINX_SITE_NAME" "/etc/nginx/sites-enabled/$NGINX_SITE_NAME" + +run_as_root nginx -t +if command -v systemctl >/dev/null 2>&1; then + run_as_root systemctl reload nginx +else + run_as_root service nginx reload +fi + +( + cd "$APP_DIR" + docker compose pull web + docker compose up -d +) + +cron_tmp=$(mktemp) +existing_cron=$(mktemp) +trap 'rm -f "$rendered_backup" "$rendered_nginx" "$cron_tmp" "$existing_cron"' EXIT INT TERM + +crontab -l 2>/dev/null > "$existing_cron" || true +grep -v "moving-helper-backup" "$existing_cron" > "$cron_tmp" || true +printf '10 2 * * * %s/backup_db.sh >> %s/logs/backup.log 2>&1 %s\n' "$APP_DIR" "$APP_DIR" "$CRON_MARKER" >> "$cron_tmp" +crontab "$cron_tmp" + +echo "Installation complete." +echo "- Application directory: $APP_DIR" +echo "- Backup directory: $BACKUP_DIR" +echo "- Nginx config: /etc/nginx/sites-available/$NGINX_SITE_NAME" +echo "- URL: https://$HOST_DOMAIN" +echo "- Scheduled backup: daily at 02:10 via the current user's crontab" \ No newline at end of file diff --git a/scripts/nginx/moving-helper.nginx.template b/scripts/nginx/moving-helper.nginx.template new file mode 100644 index 0000000..bf02161 --- /dev/null +++ b/scripts/nginx/moving-helper.nginx.template @@ -0,0 +1,29 @@ +server { + listen 80; + server_name __HOST_DOMAIN__; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name __HOST_DOMAIN__; + + ssl_certificate __SSL_PATH__/fullchain.pem; + ssl_certificate_key __SSL_PATH__/privkey.key; + + client_max_body_size 0; + + location / { + proxy_pass http://127.0.0.1:__APP_PORT__; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + proxy_read_timeout 300; + } +} \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index d05ab58..d784180 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -103,6 +103,14 @@ def test_boxes_page_returns_200(client): assert "箱子" in response.text +def test_boxes_page_uses_relative_stylesheet_path(client): + response = client.get("/boxes") + + assert response.status_code == 200 + assert 'href="/static/style.css"' in response.text + assert "http://" not in response.text.split("/static/style.css")[0] + + def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session): box = Box( name="Kitchen Box",