fix(deploy): provision management agent during server updates
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m21s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m21s
This commit is contained in:
1 parent
a29a8b18ad
commit
bc55528afd
5 files changed
+211
-8
No files matched your search
@@ -47,6 +47,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Установка агента при обновлении (изолированные тесты)
|
||||||
|
run: python3 -m unittest discover -s deploy -p 'test_deployment_scripts.py' -v
|
||||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||||
|
|||||||
@@ -1,18 +1,47 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Called explicitly by install.sh. Also usable once on existing installations.
|
# Host setup shared by first install and updates.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
DEPLOY="$(pwd -P)"
|
DEPLOY="$(pwd -P)"
|
||||||
|
ENSURE=0
|
||||||
|
case "${1:-}" in
|
||||||
|
--ensure) ENSURE=1 ;;
|
||||||
|
'') ;;
|
||||||
|
*) echo "Usage: bash install-management.sh [--ensure]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The active worker owns this process tree (systemd KillMode=control-group).
|
||||||
|
# Restarting it here would kill the update. It refreshes itself after success.
|
||||||
|
if [ "$ENSURE" = 1 ] && [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
|
||||||
|
systemctl is-active --quiet pipu-management.service || {
|
||||||
|
echo 'The management worker must stay active during its own update' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo '→ агент управления продолжает обновление; новая копия применится после завершения'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
|
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
|
||||||
DATA="${DATA:-/srv/pipu}"
|
DATA="${DATA:-/srv/pipu}"
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
command -v sudo >/dev/null || { echo 'Для настройки агента запустите обновление с правами root (sudo)' >&2; exit 1; }
|
||||||
|
echo '→ для настройки системного сервиса агента нужны права sudo'
|
||||||
|
exec sudo -- env "PIPU_DATA=$DATA" bash "$DEPLOY/install-management.sh" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
command -v python3 >/dev/null || { echo 'Install python3 (>=3.10) first' >&2; exit 1; }
|
command -v python3 >/dev/null || { echo 'Install python3 (>=3.10) first' >&2; exit 1; }
|
||||||
python3 -c 'import sys; assert sys.version_info >= (3, 10), "Python >=3.10 required"'
|
python3 -c 'import sys; assert sys.version_info >= (3, 10), "Python >=3.10 required"'
|
||||||
[ -d /etc/systemd/system ] || { echo 'systemd is required for server management' >&2; exit 1; }
|
[ -d /etc/systemd/system ] || { echo 'systemd is required for server management' >&2; exit 1; }
|
||||||
|
RESTART=0
|
||||||
mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu
|
mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu
|
||||||
chown 65532:65532 "$DATA/management"
|
chown 65532:65532 "$DATA/management"
|
||||||
chmod 700 "$DATA/management" /var/lib/pipu-management
|
chmod 700 "$DATA/management" /var/lib/pipu-management
|
||||||
# Copy outside the checkout: switching revisions must not replace a running worker.
|
# Copy outside the checkout: switching revisions must not replace a running worker.
|
||||||
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py
|
if ! cmp -s server-agent.py /usr/local/lib/pipu/server-agent.py; then
|
||||||
|
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py.new
|
||||||
|
mv -f /usr/local/lib/pipu/server-agent.py.new /usr/local/lib/pipu/server-agent.py
|
||||||
|
RESTART=1
|
||||||
|
fi
|
||||||
touch config.local
|
touch config.local
|
||||||
chmod 600 config.local
|
chmod 600 config.local
|
||||||
if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
|
if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
|
||||||
@@ -20,7 +49,9 @@ if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
|
|||||||
quote_unit() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g'; }
|
quote_unit() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g'; }
|
||||||
DEPLOY_UNIT="$(quote_unit "$DEPLOY")"
|
DEPLOY_UNIT="$(quote_unit "$DEPLOY")"
|
||||||
MAILBOX_UNIT="$(quote_unit "$DATA/management")"
|
MAILBOX_UNIT="$(quote_unit "$DATA/management")"
|
||||||
cat > /etc/systemd/system/pipu-management.service <<EOF
|
UNIT_TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$UNIT_TMP"' EXIT
|
||||||
|
cat > "$UNIT_TMP" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Pipu Music server management agent
|
Description=Pipu Music server management agent
|
||||||
After=docker.service
|
After=docker.service
|
||||||
@@ -37,7 +68,14 @@ KillMode=control-group
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
systemctl daemon-reload
|
if ! cmp -s "$UNIT_TMP" /etc/systemd/system/pipu-management.service; then
|
||||||
|
install -m 644 "$UNIT_TMP" /etc/systemd/system/pipu-management.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
RESTART=1
|
||||||
|
fi
|
||||||
systemctl enable pipu-management.service
|
systemctl enable pipu-management.service
|
||||||
systemctl restart pipu-management.service
|
if [ "$ENSURE" = 0 ] || [ "$RESTART" = 1 ] || ! systemctl is-active --quiet pipu-management.service; then
|
||||||
|
systemctl restart pipu-management.service
|
||||||
|
fi
|
||||||
|
systemctl is-active --quiet pipu-management.service
|
||||||
echo '→ управление сервером: pipu-management.service'
|
echo '→ управление сервером: pipu-management.service'
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Exercise deployment shell scripts with isolated host paths and command stubs."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASH = ("C:/Program Files/Git/bin/bash.exe" if os.name == "nt" else shutil.which("bash"))
|
||||||
|
SCRIPTS = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(BASH and Path(BASH).exists(), "Bash unavailable")
|
||||||
|
class DeploymentTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.deploy = self.root / "deploy"
|
||||||
|
self.bin = self.root / "bin"
|
||||||
|
self.systemd = self.root / "systemd"
|
||||||
|
for directory in (self.deploy, self.bin, self.systemd):
|
||||||
|
directory.mkdir()
|
||||||
|
self.trace = self.root / "trace"
|
||||||
|
self.active = self.root / "active"
|
||||||
|
# Git Bash on Windows prepends its binaries when importing Windows PATH.
|
||||||
|
# Source our isolated PATH after shell initialization, including re-exec.
|
||||||
|
shell_bin = self.bin.as_posix()
|
||||||
|
if os.name == "nt":
|
||||||
|
shell_bin = "/" + shell_bin[0].lower() + shell_bin[2:]
|
||||||
|
shell_env = self.root / "bash-env"
|
||||||
|
shell_env.write_text('export PATH="' + shell_bin + ':$PATH"\n', encoding="utf-8")
|
||||||
|
self.env = {**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
|
||||||
|
"BASH_ENV": shell_env.as_posix(),
|
||||||
|
"TRACE": self.trace.as_posix(), "ACTIVE": self.active.as_posix()}
|
||||||
|
for key in ("PIPU_UPDATE_REEXEC", "PIPU_UPDATE_SOURCE", "PIPU_UPDATE_COMMIT", "PIPU_UPDATE_REF", "PIPU_DATA", "SUDO_USER"):
|
||||||
|
self.env.pop(key, None)
|
||||||
|
(self.deploy / ".env").write_text("PIPU_DATA=" + (self.root / "data").as_posix() + "\nPIPU_VERSION=1.9.0\nJWT_PRIVATE_KEY=fixture\nSTREAM_SIGNING_KEY=fixture\nGITEA_URL=https://git.example.org\nGITEA_REPO=pipu/music\n", encoding="utf-8")
|
||||||
|
self.stub("systemctl", 'echo "systemctl $*" >> "$TRACE"\ncase "$1" in is-active) test -f "$ACTIVE";; restart) touch "$ACTIVE";; esac\n')
|
||||||
|
self.stub("id", 'echo "${TEST_UID:-0}"\n')
|
||||||
|
self.stub("chown", 'exit 0\n')
|
||||||
|
self.stub("python3", 'exit 0\n')
|
||||||
|
self.stub("flock", 'exit 0\n')
|
||||||
|
|
||||||
|
def stub(self, name, body):
|
||||||
|
path = self.bin / name
|
||||||
|
path.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8", newline="\n")
|
||||||
|
path.chmod(0o755)
|
||||||
|
|
||||||
|
def run_script(self, name, *args, extra=None):
|
||||||
|
return subprocess.run([BASH, (self.deploy / name).as_posix(), *args], cwd=self.deploy,
|
||||||
|
env={**self.env, **(extra or {})}, text=True, encoding="utf-8",
|
||||||
|
errors="replace", capture_output=True, timeout=20)
|
||||||
|
|
||||||
|
def install_fixture(self):
|
||||||
|
source = (SCRIPTS / "install-management.sh").read_text(encoding="utf-8")
|
||||||
|
# Relocate host constants only; run the real installer logic unchanged.
|
||||||
|
for original, replacement in (("/etc/systemd/system", self.systemd),
|
||||||
|
("/var/lib/pipu-management", self.root / "state"),
|
||||||
|
("/usr/local/lib/pipu", self.root / "installed")):
|
||||||
|
source = source.replace(original, replacement.as_posix())
|
||||||
|
(self.deploy / "install-management.sh").write_text(source, encoding="utf-8", newline="\n")
|
||||||
|
(self.deploy / "server-agent.py").write_text("# worker fixture\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def test_ensure_installs_starts_and_preserves_unchanged_running_worker(self):
|
||||||
|
self.install_fixture()
|
||||||
|
first = self.run_script("install-management.sh", "--ensure")
|
||||||
|
self.assertEqual(0, first.returncode, first.stderr)
|
||||||
|
self.assertTrue((self.root / "installed/server-agent.py").is_file())
|
||||||
|
self.assertTrue((self.root / "data/management").is_dir())
|
||||||
|
unit = (self.systemd / "pipu-management.service").read_text(encoding="utf-8")
|
||||||
|
canonical = subprocess.run([BASH, "-c", 'cd "$1" && pwd -P', "test", self.deploy.as_posix()],
|
||||||
|
capture_output=True, text=True, check=True).stdout.strip()
|
||||||
|
self.assertIn(canonical, unit)
|
||||||
|
self.assertTrue(self.active.exists())
|
||||||
|
self.trace.write_text("", encoding="utf-8")
|
||||||
|
again = self.run_script("install-management.sh", "--ensure")
|
||||||
|
self.assertEqual(0, again.returncode, again.stderr)
|
||||||
|
self.assertNotIn("restart", self.trace.read_text())
|
||||||
|
self.assertNotIn("daemon-reload", self.trace.read_text())
|
||||||
|
(self.deploy / "server-agent.py").write_text("# upgraded worker\n", encoding="utf-8")
|
||||||
|
updated = self.run_script("install-management.sh", "--ensure")
|
||||||
|
self.assertEqual(0, updated.returncode, updated.stderr)
|
||||||
|
self.assertIn("restart", self.trace.read_text())
|
||||||
|
|
||||||
|
def test_ensure_restarts_stopped_worker(self):
|
||||||
|
self.install_fixture()
|
||||||
|
self.assertEqual(0, self.run_script("install-management.sh", "--ensure").returncode)
|
||||||
|
self.active.unlink()
|
||||||
|
result = self.run_script("install-management.sh", "--ensure")
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertTrue(self.active.exists())
|
||||||
|
|
||||||
|
def test_agent_owned_update_never_replaces_or_restarts_itself(self):
|
||||||
|
self.install_fixture()
|
||||||
|
self.active.touch()
|
||||||
|
result = self.run_script("install-management.sh", "--ensure", extra={"PIPU_UPDATE_SOURCE": "https://git.example.org/pipu/music"})
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertEqual("systemctl is-active --quiet pipu-management.service\n", self.trace.read_text())
|
||||||
|
self.assertFalse((self.root / "installed").exists())
|
||||||
|
|
||||||
|
def test_nonroot_setup_delegates_privileged_step_to_sudo(self):
|
||||||
|
self.install_fixture()
|
||||||
|
self.stub("sudo", 'printf "%s\\n" "$@" > "$TRACE"\n')
|
||||||
|
result = self.run_script("install-management.sh", "--ensure", extra={"TEST_UID": "1000"})
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
arguments = self.trace.read_text().splitlines()
|
||||||
|
self.assertEqual(["--", "env"], arguments[:2])
|
||||||
|
self.assertEqual("PIPU_DATA=" + (self.root / "data").as_posix(), arguments[2])
|
||||||
|
self.assertEqual("--ensure", arguments[-1])
|
||||||
|
|
||||||
|
def update_fixture(self, install_exit=0):
|
||||||
|
shutil.copyfile(SCRIPTS / "update.sh", self.deploy / "update.sh")
|
||||||
|
target = self.root / "target-installer"
|
||||||
|
target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n")
|
||||||
|
self.env["TARGET_INSTALLER"] = target.as_posix()
|
||||||
|
(self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8")
|
||||||
|
self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; esac\n')
|
||||||
|
self.stub("docker", 'echo "docker $*" >> "$TRACE"\n')
|
||||||
|
|
||||||
|
def test_update_installs_target_revision_worker_before_container_operations(self):
|
||||||
|
self.update_fixture()
|
||||||
|
result = self.run_script("update.sh", "main")
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
trace = self.trace.read_text()
|
||||||
|
self.assertLess(trace.index("git checkout"), trace.index("setup --ensure"))
|
||||||
|
self.assertLess(trace.index("setup --ensure"), trace.index("docker compose"))
|
||||||
|
self.assertIn("up -d --remove-orphans", trace)
|
||||||
|
|
||||||
|
def test_failed_host_setup_stops_update_before_container_operations(self):
|
||||||
|
self.update_fixture(install_exit=1)
|
||||||
|
result = self.run_script("update.sh", "main")
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertNotIn("docker ", self.trace.read_text())
|
||||||
|
self.assertIn("PIPU_VERSION=1.9.0", (self.deploy / ".env").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -124,6 +124,12 @@ else
|
|||||||
git checkout --quiet --detach "$REF"
|
git checkout --quiet --detach "$REF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Use the target revision's setup, even when this updater was copied before
|
||||||
|
# checkout. Existing deployments must gain new host services during upgrades.
|
||||||
|
if [ -f ./install-management.sh ]; then
|
||||||
|
bash ./install-management.sh --ensure
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
|
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
|
||||||
if [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi
|
if [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -6,15 +6,32 @@
|
|||||||
|
|
||||||
## Установка на существующий сервер
|
## Установка на существующий сервер
|
||||||
|
|
||||||
После получения версии с управлением сервером выполните на Linux-хосте:
|
Обычное обновление на Linux-хосте автоматически настраивает агент после
|
||||||
|
переключения на выбранную версию и до пересоздания контейнеров:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/pipu-music/deploy
|
cd /opt/pipu-music/deploy
|
||||||
sudo bash ./install-management.sh
|
|
||||||
./update.sh main # либо тег версии, содержащей эту возможность
|
./update.sh main # либо тег версии, содержащей эту возможность
|
||||||
```
|
```
|
||||||
|
|
||||||
Новая установка через `sudo ./install.sh` устанавливает агент автоматически.
|
Если прав не хватает, установщик запросит sudo только для настройки системного
|
||||||
|
сервиса. Повторный запуск проверяет установку и запускает остановленный агент;
|
||||||
|
исправный агент с тем же кодом и unit-файлом не перезапускается. Ошибка установки
|
||||||
|
останавливает обновление до пересоздания контейнеров. При обновлении из админки
|
||||||
|
собственный агент остаётся запущенным и заменяет свою копию после успеха.
|
||||||
|
|
||||||
|
В версии **v1.9.0** вызов установщика из `update.sh` был пропущен. Если сервер уже
|
||||||
|
обновлён до неё, агент можно включить сразу без новой сборки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/pipu-music/deploy && sudo bash ./install-management.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Чтобы получить исправленный updater на checkout ветки main, один раз выполните
|
||||||
|
`git pull --ff-only` перед `./update.sh main`: старая временная копия updater,
|
||||||
|
запущенная до checkout, ещё не содержит нового шага настройки.
|
||||||
|
|
||||||
|
Новая установка через `sudo ./install.sh` также устанавливает агент автоматически.
|
||||||
Нужны systemd, Python **3.10+**, Git, Bash, `flock` (util-linux) и Docker Compose
|
Нужны systemd, Python **3.10+**, Git, Bash, `flock` (util-linux) и Docker Compose
|
||||||
**2.24+**. Compose должен поддерживать несколько `--env-file` и тег `!reset`
|
**2.24+**. Compose должен поддерживать несколько `--env-file` и тег `!reset`
|
||||||
из существующего TLS-overlay. Агент выполняется от root, чтобы запускать Compose
|
из существующего TLS-overlay. Агент выполняется от root, чтобы запускать Compose
|
||||||
|
|||||||
Reference in new issue
Block a user