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"),