feat: 支持主动触发 NAS 安全更新
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
CI / config (push) Canceled after 0s
CI / test-frontend (push) Canceled after 0s
CI / test-rust (push) Canceled after 0s
CI / test-e2e (push) Canceled after 0s
CI / test-e2e-release (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / release (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / docker-website (push) Canceled after 0s
Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
+84
-38
@@ -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=<git-short-sha>
|
||||
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://<plainleaf-domain>/.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://<Plainleaf 域名>/.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`。
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
13616066635 ALL=(root) NOPASSWD: /usr/bin/systemctl start plainleaf-update.service
|
||||
Executable
+109
@@ -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
|
||||
Executable
+48
@@ -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 已更新并通过健康检查。"
|
||||
Reference in New Issue
Block a user