4 Commits

Author SHA1 Message Date
51142081c9 Merge pull request '(release) Prepare 1.2.0' (#74) from release-1.2 into master
Reviewed-on: #74
2026-05-28 22:19:23 +02:00
02616eebbc (release) Prepare 1.2.0
Bump the package version to 1.2.0 and document the operations-focused release
with updater, readonly access, notifications, bandwidth controls, live progress,
and more robust retention cleanup.

Make the CLI version test assert against the package version so future release
bumps do not require changing a hardcoded expected value.
2026-05-28 22:19:03 +02:00
a61e3d8302 Merge pull request '(feature) Add staff updater page' (#73) from issue-39-updater-ui into master
Reviewed-on: #73
2026-05-28 22:11:07 +02:00
0450f8bdb0 (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.
2026-05-28 22:10:45 +02:00
15 changed files with 549 additions and 3 deletions

View File

@@ -1,5 +1,34 @@
# Changelog # Changelog
## 1.2.0 - 2026-05-28
Operations-focused release for more reliable production backups and maintenance.
### Added
- Staff-only updater page for checking configured Gitea releases, inspecting the installed git checkout, fetching tags, pulling the current branch, and running the native systemd updater.
- Read-only control panel access level for authenticated non-staff users, with status pages visible and credentials, logs, configs, retention, and mutating actions kept staff-only.
- Run completion notifications for email and webhooks, including recorded delivery history per run and target.
- Dedicated hosts page with host cards, enabled/disabled filtering, and quick host/schedule/retention state controls.
- Per-host rsync bandwidth limit overrides with inherit, unlimited, and explicit limit semantics.
- Backup data totals by snapshot kind on dashboard and host detail pages, including unique/non-hardlinked data totals.
### Changed
- Real backup runs now default to verbose rsync progress output so the live run view behaves consistently with dry-runs.
- Run progress panels are shared between dry-runs and real runs for more consistent status, timing, cancellation, and log display.
- Incomplete snapshot cleanup now requires operator review before deletion.
- Incomplete snapshot size reporting now prefers on-disk measurement when metadata is stale or missing.
- Installer and environment examples now include optional updater configuration.
### Fixed
- Remote preflight shell commands are now quoted correctly, including roots such as `/`.
- Worker reconciliation now detects real rsync failures and stale/running process state more reliably.
- Retention pruning and incomplete cleanup can delete snapshots containing restrictive directory modes preserved by rsync archive mode.
- Snapshot data summaries no longer count incomplete metadata/log files as backup data when measuring from disk.
- Filesystem SSH credential tests use writable test state without changing production defaults.
## 1.1.0 - 2026-05-21 ## 1.1.0 - 2026-05-21
UI-focused release for the Django control panel. UI-focused release for the Django control panel.

View File

@@ -158,6 +158,7 @@ The UI includes:
- Django-managed SSH keys - Django-managed SSH keys
- `/self-check/` for runtime checks - `/self-check/` for runtime checks
- `/logs/` for filtered pobsync service logs - `/logs/` for filtered pobsync service logs
- `/updater/` for checking Gitea releases, pulling the git checkout, and running the native updater
## Bandwidth Limits ## 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 Python dependencies, runs migrations, collects static files, and restarts the systemd services so new Django code is
loaded. 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 Use the full installer again when you intentionally want to change install-time settings, install OS packages, enable
nginx, or rewrite the environment file: nginx, or rewrite the environment file:

View File

@@ -16,3 +16,11 @@ POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120 POBSYNC_GUNICORN_TIMEOUT=120
POBSYNC_WORKER_INTERVAL=15 POBSYNC_WORKER_INTERVAL=15
POBSYNC_SCHEDULER_INTERVAL=60 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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pobsync" name = "pobsync"
version = "1.1.0" version = "1.2.0"
description = "Pull-based rsync backup tool with hardlinked snapshots" description = "Pull-based rsync backup tool with hardlinked snapshots"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -472,6 +472,10 @@ POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120 POBSYNC_GUNICORN_TIMEOUT=120
POBSYNC_WORKER_INTERVAL=15 POBSYNC_WORKER_INTERVAL=15
POBSYNC_SCHEDULER_INTERVAL=60 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 EOF
chmod 0640 "$ENV_FILE" chmod 0640 "$ENV_FILE"
chown "root:$SERVICE_GROUP" "$ENV_FILE" chown "root:$SERVICE_GROUP" "$ENV_FILE"

View File

@@ -1,2 +1,2 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "1.1.0" __version__ = "1.2.0"

View File

@@ -275,6 +275,23 @@
.status.skipped { color: var(--muted); background: #f7f9fb; } .status.skipped { color: var(--muted); background: #f7f9fb; }
.stack { display: grid; gap: 5px; } .stack { display: grid; gap: 5px; }
.stack.spaced { margin-bottom: 14px; } .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); } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.panel-grid { .panel-grid {
display: grid; display: grid;
@@ -923,6 +940,7 @@
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a> <a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
<a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a> <a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a>
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a> <a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
<a href="{% url 'updater' %}" {% if request.resolver_match.url_name == "updater" %}aria-current="page"{% endif %}>Updater</a>
{% endif %} {% endif %}
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a> <a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
</span> </span>

View File

@@ -0,0 +1,122 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Updater | pobsync{% endblock %}
{% block content %}
<header class="page-header">
<div class="page-title">
<div class="page-kicker">Operations</div>
<h1>Updater</h1>
<div class="page-subtitle">Check Gitea releases, pull the installed git checkout, and run the native systemd updater.</div>
</div>
<section class="actions" aria-label="Updater actions">
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
</header>
<section class="panel-grid">
<section class="panel">
<h2>Installed App</h2>
<dl class="detail-list">
<dt>Version</dt>
<dd>{{ status.installed_version }}</dd>
<dt>Git branch</dt>
<dd>{{ status.git.branch|default:"unknown" }}</dd>
<dt>Git commit</dt>
<dd>{{ status.git.commit|default:"unknown" }}</dd>
<dt>Git describe</dt>
<dd>{{ status.git.describe|default:"unknown" }}</dd>
<dt>App directory</dt>
<dd>{{ status.app_dir }}</dd>
</dl>
</section>
<section class="panel">
<h2>Release Check</h2>
<dl class="detail-list">
<dt>Status</dt>
<dd>
{% if status.update_available == True %}
<span class="status warning">update available</span>
{% elif status.update_available == False %}
<span class="status ok">up to date</span>
{% elif status.release_check_configured %}
<span class="status skipped">not checked</span>
{% else %}
<span class="status skipped">not configured</span>
{% endif %}
</dd>
<dt>Latest release</dt>
<dd>
{% if status.latest_release %}
{% if status.latest_release.html_url %}
<a href="{{ status.latest_release.html_url }}">
{% if status.latest_release.tag_name %}{{ status.latest_release.tag_name }}{% else %}{{ status.latest_release.name }}{% endif %}
</a>
{% else %}
{% if status.latest_release.tag_name %}{{ status.latest_release.tag_name }}{% else %}{{ status.latest_release.name }}{% endif %}
{% endif %}
{% else %}
none
{% endif %}
</dd>
<dt>Release endpoint</dt>
<dd>{% if status.release_check_configured %}configured{% else %}set POBSYNC_UPDATE_RELEASES_URL{% endif %}</dd>
</dl>
{% if status.release_error %}
<p class="status failed">{{ status.release_error }}</p>
{% endif %}
<form method="post" class="actions inline">
{% csrf_token %}
<button type="submit" name="action" value="check_release">Check releases</button>
</form>
</section>
</section>
<section class="panel">
<h2>Update Actions</h2>
<p class="muted">Run these from the installed checkout. The native updater may require a sudoers rule for the pobsync service user.</p>
<dl class="detail-list">
<dt>Git remote</dt>
<dd>{{ status.git_remote }}</dd>
<dt>Update command</dt>
<dd><code>{{ status.update_command }}</code></dd>
</dl>
<div class="actions">
<form method="post" class="inline-form">
{% csrf_token %}
<button class="secondary" type="submit" name="action" value="git_fetch">Fetch releases</button>
</form>
<form method="post" class="inline-form">
{% csrf_token %}
<button class="secondary" type="submit" name="action" value="git_pull">Pull current branch</button>
</form>
<form method="post" class="inline-form">
{% csrf_token %}
<button type="submit" name="action" value="run_update">Run native updater</button>
</form>
</div>
</section>
{% if action_result %}
<section class="panel">
<h2>Last Action Result</h2>
<dl class="detail-list">
<dt>Status</dt>
<dd><span class="status {% if action_result.ok %}ok{% else %}failed{% endif %}">{% if action_result.ok %}ok{% else %}failed{% endif %}</span></dd>
<dt>Exit code</dt>
<dd>{{ action_result.exit_code }}</dd>
<dt>Command</dt>
<dd><code>{{ action_result.command|join:" " }}</code></dd>
</dl>
{% if action_result.stdout %}
<h3>Stdout</h3>
<pre>{{ action_result.stdout }}</pre>
{% endif %}
{% if action_result.stderr %}
<h3>Stderr</h3>
<pre>{{ action_result.stderr }}</pre>
{% endif %}
</section>
{% endif %}
{% endblock %}

View File

@@ -5,6 +5,7 @@ from unittest.mock import patch
from django.test import SimpleTestCase from django.test import SimpleTestCase
from pobsync import __version__
from pobsync.cli import main from pobsync.cli import main
@@ -15,7 +16,7 @@ class ConsoleEntrypointTests(SimpleTestCase):
exit_code = main(["--version"]) exit_code = main(["--version"])
self.assertEqual(exit_code, 0) self.assertEqual(exit_code, 0)
self.assertEqual(stdout.getvalue().strip(), "pobsync 1.1.0") self.assertEqual(stdout.getvalue().strip(), f"pobsync {__version__}")
def test_maps_backup_alias_to_django_command(self) -> None: def test_maps_backup_alias_to_django_command(self) -> None:
with patch("pobsync.cli.execute_from_command_line") as execute: with patch("pobsync.cli.execute_from_command_line") as execute:

View File

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

View File

@@ -62,6 +62,7 @@ class ViewTests(TestCase):
self.assertContains(response, reverse("ssh_credentials")) self.assertContains(response, reverse("ssh_credentials"))
self.assertContains(response, reverse("notification_targets")) self.assertContains(response, reverse("notification_targets"))
self.assertContains(response, reverse("logs")) self.assertContains(response, reverse("logs"))
self.assertContains(response, reverse("updater"))
self.assertContains(response, reverse("purged_snapshots")) self.assertContains(response, reverse("purged_snapshots"))
self.assertContains(response, reverse("self_check")) self.assertContains(response, reverse("self_check"))
self.assertContains(response, reverse("changelog")) self.assertContains(response, reverse("changelog"))
@@ -82,6 +83,7 @@ class ViewTests(TestCase):
self.assertNotContains(response, reverse("ssh_credentials")) self.assertNotContains(response, reverse("ssh_credentials"))
self.assertNotContains(response, reverse("notification_targets")) self.assertNotContains(response, reverse("notification_targets"))
self.assertNotContains(response, reverse("logs")) self.assertNotContains(response, reverse("logs"))
self.assertNotContains(response, reverse("updater"))
self.assertNotContains(response, reverse("self_check")) self.assertNotContains(response, reverse("self_check"))
self.assertNotContains(response, reverse("admin:index")) self.assertNotContains(response, reverse("admin:index"))
@@ -135,6 +137,7 @@ class ViewTests(TestCase):
blocked_urls = [ blocked_urls = [
reverse("ssh_credentials"), reverse("ssh_credentials"),
reverse("logs"), reverse("logs"),
reverse("updater"),
reverse("self_check"), reverse("self_check"),
reverse("edit_global_config"), reverse("edit_global_config"),
reverse("create_host_config"), reverse("create_host_config"),
@@ -147,6 +150,90 @@ class ViewTests(TestCase):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 403) 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: def test_readonly_host_detail_hides_backup_controls_and_sensitive_config(self) -> None:
self.client.force_login(self.readonly_user) self.client.force_login(self.readonly_user)
GlobalConfig.objects.create(name="default", backup_root="/backups") GlobalConfig.objects.create(name="default", backup_root="/backups")

View File

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

View File

@@ -57,6 +57,7 @@ from .scheduler import next_due_after
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery 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 .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 .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 @status_view_required
@@ -333,6 +334,40 @@ def logs(request):
return render(request, "pobsync_backend/logs.html", context) 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 @control_panel_admin_required
def notification_targets(request): def notification_targets(request):
targets = NotificationTarget.objects.order_by("name") targets = NotificationTarget.objects.order_by("name")

View File

@@ -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_ENV_FILE = os.getenv("POBSYNC_ENV_FILE", "/etc/pobsync/pobsync.env")
POBSYNC_SERVICE_USER = os.getenv("POBSYNC_SERVICE_USER", "pobsync") POBSYNC_SERVICE_USER = os.getenv("POBSYNC_SERVICE_USER", "pobsync")
POBSYNC_SERVICE_GROUP = os.getenv("POBSYNC_SERVICE_GROUP", "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")

View File

@@ -13,6 +13,7 @@ urlpatterns = [
path("changelog/", views.changelog, name="changelog"), path("changelog/", views.changelog, name="changelog"),
path("self-check/", views.self_check, name="self_check"), path("self-check/", views.self_check, name="self_check"),
path("logs/", views.logs, name="logs"), path("logs/", views.logs, name="logs"),
path("updater/", views.updater, name="updater"),
path("notifications/", views.notification_targets, name="notification_targets"), path("notifications/", views.notification_targets, name="notification_targets"),
path("notifications/new/", views.create_notification_target, name="create_notification_target"), path("notifications/new/", views.create_notification_target, name="create_notification_target"),
path("notifications/<int:target_id>/", views.edit_notification_target, name="edit_notification_target"), path("notifications/<int:target_id>/", views.edit_notification_target, name="edit_notification_target"),