Backups: add restic errors
This commit is contained in:
+53
-47
@@ -6,18 +6,19 @@ then creates restic backups and sends notifications.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import tomllib
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import tomllib
|
|
||||||
|
|
||||||
# Default config path
|
# Default config path
|
||||||
CONFIG_PATH = Path("/etc/backup/config.toml")
|
CONFIG_PATH = Path("/etc/backup/config.toml")
|
||||||
@@ -54,6 +55,12 @@ class Application:
|
|||||||
backup_targets: List[Path]
|
backup_targets: List[Path]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupResult:
|
||||||
|
success: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StorageRunResult:
|
class StorageRunResult:
|
||||||
name: str
|
name: str
|
||||||
@@ -76,7 +83,7 @@ def format_duration(seconds: float) -> str:
|
|||||||
class Storage(ABC):
|
class Storage(ABC):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
def backup(self, backup_dirs: List[str]) -> bool:
|
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
||||||
"""Backup directories"""
|
"""Backup directories"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@@ -101,17 +108,17 @@ class ResticStorage(Storage):
|
|||||||
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def backup(self, backup_dirs: List[str]) -> bool:
|
def backup(self, backup_dirs: List[str]) -> BackupResult:
|
||||||
if not backup_dirs:
|
if not backup_dirs:
|
||||||
logger.warning("No backup directories found")
|
logger.warning("No backup directories found")
|
||||||
return True
|
return BackupResult(success=True)
|
||||||
try:
|
try:
|
||||||
return self.__backup_internal(backup_dirs)
|
return self.__backup_internal(backup_dirs)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("Restic backup process failed: %s", exc)
|
logger.error("Restic backup process failed: %s", exc)
|
||||||
return False
|
return BackupResult(success=False, error=str(exc))
|
||||||
|
|
||||||
def __backup_internal(self, backup_dirs: List[str]) -> bool:
|
def __backup_internal(self, backup_dirs: List[str]) -> BackupResult:
|
||||||
logger.info("Starting restic backup for storage '%s'", self.name)
|
logger.info("Starting restic backup for storage '%s'", self.name)
|
||||||
logger.info("Destination: %s", self.restic_repository)
|
logger.info("Destination: %s", self.restic_repository)
|
||||||
|
|
||||||
@@ -120,25 +127,13 @@ class ResticStorage(Storage):
|
|||||||
env["RESTIC_PASSWORD"] = self.restic_password
|
env["RESTIC_PASSWORD"] = self.restic_password
|
||||||
env.update(self.env)
|
env.update(self.env)
|
||||||
|
|
||||||
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
|
|
||||||
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error("Restic backup failed: %s", result.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info("Restic backup completed successfully")
|
|
||||||
|
|
||||||
check_cmd = ["restic", "check"]
|
check_cmd = ["restic", "check"]
|
||||||
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
|
steps = [
|
||||||
|
("backup", ["restic", "backup", "--verbose"] + backup_dirs),
|
||||||
if result.returncode != 0:
|
("check", check_cmd),
|
||||||
logger.error("Restic check failed: %s", result.stderr)
|
(
|
||||||
return False
|
"forget/prune",
|
||||||
|
[
|
||||||
logger.info("Restic check completed successfully")
|
|
||||||
|
|
||||||
forget_cmd = [
|
|
||||||
"restic",
|
"restic",
|
||||||
"forget",
|
"forget",
|
||||||
"--compact",
|
"--compact",
|
||||||
@@ -147,23 +142,31 @@ class ResticStorage(Storage):
|
|||||||
"90",
|
"90",
|
||||||
"--keep-monthly",
|
"--keep-monthly",
|
||||||
"36",
|
"36",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("final check", check_cmd),
|
||||||
]
|
]
|
||||||
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
|
|
||||||
|
for step, cmd in steps:
|
||||||
|
error = self.__run_step(step, cmd, env)
|
||||||
|
if error is not None:
|
||||||
|
return BackupResult(success=False, error=f"restic {step}: {error}")
|
||||||
|
|
||||||
|
return BackupResult(success=True)
|
||||||
|
|
||||||
|
def __run_step(
|
||||||
|
self, step: str, cmd: List[str], env: Dict[str, str]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Run a single restic command. Return None on success or error text."""
|
||||||
|
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Restic forget/prune failed: %s", result.stderr)
|
error = result.stderr.strip() or result.stdout.strip() or "no output"
|
||||||
return False
|
logger.error("Restic %s failed: %s", step, error)
|
||||||
|
return error
|
||||||
|
|
||||||
logger.info("Restic forget/prune completed successfully")
|
logger.info("Restic %s completed successfully", step)
|
||||||
|
return None
|
||||||
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error("Final restic check failed: %s", result.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info("Final restic check completed successfully")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class Notifier(ABC):
|
class Notifier(ABC):
|
||||||
@@ -357,12 +360,12 @@ class BackupManager:
|
|||||||
logger.error(
|
logger.error(
|
||||||
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
||||||
)
|
)
|
||||||
backup_result = False
|
backup_result = BackupResult(success=False, error=str(exc))
|
||||||
storage_duration = time.monotonic() - storage_start
|
storage_duration = time.monotonic() - storage_start
|
||||||
self.storage_results.append(
|
self.storage_results.append(
|
||||||
StorageRunResult(
|
StorageRunResult(
|
||||||
name=storage.name,
|
name=storage.name,
|
||||||
success=backup_result,
|
success=backup_result.success,
|
||||||
duration=storage_duration,
|
duration=storage_duration,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -370,13 +373,16 @@ class BackupManager:
|
|||||||
"Storage '%s' finished in %s (success=%s)",
|
"Storage '%s' finished in %s (success=%s)",
|
||||||
storage.name,
|
storage.name,
|
||||||
format_duration(storage_duration),
|
format_duration(storage_duration),
|
||||||
backup_result,
|
backup_result.success,
|
||||||
)
|
)
|
||||||
if not backup_result:
|
if not backup_result.success:
|
||||||
self.errors.append(f"Storage '{storage.name}' backup failed")
|
error_msg = f"Storage '{storage.name}' backup failed"
|
||||||
|
if backup_result.error:
|
||||||
|
error_msg += f": {backup_result.error}"
|
||||||
|
self.errors.append(error_msg)
|
||||||
|
|
||||||
# Determine overall success
|
# Determine overall success
|
||||||
overall_success = overall_success and backup_result
|
overall_success = overall_success and backup_result.success
|
||||||
|
|
||||||
# Send notification
|
# Send notification
|
||||||
self._send_notification(overall_success)
|
self._send_notification(overall_success)
|
||||||
|
|||||||
Reference in New Issue
Block a user