diff --git a/docs/cloud_backup.md b/docs/cloud_backup.md index 2edd26b..4924477 100644 --- a/docs/cloud_backup.md +++ b/docs/cloud_backup.md @@ -11,6 +11,9 @@ The Cloud Backup page manages cloud backup settings, schedules, and status. ## Backup Schedule & Bandwidth - **Backup Time**: Set the daily backup time in two-digit 24-hour `HH:MM` format. - **Bandwidth Limit**: (Optional) Limit backup bandwidth (e.g., 4M for 4 MB/s). +- **Healthchecks Success Ping URL**: (Optional) Ping a Healthchecks.io check only after the + cloud backup finishes successfully. Failed backups, drive checks, DDNS updates, and other tasks do + not send this ping. - **Save**: Button to save schedule settings by disabling during the request. - **Error/Success Feedback**: Inline messages for save actions. @@ -37,6 +40,9 @@ Fake mode avoids local system changes, but cloud-backup provider calls can still credentials and destinations are configured. Use a test destination when developing against a real provider from fake mode. +If a Healthchecks success ping URL is configured in fake mode, a successful fake Cloud Backup run can +send the same success ping. Use a test Healthchecks check when developing. + - **Save**: Button to save backup configuration by disabling during the request. - **Error/Success Feedback**: Inline messages for save actions. diff --git a/docs/manual_install.md b/docs/manual_install.md index 0d5ed3c..2cd031f 100644 --- a/docs/manual_install.md +++ b/docs/manual_install.md @@ -76,7 +76,7 @@ The app environment is `/opt/SimpleSaferServer/.venv`. Do not use distro Python ```bash sudo mkdir -p /usr/local/bin sudo cp scripts/* /usr/local/bin/ -sudo chmod +x /usr/local/bin/check_mount.sh /usr/local/bin/check_health.sh /usr/local/bin/check_health.py /usr/local/bin/backup_cloud.sh /usr/local/bin/app_update.sh /usr/local/bin/app_update.py /usr/local/bin/log_alert.py /usr/local/bin/ddns_update.sh /usr/local/bin/ddns_update.py /usr/local/bin/restore_disabled_timers.py +sudo chmod +x /usr/local/bin/check_mount.sh /usr/local/bin/check_health.sh /usr/local/bin/check_health.py /usr/local/bin/backup_cloud.sh /usr/local/bin/ping_healthchecks.py /usr/local/bin/app_update.sh /usr/local/bin/app_update.py /usr/local/bin/log_alert.py /usr/local/bin/ddns_update.sh /usr/local/bin/ddns_update.py /usr/local/bin/restore_disabled_timers.py ``` ## 7. Prepare Samba Layout diff --git a/scripts/backup_cloud.sh b/scripts/backup_cloud.sh index efda971..ef8a82f 100644 --- a/scripts/backup_cloud.sh +++ b/scripts/backup_cloud.sh @@ -1,7 +1,8 @@ #!/bin/bash -CONFIG_FILE="/etc/SimpleSaferServer/config.conf" -PYTHON_BIN="/opt/SimpleSaferServer/.venv/bin/python" +CONFIG_FILE="${SSS_CONFIG_FILE:-/etc/SimpleSaferServer/config.conf}" +PYTHON_BIN="${SSS_PYTHON_BIN:-/opt/SimpleSaferServer/.venv/bin/python}" +SCRIPTS_DIR="${SSS_SCRIPTS_DIR:-/opt/SimpleSaferServer/scripts}" if [ ! -x "$PYTHON_BIN" ]; then echo "Missing SimpleSaferServer Python environment at $PYTHON_BIN" >&2 @@ -24,6 +25,7 @@ EMAIL_ADDRESS=$(get_config_value backup email_address) SERVER_NAME=$(get_config_value system server_name) RCLONE_DIR=$(get_config_value backup rclone_dir) BANDWIDTH_LIMIT=$(get_config_value backup bandwidth_limit) +HEALTHCHECKS_PING_URL=$(get_config_value backup healthchecks_ping_url) # Function to send email and log alert function send_email { @@ -33,6 +35,19 @@ function send_email { "$PYTHON_BIN" /opt/SimpleSaferServer/scripts/log_alert.py "$1" "$2" "error" "backup_cloud" } +function ping_healthchecks_success { + if [ -z "$HEALTHCHECKS_PING_URL" ]; then + return 0 + fi + # The ping URL contains the Healthchecks check UUID. Pass it on stdin so it + # does not appear in process arguments or journal lines. + if printf '%s' "$HEALTHCHECKS_PING_URL" | "$PYTHON_BIN" "$SCRIPTS_DIR/ping_healthchecks.py"; then + echo "Healthchecks.io success ping sent." + else + echo "Healthchecks.io success ping failed." >&2 + fi +} + echo "Starting cloud backup process..." # Check if drive is mounted @@ -73,5 +88,6 @@ if ! rclone sync "$MOUNT_POINT" "$RCLONE_DIR" --create-empty-src-dirs -v "${extr exit 1 fi +ping_healthchecks_success echo "Cloud backup completed successfully" exit 0 diff --git a/scripts/ping_healthchecks.py b/scripts/ping_healthchecks.py new file mode 100644 index 0000000..e13f2df --- /dev/null +++ b/scripts/ping_healthchecks.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +import sys + +from simple_safer_server.services.healthchecks import ( + HealthchecksPingError, + ping_healthchecks_url, +) + + +def main() -> int: + url = sys.stdin.read().strip() + if not url: + return 0 + try: + ping_healthchecks_url(url) + except (HealthchecksPingError, ValueError) as exc: + print(f"Healthchecks.io success ping failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/simple_safer_server/services/cloud_backup_service.py b/simple_safer_server/services/cloud_backup_service.py index 4397bf3..9830fea 100644 --- a/simple_safer_server/services/cloud_backup_service.py +++ b/simple_safer_server/services/cloud_backup_service.py @@ -6,6 +6,7 @@ from typing import Any from simple_safer_server.adapters.command_runner import PIPE, CommandRunner +from simple_safer_server.services.healthchecks import normalize_healthchecks_ping_url from simple_safer_server.services.schedule_time import ( ScheduleTimeError, normalize_ui_schedule_time, @@ -73,6 +74,7 @@ def get_config(self) -> dict[str, Any]: "mega_folder": backup.get("mega_folder", ""), "rclone_dir": backup.get("rclone_dir", ""), "bandwidth_limit": backup.get("bandwidth_limit", ""), + "healthchecks_ping_url": backup.get("healthchecks_ping_url", ""), "backup_cloud_time": schedule.get("backup_cloud_time", ""), } rclone_conf_path = self._runtime.rclone_config_dir / "rclone.conf" @@ -199,12 +201,20 @@ def get_schedule(self) -> dict[str, Any]: return { "backup_cloud_time": schedule.get("backup_cloud_time", ""), "bandwidth_limit": backup.get("bandwidth_limit", ""), + "healthchecks_ping_url": backup.get("healthchecks_ping_url", ""), } def save_schedule(self, data: dict[str, Any]) -> dict[str, Any]: backup_time = data.get("backup_cloud_time") has_bandwidth_limit = "bandwidth_limit" in data bandwidth_limit = normalize_bandwidth_limit(data.get("bandwidth_limit")) + has_healthchecks_ping_url = "healthchecks_ping_url" in data + try: + healthchecks_ping_url = normalize_healthchecks_ping_url( + data.get("healthchecks_ping_url") + ) + except ValueError as exc: + raise ValidationProblem(str(exc)) from exc if backup_time: try: backup_time = normalize_ui_schedule_time(backup_time) @@ -216,25 +226,34 @@ def save_schedule(self, data: dict[str, Any]) -> dict[str, Any]: self._config_manager.set_value("schedule", "backup_cloud_time", backup_time) if has_bandwidth_limit: self._config_manager.set_value("backup", "bandwidth_limit", bandwidth_limit) + if has_healthchecks_ping_url: + self._config_manager.set_value( + "backup", "healthchecks_ping_url", healthchecks_ping_url + ) return {} - config = self._config_manager.get_all_config() - if backup_time: - config.setdefault("schedule", {})["backup_cloud_time"] = backup_time - if has_bandwidth_limit: - config.setdefault("backup", {})["bandwidth_limit"] = bandwidth_limit - ok, err = self._system_utils.create_systemd_config_file(config) - if not ok: - raise OperationProblem(f"Failed to update systemd config: {err}") + if backup_time or has_bandwidth_limit: + config = self._config_manager.get_all_config() + if backup_time: + config.setdefault("schedule", {})["backup_cloud_time"] = backup_time + if has_bandwidth_limit: + config.setdefault("backup", {})["bandwidth_limit"] = bandwidth_limit + if has_healthchecks_ping_url: + config.setdefault("backup", {})["healthchecks_ping_url"] = healthchecks_ping_url + ok, err = self._system_utils.create_systemd_config_file(config) + if not ok: + raise OperationProblem(f"Failed to update systemd config: {err}") - ok, err = self._system_utils.install_systemd_services_and_timers(config) - if not ok: - raise OperationProblem(f"Failed to update systemd timers: {err}") + ok, err = self._system_utils.install_systemd_services_and_timers(config) + if not ok: + raise OperationProblem(f"Failed to update systemd timers: {err}") if backup_time: self._config_manager.set_value("schedule", "backup_cloud_time", backup_time) if has_bandwidth_limit: self._config_manager.set_value("backup", "bandwidth_limit", bandwidth_limit) + if has_healthchecks_ping_url: + self._config_manager.set_value("backup", "healthchecks_ping_url", healthchecks_ping_url) return {} def validate_mega(self, data: dict[str, Any]) -> None: diff --git a/simple_safer_server/services/config_manager.py b/simple_safer_server/services/config_manager.py index f6426eb..484b71e 100644 --- a/simple_safer_server/services/config_manager.py +++ b/simple_safer_server/services/config_manager.py @@ -101,6 +101,7 @@ def _default_config_parser(self): 'mount_point': self.runtime.default_mount_point, 'rclone_dir': '', 'bandwidth_limit': '', + 'healthchecks_ping_url': '', } config['schedule'] = {'backup_cloud_time': '03:00'} diff --git a/simple_safer_server/services/healthchecks.py b/simple_safer_server/services/healthchecks.py new file mode 100644 index 0000000..817dc0d --- /dev/null +++ b/simple_safer_server/services/healthchecks.py @@ -0,0 +1,51 @@ +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +HEALTHCHECKS_TIMEOUT_SECONDS = 10 + + +class HealthchecksPingError(RuntimeError): + """Raised when a configured Healthchecks.io ping could not be sent.""" + + +def normalize_healthchecks_ping_url(value) -> str: + """Return a safe optional Healthchecks.io ping URL.""" + if value is None: + return "" + url = str(value).strip() + if not url: + return "" + if any(character.isspace() for character in url): + raise ValueError("Healthchecks success ping URL must not contain spaces.") + if len(url) > 2048: + raise ValueError("Healthchecks success ping URL is too long.") + + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Healthchecks success ping URL must start with http:// or https://.") + return url + + +def ping_healthchecks_url(url: str, *, timeout: int = HEALTHCHECKS_TIMEOUT_SECONDS) -> None: + """Ping a Healthchecks.io URL without putting the URL in a command line.""" + normalized_url = normalize_healthchecks_ping_url(url) + if not normalized_url: + return + + request = Request( + normalized_url, + method="GET", + headers={"User-Agent": "SimpleSaferServer cloud-backup"}, + ) + try: + with urlopen(request, timeout=timeout) as response: + status = getattr(response, "status", response.getcode()) + if status >= 400: + raise HealthchecksPingError(f"Healthchecks.io returned HTTP {status}.") + except HTTPError as exc: + raise HealthchecksPingError(f"Healthchecks.io returned HTTP {exc.code}.") from exc + except URLError as exc: + # Do not include the target URL here; the path usually contains the + # Healthchecks check UUID and can end up in service logs. + raise HealthchecksPingError(f"Healthchecks.io request failed: {exc.reason}") from exc diff --git a/simple_safer_server/services/task_service.py b/simple_safer_server/services/task_service.py index c56ab60..24c61d6 100644 --- a/simple_safer_server/services/task_service.py +++ b/simple_safer_server/services/task_service.py @@ -23,6 +23,7 @@ hdsentinel_snapshot_has_health, run_scheduled_drive_health_check, ) +from simple_safer_server.services.healthchecks import HealthchecksPingError, ping_healthchecks_url class Status: @@ -524,6 +525,21 @@ def _run_fake_cloud_backup(self, cancel_event: threading.Event) -> None: raise RuntimeError("Cloud backup was cancelled.") if proc.returncode != 0: raise RuntimeError(output.strip() or "Cloud backup failed.") + self._ping_healthchecks_after_cloud_backup_success(fake_state) + + def _ping_healthchecks_after_cloud_backup_success(self, fake_state: Any) -> None: + ping_url = self.config_manager.get_value("backup", "healthchecks_ping_url", "").strip() + if not ping_url: + return + try: + ping_healthchecks_url(ping_url) + fake_state.append_task_log("Cloud Backup", "Healthchecks.io success ping sent.") + except (HealthchecksPingError, ValueError) as exc: + # A Healthchecks network problem should not turn a completed cloud + # backup into a failed backup. Keep the URL out of fake task logs. + fake_state.append_task_log( + "Cloud Backup", f"Healthchecks.io success ping failed: {exc}" + ) def _start_fake_task(self, task_name: str) -> None: fake_state = self._require_fake_state() diff --git a/static/js/cloud_backup.js b/static/js/cloud_backup.js index 5bc99f5..31d8ef0 100644 --- a/static/js/cloud_backup.js +++ b/static/js/cloud_backup.js @@ -74,6 +74,7 @@ document.addEventListener('DOMContentLoaded', function () { const remoteName = document.getElementById('remoteName'); const backupTime = document.getElementById('backupTime'); const bandwidthLimit = document.getElementById('bandwidthLimit'); + const healthchecksPingUrl = document.getElementById('healthchecksPingUrl'); const scheduleForm = document.getElementById('cloud-backup-schedule-form'); const scheduleSaveBtn = document.getElementById('cloud-backup-schedule-save-btn'); @@ -106,6 +107,7 @@ document.addEventListener('DOMContentLoaded', function () { function fillScheduleForm(cfg) { backupTime.value = (cfg.backup_cloud_time || '').padStart(5, '0'); bandwidthLimit.value = cfg.bandwidth_limit || ''; + healthchecksPingUrl.value = cfg.healthchecks_ping_url || ''; } function loadSchedule() { @@ -124,6 +126,9 @@ document.addEventListener('DOMContentLoaded', function () { if (!backupTime.value) { backupTime.classList.add('is-invalid'); valid = false; } else { backupTime.classList.remove('is-invalid'); } + if (healthchecksPingUrl.value.trim() && !/^https?:\/\/\S+$/.test(healthchecksPingUrl.value.trim())) { + healthchecksPingUrl.classList.add('is-invalid'); valid = false; + } else { healthchecksPingUrl.classList.remove('is-invalid'); } if (!valid) { return; } @@ -133,7 +138,8 @@ document.addEventListener('DOMContentLoaded', function () { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ backup_cloud_time: backupTime.value, - bandwidth_limit: bandwidthLimit.value.trim() + bandwidth_limit: bandwidthLimit.value.trim(), + healthchecks_ping_url: healthchecksPingUrl.value.trim() }) }) .then(() => { diff --git a/templates/cloud_backup.html b/templates/cloud_backup.html index 995ca87..6a79207 100644 --- a/templates/cloud_backup.html +++ b/templates/cloud_backup.html @@ -56,6 +56,12 @@
Please enter a valid bandwidth limit.
+
+ + +
Pinged only after a completed cloud backup.
+
Please enter a valid http:// or https:// URL.
+
diff --git a/tests/test_backup_cloud_script.py b/tests/test_backup_cloud_script.py new file mode 100644 index 0000000..1469d88 --- /dev/null +++ b/tests/test_backup_cloud_script.py @@ -0,0 +1,132 @@ +import os +import subprocess +import textwrap +from pathlib import Path + + +def write_executable(path: Path, text: str) -> None: + path.write_text(textwrap.dedent(text)) + path.chmod(0o755) + + +def make_backup_config(path: Path, *, healthchecks_url: str = "") -> None: + path.write_text( + textwrap.dedent( + f"""\ + [backup] + mount_point = / + from_address = alerts@example.test + email_address = admin@example.test + rclone_dir = remote:/backup + bandwidth_limit = + healthchecks_ping_url = {healthchecks_url} + + [system] + server_name = test-server + """ + ) + ) + + +def run_backup_script(tmp_path: Path, *, rclone_exit: int, healthchecks_url: str): + fake_bin = tmp_path / "bin" + fake_scripts = tmp_path / "scripts" + fake_bin.mkdir() + fake_scripts.mkdir() + config_path = tmp_path / "config.conf" + make_backup_config(config_path, healthchecks_url=healthchecks_url) + + write_executable( + fake_bin / "rclone", + """\ + #!/bin/sh + printf '%s\\n' "$*" > "$RCLONE_ARGS_FILE" + exit "$RCLONE_EXIT" + """, + ) + write_executable( + fake_bin / "msmtp", + """\ + #!/bin/sh + cat >/dev/null + exit 0 + """, + ) + write_executable( + fake_bin / "journalctl", + """\ + #!/bin/sh + printf '%s\\n' 'recent backup logs' + exit 0 + """, + ) + write_executable( + tmp_path / "python", + """\ + #!/bin/sh + case "$1" in + */ping_healthchecks.py) + printf '%s\\n' "$*" > "$PY_ARGS_FILE" + cat > "$PING_STDIN_FILE" + exit "${PING_EXIT:-0}" + ;; + *) + printf '%s\\n' "$*" > "$LOG_ALERT_ARGS_FILE" + exit 0 + ;; + esac + """, + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "SSS_CONFIG_FILE": str(config_path), + "SSS_PYTHON_BIN": str(tmp_path / "python"), + "SSS_SCRIPTS_DIR": str(fake_scripts), + "RCLONE_EXIT": str(rclone_exit), + "RCLONE_ARGS_FILE": str(tmp_path / "rclone.args"), + "PY_ARGS_FILE": str(tmp_path / "python.args"), + "PING_STDIN_FILE": str(tmp_path / "ping.stdin"), + "LOG_ALERT_ARGS_FILE": str(tmp_path / "log-alert.args"), + } + ) + + return subprocess.run( + ["bash", "scripts/backup_cloud.sh"], + cwd=Path(__file__).resolve().parents[1], + text=True, + capture_output=True, + check=False, + env=env, + ) + + +def test_backup_cloud_script_pings_healthchecks_after_success_without_argv_leak(tmp_path): + result = run_backup_script( + tmp_path, + rclone_exit=0, + healthchecks_url="https://hc-ping.com/secret-check-id", + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert (tmp_path / "ping.stdin").read_text() == "https://hc-ping.com/secret-check-id" + assert "ping_healthchecks.py" in (tmp_path / "python.args").read_text() + assert "secret-check-id" not in (tmp_path / "python.args").read_text() + assert "secret-check-id" not in (tmp_path / "rclone.args").read_text() + assert "secret-check-id" not in result.stdout + assert "secret-check-id" not in result.stderr + + +def test_backup_cloud_script_does_not_ping_healthchecks_after_rclone_failure(tmp_path): + result = run_backup_script( + tmp_path, + rclone_exit=1, + healthchecks_url="https://hc-ping.com/secret-check-id", + ) + + assert result.returncode == 1 + assert not (tmp_path / "ping.stdin").exists() + assert not (tmp_path / "python.args").exists() + assert (tmp_path / "log-alert.args").exists() diff --git a/tests/test_cloud_backup_service.py b/tests/test_cloud_backup_service.py index e980b78..3c5e3fb 100644 --- a/tests/test_cloud_backup_service.py +++ b/tests/test_cloud_backup_service.py @@ -131,6 +131,14 @@ def test_get_config_exposes_existing_rclone_text_for_admin_editing(self): self.assertNotIn("mega_pass", payload) self.assertEqual(payload["rclone_config"], "[remote]\ntype = test\n") + def test_get_config_includes_healthchecks_ping_url_for_editor(self): + service, config, _system_utils, _runtime = self.make_service() + config.config["backup"] = {"healthchecks_ping_url": "https://hc-ping.com/check-id"} + + payload = service.get_config() + + self.assertEqual(payload["healthchecks_ping_url"], "https://hc-ping.com/check-id") + def test_status_and_manual_run_use_cloud_backup_task(self): task = FakeTask() service, _config, _system_utils, _runtime = self.make_service(task=task) @@ -150,6 +158,35 @@ def test_fake_schedule_save_does_not_reinstall_timers(self): self.assertFalse(system_utils.created_systemd_config) self.assertFalse(system_utils.installed_timers) + def test_fake_schedule_save_stores_healthchecks_ping_url(self): + service, config, _system_utils, _runtime = self.make_service(is_fake=True) + + result = service.save_schedule( + { + "backup_cloud_time": "04:00", + "bandwidth_limit": "4M", + "healthchecks_ping_url": " https://hc-ping.com/check-id ", + } + ) + + self.assertEqual(result, {}) + self.assertEqual( + config.config["backup"]["healthchecks_ping_url"], "https://hc-ping.com/check-id" + ) + + def test_schedule_save_rejects_invalid_healthchecks_ping_url(self): + service, config, _system_utils, _runtime = self.make_service(is_fake=True) + + with self.assertRaisesRegex(ValidationProblem, "http:// or https://"): + service.save_schedule( + { + "backup_cloud_time": "04:00", + "healthchecks_ping_url": "notaurl", + } + ) + + self.assertNotIn("healthchecks_ping_url", config.config["backup"]) + def test_schedule_save_rejects_unsafe_bandwidth_limit(self): service, config, _system_utils, _runtime = self.make_service(is_fake=True) @@ -202,6 +239,19 @@ def test_schedule_save_allows_bandwidth_only_update(self): self.assertEqual(config.config["schedule"]["backup_cloud_time"], "03:00") self.assertEqual(config.config["backup"]["bandwidth_limit"], "8M") + def test_real_schedule_save_allows_healthchecks_only_update_without_timer_reinstall(self): + service, config, system_utils, _runtime = self.make_service(is_fake=False) + config.config["schedule"] = {"backup_cloud_time": "03:00"} + + result = service.save_schedule({"healthchecks_ping_url": "https://hc-ping.com/check-id"}) + + self.assertEqual(result, {}) + self.assertFalse(system_utils.created_systemd_config) + self.assertFalse(system_utils.installed_timers) + self.assertEqual( + config.config["backup"]["healthchecks_ping_url"], "https://hc-ping.com/check-id" + ) + def test_advanced_config_requires_remote_and_rclone_config(self): service, _config, _system_utils, _runtime = self.make_service() diff --git a/tests/test_healthchecks.py b/tests/test_healthchecks.py new file mode 100644 index 0000000..4d0188c --- /dev/null +++ b/tests/test_healthchecks.py @@ -0,0 +1,87 @@ +from urllib.error import HTTPError, URLError + +import pytest + +from simple_safer_server.services.healthchecks import ( + HealthchecksPingError, + normalize_healthchecks_ping_url, + ping_healthchecks_url, +) + + +class FakeResponse: + def __init__(self, status): + self.status = status + + def __enter__(self): + return self + + def __exit__(self, _exc_type, _exc, _traceback): + return False + + def getcode(self): + return self.status + + +def test_normalize_healthchecks_ping_url_allows_blank_optional_value(): + assert normalize_healthchecks_ping_url(None) == "" + assert normalize_healthchecks_ping_url(" ") == "" + + +def test_normalize_healthchecks_ping_url_requires_http_url_without_spaces(): + with pytest.raises(ValueError, match="http:// or https://"): + normalize_healthchecks_ping_url("ftp://example.test/check") + + with pytest.raises(ValueError, match="spaces"): + normalize_healthchecks_ping_url("https://hc-ping.com/check with space") + + +def test_ping_healthchecks_url_sends_get_without_leaking_url_in_errors(monkeypatch): + calls = [] + + def fake_urlopen(request, timeout): + calls.append((request, timeout)) + return FakeResponse(200) + + monkeypatch.setattr("simple_safer_server.services.healthchecks.urlopen", fake_urlopen) + + ping_healthchecks_url("https://hc-ping.com/secret-check-id") + + assert calls[0][0].full_url == "https://hc-ping.com/secret-check-id" + assert calls[0][0].get_method() == "GET" + + +def test_ping_healthchecks_url_reports_http_status_without_url(monkeypatch): + def fake_urlopen(_request, timeout): + _ = timeout + raise HTTPError( + "https://hc-ping.com/secret-check-id", + 500, + "server error", + hdrs=None, + fp=None, + ) + + monkeypatch.setattr("simple_safer_server.services.healthchecks.urlopen", fake_urlopen) + + with pytest.raises(HealthchecksPingError) as exc_info: + ping_healthchecks_url("https://hc-ping.com/secret-check-id") + + message = str(exc_info.value) + assert "HTTP 500" in message + assert "secret-check-id" not in message + + +def test_ping_healthchecks_url_reports_network_error_without_url(monkeypatch): + def fake_urlopen(_request, timeout): + _ = timeout + raise URLError("connection refused") + + monkeypatch.setattr("simple_safer_server.services.healthchecks.urlopen", fake_urlopen) + + with pytest.raises(HealthchecksPingError) as exc_info: + ping_healthchecks_url("https://hc-ping.com/secret-check-id") + + message = str(exc_info.value) + assert "connection refused" in message + assert "secret-check-id" not in message diff --git a/tests/test_task_service.py b/tests/test_task_service.py index c860f90..35bb409 100644 --- a/tests/test_task_service.py +++ b/tests/test_task_service.py @@ -17,15 +17,17 @@ class FakeConfigManager: - def __init__(self, mount_point, rclone_dir=""): + def __init__(self, mount_point, rclone_dir="", healthchecks_ping_url=""): self.mount_point = mount_point self.rclone_dir = rclone_dir + self.healthchecks_ping_url = healthchecks_ping_url def get_value(self, section, key, default=None): values = { ("backup", "mount_point"): self.mount_point, ("backup", "rclone_dir"): self.rclone_dir, ("backup", "bandwidth_limit"): "", + ("backup", "healthchecks_ping_url"): self.healthchecks_ping_url, ("schedule", "backup_cloud_time"): "03:00", } return values.get((section, key), default) @@ -153,6 +155,7 @@ def build_service( is_fake=True, systemd_adapter=None, rclone_dir="", + healthchecks_ping_url="", ): runtime = SimpleNamespace( is_fake=is_fake, @@ -164,7 +167,11 @@ def build_service( fake_state = FakeState() service = TaskService( runtime=runtime, - config_manager=FakeConfigManager(mount_point, rclone_dir=rclone_dir), + config_manager=FakeConfigManager( + mount_point, + rclone_dir=rclone_dir, + healthchecks_ping_url=healthchecks_ping_url, + ), system_utils=MagicMock(), fake_state=fake_state, logger=MagicMock(), @@ -259,6 +266,39 @@ def test_fake_cloud_backup_runs_rclone_for_provider_parity(self): fake_state.logs, ) + @patch("simple_safer_server.services.task_service.ping_healthchecks_url") + def test_fake_cloud_backup_pings_healthchecks_after_success(self, mock_ping): + service, fake_state = self.build_service( + mount_point=".", + rclone_dir="/tmp/fake-backup", + healthchecks_ping_url="https://hc-ping.com/check-id", + ) + service.rclone_adapter = MagicMock() + service.rclone_adapter.sync.return_value = FakeProcess(stdout="copied\n") + + service._run_fake_cloud_backup(threading.Event()) + + mock_ping.assert_called_once_with("https://hc-ping.com/check-id") + self.assertIn( + ("Cloud Backup", "Healthchecks.io success ping sent."), + fake_state.logs, + ) + + @patch("simple_safer_server.services.task_service.ping_healthchecks_url") + def test_fake_cloud_backup_does_not_ping_healthchecks_after_failure(self, mock_ping): + service, _fake_state = self.build_service( + mount_point=".", + rclone_dir="/tmp/fake-backup", + healthchecks_ping_url="https://hc-ping.com/check-id", + ) + service.rclone_adapter = MagicMock() + service.rclone_adapter.sync.return_value = FakeProcess(returncode=1, stderr="failed\n") + + with self.assertRaisesRegex(RuntimeError, "failed"): + service._run_fake_cloud_backup(threading.Event()) + + mock_ping.assert_not_called() + @patch("simple_safer_server.services.task_service.run_scheduled_drive_health_check") def test_fake_drive_health_logs_smart_collection(self, mock_health_check): service, fake_state = self.build_service(mount_point=".") diff --git a/uninstall.sh b/uninstall.sh index e4bd7ca..8a245bb 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -36,6 +36,7 @@ SCRIPT_FILES=( check_health.sh check_health.py backup_cloud.sh + ping_healthchecks.py log_alert.py import_legacy.py ddns_update.sh