Files
bambuddy/backend/app/services/github_backup.py
T
BurntOutHylian 7afb303ffd feat(#1239): Update Gitea and Forgejo due to API changes from initial cut (#1255)
feat(#1239): first cut at Gitea backups silently failing after 1st run
feat(#1239): Added Token Scope for Forgejo edge case. Also included: test coverage for fixes
2026-05-11 12:27:41 +02:00

568 lines
22 KiB
Python

"""GitHub backup service for printer profiles.
Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import async_session
from backend.app.models.archive import PrintArchive
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.spool import Spool
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.services.git_providers.factory import get_provider_backend
from backend.app.services.printer_manager import printer_manager
logger = logging.getLogger(__name__)
# Schedule intervals in seconds
SCHEDULE_INTERVALS = {
"hourly": 3600,
"daily": 86400,
"weekly": 604800,
}
_PROVIDER_DISPLAY_NAMES = {
"github": "GitHub",
"gitlab": "GitLab",
"gitea": "Gitea",
"forgejo": "Forgejo",
}
class GitHubBackupService:
"""Service for backing up profiles to GitHub."""
def __init__(self):
self._scheduler_task: asyncio.Task | None = None
self._check_interval = 60 # Check every minute for scheduled runs
self._running_backup: bool = False
self._backup_progress: str | None = None
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=60.0)
return self._http_client
async def start_scheduler(self):
"""Start the background scheduler loop."""
if self._scheduler_task is not None:
return
logger.info("Starting GitHub backup scheduler")
self._scheduler_task = asyncio.create_task(self._scheduler_loop())
def stop_scheduler(self):
"""Stop the scheduler."""
if self._scheduler_task:
self._scheduler_task.cancel()
self._scheduler_task = None
logger.info("Stopped GitHub backup scheduler")
async def _scheduler_loop(self):
"""Main scheduler loop - checks for due backups."""
while True:
try:
await asyncio.sleep(self._check_interval)
await self._check_scheduled_backups()
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error in GitHub backup scheduler")
await asyncio.sleep(60)
async def _check_scheduled_backups(self):
"""Check if any scheduled backups are due."""
async with async_session() as db:
result = await db.execute(
select(GitHubBackupConfig).where(
GitHubBackupConfig.enabled == True, # noqa: E712
GitHubBackupConfig.schedule_enabled == True, # noqa: E712
)
)
configs = result.scalars().all()
now = datetime.now(timezone.utc)
for config in configs:
# Handle both naive (from DB) and aware datetimes
next_run = config.next_scheduled_run
if next_run and next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
if next_run and next_run <= now:
logger.info("Running scheduled backup for config %s", config.id)
await self.run_backup(config.id, trigger="scheduled")
def calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
"""Calculate the next scheduled run time."""
now = from_time or datetime.now(timezone.utc)
interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
return now + timedelta(seconds=interval)
async def test_connection(self, repo_url: str, token: str, provider: str = "github") -> dict:
"""Test connection and permissions for the given provider."""
backend = get_provider_backend(provider)
client = await self._get_client()
return await backend.test_connection(repo_url, token, client)
async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
"""Run a backup operation.
Args:
config_id: ID of the backup configuration
trigger: "manual" or "scheduled"
Returns:
dict with success, message, log_id, commit_sha, files_changed
"""
if self._running_backup:
return {"success": False, "message": "A backup is already running", "log_id": None}
self._running_backup = True
log_id = None
try:
async with async_session() as db:
# Get config
result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
config = result.scalar_one_or_none()
if not config:
return {"success": False, "message": "Configuration not found", "log_id": None}
if not config.enabled:
return {"success": False, "message": "Backup is disabled", "log_id": None}
# Create log entry
log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
db.add(log)
await db.commit()
await db.refresh(log)
log_id = log.id
try:
# Collect backup data
self._backup_progress = "Collecting profiles..."
backup_data = await self._collect_backup_data(db, config)
if not backup_data:
# No data to backup
log.status = "skipped"
log.completed_at = datetime.now(timezone.utc)
log.error_message = "No data to backup"
config.last_backup_at = datetime.now(timezone.utc)
config.last_backup_status = "skipped"
config.last_backup_message = "No data to backup"
if config.schedule_enabled:
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": True,
"message": "No data to backup",
"log_id": log_id,
"commit_sha": None,
"files_changed": 0,
}
provider_name = _PROVIDER_DISPLAY_NAMES.get(config.provider, config.provider)
self._backup_progress = f"Pushing to {provider_name}..."
push_result = await self._push_to_provider(config, backup_data)
# Update log and config
log.status = push_result["status"]
log.completed_at = datetime.now(timezone.utc)
log.commit_sha = push_result.get("commit_sha")
log.files_changed = push_result.get("files_changed", 0)
log.error_message = push_result.get("error")
config.last_backup_at = datetime.now(timezone.utc)
config.last_backup_status = push_result["status"]
config.last_backup_message = push_result.get("message", "")
config.last_backup_commit_sha = push_result.get("commit_sha")
if config.schedule_enabled:
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": push_result["status"] in ("success", "skipped"),
"message": push_result.get("message", "Backup completed"),
"log_id": log_id,
"commit_sha": push_result.get("commit_sha"),
"files_changed": push_result.get("files_changed", 0),
}
except Exception as e:
logger.exception("Backup failed")
log.status = "failed"
log.completed_at = datetime.now(timezone.utc)
log.error_message = str(e)
config.last_backup_at = datetime.now(timezone.utc)
config.last_backup_status = "failed"
config.last_backup_message = str(e)
if config.schedule_enabled:
config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
await db.commit()
return {
"success": False,
"message": str(e),
"log_id": log_id,
"commit_sha": None,
"files_changed": 0,
}
finally:
self._running_backup = False
self._backup_progress = None
async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
"""Collect data to backup based on config settings.
Returns dict with structure:
{
"backup_metadata.json": {...},
"kprofiles/{serial}/{nozzle}.json": {...},
"cloud_profiles/filament.json": [...],
"cloud_profiles/printer.json": [...],
"cloud_profiles/process.json": [...],
"settings/app_settings.json": {...},
}
"""
files: dict[str, dict | list] = {}
# Metadata file (no timestamps - git tracks file history)
metadata = {
"version": "1.0",
"backup_type": "bambuddy_profiles",
"contents": {
"kprofiles": config.backup_kprofiles,
"cloud_profiles": config.backup_cloud_profiles,
"settings": config.backup_settings,
"spools": config.backup_spools,
"archives": config.backup_archives,
},
}
files["backup_metadata.json"] = metadata
# Collect K-profiles from all connected printers
if config.backup_kprofiles:
self._backup_progress = "Collecting K-profiles from printers..."
await self._collect_kprofiles(db, files)
# Collect cloud profiles
if config.backup_cloud_profiles:
self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
await self._collect_cloud_profiles(db, files)
# Collect app settings
if config.backup_settings:
self._backup_progress = "Collecting app settings..."
await self._collect_settings(db, files)
# Collect spool inventory
if config.backup_spools:
self._backup_progress = "Collecting spool inventory..."
await self._collect_spools(db, files)
# Collect print archives
if config.backup_archives:
self._backup_progress = "Collecting print archives..."
await self._collect_archives(db, files)
return files
async def _collect_kprofiles(self, db: AsyncSession, files: dict):
"""Collect K-profiles from all connected printers."""
result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
printers = result.scalars().all()
nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
for printer in printers:
client = printer_manager.get_client(printer.id)
if not client or not client.state.connected:
continue
serial = printer.serial_number
printer_profiles = {}
for nozzle in nozzle_diameters:
try:
profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
if profiles:
profile_data = {
"version": "1.0",
"printer_name": printer.name,
"printer_serial": serial,
"nozzle_diameter": nozzle,
"profiles": [
{
"slot_id": p.slot_id,
"name": p.name,
"k_value": p.k_value,
"filament_id": p.filament_id,
"nozzle_id": p.nozzle_id,
"extruder_id": p.extruder_id,
"setting_id": p.setting_id,
"n_coef": p.n_coef,
}
for p in profiles
],
}
files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
printer_profiles[nozzle] = len(profiles)
except Exception as e:
logger.warning("Failed to get K-profiles for printer %s nozzle %s: %s", serial, nozzle, e)
if printer_profiles:
logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
"""Collect Bambu Cloud profiles if authenticated."""
# Backup runs without a user context, so fall back to the auth-disabled
# Settings storage. ``build_authenticated_cloud`` honours the stored
# region so China-region tokens are validated against api.bambulab.cn.
from backend.app.api.routes.cloud import build_authenticated_cloud
cloud = await build_authenticated_cloud(db, user=None)
if cloud is None or not cloud.is_authenticated:
if cloud is not None:
await cloud.close()
logger.info("Cloud not authenticated, skipping cloud profiles")
return
try:
settings = await cloud.get_slicer_settings()
if not settings:
return
# Separate by type
filament_settings = []
printer_settings = []
process_settings = []
for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
setting_type = setting.get("type", "")
if setting_type == "filament":
filament_settings.append(setting)
elif setting_type == "printer":
printer_settings.append(setting)
elif setting_type == "process":
process_settings.append(setting)
if filament_settings:
files["cloud_profiles/filament.json"] = {
"version": "1.0",
"profiles": filament_settings,
}
if printer_settings:
files["cloud_profiles/printer.json"] = {
"version": "1.0",
"profiles": printer_settings,
}
if process_settings:
files["cloud_profiles/process.json"] = {
"version": "1.0",
"profiles": process_settings,
}
logger.info(
"Collected cloud profiles: %d filament, %d printer, %d process",
len(filament_settings),
len(printer_settings),
len(process_settings),
)
except Exception:
logger.warning("Failed to collect cloud profiles", exc_info=True)
finally:
await cloud.close()
async def _collect_settings(self, db: AsyncSession, files: dict):
"""Collect app settings."""
result = await db.execute(select(Settings))
settings = result.scalars().all()
# Filter out sensitive settings
sensitive_keys = {"bambu_cloud_token", "auth_secret_key"}
settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
files["settings/app_settings.json"] = {
"version": "1.0",
"settings": settings_data,
}
async def _collect_spools(self, db: AsyncSession, files: dict):
"""Collect spool inventory data."""
result = await db.execute(select(Spool))
spools = result.scalars().all()
if not spools:
return
spool_list = []
for s in spools:
spool_data = {
"id": s.id,
"material": s.material,
"subtype": s.subtype,
"color_name": s.color_name,
"rgba": s.rgba,
"brand": s.brand,
"label_weight": s.label_weight,
"core_weight": s.core_weight,
"weight_used": s.weight_used,
"weight_locked": s.weight_locked,
"slicer_filament": s.slicer_filament,
"slicer_filament_name": s.slicer_filament_name,
"nozzle_temp_min": s.nozzle_temp_min,
"nozzle_temp_max": s.nozzle_temp_max,
"note": s.note,
"cost_per_kg": s.cost_per_kg,
"tag_uid": s.tag_uid,
"tray_uuid": s.tray_uuid,
"data_origin": s.data_origin,
"tag_type": s.tag_type,
"archived_at": str(s.archived_at) if s.archived_at else None,
"created_at": str(s.created_at) if s.created_at else None,
}
spool_list.append(spool_data)
files["spools/inventory.json"] = {
"version": "1.0",
"spools": spool_list,
}
# Collect usage history
usage_result = await db.execute(select(SpoolUsageHistory))
usages = usage_result.scalars().all()
if usages:
usage_list = []
for u in usages:
usage_list.append(
{
"id": u.id,
"spool_id": u.spool_id,
"printer_id": u.printer_id,
"print_name": u.print_name,
"archive_id": u.archive_id,
"weight_used": u.weight_used,
"percent_used": u.percent_used,
"status": u.status,
"cost": u.cost,
"created_at": str(u.created_at) if u.created_at else None,
}
)
files["spools/usage_history.json"] = {
"version": "1.0",
"usage_history": usage_list,
}
logger.info("Collected %d spools and %d usage records", len(spool_list), len(usages))
async def _collect_archives(self, db: AsyncSession, files: dict):
"""Collect print archive metadata (no binary files)."""
result = await db.execute(select(PrintArchive))
archives = result.scalars().all()
if not archives:
return
archive_list = []
for a in archives:
archive_data = {
"id": a.id,
"printer_id": a.printer_id,
"project_id": a.project_id,
"filename": a.filename,
"file_size": a.file_size,
"content_hash": a.content_hash,
"print_name": a.print_name,
"print_time_seconds": a.print_time_seconds,
"filament_used_grams": a.filament_used_grams,
"filament_type": a.filament_type,
"filament_color": a.filament_color,
"layer_height": a.layer_height,
"total_layers": a.total_layers,
"nozzle_diameter": a.nozzle_diameter,
"bed_temperature": a.bed_temperature,
"nozzle_temperature": a.nozzle_temperature,
"sliced_for_model": a.sliced_for_model,
"status": a.status,
"started_at": str(a.started_at) if a.started_at else None,
"completed_at": str(a.completed_at) if a.completed_at else None,
"makerworld_url": a.makerworld_url,
"designer": a.designer,
"external_url": a.external_url,
"is_favorite": a.is_favorite,
"tags": a.tags,
"notes": a.notes,
"cost": a.cost,
"failure_reason": a.failure_reason,
"quantity": a.quantity,
"energy_kwh": a.energy_kwh,
"energy_cost": a.energy_cost,
"created_at": str(a.created_at) if a.created_at else None,
}
archive_list.append(archive_data)
files["archives/print_history.json"] = {
"version": "1.0",
"archives": archive_list,
}
logger.info("Collected %d print archives", len(archive_list))
async def _push_to_provider(self, config: GitHubBackupConfig, files: dict) -> dict:
"""Push files to the configured Git provider."""
backend = get_provider_backend(config.provider)
client = await self._get_client()
return await backend.push_files(
repo_url=config.repository_url,
token=config.access_token,
branch=config.branch,
files=files,
client=client,
)
@property
def is_running(self) -> bool:
"""Check if a backup is currently running."""
return self._running_backup
@property
def progress(self) -> str | None:
"""Get current backup progress message."""
return self._backup_progress
async def get_logs(self, config_id: int, limit: int = 50, offset: int = 0) -> list[GitHubBackupLog]:
"""Get backup logs for a configuration."""
async with async_session() as db:
result = await db.execute(
select(GitHubBackupLog)
.where(GitHubBackupLog.config_id == config_id)
.order_by(desc(GitHubBackupLog.started_at))
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
# Singleton instance
github_backup_service = GitHubBackupService()