From 0450f8bdb0a80a572f92317d0cb4e0c7808009c3 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Thu, 28 May 2026 22:10:45 +0200 Subject: [PATCH] (feature) Add staff updater page Add a Django updater view for checking configured Gitea releases, inspecting the installed git checkout, fetching tags, pulling the current branch, and running the configured native systemd update command. Document the updater environment settings and keep the page staff-only so readonly status users cannot trigger deployment actions. --- README.md | 16 ++ deploy/pobsync.env.example | 8 + scripts/install-systemd | 4 + .../templates/pobsync_backend/base.html | 18 +++ .../templates/pobsync_backend/updater.html | 122 ++++++++++++++ src/pobsync_backend/tests/test_updater.py | 72 +++++++++ src/pobsync_backend/tests/test_views.py | 87 ++++++++++ src/pobsync_backend/updater.py | 149 ++++++++++++++++++ src/pobsync_backend/views.py | 35 ++++ src/pobsync_server/settings.py | 4 + src/pobsync_server/urls.py | 1 + 11 files changed, 516 insertions(+) create mode 100644 src/pobsync_backend/templates/pobsync_backend/updater.html create mode 100644 src/pobsync_backend/tests/test_updater.py create mode 100644 src/pobsync_backend/updater.py diff --git a/README.md b/README.md index a51968f..84f6fc1 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ The UI includes: - Django-managed SSH keys - `/self-check/` for runtime checks - `/logs/` for filtered pobsync service logs +- `/updater/` for checking Gitea releases, pulling the git checkout, and running the native updater ## Bandwidth Limits @@ -243,6 +244,21 @@ The updater is a thin wrapper around the installer for normal production deploys Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is loaded. +The Django control panel also exposes an `/updater/` page for staff users. It can check a Gitea releases endpoint, run +`git fetch`, run a fast-forward-only pull for the installed branch, and invoke the configured native update command. +Configure these optional environment variables in `/etc/pobsync/pobsync.env`: + +``` +POBSYNC_UPDATE_RELEASES_URL=https://code.example.test/api/v1/repos/owner/pobsync/releases +POBSYNC_UPDATE_RELEASES_TOKEN= +POBSYNC_UPDATE_GIT_REMOTE=origin +POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd +``` + +If the web service runs as the `pobsync` user, `POBSYNC_UPDATE_COMMAND` needs a matching sudoers rule or a different +operator-approved command. Without that, the page still shows update status and command output, but the native update +action will fail with a permission error instead of silently doing the wrong thing. + Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable nginx, or rewrite the environment file: diff --git a/deploy/pobsync.env.example b/deploy/pobsync.env.example index 317d9a5..1ba438d 100644 --- a/deploy/pobsync.env.example +++ b/deploy/pobsync.env.example @@ -16,3 +16,11 @@ POBSYNC_GUNICORN_WORKERS=2 POBSYNC_GUNICORN_TIMEOUT=120 POBSYNC_WORKER_INTERVAL=15 POBSYNC_SCHEDULER_INTERVAL=60 + +# Optional UI updater integration. +# Point this at the Gitea releases API endpoint, for example: +# https://code.example.test/api/v1/repos/owner/pobsync/releases +POBSYNC_UPDATE_RELEASES_URL= +POBSYNC_UPDATE_RELEASES_TOKEN= +POBSYNC_UPDATE_GIT_REMOTE=origin +POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd diff --git a/scripts/install-systemd b/scripts/install-systemd index d77a521..6e87561 100755 --- a/scripts/install-systemd +++ b/scripts/install-systemd @@ -472,6 +472,10 @@ POBSYNC_GUNICORN_WORKERS=2 POBSYNC_GUNICORN_TIMEOUT=120 POBSYNC_WORKER_INTERVAL=15 POBSYNC_SCHEDULER_INTERVAL=60 +POBSYNC_UPDATE_RELEASES_URL= +POBSYNC_UPDATE_RELEASES_TOKEN= +POBSYNC_UPDATE_GIT_REMOTE=origin +POBSYNC_UPDATE_COMMAND=sudo -n scripts/update-systemd EOF chmod 0640 "$ENV_FILE" chown "root:$SERVICE_GROUP" "$ENV_FILE" diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 0ff25f9..b0c10f1 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -275,6 +275,23 @@ .status.skipped { color: var(--muted); background: #f7f9fb; } .stack { display: grid; gap: 5px; } .stack.spaced { margin-bottom: 14px; } + .detail-list { + display: grid; + gap: 8px 14px; + grid-template-columns: minmax(120px, max-content) minmax(0, 1fr); + margin: 0 0 12px; + } + .detail-list dt { + color: var(--muted); + font-size: 12px; + font-weight: 750; + text-transform: uppercase; + } + .detail-list dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .panel-grid { display: grid; @@ -923,6 +940,7 @@ SSH Keys Notifications Logs + Updater {% endif %} Purged diff --git a/src/pobsync_backend/templates/pobsync_backend/updater.html b/src/pobsync_backend/templates/pobsync_backend/updater.html new file mode 100644 index 0000000..d6406ab --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/updater.html @@ -0,0 +1,122 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Updater | pobsync{% endblock %} + +{% block content %} + + +
+
+

