feat(update): update the backend like nodes via quadlet restart [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-18 00:36:38 +08:00
1 parent 34190f5a04
commit 68c7113adf
23 files changed
+309 -357

No files matched your search

-3
View File
@@ -27,7 +27,6 @@ services:
MoShiro__DataDirectory: /data
volumes:
- moshiro-backend-data:/data
- moshiro-backend-status:/data/update-status:ro
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
@@ -40,5 +39,3 @@ services:
volumes:
moshiro-backend-data:
name: moshiro-backend-data
moshiro-backend-status:
name: moshiro-backend-status
+12 -64
View File
@@ -1,29 +1,25 @@
#!/bin/sh
# 部署或更新容器服务(Backend、Agent、Probe)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、
# 部署或更新容器服务(Agent、Probe;Backend 用 Quadlet,见 packaging/quadlet/moshiro-backend.container)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、
# 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。
#
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
#
# Backend 只支持 Podman,更新策略固定为 manual(管理端确认),监听宿主网络的 --port。
# 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。
# rootless Podman 见 docs/ops/deployment.md。
set -eu
usage() {
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
echo "usage: deploy-node.sh <agent|probe> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2
echo " backend: [--channel dev|release] [--port N] [--admin-user U --admin-password P:只给自动化用,正常不要] [--public-url URL] [--env KEY=VALUE]..." >&2
exit 2
}
ROLE="${1:-}"; shift || true
case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac
ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV=""
case "$ROLE" in agent|probe) ;; *) usage ;; esac
ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""
while [ $# -gt 0 ]; do
case "$1" in
--engine) ENGINE="$2"; shift 2 ;;
@@ -36,12 +32,6 @@ while [ $# -gt 0 ]; do
--policy) POLICY="$2"; shift 2 ;;
--authfile) AUTHFILE="$2"; shift 2 ;;
--channel) CHANNEL="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--public-url) PUBLIC_URL="$2"; shift 2 ;;
--env) EXTRA_ENV="$EXTRA_ENV$2
"; shift 2 ;;
*) usage ;;
esac
done
@@ -59,13 +49,6 @@ conf_get() { [ -r "$CONF" ] && sed -n "s/^$1='\\(.*\\)'\$/\\1/p" "$CONF" | head
[ -n "$AUTHFILE" ] || AUTHFILE="$(conf_get AUTHFILE)"
[ -n "$ENGINE" ] || ENGINE="$(conf_get ENGINE)"
[ -n "$CHANNEL" ] || CHANNEL="$(conf_get CHANNEL)"
if [ "$ROLE" = backend ]; then
[ -n "$CHANNEL" ] || { echo "backend needs --channel dev or release" >&2; exit 2; }
case "$CHANNEL" in dev) NAMESPACE=moshiro-dev ;; release) NAMESPACE=moshiro ;; *) echo "--channel must be dev or release" >&2; exit 2 ;; esac
[ -n "$REPO" ] || REPO="registry.oeo.one/$NAMESPACE/moshiro-backend"
[ -n "$PORT" ] || PORT=29181
ENGINE=podman; POLICY=manual
fi
[ -n "$REPO" ] || REPO="registry.oeo.one/moshiro/moshiro-$ROLE"
[ -n "$POLICY" ] || POLICY=manual
if [ -z "$ENGINE" ]; then
@@ -78,14 +61,13 @@ case "$ENGINE" in
esac
V_CFG="moshiro-$ROLE-config"
V_STATUS="moshiro-$ROLE-status"
if [ "$ROLE" = backend ]; then UNIT=moshiro-backend.service; V_CFG=moshiro-backend-data; fi
for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" "$PACKAGING/update/moshiro-update.sh" "$PACKAGING/update/release-key.pub.pem"; do
[ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; }
done
# 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用。
# 默认从仓库里的签名清单取(<仓库>-manifest:<通道>,CI 发布的),用随脚本带的发布公钥验签,按清单里的 digest 固定——
# 仓库和网络都不需要可信,也不需要面板在线(Backend 第一次部署时还没有面板)。
# 仓库和网络都不需要可信,也不需要面板在线。
# --tag 指定标签就不验签(拉那个标签再固定 digest);--image 直接用你给的引用。
if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi
pull_quiet() {
@@ -137,38 +119,14 @@ in_volume() {
"$ENGINE" run --rm -i --pull=never --network none --cap-drop ALL --security-opt no-new-privileges \
-v "$V_CFG:/etc/moshiro" --entrypoint /bin/sh "$IMAGE" -c "$1"
}
[ "$ROLE" = backend ] || in_volume 'mkdir -p /etc/moshiro/update' </dev/null
in_volume 'mkdir -p /etc/moshiro/update' </dev/null
# 3. 配置:身份由节点自己生成(Agent 和 Probe 一样),注册之后面板只认这把公钥
if [ "$ROLE" = backend ]; then
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
install -d -m 755 /etc/moshiro
if [ ! -f /etc/moshiro/backend.env ]; then
(
umask 077
{
echo "MoShiro__DataDirectory=/data"
echo "MoShiro__UpdateChannel=$CHANNEL"
echo "MoShiro__ImageNamespace=$NAMESPACE"
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
if [ -n "$ADMIN_PASSWORD" ]; then
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
fi
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
} > /etc/moshiro/backend.env
)
echo "config: created /etc/moshiro/backend.env"
fi
else
ID_DIR="/etc/moshiro/$ROLE-id"
if ! in_volume "test -f $ID_DIR/node.json" </dev/null; then
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&2; exit 1; }
"$ENGINE" run --rm --pull=never --network host -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \
--server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR"
fi
ID_DIR="/etc/moshiro/$ROLE-id"
if ! in_volume "test -f $ID_DIR/node.json" </dev/null; then
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&2; exit 1; }
"$ENGINE" run --rm --pull=never --network host -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \
--server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR"
fi
if [ "$ROLE" = agent ]; then
if ! in_volume 'test -f /etc/moshiro/agent.json' </dev/null; then
@@ -199,10 +157,8 @@ fi
install -d -m 755 "$CONF_DIR"
printf 'IMAGE=%s\n' "$IMAGE" > "$CONF_DIR/$ROLE.image"
install -m 644 "$PACKAGING/systemd/$UNIT_SRC" "/etc/systemd/system/$UNIT"
[ "$ROLE" != backend ] || sed -i "s/:29181/:$PORT/" "/etc/systemd/system/$UNIT"
set -- "$ROLE" --engine "$ENGINE" --volume "$V_CFG" --status-volume "$V_STATUS" --helper-image "$IMAGE" \
--image-repo "$REPO" --unit "$UNIT" --policy "$POLICY" --no-enable
[ "$ROLE" != backend ] || set -- "$@" --channel "$CHANNEL" --health-url "http://127.0.0.1:$PORT/healthz"
[ -z "$AUTHFILE" ] || set -- "$@" --authfile "$AUTHFILE"
sh "$PACKAGING/update/install-updater.sh" "$@"
@@ -211,13 +167,5 @@ systemctl daemon-reload
systemctl enable "$UNIT" "moshiro-update-$ROLE.timer" >/dev/null 2>&1
systemctl restart "$UNIT"
systemctl start "moshiro-update-$ROLE.timer"
if [ "$ROLE" = backend ]; then
n=0
until curl -fsS -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null; do
n=$((n + 1)); [ "$n" -lt 30 ] || { echo "backend did not become healthy" >&2; systemctl status "$UNIT" --no-pager >&2 || true; exit 1; }
sleep 2
done
else
sleep 5
fi
sleep 5
systemctl is-active "$UNIT"
@@ -0,0 +1,50 @@
# MoShiro Backend(面板)的 Podman Quadlet 单元(Podman 4.6 及以上),更新方式和 Agent、Probe 完全一样:
# systemd 每次启动这个单元时 Pull=always 先从镜像仓库拉最新的镜像,Backend 在管理端确认更新之后有序退出(先把数据库备份到数据卷的 backups/),
# Restart=always 把它拉起来,退出再被拉起就是更新(Backend 自己不下载、不替换任何东西)。
# 不会自动重启:Backend 指挥所有节点,更新要管理员在管理端点「更新并重启」。
# 回滚:新版启动时会迁移数据库,旧版不认比自己新的数据库。想回到旧版,先停服务,把 backups/pre-update-*.db 复制成数据卷里的 moshiro.db,再把 Image 固定成旧版本号。
#
# 安装(rootless,普通用户,不用 sudo):
# mkdir -p ~/.config/containers/systemd ~/.config/moshiro
# 写 ~/.config/moshiro/backend.env,chmod 600,每行一个变量,至少:
# MoShiro__DataDirectory=/data
# MoShiro__PublicUrl=https://panel.example.com # 面板对外的地址;passkey 绑定在这个域名上,改了会让已注册的 passkey 失效
# ASPNETCORE_HTTP_PORTS=8080
# 前面有反向代理(Caddy、cloudflared、frp)时再加 MoShiro__TrustedProxies(代理连到 Backend 的地址,rootless pasta 网络下是宿主的局域网地址和 127.0.0.1)
# 和 MoShiro__TrustedProxyHeader(Cloudflare 用 CF-Connecting-IP,其他用 X-Forwarded-For),否则登录限流、审计和节点的国家识别看到的都是代理的地址。
# 把本文件放进 ~/.config/containers/systemd/
# systemctl --user daemon-reload && systemctl --user start moshiro-backend # Quadlet 会自动生成服务,不用 enable;[Install] 让它开机自启
# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次)
# 第一次打开面板按页面引导认领:没有默认管理员,认领码在日志里(podman logs moshiro-backend)。
# rootful:本文件放进 /etc/containers/systemd/,env 文件放 /etc/moshiro/backend.env,把下面的 EnvironmentFile 改成这个路径、
# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。
# 开发通道:把 Image 的 moshiro 换成 moshiro-dev,并在 env 文件里加 MoShiro__UpdateChannel=dev。固定版本:把 :latest 换成 :<版本号>(固定之后更新不会换版本)。
# 说明见 docs/ops/deployment.md。
[Unit]
Description=MoShiro Backend
After=network-online.target
Wants=network-online.target
[Container]
Image=registry.oeo.one/moshiro/moshiro-backend:latest
ContainerName=moshiro-backend
Pull=always
PublishPort=127.0.0.1:8080:8080
EnvironmentFile=%h/.config/moshiro/backend.env
Volume=moshiro-backend-data:/data
ReadOnly=true
NoNewPrivileges=true
DropCapability=ALL
Tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777
StopTimeout=30
# 告诉 Backend:退出之后外面会重新拉取,所以「更新」就是退出(没有这一行,管理端不能更新 Backend)
Environment=MOSHIRO_UPDATE=restart
[Service]
Restart=always
RestartSec=5
# 第一次拉取镜像可能要一会儿
TimeoutStartSec=600
[Install]
WantedBy=default.target
@@ -1,16 +0,0 @@
[Unit]
Description=MoShiro Backend (signed container updates)
After=network-online.target
Wants=network-online.target
[Service]
EnvironmentFile=/etc/moshiro-update/backend.image
Restart=always
RestartSec=5
TimeoutStopSec=30
ExecStartPre=-/usr/bin/podman rm -f moshiro-backend
ExecStart=/usr/bin/podman run --rm --name moshiro-backend --network host -e ASPNETCORE_URLS=http://0.0.0.0:29181 --read-only --tmpfs /tmp --security-opt no-new-privileges --cap-drop all --env-file /etc/moshiro/backend.env -v moshiro-backend-data:/data -v moshiro-backend-status:/data/update-status:ro ${IMAGE}
ExecStop=/usr/bin/podman stop -t 20 moshiro-backend
[Install]
WantedBy=multi-user.target
+1 -5
View File
@@ -5,7 +5,7 @@
set -eu
COMPONENT="${1:-}"; shift || true
case "$COMPONENT" in agent|probe|backend) ;; *) echo "usage: install-updater.sh <agent|probe|backend> [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac
case "$COMPONENT" in agent|probe) ;; *) echo "usage: install-updater.sh <agent|probe> [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac
DATA_DIR=""; IMAGE_REPO=""; UNIT=""; POLICY=manual; AUTHFILE=""; ENGINE=podman; SERVICE_SCOPE=system; ENABLE=true; DATA_VOLUME=""; STATUS_VOLUME=""; HELPER_IMAGE=""
CHANNEL=""; HEALTH_URL=""
while [ $# -gt 0 ]; do
@@ -31,10 +31,6 @@ if [ -z "$IMAGE_REPO" ] || [ -z "$UNIT" ] || { [ -z "$DATA_DIR" ] && [ -z "$DATA
fi
case "$POLICY" in auto|manual) ;; *) echo "--policy must be auto or manual" >&2; exit 2 ;; esac
case "$ENGINE" in podman|docker) ;; *) echo "--engine must be podman or docker" >&2; exit 2 ;; esac
if [ "$COMPONENT" = backend ]; then
[ "$POLICY" = manual ] && [ -n "$DATA_VOLUME" ] && [ -n "$HEALTH_URL" ] || { echo "Backend requires manual policy, a data volume and --health-url" >&2; exit 2; }
case "$CHANNEL" in dev|release) ;; *) echo "Backend requires --channel dev or release" >&2; exit 2 ;; esac
fi
if [ -n "$DATA_VOLUME" ]; then
[ -z "$DATA_DIR" ] || { echo "use --volume or --data-dir, not both" >&2; exit 2; }
[ -n "$HELPER_IMAGE" ] || { echo "--volume needs --helper-image (an already pulled MoShiro image)" >&2; exit 2; }
+5 -44
View File
@@ -14,8 +14,8 @@ umask 077
CMD="${1:-}"
COMPONENT="${2:-}"
case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update <check|apply> <agent|probe|backend>" >&2; exit 2 ;; esac
case "$COMPONENT" in agent|probe|backend) ;; *) echo "component must be agent, probe or backend" >&2; exit 2 ;; esac
case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update <check|apply> <agent|probe>" >&2; exit 2 ;; esac
case "$COMPONENT" in agent|probe) ;; *) echo "component must be agent or probe" >&2; exit 2 ;; esac
if [ "$(id -u)" -eq 0 ]; then
DEFAULT_CONF_DIR=/etc/moshiro-update
@@ -37,12 +37,6 @@ DATA_VOLUME="${DATA_VOLUME:-}"
STATUS_VOLUME="${STATUS_VOLUME:-moshiro-$COMPONENT-status}"
CHANNEL="${CHANNEL:-}"
HEALTH_URL="${HEALTH_URL:-}"
if [ "$COMPONENT" = backend ]; then
[ "$POLICY" = manual ] || { echo "Backend updates require manual confirmation" >&2; exit 2; }
case "$CHANNEL" in dev|release) ;; *) echo "Backend needs CHANNEL=dev or release" >&2; exit 2 ;; esac
[ -n "$HEALTH_URL" ] || { echo "Backend needs a local HEALTH_URL" >&2; exit 2; }
[ -n "$DATA_VOLUME" ] || { echo "Backend backup needs DATA_VOLUME" >&2; exit 2; }
fi
case "$ENGINE" in podman|docker) ;; *) echo "unsupported engine: $ENGINE" >&2; exit 2 ;; esac
case "$SERVICE_SCOPE" in system|user) ;; *) echo "unsupported service scope: $SERVICE_SCOPE" >&2; exit 2 ;; esac
service_ctl() {
@@ -174,38 +168,17 @@ seed_installed() {
current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; }
installed_seq() { [ -r "$INSTALLED" ] && sed -n 1p "$INSTALLED" || echo 0; }
# Backend 的请求带用户确认的 seq;复制以后再验清单,避免后来到达的 tag 被自动套用。
# 面板(管理员)请求更新:apply.request 在数据卷里放着就是请求了
fetch_request() {
if [ -n "$DATA_VOLUME" ]; then
"$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/etc/moshiro:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \
-c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then if [ "$1" = backend ]; then head -c 64 "$f"; else printf yes; fi; fi' sh "$COMPONENT"
-c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then printf yes; fi'
elif [ -f "$UPDATE_DIR/apply.request" ] && [ ! -L "$UPDATE_DIR/apply.request" ]; then
if [ "$COMPONENT" = backend ]; then head -c 64 "$UPDATE_DIR/apply.request"; else printf yes; fi
printf yes
fi
}
backend_backup() {
BACKUP="$STATE_DIR/backend-before-$M_SEQ.tar.gz"
service_ctl stop "$UNIT"
# 容器的标准输出就是备份本身:不能再让引擎把它当日志写进 journal(每次更新会多灌一份备份大小的日志)
if ! "$ENGINE" run --pull=never --rm --log-driver=none --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/data:ro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -czf - -C /data .' > "$BACKUP.tmp"; then
rm -f "$BACKUP.tmp"
service_ctl start "$UNIT" || true
write_status failed "$avail" "database backup failed"
return 1
fi
chmod 600 "$BACKUP.tmp"
mv -f "$BACKUP.tmp" "$BACKUP"
}
backend_restore() {
service_ctl stop "$UNIT" || true
"$ENGINE" run --pull=never --rm -i --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/data" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -xzf - --no-same-owner -C /data' < "$BACKUP"
}
# 从扁平的清单里取字段(清单由 CI 生成,只有字符串和整数,没有嵌套)
field() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$OFFER_MANIFEST" | head -n 1; }
num_field() { sed -n 's/.*"'"$1"'":\([0-9][0-9]*\).*/\1/p' "$OFFER_MANIFEST" | head -n 1; }
@@ -277,7 +250,6 @@ do_check() {
}
do_apply() {
requested_seq="$(fetch_request)"
if [ -n "$DATA_VOLUME" ]; then
"$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$DATA_VOLUME:/etc/moshiro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'rm -f /etc/moshiro/update/apply.request'
@@ -287,10 +259,6 @@ do_apply() {
fetch_offer
verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; }
adopt_next_key
if [ "$COMPONENT" = backend ]; then
case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac
[ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; }
fi
avail="$(available_json)"
write_status applying "$avail" ""
ref="$IMAGE_REPO@$M_DIGEST"
@@ -298,7 +266,6 @@ do_apply() {
write_status failed "$avail" "pull failed"; echo "pull failed: $ref" >&2; return 1
fi
prev="$(cat "$ENV_FILE" 2>/dev/null || true)"
if [ "$COMPONENT" = backend ]; then backend_backup || return 1; fi
printf 'IMAGE=%s\n' "$ref" > "$ENV_FILE.new" && mv -f "$ENV_FILE.new" "$ENV_FILE"
service_ctl restart "$UNIT" || true
# 健康确认:服务在跑,并且 20 秒内没有再被重启(崩溃循环算失败)
@@ -317,12 +284,6 @@ do_apply() {
return 0
fi
echo "new version did not become healthy, rolling back" >&2
if [ "$COMPONENT" = backend ]; then
if ! backend_restore; then
write_status failed "$avail" "database restore failed; backup retained"
return 1
fi
fi
if [ -n "$prev" ]; then printf '%s\n' "$prev" > "$ENV_FILE"; fi
service_ctl restart "$UNIT" || true
write_status rolled_back "$avail" "new version did not become healthy"