feat: mbs update takes a mirror url, backs up first and survives manual edits

Manual edits on the server are stashed (and saved as a patch) instead of aborting the update,
a full backup is taken before every update, mbs mirror remembers a custom source, unsafe urls
are refused, rollback restores the stashed edits. Covered by tests/test_mbs_update.sh.
This commit is contained in:
Savsis? 2026-10-04 03:51:16 +05:00
parent 2c5ec8b06c
commit 264991593a
3 changed files with 332 additions and 19 deletions

2
.gitignore vendored
View file

@ -7,3 +7,5 @@ __pycache__/
*.pyc *.pyc
venv/ venv/
.claude/ .claude/
.update_mirror
local-changes/

194
mbs
View file

@ -13,7 +13,10 @@ mbs — управление MBS Panel
mbs restart перезапустить всё (bot, api, xray, reload nginx) mbs restart перезапустить всё (bot, api, xray, reload nginx)
mbs logs [bot|api|xray] последние строки лога (по умолчанию api) mbs logs [bot|api|xray] последние строки лога (по умолчанию api)
mbs domain показать текущий домен панели mbs domain показать текущий домен панели
mbs update обновить код (зеркало api.savsis.xyz, потом GitHub) и перезапустить (не трогает .env и базу) mbs backup полная копия панели (база, .env, ручные правки, конфиг Xray) в /root/mbs-backups
mbs update [ссылка] обновить код и перезапустить (не трогает .env и базу, перед этим сам делает копию). Без ссылки: своё зеркало -> api.savsis.xyz -> GitHub.
Со ссылкой на git-репозиторий (зеркало) — берёт обновление оттуда, один раз
mbs mirror [ссылка|off] показать / запомнить / убрать своё зеркало, которое mbs update проверяет первым
EOF EOF
} }
@ -55,34 +58,172 @@ cmd_domain() {
grep "^PANEL_DOMAIN=" "$ENV_FILE" grep "^PANEL_DOMAIN=" "$ENV_FILE"
} }
cmd_update() { MIRROR_FILE="$APP_DIR/.update_mirror"
cd "$APP_DIR" BACKUP_DIR="${MBS_BACKUP_DIR:-/root/mbs-backups}"
echo "проверяю обновления..." XRAY_CONFIG="/usr/local/etc/xray/config.json"
local remote="origin" BACKUP_FILE=""
if ! git fetch --quiet origin main 2>/dev/null; then UPDATE_STASHED=0
if git remote | grep -q '^github$'; then
echo "зеркало недоступно, пробую github..." snapshot_backup() {
if ! git fetch --quiet github main 2>/dev/null; then local stamp dbsnap file
echo "не удалось получить обновления ни с зеркала, ни с github" local -a targs=(--exclude=venv --exclude=__pycache__)
stamp=$(date +%Y%m%d-%H%M%S)
file="$BACKUP_DIR/mbs-$1-$stamp.tar.gz"
dbsnap="$APP_DIR/.mbs.db.snapshot"
mkdir -p "$BACKUP_DIR" || return 1
chmod 700 "$BACKUP_DIR"
rm -f "$dbsnap"
if [ -f "$APP_DIR/mbs.db" ] && [ -x "$APP_DIR/venv/bin/python" ] && \
"$APP_DIR/venv/bin/python" -c "import sqlite3,sys; s=sqlite3.connect(sys.argv[1]); d=sqlite3.connect(sys.argv[2]); s.backup(d); d.close(); s.close()" "$APP_DIR/mbs.db" "$dbsnap" 2>/dev/null; then
targs+=(--exclude=mbs.db --exclude=mbs.db-wal --exclude=mbs.db-shm "--transform=s#\.mbs\.db\.snapshot#mbs.db#")
fi
if [ -f "$XRAY_CONFIG" ]; then
tar czf "$file" "${targs[@]}" "$APP_DIR" "$XRAY_CONFIG" 2>/dev/null
else
tar czf "$file" "${targs[@]}" "$APP_DIR" 2>/dev/null
fi
local rc=$?
rm -f "$dbsnap"
if [ $rc -ne 0 ] || [ ! -s "$file" ]; then
rm -f "$file"
return 1
fi
chmod 600 "$file"
ls -1t "$BACKUP_DIR"/mbs-*.tar.gz 2>/dev/null | tail -n +6 | while read -r old; do rm -f "$old"; done
BACKUP_FILE="$file"
return 0
}
cmd_backup() {
if snapshot_backup manual; then
echo "копия сохранена: $BACKUP_FILE"
echo "внутри: база (консистентный снапшот), .env, код с твоими правками, конфиг Xray. Хранится 5 последних."
else
echo "не удалось сделать копию в $BACKUP_DIR (место на диске?)"
return 1
fi
}
valid_url() {
case "$1" in
https://*|http://*|ssh://*|git@*:*) ;;
*) return 1 ;;
esac
case "$1" in
*[[:space:]]*) return 1 ;;
esac
return 0
}
fetch_url() {
git -c protocol.ext.allow=never fetch --quiet -- "$1" main 2>/dev/null
}
restore_stash() {
if [ "$UPDATE_STASHED" = "1" ]; then
UPDATE_STASHED=0
if git stash pop --quiet 2>/dev/null; then
echo "ручные правки вернул на место"
else
echo "ручные правки остались в git stash (git stash list)"
fi
fi
}
cmd_mirror() {
local url="$1"
case "$url" in
"")
if [ -f "$MIRROR_FILE" ]; then
echo "своё зеркало для обновлений: $(head -n 1 "$MIRROR_FILE")"
else
echo "своё зеркало не задано — mbs update берёт api.savsis.xyz, потом GitHub"
fi
;;
off|clear)
rm -f "$MIRROR_FILE"
echo "своё зеркало убрано"
;;
*)
if ! valid_url "$url"; then
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
return 1 return 1
fi fi
remote="github" printf '%s\n' "$url" > "$MIRROR_FILE"
echo "зеркало запомнил: $url — mbs update теперь проверяет его первым"
;;
esac
}
cmd_update() {
local arg_url="$1" target="" saved="" before="" stamp="" patch=""
cd "$APP_DIR"
echo "проверяю обновления..."
if [ -n "$arg_url" ]; then
if ! valid_url "$arg_url"; then
echo "это не похоже на ссылку на git-репозиторий (нужна https://..., ssh://... или git@хост:путь)"
return 1
fi
if ! fetch_url "$arg_url"; then
echo "не удалось получить обновления по ссылке: $arg_url"
return 1
fi
target=$(git rev-parse FETCH_HEAD)
echo "источник: $arg_url"
else
if [ -f "$MIRROR_FILE" ]; then
saved=$(head -n 1 "$MIRROR_FILE" | tr -d '[:space:]')
fi
if [ -n "$saved" ] && valid_url "$saved" && fetch_url "$saved"; then
target=$(git rev-parse FETCH_HEAD)
echo "источник: своё зеркало $saved"
elif git fetch --quiet origin main 2>/dev/null; then
target=$(git rev-parse origin/main)
elif git remote | grep -q '^github$' && git fetch --quiet github main 2>/dev/null; then
echo "зеркало недоступно, взял с github..."
target=$(git rev-parse github/main)
else else
echo "не удалось получить обновления" echo "не удалось получить обновления ни с зеркала, ни с github"
return 1 return 1
fi fi
fi fi
local before after
before=$(git rev-parse HEAD) before=$(git rev-parse HEAD)
after=$(git rev-parse "$remote/main") if [ "$before" = "$target" ]; then
if [ "$before" = "$after" ]; then
echo "уже последняя версия ($before)." echo "уже последняя версия ($before)."
return 0 return 0
fi fi
if git merge-base --is-ancestor "$target" "$before" 2>/dev/null; then
echo "на сервере версия новее, чем в источнике ($before) — ничего не делаю."
return 0
fi
echo "текущая: $before" echo "текущая: $before"
echo "новая: $after" echo "новая: $target"
if ! git merge --ff-only "$remote/main" --quiet; then
echo "не вышло быстро обновиться (похоже, файлы правились вручную на сервере) — разберись руками: git status" if ! snapshot_backup before-update; then
echo "не вышло сделать резервную копию в $BACKUP_DIR — обновление не начинаю, чтобы ничего не потерять (место на диске?)"
return 1
fi
echo "резервная копия перед обновлением: $BACKUP_FILE"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
stamp=$(date +%Y%m%d-%H%M%S)
mkdir -p "$APP_DIR/local-changes"
patch="$APP_DIR/local-changes/local-changes-$stamp.patch"
git diff HEAD > "$patch"
if GIT_AUTHOR_NAME=mbs GIT_AUTHOR_EMAIL=mbs@localhost GIT_COMMITTER_NAME=mbs GIT_COMMITTER_EMAIL=mbs@localhost git stash push --quiet -m "mbs-update-$stamp"; then
UPDATE_STASHED=1
echo "на сервере были ручные правки — убрал в сторону, ничего не потеряно:"
echo " патч: $patch"
echo " stash: git stash list (вернуть обратно: git stash pop)"
else
echo "не вышло спрятать ручные правки, остановился — разберись руками: git status"
return 1
fi
fi
if ! git merge --ff-only "$target" --quiet 2>/dev/null; then
echo "не вышло быстро обновиться (на сервере есть свои коммиты, которых нет в источнике) — разберись руками: git log"
restore_stash
return 1 return 1
fi fi
echo "обновляю зависимости..." echo "обновляю зависимости..."
@ -92,6 +233,7 @@ cmd_update() {
echo "новый код не проходит проверку, откатываюсь на $before..." echo "новый код не проходит проверку, откатываюсь на $before..."
git reset --hard "$before" --quiet git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt venv/bin/pip install --quiet -r requirements.txt
restore_stash
return 1 return 1
fi fi
echo "обновляю сам CLI..." echo "обновляю сам CLI..."
@ -115,22 +257,36 @@ cmd_update() {
sleep 2 sleep 2
if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then if systemctl is-active --quiet mbs-bot && systemctl is-active --quiet mbs-api; then
echo "обновлено: $before -> $(git rev-parse --short HEAD)" echo "обновлено: $before -> $(git rev-parse --short HEAD)"
if [ "$UPDATE_STASHED" = "1" ]; then
echo "твои ручные правки лежат в git stash и в $patch — если они нужны, посмотри git stash show -p"
fi
echo "если что-то пошло не так: копия $BACKUP_FILE (распаковать: tar xzf файл -C /)"
if [ -n "$arg_url" ]; then
echo "чтобы всегда обновляться с этого зеркала: mbs mirror $arg_url"
fi
else else
echo "сервисы не поднялись после обновления, откатываюсь на $before..." echo "сервисы не поднялись после обновления, откатываюсь на $before..."
git reset --hard "$before" --quiet git reset --hard "$before" --quiet
venv/bin/pip install --quiet -r requirements.txt venv/bin/pip install --quiet -r requirements.txt
systemctl restart mbs-bot mbs-api systemctl restart mbs-bot mbs-api
restore_stash
echo "откачено обратно на $before" echo "откачено обратно на $before"
return 1 return 1
fi fi
} }
main() {
case "$1" in case "$1" in
pass) cmd_pass "$2" ;; pass) cmd_pass "$2" ;;
status) cmd_status ;; status) cmd_status ;;
restart) cmd_restart ;; restart) cmd_restart ;;
logs) cmd_logs "$2" ;; logs) cmd_logs "$2" ;;
domain) cmd_domain ;; domain) cmd_domain ;;
update) cmd_update ;; update) cmd_update "$2" ;;
mirror) cmd_mirror "$2" ;;
backup) cmd_backup ;;
*) usage ;; *) usage ;;
esac esac
}
main "$@"; exit $?