Installed App

+
+
Version
+
{{ status.installed_version }}
+
Git branch
+
{{ status.git.branch|default:"unknown" }}
+
Git commit
+
{{ status.git.commit|default:"unknown" }}
+
Git describe
+
{{ status.git.describe|default:"unknown" }}
+
App directory
+
{{ status.app_dir }}
+
+
+ +
+

Release Check

+
+
Status
+
+ {% if status.update_available == True %} + update available + {% elif status.update_available == False %} + up to date + {% elif status.release_check_configured %} + not checked + {% else %} + not configured + {% endif %} +
+
Latest release
+
+ {% if status.latest_release %} + {% if status.latest_release.html_url %} + + {% if status.latest_release.tag_name %}{{ status.latest_release.tag_name }}{% else %}{{ status.latest_release.name }}{% endif %} + + {% else %} + {% if status.latest_release.tag_name %}{{ status.latest_release.tag_name }}{% else %}{{ status.latest_release.name }}{% endif %} + {% endif %} + {% else %} + none + {% endif %} +
+
Release endpoint
+
{% if status.release_check_configured %}configured{% else %}set POBSYNC_UPDATE_RELEASES_URL{% endif %}
+
+ {% if status.release_error %} +

{{ status.release_error }}

+ {% endif %} +
+ {% csrf_token %} + +
+
+
+ +
+

Update Actions

+

Run these from the installed checkout. The native updater may require a sudoers rule for the pobsync service user.

+
+
Git remote
+
{{ status.git_remote }}
+
Update command
+
{{ status.update_command }}
+
+
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+
+ + {% if action_result %} +
+

Last Action Result

+
+
Status
+
{% if action_result.ok %}ok{% else %}failed{% endif %}
+
Exit code
+
{{ action_result.exit_code }}
+
Command
+
{{ action_result.command|join:" " }}
+
+ {% if action_result.stdout %} +

Stdout

+
{{ action_result.stdout }}
+ {% endif %} + {% if action_result.stderr %} +

Stderr

