diff --git a/README.md b/README.md index 5b829c5..9eca48e 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,9 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge ``` **Обновление** — та же команда или кнопка в панели (вкладка «Настройки» → -«Версия панели»). Конфиг, миры и учётные записи не трогаются, меняется только -бинарник. +«Версия панели»). Конфиг, миры и учётные записи не трогаются. Повторный запуск +с той же версией службу не трогает вовсе: если бинарник не изменился, +установщик не станет ради него ронять игровой сервер. Дальше: @@ -235,9 +236,19 @@ Mod ID, который нужен серверу, лежит внутри сам pzserver ALL=(root) NOPASSWD: /usr/local/bin/pzmanager-admin ``` -Скрипт качает бинарник нужной архитектуры, сверяет sha256 с `checksums.txt` -релиза, подменяет файл и перезапускает службу. Панель к этим файлам доступа не -имеет — она может только попросить выполнить `update` или `restart`. +Скрипт не повторяет логику установки, а забирает из релиза `get.sh` (сверив +его sha256) и отдаёт работу ему — поэтому кнопка обновляет всё сразу: бинарник, +скрипты установки и systemd-юнит. Панель к этим файлам доступа не имеет, она +может только попросить выполнить `update` или `restart`. + +Работа уходит в отдельный разовый юнит (`systemd-run --unit=pzmanager-update`). +Иначе обновление не пережило бы само себя: панель вызывает скрипт через sudo, +тот живёт в cgroup её юнита, и остановка службы убила бы его на середине. +Побочный эффект — вывод идёт не в консоль панели, а в свой журнал: + +```bash +journalctl -u pzmanager-update -f +``` Если правило sudo не установлено (панель поставлена вручную или в системе нет `sudo`), кнопка не появится, а рядом будет показана команда для терминала: diff --git a/deploy/install.sh b/deploy/install.sh index 5b60802..38aa752 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -205,17 +205,29 @@ EOF chmod 0640 "$CONFIG" fi +# Если ставим ровно то же самое, что уже лежит, службу трогать незачем: её +# остановка кладёт и игровой сервер, а повторный запуск установщика — обычное +# дело (например, чтобы обновить скрипты). +SAME_BINARY=0 +if [[ "$MODE" == "update" ]] && + [[ "$(sha256sum "$BINARY" | cut -d' ' -f1)" == "$(sha256sum "$TARGET_BINARY" | cut -d' ' -f1)" ]]; then + SAME_BINARY=1 + echo "==> Бинарник не изменился — службу не останавливаю" +fi + # Останавливаем сервис перед заменой бинарника: менеджер должен корректно # завершить игровой сервер, а не быть подменённым на ходу. RESTART_AFTER=0 -if [[ "$MODE" == "update" ]] && systemctl is-active --quiet pzmanager; then +if [[ "$MODE" == "update" && "$SAME_BINARY" == "0" ]] && systemctl is-active --quiet pzmanager; then echo "==> Останавливаю pzmanager (игровой сервер завершится штатно)" systemctl stop pzmanager RESTART_AFTER=1 fi -echo "==> Копирую бинарник в $TARGET_BINARY" -install -m 0755 "$BINARY" "$TARGET_BINARY" +if [[ "$SAME_BINARY" == "0" ]]; then + echo "==> Копирую бинарник в $TARGET_BINARY" + install -m 0755 "$BINARY" "$TARGET_BINARY" +fi # Панель работает от непривилегированного пользователя и не может ни заменить # бинарник, ни дёрнуть systemctl. Даём ей ровно одну команду через sudo. @@ -223,6 +235,8 @@ if [[ -f "$ADMIN_SRC" ]] && command -v sudo >/dev/null 2>&1; then echo "==> Ставлю $ADMIN_BINARY и правило sudo" sed -e "s|@PZ_REPO@|${PZ_REPO}|" \ -e "s|@PZ_BINARY@|${TARGET_BINARY}|" \ + -e "s|@PZ_HOME@|${PZ_HOME}|" \ + -e "s|@PZ_USER@|${PZ_USER}|" \ "$ADMIN_SRC" > "$ADMIN_BINARY" chown root:root "$ADMIN_BINARY" chmod 0755 "$ADMIN_BINARY" @@ -256,10 +270,25 @@ sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \ systemctl daemon-reload systemctl enable --quiet pzmanager +# wait_active ждёт, пока служба поднимется, и говорит, чем всё кончилось: +# молчаливый systemctl start оставлял пользователя гадать, работает ли панель. +wait_active() { + local i + for i in $(seq 1 15); do + if systemctl is-active --quiet pzmanager; then + return 0 + fi + sleep 1 + done + return 1 +} + if [[ "$MODE" == "update" ]]; then if [[ "$RESTART_AFTER" == "1" ]]; then + echo "==> Запускаю pzmanager" systemctl start pzmanager fi + echo if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then echo "Переустановлена версия ${NEW_VERSION}." @@ -267,8 +296,22 @@ if [[ "$MODE" == "update" ]]; then echo "Обновлено: ${OLD_VERSION} -> ${NEW_VERSION}." fi echo "Конфиг, миры и учётные записи не тронуты." + LISTEN_NOW="$(grep -E '^listen:' "$CONFIG" | head -1 | awk '{print $2}')" - echo "Панель слушает ${LISTEN_NOW:-?}." + if [[ "$RESTART_AFTER" == "1" ]]; then + if wait_active; then + echo "Панель работает и слушает ${LISTEN_NOW:-?}." + else + echo "!! Служба не поднялась. Посмотрите, что случилось:" >&2 + echo " journalctl -u pzmanager -n 40 --no-pager" >&2 + exit 1 + fi + elif systemctl is-active --quiet pzmanager; then + echo "Панель работает и слушает ${LISTEN_NOW:-?}." + else + # Службу останавливали не мы — значит и поднимать её самовольно не стоит. + echo "Служба сейчас остановлена. Запустить: systemctl start pzmanager" + fi exit 0 fi @@ -290,7 +333,13 @@ for _ in $(seq 1 20); do done echo -echo "Готово. Панель запущена и слушает ${PZ_LISTEN}." +if wait_active; then + echo "Готово. Панель запущена и слушает ${PZ_LISTEN}." +else + echo "!! Служба не поднялась. Посмотрите, что случилось:" >&2 + echo " journalctl -u pzmanager -n 40 --no-pager" >&2 + exit 1 +fi echo if [[ "$PZ_LISTEN" == 127.0.0.1:* || "$PZ_LISTEN" == localhost:* ]]; then echo "Панель доступна только с самой машины. Пробросьте порт:" diff --git a/deploy/pzmanager-admin.sh b/deploy/pzmanager-admin.sh index fb1f9a7..e940409 100644 --- a/deploy/pzmanager-admin.sh +++ b/deploy/pzmanager-admin.sh @@ -11,7 +11,11 @@ set -euo pipefail PZ_REPO="@PZ_REPO@" PZ_BINARY="@PZ_BINARY@" +PZ_HOME="@PZ_HOME@" +PZ_USER="@PZ_USER@" PZ_SERVICE="pzmanager" +# Имя разового юнита, в котором идёт обновление. +UPDATE_UNIT="pzmanager-update" die() { echo "Ошибка: $*" >&2; exit 1; } @@ -22,21 +26,57 @@ api_base() { printf '%s/api/v1/repos/%s' "$host" "${url#"$host"/}" } +# asset_url ищет в описании релиза ссылку на файл с точным именем. +asset_url() { + local release="$1" name="$2" + printf '%s' "$release" \ + | grep -o '"browser_download_url" *: *"[^"]*"' \ + | cut -d'"' -f4 \ + | while read -r url; do + if [[ "${url##*/}" == "$name" ]]; then + printf '%s' "$url" + return + fi + done +} + +# cmd_update только запускает работу и сразу возвращает управление панели. +# +# Обновление обязано пережить перезапуск службы: установщик остановит +# pzmanager, а вместе с ним systemd убьёт и этот скрипт — панель вызывает его +# через sudo, так что он живёт в cgroup её юнита. Поэтому саму работу уносим +# в отдельный разовый юнит, который к pzmanager отношения не имеет. cmd_update() { local version="${1:-latest}" - local api release tag url name tmp arch + # Версия приходит из панели: проверяем до запуска юнита, чтобы не плодить + # заведомо провальные. + [[ "$version" == "latest" || "$version" =~ ^[A-Za-z0-9._-]+$ ]] \ + || die "недопустимая версия: $version" + command -v systemd-run >/dev/null 2>&1 \ + || die "нет systemd-run — обновите панель командой из README" - case "$(uname -m)" in - x86_64 | amd64) arch=amd64 ;; - aarch64 | arm64) arch=arm64 ;; - *) die "архитектура $(uname -m) не поддерживается" ;; - esac + # --collect убирает юнит после завершения, в том числе неуспешного: иначе + # следующий запуск упрётся в оставшийся failed-юнит с тем же именем. + systemd-run --quiet --collect --unit="$UPDATE_UNIT" \ + --description="Обновление pzmanager" \ + "$0" do-update "$version" \ + || die "не удалось запустить обновление разовым юнитом" + + echo "Обновление запущено в фоне." + echo "Ход работы: journalctl -u ${UPDATE_UNIT} -f" +} + +# cmd_do_update выполняется уже внутри разового юнита. Обновляет всё сразу: +# бинарник, скрипты установки и systemd-юнит, — поэтому просто отдаёт работу +# get.sh из релиза вместо того, чтобы повторять его логику. +cmd_do_update() { + local version="${1:-latest}" api release tag url tmp api="$(api_base)" if [[ "$version" == "latest" ]]; then release="$(curl -fsSL "${api}/releases/latest")" || die "не удалось запросить релиз" else - # Тег приходит из панели; всё, кроме безопасных символов, отвергаем. + # Версия приходит из панели; всё, кроме безопасных символов, отвергаем. [[ "$version" =~ ^[A-Za-z0-9._-]+$ ]] || die "недопустимая версия: $version" release="$(curl -fsSL "${api}/releases/tags/${version}")" || die "релиз ${version} не найден" fi @@ -44,52 +84,46 @@ cmd_update() { tag="$(printf '%s' "$release" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)" [[ -n "$tag" ]] || die "в ответе нет тега" - name="pzmanager-linux-${arch}" - url="$(printf '%s' "$release" \ - | grep -o '"browser_download_url" *: *"[^"]*"' | cut -d'"' -f4 \ - | while read -r candidate; do - [[ "${candidate##*/}" == "$name" ]] && { printf '%s' "$candidate"; break; } - done)" - [[ -n "$url" ]] || die "в релизе ${tag} нет файла ${name}" - + url="$(asset_url "$release" get.sh)" + [[ -n "$url" ]] || die "в релизе ${tag} нет get.sh" local sums_url - sums_url="$(printf '%s' "$release" \ - | grep -o '"browser_download_url" *: *"[^"]*"' | cut -d'"' -f4 \ - | while read -r candidate; do - [[ "${candidate##*/}" == "checksums.txt" ]] && { printf '%s' "$candidate"; break; } - done)" + sums_url="$(asset_url "$release" checksums.txt)" [[ -n "$sums_url" ]] || die "в релизе ${tag} нет checksums.txt" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' RETURN - echo "==> Скачиваю ${tag} (${arch})" - curl -fsSL -o "${tmp}/${name}" "$url" || die "не удалось скачать ${name}" + echo "==> Беру установщик из ${tag}" + curl -fsSL -o "${tmp}/get.sh" "$url" || die "не удалось скачать get.sh" curl -fsSL -o "${tmp}/checksums.txt" "$sums_url" || die "не удалось скачать checksums.txt" echo "==> Проверяю контрольную сумму" - ( cd "$tmp" && grep " ${name}\$" checksums.txt > expected.txt && sha256sum -c expected.txt ) \ - || die "контрольная сумма не сошлась — файл повреждён или подменён" + ( cd "$tmp" && awk '$NF == "get.sh"' checksums.txt > expected.txt \ + && [[ -s expected.txt ]] && sha256sum -c expected.txt ) \ + || die "контрольная сумма get.sh не сошлась" - echo "==> Заменяю ${PZ_BINARY}" - chmod 0755 "${tmp}/${name}" - # Через временный файл рядом и rename: перезаписать работающий бинарник - # нельзя, а переименовать поверх — можно. - install -m 0755 "${tmp}/${name}" "${PZ_BINARY}.new" - mv -f "${PZ_BINARY}.new" "${PZ_BINARY}" + echo "==> Обновляю до ${tag}" + # get.sh скачает бинарник нужной архитектуры и запустит install.sh, который + # сам остановит службу, заменит файлы и поднимет её обратно. + PZ_HOME="$PZ_HOME" PZ_USER="$PZ_USER" PZ_REPO="$PZ_REPO" PZ_VERSION="$tag" \ + bash "${tmp}/get.sh" \ + || die "обновление не удалось" - echo "==> Перезапускаю ${PZ_SERVICE}" - systemctl restart "$PZ_SERVICE" - echo "Обновлено до ${tag}" + echo "Обновление до ${tag} завершено" } case "${1:-}" in update) cmd_update "${2:-latest}" ;; + do-update) + cmd_do_update "${2:-latest}" + ;; restart) echo "==> Перезапускаю ${PZ_SERVICE}" - systemctl restart "$PZ_SERVICE" + # --no-block: команда ставит задание и выходит, не дожидаясь остановки. + # Иначе systemd убил бы нас вместе со службой на середине ожидания. + systemctl restart --no-block "$PZ_SERVICE" ;; version) "$PZ_BINARY" version diff --git a/internal/api/handlers_update.go b/internal/api/handlers_update.go index fe183b5..e890f3d 100644 --- a/internal/api/handlers_update.go +++ b/internal/api/handlers_update.go @@ -35,7 +35,8 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) { // Игровой сервер останавливаем сами: иначе это сделает systemd уже в // момент перезапуска, и ожидание растянется на таймаут остановки. s.stopServerBefore(log, "обновлением") - log("Запускаю обновление, панель перезапустится") + log("Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели") + log("Подробности: journalctl -u pzmanager-update -f") return s.updater.Apply(ctx, log, body.Version) }) if err != nil { diff --git a/web/static/app.js b/web/static/app.js index eeb7e6b..7d15476 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1212,6 +1212,9 @@ $("#btn-update-apply").addEventListener("click", async () => { try { await post("/api/update", { version: updateInfo.latest }); waitForRestart("Обновление запущено"); + // Обновление выполняется отдельной службой, и её вывод в консоль панели + // не попадает — панель в это время перезапускается. + toast("Ход обновления: journalctl -u pzmanager-update -f"); } catch (error) { fail(error); }