diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 29198dd..5abf9e6 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -47,6 +47,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Установка агента при обновлении (изолированные тесты) + run: python3 -m unittest discover -s deploy -p 'test_deployment_scripts.py' -v # Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и # --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`. diff --git a/deploy/install-management.sh b/deploy/install-management.sh index 93e4559..0ecd3ec 100644 --- a/deploy/install-management.sh +++ b/deploy/install-management.sh @@ -1,18 +1,47 @@ #!/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 cd "$(dirname "$0")" 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="${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; } 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; } +RESTART=0 mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu chown 65532:65532 "$DATA/management" chmod 700 "$DATA/management" /var/lib/pipu-management # 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 chmod 600 config.local 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'; } DEPLOY_UNIT="$(quote_unit "$DEPLOY")" MAILBOX_UNIT="$(quote_unit "$DATA/management")" -cat > /etc/systemd/system/pipu-management.service < "$UNIT_TMP" <> "$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() diff --git a/deploy/update.sh b/deploy/update.sh index 9277374..6a40fac 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -124,6 +124,12 @@ else git checkout --quiet --detach "$REF" 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 [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi else diff --git a/docs/server-management.md b/docs/server-management.md index d287267..4b14a60 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -6,15 +6,32 @@ ## Установка на существующий сервер -После получения версии с управлением сервером выполните на Linux-хосте: +Обычное обновление на Linux-хосте автоматически настраивает агент после +переключения на выбранную версию и до пересоздания контейнеров: ```bash cd /opt/pipu-music/deploy -sudo bash ./install-management.sh ./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 **2.24+**. Compose должен поддерживать несколько `--env-file` и тег `!reset` из существующего TLS-overlay. Агент выполняется от root, чтобы запускать Compose