+
{{ action_result.stderr }}
+ {% endif %} +
+ {% endif %} +{% endblock %} diff --git a/src/pobsync_backend/tests/test_updater.py b/src/pobsync_backend/tests/test_updater.py new file mode 100644 index 0000000..8a35673 --- /dev/null +++ b/src/pobsync_backend/tests/test_updater.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase, override_settings + +from pobsync_backend import updater + + +class UpdaterTests(SimpleTestCase): + @override_settings( + POBSYNC_UPDATE_RELEASES_URL="https://code.example.test/api/v1/repos/owner/pobsync/releases", + POBSYNC_UPDATE_RELEASES_TOKEN="secret", + ) + def test_fetch_latest_release_reads_first_gitea_release(self) -> None: + response = MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + [ + { + "tag_name": "v1.2.0", + "name": "1.2.0", + "html_url": "https://code.example.test/releases/v1.2.0", + } + ] + ).encode("utf-8") + + with patch("pobsync_backend.updater.urlopen", return_value=response) as urlopen: + release = updater.fetch_latest_release() + + self.assertEqual(release["tag_name"], "v1.2.0") + request = urlopen.call_args.args[0] + self.assertEqual(request.full_url, "https://code.example.test/api/v1/repos/owner/pobsync/releases") + self.assertEqual(request.headers["Authorization"], "token secret") + + @override_settings(POBSYNC_UPDATE_RELEASES_URL="") + def test_collect_update_status_reports_unconfigured_release_check(self) -> None: + with patch("pobsync_backend.updater._git_status", return_value={"branch": "master"}): + status = updater.collect_update_status(check_release=True) + + self.assertFalse(status["release_check_configured"]) + self.assertEqual(status["release_error"], "POBSYNC_UPDATE_RELEASES_URL is not configured.") + self.assertIsNone(status["update_available"]) + + @override_settings(POBSYNC_UPDATE_GIT_REMOTE="upstream") + def test_run_git_fetch_uses_configured_remote(self) -> None: + completed = MagicMock(returncode=0, stdout="ok", stderr="") + with patch("pobsync_backend.updater.subprocess.run", return_value=completed) as run: + result = updater.run_git_fetch() + + self.assertTrue(result.ok) + self.assertEqual(result.command, ["git", "fetch", "--tags", "--prune", "upstream"]) + run.assert_called_once() + + @override_settings(POBSYNC_UPDATE_GIT_REMOTE="origin") + def test_run_git_pull_rejects_detached_checkout(self) -> None: + with patch("pobsync_backend.updater._git_current_branch", return_value=""): + result = updater.run_git_pull() + + self.assertFalse(result.ok) + self.assertEqual(result.exit_code, 2) + self.assertIn("not on a branch", result.stderr) + + @override_settings(POBSYNC_UPDATE_COMMAND="sudo -n scripts/update-systemd --verbose") + def test_run_native_update_splits_configured_command(self) -> None: + completed = MagicMock(returncode=1, stdout="", stderr="sudo failed") + with patch("pobsync_backend.updater.subprocess.run", return_value=completed): + result = updater.run_native_update() + + self.assertFalse(result.ok) + self.assertEqual(result.command, ["sudo", "-n", "scripts/update-systemd", "--verbose"]) + self.assertEqual(result.stderr, "sudo failed") diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index cf7d83b..f9dd32c 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -62,6 +62,7 @@ class ViewTests(TestCase): self.assertContains(response, reverse("ssh_credentials")) self.assertContains(response, reverse("notification_targets")) self.assertContains(response, reverse("logs")) + self.assertContains(response, reverse("updater")) self.assertContains(response, reverse("purged_snapshots")) self.assertContains(response, reverse("self_check")) self.assertContains(response, reverse("changelog")) @@ -82,6 +83,7 @@ class ViewTests(TestCase): self.assertNotContains(response, reverse("ssh_credentials")) self.assertNotContains(response, reverse("notification_targets")) self.assertNotContains(response, reverse("logs")) + self.assertNotContains(response, reverse("updater")) self.assertNotContains(response, reverse("self_check")) self.assertNotContains(response, reverse("admin:index")) @@ -135,6 +137,7 @@ class ViewTests(TestCase): blocked_urls = [ reverse("ssh_credentials"), reverse("logs"), + reverse("updater"), reverse("self_check"), reverse("edit_global_config"), reverse("create_host_config"), @@ -147,6 +150,90 @@ class ViewTests(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 403) + def test_updater_view_renders_status(self) -> None: + self.client.force_login(self.staff_user) + + with patch("pobsync_backend.views.collect_update_status") as collect_update_status: + collect_update_status.return_value = { + "app_dir": "/opt/pobsync/app", + "installed_version": "1.1.0", + "release_check_configured": True, + "update_command": "sudo -n scripts/update-systemd", + "git_remote": "origin", + "git": {"branch": "master", "commit": "abc1234", "describe": "v1.1.0"}, + "latest_release": None, + "release_error": "", + "update_available": None, + } + + response = self.client.get(reverse("updater")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Updater") + self.assertContains(response, "1.1.0") + self.assertContains(response, "master") + collect_update_status.assert_called_once_with(check_release=False) + + def test_updater_check_release_requests_release_status(self) -> None: + self.client.force_login(self.staff_user) + + with patch("pobsync_backend.views.collect_update_status") as collect_update_status: + collect_update_status.return_value = { + "app_dir": "/opt/pobsync/app", + "installed_version": "1.1.0", + "release_check_configured": True, + "update_command": "sudo -n scripts/update-systemd", + "git_remote": "origin", + "git": {"branch": "master", "commit": "abc1234", "describe": "v1.1.0"}, + "latest_release": {"tag_name": "v1.2.0", "html_url": "https://code.example.test/releases/v1.2.0"}, + "release_error": "", + "update_available": True, + } + + response = self.client.post(reverse("updater"), {"action": "check_release"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "update available") + self.assertContains(response, "v1.2.0") + collect_update_status.assert_called_once_with(check_release=True) + + def test_updater_git_fetch_runs_action_and_renders_output(self) -> None: + self.client.force_login(self.staff_user) + + result = type( + "Result", + (), + { + "ok": True, + "exit_code": 0, + "command": ["git", "fetch", "--tags", "--prune", "origin"], + "stdout": "fetched", + "stderr": "", + }, + )() + with patch("pobsync_backend.views.run_git_fetch", return_value=result) as run_git_fetch, patch( + "pobsync_backend.views.collect_update_status" + ) as collect_update_status: + collect_update_status.return_value = { + "app_dir": "/opt/pobsync/app", + "installed_version": "1.1.0", + "release_check_configured": False, + "update_command": "sudo -n scripts/update-systemd", + "git_remote": "origin", + "git": {"branch": "master", "commit": "abc1234", "describe": "v1.1.0"}, + "latest_release": None, + "release_error": "", + "update_available": None, + } + + response = self.client.post(reverse("updater"), {"action": "git_fetch"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "fetched") + self.assertContains(response, "Updater action completed successfully.") + run_git_fetch.assert_called_once_with() + collect_update_status.assert_called_once_with(check_release=True) + def test_readonly_host_detail_hides_backup_controls_and_sensitive_config(self) -> None: self.client.force_login(self.readonly_user) GlobalConfig.objects.create(name="default", backup_root="/backups") diff --git a/src/pobsync_backend/updater.py b/src/pobsync_backend/updater.py new file mode 100644 index 0000000..2247f5b --- /dev/null +++ b/src/pobsync_backend/updater.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from django.conf import settings + +from pobsync import __version__ + + +@dataclass(frozen=True) +class CommandResult: + command: list[str] + exit_code: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.exit_code == 0 + + +def collect_update_status(*, check_release: bool = False) -> dict[str, Any]: + app_dir = Path(settings.BASE_DIR) + status: dict[str, Any] = { + "app_dir": app_dir, + "installed_version": __version__, + "release_check_configured": bool(settings.POBSYNC_UPDATE_RELEASES_URL), + "update_command": settings.POBSYNC_UPDATE_COMMAND, + "git_remote": settings.POBSYNC_UPDATE_GIT_REMOTE, + "git": _git_status(app_dir), + "latest_release": None, + "release_error": "", + "update_available": None, + } + + if check_release: + if not settings.POBSYNC_UPDATE_RELEASES_URL: + status["release_error"] = "POBSYNC_UPDATE_RELEASES_URL is not configured." + else: + try: + latest_release = fetch_latest_release() + status["latest_release"] = latest_release + status["update_available"] = _version_key(latest_release.get("tag_name", "")) != _version_key(__version__) + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError, ValueError) as exc: + status["release_error"] = str(exc) + + return status + + +def fetch_latest_release() -> dict[str, Any]: + request = Request(settings.POBSYNC_UPDATE_RELEASES_URL, headers={"Accept": "application/json"}) + if settings.POBSYNC_UPDATE_RELEASES_TOKEN: + request.add_header("Authorization", f"token {settings.POBSYNC_UPDATE_RELEASES_TOKEN}") + + with urlopen(request, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8")) + + if isinstance(payload, list): + if not payload: + raise ValueError("No releases were returned.") + release = payload[0] + elif isinstance(payload, dict): + release = payload + else: + raise ValueError("Release endpoint returned an unexpected payload.") + + if not isinstance(release, dict): + raise ValueError("Release endpoint returned an unexpected release entry.") + return release + + +def run_git_fetch() -> CommandResult: + remote = settings.POBSYNC_UPDATE_GIT_REMOTE + return _run_command(["git", "fetch", "--tags", "--prune", remote]) + + +def run_git_pull() -> CommandResult: + remote = settings.POBSYNC_UPDATE_GIT_REMOTE + branch = _git_current_branch(Path(settings.BASE_DIR)) + if not branch: + return CommandResult( + command=["git", "pull", "--ff-only", remote], + exit_code=2, + stdout="", + stderr="Cannot pull automatically because the installed checkout is not on a branch.", + ) + return _run_command(["git", "pull", "--ff-only", remote, branch]) + + +def run_native_update() -> CommandResult: + return _run_command(shlex.split(settings.POBSYNC_UPDATE_COMMAND)) + + +def _run_command(command: list[str]) -> CommandResult: + completed = subprocess.run( + command, + cwd=settings.BASE_DIR, + capture_output=True, + check=False, + text=True, + timeout=600, + ) + return CommandResult( + command=command, + exit_code=completed.returncode, + stdout=completed.stdout[-6000:], + stderr=completed.stderr[-6000:], + ) + + +def _git_status(app_dir: Path) -> dict[str, str]: + return { + "branch": _git_current_branch(app_dir), + "commit": _git_output(app_dir, ["git", "rev-parse", "--short", "HEAD"]), + "describe": _git_output(app_dir, ["git", "describe", "--tags", "--always", "--dirty"]), + } + + +def _git_current_branch(app_dir: Path) -> str: + branch = _git_output(app_dir, ["git", "branch", "--show-current"]) + return branch or _git_output(app_dir, ["git", "rev-parse", "--abbrev-ref", "HEAD"]) + + +def _git_output(app_dir: Path, command: list[str]) -> str: + try: + completed = subprocess.run( + command, + cwd=app_dir, + capture_output=True, + check=False, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + if completed.returncode != 0: + return "" + return completed.stdout.strip() + + +def _version_key(value: str) -> str: + return value.strip().removeprefix("v") diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index edaf30e..c8144a5 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -57,6 +57,7 @@ from .scheduler import next_due_after from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key, merge_known_hosts, scan_known_host from .stats_summary import collect_dashboard_stats, collect_host_stats +from .updater import collect_update_status, run_git_fetch, run_git_pull, run_native_update @status_view_required @@ -333,6 +334,40 @@ def logs(request): return render(request, "pobsync_backend/logs.html", context) +@control_panel_admin_required +def updater(request): + action_result = None + check_release = request.method == "POST" and request.POST.get("action") == "check_release" + if request.method == "POST": + action = request.POST.get("action") + if action == "git_fetch": + action_result = run_git_fetch() + check_release = True + elif action == "git_pull": + action_result = run_git_pull() + check_release = True + elif action == "run_update": + action_result = run_native_update() + check_release = True + elif action != "check_release": + messages.error(request, "Unknown updater action.") + + if action_result is not None: + if action_result.ok: + messages.success(request, "Updater action completed successfully.") + else: + messages.error(request, f"Updater action failed with exit code {action_result.exit_code}.") + + return render( + request, + "pobsync_backend/updater.html", + { + "status": collect_update_status(check_release=check_release), + "action_result": action_result, + }, + ) + + @control_panel_admin_required def notification_targets(request): targets = NotificationTarget.objects.order_by("name") diff --git a/src/pobsync_server/settings.py b/src/pobsync_server/settings.py index b58632e..3fe315e 100644 --- a/src/pobsync_server/settings.py +++ b/src/pobsync_server/settings.py @@ -105,3 +105,7 @@ POBSYNC_BACKUP_ROOT = os.getenv("POBSYNC_BACKUP_ROOT", "/backups") POBSYNC_ENV_FILE = os.getenv("POBSYNC_ENV_FILE", "/etc/pobsync/pobsync.env") POBSYNC_SERVICE_USER = os.getenv("POBSYNC_SERVICE_USER", "pobsync") POBSYNC_SERVICE_GROUP = os.getenv("POBSYNC_SERVICE_GROUP", "pobsync") +POBSYNC_UPDATE_RELEASES_URL = os.getenv("POBSYNC_UPDATE_RELEASES_URL", "") +POBSYNC_UPDATE_RELEASES_TOKEN = os.getenv("POBSYNC_UPDATE_RELEASES_TOKEN", "") +POBSYNC_UPDATE_GIT_REMOTE = os.getenv("POBSYNC_UPDATE_GIT_REMOTE", "origin") +POBSYNC_UPDATE_COMMAND = os.getenv("POBSYNC_UPDATE_COMMAND", "sudo -n scripts/update-systemd") diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 7f2a561..d85e7c1 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path("changelog/", views.changelog, name="changelog"), path("self-check/", views.self_check, name="self_check"), path("logs/", views.logs, name="logs"), + path("updater/", views.updater, name="updater"), path("notifications/", views.notification_targets, name="notification_targets"), path("notifications/new/", views.create_notification_target, name="create_notification_target"), path("notifications//", views.edit_notification_target, name="edit_notification_target"),