From a9e653009ade8c222dd484754db38f78cc9a9ed8 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Wed, 12 Aug 2026 23:07:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=B8=BB=E5=8A=A8?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=20NAS=20=E5=AE=89=E5=85=A8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Codex --- deploy/nas/README.md | 122 +++++++++++++++++++--------- deploy/nas/compose.yaml | 2 +- deploy/nas/deployment.test.ts | 50 ++++++++++++ deploy/nas/plainleaf-update.service | 16 ++++ deploy/nas/plainleaf-update.sudoers | 1 + deploy/nas/update-plainleaf.sh | 109 +++++++++++++++++++++++++ scripts/publish-nas-image.sh | 48 +++++++++++ 7 files changed, 309 insertions(+), 39 deletions(-) create mode 100644 deploy/nas/plainleaf-update.service create mode 100644 deploy/nas/plainleaf-update.sudoers create mode 100755 deploy/nas/update-plainleaf.sh create mode 100755 scripts/publish-nas-image.sh diff --git a/deploy/nas/README.md b/deploy/nas/README.md index 8309a68c..561f3f02 100644 --- a/deploy/nas/README.md +++ b/deploy/nas/README.md @@ -1,54 +1,100 @@ -# Plainleaf on ZSpace NAS +# Plainleaf 极空间部署 -This deployment runs the account-managed Plainleaf server on the ZSpace NAS. -Build the amd64 image on the development machine, then transfer and load it on -the NAS; the NAS does not need Docker Hub access. Server configuration, -deployment files, and Markdown data remain separate: +Plainleaf 使用 Gitea Container Registry 发布镜像。本机发布脚本推送 `edge` 后会 +立即通过 SSH 主动触发 NAS 上的更新服务,不运行定时器,也不会轮询 Registry。 +只有镜像 ID 变化时才会重建 `plainleaf` 容器,不会执行 `compose down`、Docker +清理或删除数据卷。 -- Deployment: `个人空间/docker/plainleaf` -- Server configuration: `个人空间/docker/plainleaf/data` -- Markdown space: `个人空间/笔记管理/Plainleaf` +固定目录和端口: -The server listens on NAS port `31230`, runs as the NAS account's UID/GID, and -has shell execution disabled. Accounts are stored as password hashes in the -server configuration directory. No password belongs in `.env`. +- 部署目录:`/data_s001/data/udata/real/13616066635/docker/plainleaf` +- root 更新配置:`/etc/plainleaf` +- 服务配置:`<部署目录>/data`,容器内为 `/data` +- Markdown:`/data_s001/data/udata/real/13616066635/笔记管理/Plainleaf`,容器内为 `/notes` +- NAS 端口:`31230` +- 镜像:`gitea.aichickenfarm.cn/wushenghua/plainleaf:edge` -The intended `.env` keys are: +Markdown 目录始终是数据真源,发布和更新都不会移动、重命名或改写已有文档。 + +## 日常发布 + +本机只需运行: + +```bash +./scripts/publish-nas-image.sh +``` + +脚本要求 Git 工作区干净,然后构建 `linux/amd64` 镜像并同时推送两个标签: + +- 当前 Git 短提交号,用于定位和人工回滚 +- `edge`,供 NAS 自动更新 + +脚本不保存 Gitea 密码或令牌。首次使用时,如果 Docker 尚未登录 Registry,先在 +本机可见终端执行一次: + +```bash +docker login gitea.aichickenfarm.cn +``` + +建议使用 Gitea 访问令牌,不要把密码或令牌写入仓库、脚本或聊天。 + +## NAS 首次启用 + +首次安装需要一次 SSH 和 `sudo`,之后日常发布不再上传镜像包,也不需要重复输入 +SSH 或 `sudo` 密码。安装时要完成以下动作: + +1. 将 `compose.yaml` 和现有 `.env` 复制到 root 持有的 `/etc/plainleaf`,配置 + 文件名为 `/etc/plainleaf/plainleaf.env`,并把 `PLAINLEAF_IMAGE_TAG` 设为 + `edge`。更新服务只读取该目录,个人账号不能修改 root 更新流程。 +2. 将 `update-plainleaf.sh` 安装为 `/usr/local/sbin/plainleaf-update`。 +3. 将 systemd 单元安装到 `/etc/systemd/system/plainleaf-update.service`。 +4. 安装本机 SSH 公钥,让发布脚本可用 `BatchMode` 连接 NAS。 +5. 安装 `plainleaf-update.sudoers`,其中只有一条受限规则,只允许 NAS 账号免密启动 + `plainleaf-update.service`,不能免密执行其他 root 命令。 +6. 执行 `systemctl daemon-reload`,再手动启动一次服务,确认拉取、重建和健康检查 + 成功。服务不会常驻,只有本机发布脚本主动调用时才运行。 + +部署 `.env` 不存放账号密码,保留以下字段: ```dotenv -PLAINLEAF_IMAGE_TAG= +PLAINLEAF_IMAGE_TAG=edge PLAINLEAF_UID=1001 PLAINLEAF_GID=1001 PLAINLEAF_PORT=31230 -PLAINLEAF_SERVER_PATH=/tmp/zfsv3/sata11/13616066635/data/docker/plainleaf/data -PLAINLEAF_SPACE_PATH=/tmp/zfsv3/sata11/13616066635/data/笔记管理/Plainleaf +PLAINLEAF_SERVER_PATH=/data_s001/data/udata/real/13616066635/docker/plainleaf/data +PLAINLEAF_SPACE_PATH=/data_s001/data/udata/real/13616066635/笔记管理/Plainleaf ``` -## Single-user to account-managed migration +## 更新与回滚行为 -1. Keep the existing Markdown directory unchanged and take a NAS snapshot or - file-level backup before deployment. -2. Create the empty `PLAINLEAF_SERVER_PATH` directory. It must be writable by - `PLAINLEAF_UID:PLAINLEAF_GID`. -3. Remove the old `PLAINLEAF_USER` line from `.env` and add - `PLAINLEAF_SERVER_PATH`. -4. Start this Compose project. An empty `/data` opens the setup wizard at - `https:///.setup/`. -5. In the wizard, create the administrator account. For the first space use - name `Plainleaf`, URL path `/`, and data directory `/notes`. -6. After setup, open `/.spaces/users` to add users and `/.spaces` to assign - space access. Administrators always retain access. +`plainleaf-update` 每次只做以下事情: -The migration does not move, rename, or rewrite any Markdown. `users.json`, -`spaces.json`, and the server session secret are written only below -`PLAINLEAF_SERVER_PATH`. +1. 验证 Compose 中只有 `plainleaf` 服务,且镜像来自固定 Gitea 仓库。 +2. 给当前容器镜像保留 `rollback` 标签。 +3. 仅执行 `docker compose pull plainleaf`。 +4. 镜像 ID 未变化时直接退出;变化时仅重建 `plainleaf`。 +5. 等待容器健康;失败时重新标记旧镜像并恢复旧容器。 -## Rollback +新镜像启动失败时不会删除,方便后续排查。更新日志可通过以下命令只读查看: -Stop only the `plainleaf` container, restore the previous Compose file and its -`PLAINLEAF_USER`, and mount `PLAINLEAF_SPACE_PATH` at `/space` again. Do not -delete either data directory. Because the Markdown path never moved, the old -single-user container can read it immediately. +```bash +sudo systemctl status plainleaf-update.service +sudo journalctl -u plainleaf-update.service -n 100 --no-pager +``` -Do not commit the real `.env` file. Keep HTTPS enabled before exposing the -account-managed server publicly. +## 账号模式初始化 + +空的 `/data` 会打开 `https:///.setup/`。首个空间建议使用: + +- 名称:`Plainleaf` +- URL 路径:`/` +- 数据目录:`/notes` + +管理员可在 `/.spaces/users` 管理用户,在 `/.spaces` 分配空间访问权限。初始化和 +更新都不会移动 Markdown;`users.json`、`spaces.json` 和会话密钥只写入 `/data`。 + +## 人工回滚 + +自动回滚失败时,可把 `.env` 的 `PLAINLEAF_IMAGE_TAG` 改为之前发布的 Git 短提交 +号,再仅重建 `plainleaf` 服务。不要删除 `/data`、`/notes`、Docker volume, +也不要运行 `docker system prune` 或 `docker compose down`。 diff --git a/deploy/nas/compose.yaml b/deploy/nas/compose.yaml index 44295b30..849597c7 100644 --- a/deploy/nas/compose.yaml +++ b/deploy/nas/compose.yaml @@ -2,7 +2,7 @@ name: plainleaf services: plainleaf: - image: plainleaf:${PLAINLEAF_IMAGE_TAG:-local} + image: gitea.aichickenfarm.cn/wushenghua/plainleaf:${PLAINLEAF_IMAGE_TAG:-edge} container_name: plainleaf restart: unless-stopped user: "${PLAINLEAF_UID:-1001}:${PLAINLEAF_GID:-1001}" diff --git a/deploy/nas/deployment.test.ts b/deploy/nas/deployment.test.ts index 8404287d..988a6250 100644 --- a/deploy/nas/deployment.test.ts +++ b/deploy/nas/deployment.test.ts @@ -3,6 +3,16 @@ import { expect, test } from "vitest"; const dockerfile = readFileSync("Dockerfile.nas", "utf8"); const compose = readFileSync("deploy/nas/compose.yaml", "utf8"); +const publisher = readFileSync("scripts/publish-nas-image.sh", "utf8"); +const updater = readFileSync("deploy/nas/update-plainleaf.sh", "utf8"); +const updateService = readFileSync( + "deploy/nas/plainleaf-update.service", + "utf8", +); +const updateSudoers = readFileSync( + "deploy/nas/plainleaf-update.sudoers", + "utf8", +); test("NAS image allows automatic setup, multi-space, and legacy mode detection", () => { expect(dockerfile).toContain('ENTRYPOINT ["/usr/local/bin/plainleaf"]'); @@ -19,3 +29,43 @@ test("account-managed NAS compose separates server data from Markdown", () => { expect(compose).toContain(":/data"); expect(compose).toContain(":/notes"); }); + +test("NAS deployment follows the Gitea edge image", () => { + expect(compose).toContain( + "gitea.aichickenfarm.cn/wushenghua/plainleaf:${PLAINLEAF_IMAGE_TAG:-edge}", + ); + expect(publisher).toContain("--platform linux/amd64"); + expect(publisher).toContain('--tag "${IMAGE_REPOSITORY}:${short_sha}"'); + expect(publisher).toContain('--tag "${IMAGE_REPOSITORY}:edge"'); + expect(publisher).toContain("--push"); + expect(publisher).toContain("-o BatchMode=yes"); + expect(publisher).toContain( + "sudo -n /usr/bin/systemctl start plainleaf-update.service", + ); +}); + +test("automatic updates only recreate Plainleaf and preserve persistent data", () => { + expect(updater).toContain("pull plainleaf"); + expect(updater).toContain("up --detach --no-deps --force-recreate plainleaf"); + expect(updater).toContain("restore_previous_image"); + expect(updater).not.toMatch(/compose(?:\[.*?\])?[^\n]*\bdown\b/); + expect(updater).not.toContain("prune"); + expect(updater).not.toMatch(/\bvolume\s+(?:rm|remove)\b/); + expect(compose).toContain(":/data"); + expect(compose).toContain(":/notes"); +}); + +test("root service only runs the Plainleaf updater when explicitly triggered", () => { + expect(updateService).toContain("User=root"); + expect(updateService).toContain("PLAINLEAF_DEPLOY_DIR=/etc/plainleaf"); + expect(updateService).toContain("ExecStart=/usr/local/sbin/plainleaf-update"); + expect(updater).toContain( + 'readonly DEPLOY_DIR="${PLAINLEAF_DEPLOY_DIR:-/etc/plainleaf}"', + ); + expect(updateSudoers.trim()).toBe( + "13616066635 ALL=(root) NOPASSWD: /usr/bin/systemctl start plainleaf-update.service", + ); + expect(updateSudoers).not.toMatch(/NOPASSWD:\s*ALL/); + expect(publisher).not.toContain("StrictHostKeyChecking=no"); + expect(publisher).not.toContain("sshpass"); +}); diff --git a/deploy/nas/plainleaf-update.service b/deploy/nas/plainleaf-update.service new file mode 100644 index 00000000..8e47ecbb --- /dev/null +++ b/deploy/nas/plainleaf-update.service @@ -0,0 +1,16 @@ +[Unit] +Description=Update Plainleaf from the private Gitea registry +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +Group=root +UMask=0077 +Environment=PLAINLEAF_DEPLOY_DIR=/etc/plainleaf +ExecStart=/usr/local/sbin/plainleaf-update +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only diff --git a/deploy/nas/plainleaf-update.sudoers b/deploy/nas/plainleaf-update.sudoers new file mode 100644 index 00000000..d0f4b5c1 --- /dev/null +++ b/deploy/nas/plainleaf-update.sudoers @@ -0,0 +1 @@ +13616066635 ALL=(root) NOPASSWD: /usr/bin/systemctl start plainleaf-update.service diff --git a/deploy/nas/update-plainleaf.sh b/deploy/nas/update-plainleaf.sh new file mode 100755 index 00000000..e69de26a --- /dev/null +++ b/deploy/nas/update-plainleaf.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly DEPLOY_DIR="${PLAINLEAF_DEPLOY_DIR:-/etc/plainleaf}" +readonly COMPOSE_FILE="${DEPLOY_DIR}/compose.yaml" +readonly ENV_FILE="${DEPLOY_DIR}/plainleaf.env" +readonly EXPECTED_IMAGE_PREFIX="gitea.aichickenfarm.cn/wushenghua/plainleaf:" +readonly HEALTH_TIMEOUT_SECONDS="${PLAINLEAF_HEALTH_TIMEOUT_SECONDS:-120}" + +compose=(docker compose --env-file "$ENV_FILE" --file "$COMPOSE_FILE") + +log() { + printf '[%s] %s\n' "$(date '+%F %T')" "$*" +} + +wait_for_healthy_container() { + local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS)) + local container_id state + + while ((SECONDS < deadline)); do + container_id="$("${compose[@]}" ps --quiet plainleaf 2>/dev/null || true)" + if [[ -n "$container_id" ]]; then + state="$(docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ + "$container_id" 2>/dev/null || true)" + case "$state" in + healthy | running) + return 0 + ;; + exited | dead) + return 1 + ;; + esac + fi + sleep 3 + done + + return 1 +} + +restore_previous_image() { + local previous_image_id="$1" + local target_image="$2" + + if [[ -z "$previous_image_id" ]]; then + log "首次部署没有可回滚的旧镜像,请检查 Plainleaf 日志。" + return 1 + fi + + log "新版本启动失败,正在恢复旧镜像 ${previous_image_id}。" + docker image tag "$previous_image_id" "$target_image" + "${compose[@]}" up --detach --no-deps --force-recreate plainleaf + wait_for_healthy_container +} + +if [[ ! -r "$COMPOSE_FILE" || ! -r "$ENV_FILE" ]]; then + log "缺少部署文件:${COMPOSE_FILE} 或 ${ENV_FILE}。" + exit 1 +fi + +mapfile -t services < <("${compose[@]}" config --services) +if [[ "${#services[@]}" -ne 1 || "${services[0]}" != "plainleaf" ]]; then + log "安全检查失败:该 Compose 必须只包含 plainleaf 服务。" + exit 1 +fi + +target_image="$("${compose[@]}" config --images)" +if [[ "$target_image" != "${EXPECTED_IMAGE_PREFIX}"* ]]; then + log "安全检查失败:不允许更新镜像 ${target_image}。" + exit 1 +fi + +current_container_id="$("${compose[@]}" ps --quiet plainleaf 2>/dev/null || true)" +current_image_id="" +if [[ -n "$current_container_id" ]]; then + current_image_id="$(docker inspect --format '{{.Image}}' "$current_container_id")" + rollback_image="${EXPECTED_IMAGE_PREFIX}rollback" + docker image tag "$current_image_id" "$rollback_image" + log "已保留回滚镜像 ${rollback_image}。" +fi + +log "检查 ${target_image} 是否有新版本。" +"${compose[@]}" pull plainleaf +target_image_id="$(docker image inspect --format '{{.Id}}' "$target_image")" + +if [[ -n "$current_image_id" && "$current_image_id" == "$target_image_id" ]]; then + log "当前已经是最新镜像,无需重建容器。" + exit 0 +fi + +log "发现新镜像 ${target_image_id},仅重建 plainleaf 服务。" +if ! "${compose[@]}" up --detach --no-deps --force-recreate plainleaf; then + restore_previous_image "$current_image_id" "$target_image" + exit 1 +fi + +if wait_for_healthy_container; then + log "Plainleaf 已更新并通过健康检查。" + exit 0 +fi + +docker logs --tail 100 plainleaf >&2 2>/dev/null || true +if restore_previous_image "$current_image_id" "$target_image"; then + log "已恢复旧版本;新版本仍保留在本机供排查。" +else + log "自动回滚失败,需要人工检查 Plainleaf。" +fi +exit 1 diff --git a/scripts/publish-nas-image.sh b/scripts/publish-nas-image.sh new file mode 100755 index 00000000..665dfe9b --- /dev/null +++ b/scripts/publish-nas-image.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +readonly IMAGE_REPOSITORY="gitea.aichickenfarm.cn/wushenghua/plainleaf" +readonly NAS_HOST="${PLAINLEAF_NAS_HOST:-192.168.31.68}" +readonly NAS_PORT="${PLAINLEAF_NAS_PORT:-10000}" +readonly NAS_USER="${PLAINLEAF_NAS_USER:-13616066635}" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +if [[ -n "$(git status --porcelain)" ]]; then + echo "发布已取消:工作区存在未提交修改。请先提交代码再发布。" >&2 + exit 1 +fi + +if ! docker buildx version >/dev/null 2>&1; then + echo "发布已取消:当前 Docker 未安装或未启用 buildx。" >&2 + exit 1 +fi + +git_sha="$(git rev-parse HEAD)" +short_sha="$(git rev-parse --short=8 HEAD)" + +echo "正在构建并推送 Plainleaf 镜像:" +echo " ${IMAGE_REPOSITORY}:${short_sha}" +echo " ${IMAGE_REPOSITORY}:edge" + +docker buildx build \ + --platform linux/amd64 \ + --build-arg "GITHUB_SHA=${git_sha}" \ + --file Dockerfile.nas \ + --tag "${IMAGE_REPOSITORY}:${short_sha}" \ + --tag "${IMAGE_REPOSITORY}:edge" \ + --provenance=false \ + --push \ + . + +echo "镜像推送完成,正在触发 NAS 更新。" +ssh \ + -o BatchMode=yes \ + -o ConnectTimeout=8 \ + -p "$NAS_PORT" \ + "${NAS_USER}@${NAS_HOST}" \ + "sudo -n /usr/bin/systemctl start plainleaf-update.service" + +echo "发布完成,NAS 上的 Plainleaf 已更新并通过健康检查。"