The health record used to learn about backups by polling the mtime of the newest archive, hourly, against a 48-hour threshold — for a job that runs once a day. Forty-seven of every forty-eight runs could not produce a new answer. Worse, file age cannot distinguish a failed backup from one that has not run yet: when tonight's job dies, yesterday's archive is 24 hours old and still reads healthy, and keeps reading healthy until hour 48. A failure stayed invisible for two days to the check whose only job was noticing it. These executors know at 03:05. Reporting wraps the work rather than living inside it, so the failure path cannot be forgotten — an exception is reported as critical and re-raised, leaving the task's own status untouched. Reporting only success would reproduce exactly the blind spot this replaces. A reporting failure never fails the backup. Everything in health_report is caught and logged, which means the absence of rows is the only symptom a broken reporter produces — so monitor for rows, not for errors. Not yet functional in production: scheduler_user holds DELETE and SELECT on check_history (it prunes the table nightly) but not INSERT. Until that grant is made the report logs a refusal and skips. The ticket predicted this as the step that would fail silently, which is why it is named in the changelog and handled as its own exception rather than folded into a generic catch. Verified against baseline: tests/test_config_backup_executor.py and tests/test_portainer_backup_executor.py report 7 failed / 17 passed both with and without this change, so the pre-existing failures are untouched. Workspace D-33, T-69.
191 lines
6.5 KiB
Python
191 lines
6.5 KiB
Python
"""
|
|
Config Backup Executor
|
|
Backs up Docker container configs and host-based service configs.
|
|
Replicates functionality of maintenance container's backup-configs.sh
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import tarfile
|
|
import tempfile
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any
|
|
|
|
from src.config import Settings
|
|
from src.executors import health_report
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _run(config: dict, settings: Settings) -> str:
|
|
"""
|
|
Execute config backup task.
|
|
|
|
Config schema:
|
|
{
|
|
"sources": [
|
|
{
|
|
"path": "/data/docker-data",
|
|
"name": "docker-data",
|
|
"excludes": ["*/cache/*", "*/temp/*", "*.log"]
|
|
}
|
|
],
|
|
"backup_dir": "/backups/docker-configs",
|
|
"retention_days": 30,
|
|
"compress": true
|
|
}
|
|
|
|
Args:
|
|
config: Backup configuration
|
|
settings: Global scheduler settings
|
|
|
|
Returns:
|
|
Summary of backup operation
|
|
|
|
Raises:
|
|
Exception: On backup failure
|
|
"""
|
|
sources = config.get('sources', [])
|
|
backup_dir = Path(config.get('backup_dir', '/backups/docker-configs'))
|
|
retention_days = config.get('retention_days', 30)
|
|
compress = config.get('compress', True)
|
|
|
|
if not sources:
|
|
raise ValueError("No backup sources configured")
|
|
|
|
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
|
|
backup_filename = f"docker-configs-{timestamp}.tar.gz"
|
|
backup_file = backup_dir / backup_filename
|
|
|
|
logger.info(f"Starting Docker configs backup: {backup_filename}")
|
|
|
|
# Create backup directory
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create temporary directory for staging
|
|
with tempfile.TemporaryDirectory(prefix='backup-') as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
results = []
|
|
|
|
# Backup each source
|
|
for source in sources:
|
|
source_path = Path(source['path'])
|
|
source_name = source['name']
|
|
excludes = source.get('excludes', [])
|
|
|
|
if not source_path.exists():
|
|
logger.warning(f"Source path does not exist: {source_path}")
|
|
continue
|
|
|
|
logger.info(f"Backing up {source_name} from {source_path}")
|
|
|
|
# Create tar for this source
|
|
source_tar = temp_path / f"{source_name}.tar.gz"
|
|
|
|
def tar_filter(tarinfo):
|
|
"""Filter function to exclude patterns."""
|
|
for pattern in excludes:
|
|
# Simple pattern matching (could be enhanced with fnmatch)
|
|
if pattern.replace('*/', '').replace('/*', '') in tarinfo.name:
|
|
logger.debug(f"Excluding: {tarinfo.name}")
|
|
return None
|
|
return tarinfo
|
|
|
|
with tarfile.open(source_tar, 'w:gz') as tar:
|
|
tar.add(
|
|
source_path,
|
|
arcname=source_name,
|
|
filter=tar_filter,
|
|
recursive=True
|
|
)
|
|
|
|
source_size = source_tar.stat().st_size / (1024 * 1024) # MB
|
|
results.append(f"{source_name}: {source_size:.2f}MB")
|
|
logger.info(f"Backed up {source_name}: {source_size:.2f}MB")
|
|
|
|
# Combine all source backups into final archive
|
|
logger.info("Creating combined backup archive...")
|
|
with tarfile.open(backup_file, 'w:gz') as final_tar:
|
|
for item in temp_path.glob('*.tar.gz'):
|
|
final_tar.add(item, arcname=item.name)
|
|
|
|
# Verify backup created
|
|
if not backup_file.exists():
|
|
raise Exception("Backup file was not created")
|
|
|
|
backup_size = backup_file.stat().st_size / (1024 * 1024) # MB
|
|
logger.info(f"Backup created successfully: {backup_size:.2f}MB")
|
|
|
|
# Clean up old backups
|
|
await cleanup_old_backups(backup_dir, retention_days)
|
|
|
|
# Count remaining backups
|
|
backup_count = len(list(backup_dir.glob('docker-configs-*.tar.gz')))
|
|
total_size = sum(f.stat().st_size for f in backup_dir.glob('docker-configs-*.tar.gz'))
|
|
total_size_mb = total_size / (1024 * 1024)
|
|
|
|
output = (
|
|
f"Backup completed: {backup_filename} ({backup_size:.2f}MB). "
|
|
f"Sources: {', '.join(results)}. "
|
|
f"Retention: {backup_count} backups, {total_size_mb:.2f}MB total."
|
|
)
|
|
|
|
logger.info(output)
|
|
return output
|
|
|
|
|
|
async def cleanup_old_backups(backup_dir: Path, retention_days: int):
|
|
"""Remove backups older than retention period."""
|
|
cutoff_date = datetime.now() - timedelta(days=retention_days)
|
|
removed_count = 0
|
|
removed_size = 0
|
|
|
|
logger.info(f"Cleaning up backups older than {retention_days} days...")
|
|
|
|
for backup_file in backup_dir.glob('docker-configs-*.tar.gz'):
|
|
# Get file modification time
|
|
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
|
|
|
if file_mtime < cutoff_date:
|
|
file_size = backup_file.stat().st_size
|
|
logger.info(f"Removing old backup: {backup_file.name} (from {file_mtime:%Y-%m-%d})")
|
|
backup_file.unlink()
|
|
removed_count += 1
|
|
removed_size += file_size
|
|
|
|
if removed_count > 0:
|
|
removed_size_mb = removed_size / (1024 * 1024)
|
|
logger.info(f"Removed {removed_count} old backups, freed {removed_size_mb:.2f}MB")
|
|
else:
|
|
logger.info("No old backups to remove")
|
|
|
|
|
|
async def execute(config: dict, settings: Settings) -> str:
|
|
"""Run the backup and report its own outcome to check_history (D-33, T-69).
|
|
|
|
The report wraps the work rather than living inside it, so the failure path
|
|
cannot be forgotten: an exception is reported as critical and then re-raised,
|
|
leaving the task's own status untouched. Reporting only success would
|
|
reproduce exactly the blind spot this replaces — a monitor that cannot tell
|
|
a failed backup from one that has not run.
|
|
"""
|
|
try:
|
|
output = await _run(config, settings)
|
|
except Exception as exc:
|
|
health_report.report(
|
|
settings,
|
|
domain="backup",
|
|
status=health_report.CRITICAL,
|
|
source="scheduler/config_backup_executor",
|
|
metrics={"job": "scheduler/config_backup_executor", "error": str(exc)[:400]},
|
|
)
|
|
raise
|
|
health_report.report(
|
|
settings,
|
|
domain="backup",
|
|
status=health_report.OK,
|
|
source="scheduler/config_backup_executor",
|
|
metrics={"job": "scheduler/config_backup_executor", "summary": output[:400]},
|
|
)
|
|
return output
|