(feature) Add staff updater page #73
16
README.md
16
README.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
122
src/pobsync_backend/templates/pobsync_backend/updater.html
Normal file
122
src/pobsync_backend/templates/pobsync_backend/updater.html
Normal 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 %}
|
||||||
72
src/pobsync_backend/tests/test_updater.py
Normal file
72
src/pobsync_backend/tests/test_updater.py
Normal 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")
|
||||||
@@ -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")
|
||||||
|
|||||||
149
src/pobsync_backend/updater.py
Normal file
149
src/pobsync_backend/updater.py
Normal 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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user