155
tests/test_mbs_update.sh Normal file
View file

@ -0,0 +1,155 @@
#!/bin/bash
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
T=$(mktemp -d)
cd "$T"
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
mkdir -p stub bin sysd
cat > stub/systemctl << 'EOF'
#!/bin/sh
exit 0
EOF
chmod +x stub/systemctl
export PATH="$T/stub:$PATH"
export MBS_BACKUP_DIR="$T/backups"
PASS=0
FAIL=0
ok() { echo "PASS $1"; PASS=$((PASS+1)); }
bad() { echo "FAIL $1 :: $2"; FAIL=$((FAIL+1)); }
check() { if eval "$2"; then ok "$1"; else bad "$1" "$3"; fi; }
sed -e "s#APP_DIR=\"/opt/mbs-panel\"#APP_DIR=\"$T/app\"#" -e "s#/usr/local/bin/mbs#$T/bin/mbs#g" -e "s#/etc/systemd/system#$T/sysd#g" "$REPO/mbs" > "$T/mbs-run"
git init -q --bare -b main origin.git
git clone -q origin.git seed 2>/dev/null
cd seed
git checkout -q -b main 2>/dev/null || true
mkdir -p systemd
cp "$REPO/mbs" mbs
echo "v1" > bot.py
echo "v1" > config.py
echo "v1" > db.py
echo "v1" > settings.py
echo "x" > requirements.txt
echo "[Service]" > systemd/mbs-bot.service
echo "ExecStart=x --workers __WORKERS__" > systemd/mbs-api.service
cp "$REPO/.gitignore" .gitignore
git add -A && git commit -q -m A && git push -q origin main
cd "$T"
git clone -q origin.git app
mkdir -p app/venv/bin
printf '#!/bin/sh\nexit 0\n' > app/venv/bin/pip
printf '#!/bin/sh\nexec python "$@"\n' > app/venv/bin/python
chmod +x app/venv/bin/pip app/venv/bin/python
echo "SECRET=1" > app/.env
python -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); c.execute('pragma journal_mode=wal'); c.execute('create table t(x)'); c.execute('insert into t values (42)'); c.commit(); c.close()" app/mbs.db
cd seed && echo "v2" > bot.py && echo "v2" > db.py && git commit -qam B && git push -q origin main && cd "$T"
echo "manual edit" >> app/bot.py
echo "manual edit" >> app/config.py
echo "manual edit" >> app/db.py
echo "manual edit" >> app/settings.py
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out1.txt
check "update succeeds despite manual edits on the server (the reported bug)" "[ $RC -eq 0 ]" "rc=$RC $OUT"
check "bot.py is the new version" "[ \"\$(cat app/bot.py)\" = v2 ]" "$(cat app/bot.py)"
check "a stash with the manual edits exists" "[ \$(git -C app stash list | wc -l) -eq 1 ]"
check "patch with the manual edits saved" "ls app/local-changes/*.patch >/dev/null 2>&1 && grep -q 'manual edit' app/local-changes/*.patch"
check "user is told where the edits went" "grep -q 'ручные правки' out1.txt && grep -q 'патч' out1.txt"
check "working tree clean after update" "[ -z \"\$(git -C app status --porcelain --untracked-files=no)\" ]"
check "cli copied" "[ -f bin/mbs ]"
check "a pre-update backup was made" "[ \$(ls backups/mbs-before-update-*.tar.gz 2>/dev/null | wc -l) -eq 1 ]" "$(ls backups 2>&1)"
check "update output points at the backup" "grep -q 'резервная копия перед обновлением' out1.txt"
mkdir -p unpack && tar xzf backups/mbs-before-update-*.tar.gz -C unpack
APPREL="${T#/}/app"
check "backup keeps .env" "grep -q SECRET=1 unpack/$APPREL/.env"
check "backup keeps the manual edits as they were before the update" "grep -q 'manual edit' unpack/$APPREL/bot.py && grep -q 'manual edit' unpack/$APPREL/config.py"
check "backup database is a consistent sqlite copy" "[ \"\$(python -c \"import sqlite3,sys; print(sqlite3.connect(sys.argv[1]).execute('select x from t').fetchone()[0])\" unpack/$APPREL/mbs.db)\" = 42 ]"
check "backup does not drag the venv along" "[ ! -d unpack/$APPREL/venv ]"
check "no snapshot temp file is left behind" "[ ! -e app/.mbs.db.snapshot ]"
OUT=$(bash "$T/mbs-run" backup 2>&1); RC=$?
check "mbs backup makes a manual copy" "[ $RC -eq 0 ] && ls backups/mbs-manual-*.tar.gz >/dev/null 2>&1" "$OUT"
for i in 1 2 3 4 5 6 7; do sleep 1.1; bash "$T/mbs-run" backup >/dev/null 2>&1; done
check "only the 5 newest backups are kept" "[ \$(ls backups/mbs-*.tar.gz | wc -l) -eq 5 ]" "$(ls backups | wc -l)"
OUT=$(bash "$T/mbs-run" update 2>&1); echo "$OUT" > out2.txt
check "second run says already latest" "grep -q 'уже последняя' out2.txt" "$OUT"
cd "$T/app" && git stash drop -q && git reset -q --hard HEAD; cd "$T"
git clone -q --bare origin.git mirror2.git
cd seed && git pull -q origin main 2>/dev/null; echo "v3" > bot.py && git commit -qam C && git push -q "$T/mirror2.git" main && cd "$T"
git -C mirror2.git update-server-info
python -m http.server 8799 --directory "$T" > http.log 2>&1 &
HTTP_PID=$!
sleep 1.5
OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:8799/mirror2.git" 2>&1); RC=$?
echo "$OUT" > out3.txt
check "update by mirror url works" "[ $RC -eq 0 ] && [ \"\$(cat app/bot.py)\" = v3 ]" "rc=$RC $OUT"
check "url run mentions the source and how to save it" "grep -q 'источник: http://127.0.0.1:8799/mirror2.git' out3.txt && grep -q 'mbs mirror http' out3.txt" "$OUT"
check "url alone is not saved automatically" "[ ! -f app/.update_mirror ]"
bash "$T/mbs-run" mirror "http://127.0.0.1:8799/mirror2.git" > out4.txt 2>&1
check "mirror command saves the url" "grep -q 'http://127.0.0.1:8799/mirror2.git' app/.update_mirror"
check "mirror file is git-ignored" "[ -z \"\$(git -C app status --porcelain)\" ]" "$(git -C app status --porcelain)"
bash "$T/mbs-run" mirror > out5.txt 2>&1
check "mirror shows the saved url" "grep -q 'своё зеркало для обновлений: http://127.0.0.1:8799' out5.txt"
cd seed && echo "v4" > bot.py && git commit -qam D && git push -q "$T/mirror2.git" main && cd "$T"
git -C mirror2.git update-server-info
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out6.txt
check "plain update prefers the saved mirror" "[ $RC -eq 0 ] && grep -q 'источник: своё зеркало' out6.txt && [ \"\$(cat app/bot.py)\" = v4 ]" "rc=$RC $OUT"
bash "$T/mbs-run" mirror off > out7.txt 2>&1
check "mirror off removes it" "[ ! -f app/.update_mirror ]"
for bad_url in "ext::sh -c 'touch $T/pwned'" "-uHEAD" "file:///etc" "ftp://x/y" "https://a b/c" "--upload-pack=touch $T/pwned2"; do
OUT=$(bash "$T/mbs-run" update "$bad_url" 2>&1); RC=$?
check "rejects unsafe source '$bad_url'" "[ $RC -ne 0 ] && grep -q 'не похоже на ссылку' <<< \"\$OUT\"" "rc=$RC $OUT"
done
check "no command was executed via a crafted url" "[ ! -e pwned ] && [ ! -e pwned2 ]"
OUT=$(bash "$T/mbs-run" mirror "file:///etc" 2>&1); RC=$?
check "mirror refuses unsafe urls too" "[ $RC -ne 0 ] && [ ! -f app/.update_mirror ]"
OUT=$(bash "$T/mbs-run" update "http://127.0.0.1:1/nope.git" 2>&1); RC=$?
check "unreachable mirror fails cleanly" "[ $RC -ne 0 ] && grep -q 'не удалось получить обновления по ссылке' <<< \"\$OUT\"" "rc=$RC $OUT"
cd seed && echo "v5" > bot.py && echo "broken(" > broken.py && git add -A && git commit -qm E && git push -q origin main && cd "$T"
git -C app fetch -q origin main
git -C app merge -q --ff-only origin/main~1 2>/dev/null || true
cd app && git reset -q --hard origin/main~1 2>/dev/null; cd "$T"
echo "local tweak" >> app/settings.py
BEFORE=$(git -C app rev-parse HEAD)
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out8.txt
check "broken new code is rolled back" "[ $RC -ne 0 ] && grep -q 'откатываюсь' out8.txt && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT"
check "manual edits are restored after the rollback" "grep -q 'local tweak' app/settings.py && [ \$(git -C app stash list | wc -l) -eq 0 ]" "$(git -C app stash list)"
cd app && git checkout -q -- . && git reset -q --hard origin/main~1 && cd "$T"
cd app && echo "own" > own.txt && git add own.txt && git commit -qm "local commit" && cd "$T"
cd seed && git rm -q broken.py && echo "v6" > bot.py && git commit -qam F && git push -q origin main && cd "$T"
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
echo "$OUT" > out9.txt
check "diverged server history is refused, not merged" "[ $RC -ne 0 ] && grep -q 'свои коммиты' out9.txt" "rc=$RC $OUT"
check "refused update leaves the local commit alone" "git -C app log --oneline | grep -q 'local commit'"
cd app && git reset -q --hard origin/main && echo "ahead" > ahead.txt && git add ahead.txt && git commit -qm ahead && cd "$T"
OUT=$(bash "$T/mbs-run" update 2>&1); RC=$?
check "server newer than the source is left alone" "[ $RC -eq 0 ] && grep -q 'версия новее' <<< \"\$OUT\"" "rc=$RC $OUT"
cd "$T/app" && git reset -q --hard origin/main && cd "$T"
cd seed && echo "v7" > bot.py && git commit -qam G && git push -q origin main && cd "$T"
BEFORE=$(git -C app rev-parse HEAD)
echo "x" > notadir
OUT=$(MBS_BACKUP_DIR="$T/notadir/x" bash "$T/mbs-run" update 2>&1); RC=$?
check "update refuses to start when no backup can be made" "[ $RC -ne 0 ] && grep -q 'не начинаю' <<< \"\$OUT\" && [ \"\$(git -C app rev-parse HEAD)\" = \"$BEFORE\" ]" "rc=$RC $OUT"
check "refused update leaves the code untouched" "[ \"\$(cat app/bot.py)\" != v7 ]"
kill $HTTP_PID 2>/dev/null
cd /
rm -rf "$T"
echo "RESULT pass=$PASS fail=$FAIL"
[ "$FAIL" -eq 0 ]