mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Add GitHub profile backup feature
New feature to automatically backup K-profiles, cloud profiles, and app settings to a GitHub repository with scheduled or on-demand execution. Features: - Configure GitHub repo URL and Personal Access Token - Schedule backups hourly, daily, or weekly (background scheduler) - Manual backup trigger with real-time progress tracking - Skip unchanged commits (only creates commit when data changes) - Backup history log with status and commit links - Requires Bambu Cloud login for full profile access - New Settings → Backup & Restore tab consolidating all backup options - GitHub backup config included in local backup/restore (except PAT) Backend: - New models: GitHubBackupConfig, GitHubBackupLog - New service: GitHubBackupService with scheduler and GitHub API client - New routes: /github-backup/* for config, status, logs, and triggers - Updated settings.py to include github_backup in backup/restore Frontend: - New GitHubBackupSettings.tsx component with auto-save - Updated SettingsPage with Backup tab and status indicator - Added API types and methods to client.ts Tests: - Backend integration tests for all GitHub backup API endpoints - Frontend API type and endpoint tests
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""API routes for GitHub profile backup."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.schemas.github_backup import (
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
GitHubBackupConfigUpdate,
|
||||
GitHubBackupLogResponse,
|
||||
GitHubBackupStatus,
|
||||
GitHubBackupTriggerResponse,
|
||||
GitHubTestConnectionResponse,
|
||||
)
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/github-backup", tags=["github-backup"])
|
||||
|
||||
|
||||
def _config_to_response(config: GitHubBackupConfig) -> dict:
|
||||
"""Convert config model to response dict."""
|
||||
return {
|
||||
"id": config.id,
|
||||
"repository_url": config.repository_url,
|
||||
"has_token": bool(config.access_token),
|
||||
"branch": config.branch,
|
||||
"schedule_enabled": config.schedule_enabled,
|
||||
"schedule_type": config.schedule_type,
|
||||
"backup_kprofiles": config.backup_kprofiles,
|
||||
"backup_cloud_profiles": config.backup_cloud_profiles,
|
||||
"backup_settings": config.backup_settings,
|
||||
"enabled": config.enabled,
|
||||
"last_backup_at": config.last_backup_at,
|
||||
"last_backup_status": config.last_backup_status,
|
||||
"last_backup_message": config.last_backup_message,
|
||||
"last_backup_commit_sha": config.last_backup_commit_sha,
|
||||
"next_scheduled_run": config.next_scheduled_run,
|
||||
"created_at": config.created_at,
|
||||
"updated_at": config.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config", response_model=GitHubBackupConfigResponse | None)
|
||||
async def get_config(db: AsyncSession = Depends(get_db)):
|
||||
"""Get the current GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
return _config_to_response(config)
|
||||
|
||||
|
||||
@router.post("/config", response_model=GitHubBackupConfigResponse)
|
||||
async def save_config(
|
||||
config_data: GitHubBackupConfigCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create or update GitHub backup configuration.
|
||||
|
||||
Only one configuration is supported. If one exists, it will be updated.
|
||||
"""
|
||||
# Check for existing config
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
# Update existing
|
||||
config.repository_url = config_data.repository_url
|
||||
config.access_token = config_data.access_token
|
||||
config.branch = config_data.branch
|
||||
config.schedule_enabled = config_data.schedule_enabled
|
||||
config.schedule_type = config_data.schedule_type.value
|
||||
config.backup_kprofiles = config_data.backup_kprofiles
|
||||
config.backup_cloud_profiles = config_data.backup_cloud_profiles
|
||||
config.backup_settings = config_data.backup_settings
|
||||
config.enabled = config_data.enabled
|
||||
|
||||
# Calculate next scheduled run if enabled
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
|
||||
else:
|
||||
config.next_scheduled_run = None
|
||||
|
||||
logger.info(f"Updated GitHub backup config: {config.repository_url}")
|
||||
else:
|
||||
# Create new
|
||||
config = GitHubBackupConfig(
|
||||
repository_url=config_data.repository_url,
|
||||
access_token=config_data.access_token,
|
||||
branch=config_data.branch,
|
||||
schedule_enabled=config_data.schedule_enabled,
|
||||
schedule_type=config_data.schedule_type.value,
|
||||
backup_kprofiles=config_data.backup_kprofiles,
|
||||
backup_cloud_profiles=config_data.backup_cloud_profiles,
|
||||
backup_settings=config_data.backup_settings,
|
||||
enabled=config_data.enabled,
|
||||
)
|
||||
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
|
||||
|
||||
db.add(config)
|
||||
logger.info(f"Created GitHub backup config: {config.repository_url}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
return _config_to_response(config)
|
||||
|
||||
|
||||
@router.patch("/config", response_model=GitHubBackupConfigResponse)
|
||||
async def update_config(
|
||||
update_data: GitHubBackupConfigUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Partially update GitHub backup configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found")
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_dict.items():
|
||||
if key == "schedule_type" and value is not None:
|
||||
setattr(config, key, value.value)
|
||||
else:
|
||||
setattr(config, key, value)
|
||||
|
||||
# Recalculate next scheduled run if schedule settings changed
|
||||
if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
|
||||
if config.schedule_enabled:
|
||||
config.next_scheduled_run = github_backup_service._calculate_next_run(config.schedule_type)
|
||||
else:
|
||||
config.next_scheduled_run = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
|
||||
logger.info(f"Updated GitHub backup config: {config.repository_url}")
|
||||
|
||||
return _config_to_response(config)
|
||||
|
||||
|
||||
@router.delete("/config")
|
||||
async def delete_config(db: AsyncSession = Depends(get_db)):
|
||||
"""Delete the GitHub backup configuration and all logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found")
|
||||
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Deleted GitHub backup config")
|
||||
|
||||
return {"message": "Configuration deleted"}
|
||||
|
||||
|
||||
@router.post("/test", response_model=GitHubTestConnectionResponse)
|
||||
async def test_connection(
|
||||
repo_url: str = Query(..., description="GitHub repository URL"),
|
||||
token: str = Query(..., description="Personal Access Token"),
|
||||
):
|
||||
"""Test GitHub connection with provided credentials."""
|
||||
result = await github_backup_service.test_connection(repo_url, token)
|
||||
return GitHubTestConnectionResponse(**result)
|
||||
|
||||
|
||||
@router.post("/test-stored", response_model=GitHubTestConnectionResponse)
|
||||
async def test_stored_connection(db: AsyncSession = Depends(get_db)):
|
||||
"""Test GitHub connection using stored configuration."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found")
|
||||
|
||||
if not config.access_token:
|
||||
raise HTTPException(status_code=400, detail="No access token configured")
|
||||
|
||||
test_result = await github_backup_service.test_connection(config.repository_url, config.access_token)
|
||||
return GitHubTestConnectionResponse(**test_result)
|
||||
|
||||
|
||||
@router.post("/run", response_model=GitHubBackupTriggerResponse)
|
||||
async def trigger_backup(db: AsyncSession = Depends(get_db)):
|
||||
"""Manually trigger a backup."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
|
||||
|
||||
if not config.enabled:
|
||||
raise HTTPException(status_code=400, detail="Backup is disabled")
|
||||
|
||||
backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
|
||||
|
||||
return GitHubBackupTriggerResponse(**backup_result)
|
||||
|
||||
|
||||
@router.get("/status", response_model=GitHubBackupStatus)
|
||||
async def get_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get current backup status."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return GitHubBackupStatus(
|
||||
configured=False,
|
||||
enabled=False,
|
||||
is_running=False,
|
||||
progress=None,
|
||||
last_backup_at=None,
|
||||
last_backup_status=None,
|
||||
next_scheduled_run=None,
|
||||
)
|
||||
|
||||
return GitHubBackupStatus(
|
||||
configured=True,
|
||||
enabled=config.enabled,
|
||||
is_running=github_backup_service.is_running,
|
||||
progress=github_backup_service.progress,
|
||||
last_backup_at=config.last_backup_at,
|
||||
last_backup_status=config.last_backup_status,
|
||||
next_scheduled_run=config.next_scheduled_run,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=list[GitHubBackupLogResponse])
|
||||
async def get_logs(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get backup logs."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return []
|
||||
|
||||
logs_result = await db.execute(
|
||||
select(GitHubBackupLog)
|
||||
.where(GitHubBackupLog.config_id == config.id)
|
||||
.order_by(desc(GitHubBackupLog.started_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
logs = logs_result.scalars().all()
|
||||
|
||||
return [
|
||||
GitHubBackupLogResponse(
|
||||
id=log.id,
|
||||
config_id=log.config_id,
|
||||
started_at=log.started_at,
|
||||
completed_at=log.completed_at,
|
||||
status=log.status,
|
||||
trigger=log.trigger,
|
||||
commit_sha=log.commit_sha,
|
||||
files_changed=log.files_changed,
|
||||
error_message=log.error_message,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/logs")
|
||||
async def clear_logs(
|
||||
keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clear backup logs, optionally keeping the most recent entries."""
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
return {"deleted": 0, "message": "No configuration found"}
|
||||
|
||||
if keep_last > 0:
|
||||
# Get IDs to keep
|
||||
keep_result = await db.execute(
|
||||
select(GitHubBackupLog.id)
|
||||
.where(GitHubBackupLog.config_id == config.id)
|
||||
.order_by(desc(GitHubBackupLog.started_at))
|
||||
.limit(keep_last)
|
||||
)
|
||||
keep_ids = [row[0] for row in keep_result.fetchall()]
|
||||
|
||||
if keep_ids:
|
||||
delete_result = await db.execute(
|
||||
delete(GitHubBackupLog).where(
|
||||
GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
|
||||
)
|
||||
)
|
||||
else:
|
||||
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
|
||||
else:
|
||||
delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
|
||||
|
||||
await db.commit()
|
||||
|
||||
deleted_count = delete_result.rowcount
|
||||
logger.info(f"Deleted {deleted_count} GitHub backup logs (kept {keep_last})")
|
||||
|
||||
return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}
|
||||
@@ -15,6 +15,7 @@ from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.external_link import ExternalLink
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig
|
||||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
from backend.app.models.notification import NotificationProvider
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
@@ -256,6 +257,7 @@ async def export_backup(
|
||||
include_users: bool = Query(
|
||||
False, description="Include users (passwords not exported - users will need new passwords)"
|
||||
),
|
||||
include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
|
||||
):
|
||||
"""Export selected data as JSON backup."""
|
||||
backup: dict = {
|
||||
@@ -854,6 +856,24 @@ async def export_backup(
|
||||
)
|
||||
backup["included"].append("users")
|
||||
|
||||
# GitHub backup configuration
|
||||
if include_github_backup:
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
backup["github_backup"] = {
|
||||
"repository_url": config.repository_url,
|
||||
# access_token intentionally not exported for security
|
||||
"branch": config.branch,
|
||||
"schedule_enabled": config.schedule_enabled,
|
||||
"schedule_type": config.schedule_type,
|
||||
"backup_kprofiles": config.backup_kprofiles,
|
||||
"backup_cloud_profiles": config.backup_cloud_profiles,
|
||||
"backup_settings": config.backup_settings,
|
||||
"enabled": config.enabled,
|
||||
}
|
||||
backup["included"].append("github_backup")
|
||||
|
||||
# If there are files to include (icons or archives), create ZIP file
|
||||
if backup_files:
|
||||
zip_buffer = io.BytesIO()
|
||||
@@ -953,6 +973,7 @@ async def import_backup(
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
"github_backup": 0,
|
||||
}
|
||||
skipped = {
|
||||
"settings": 0,
|
||||
@@ -967,6 +988,7 @@ async def import_backup(
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
"github_backup": 0,
|
||||
}
|
||||
skipped_details = {
|
||||
"notification_providers": [],
|
||||
@@ -1966,6 +1988,42 @@ async def import_backup(
|
||||
restored["users"] += 1
|
||||
new_users.append(f"{user_data['username']} (temp password: {temp_password})")
|
||||
|
||||
# Restore GitHub backup configuration (note: access_token not included for security)
|
||||
if "github_backup" in backup:
|
||||
github_data = backup["github_backup"]
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.repository_url = github_data.get("repository_url", existing.repository_url)
|
||||
existing.branch = github_data.get("branch", existing.branch)
|
||||
existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled)
|
||||
existing.schedule_type = github_data.get("schedule_type", existing.schedule_type)
|
||||
existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles)
|
||||
existing.backup_cloud_profiles = github_data.get(
|
||||
"backup_cloud_profiles", existing.backup_cloud_profiles
|
||||
)
|
||||
existing.backup_settings = github_data.get("backup_settings", existing.backup_settings)
|
||||
existing.enabled = github_data.get("enabled", existing.enabled)
|
||||
# Note: access_token must be re-entered after restore
|
||||
restored["github_backup"] += 1
|
||||
else:
|
||||
skipped["github_backup"] += 1
|
||||
else:
|
||||
config = GitHubBackupConfig(
|
||||
repository_url=github_data.get("repository_url", ""),
|
||||
access_token="", # Must be entered after restore
|
||||
branch=github_data.get("branch", "main"),
|
||||
schedule_enabled=github_data.get("schedule_enabled", False),
|
||||
schedule_type=github_data.get("schedule_type", "daily"),
|
||||
backup_kprofiles=github_data.get("backup_kprofiles", True),
|
||||
backup_cloud_profiles=github_data.get("backup_cloud_profiles", True),
|
||||
backup_settings=github_data.get("backup_settings", False),
|
||||
enabled=False, # Disabled until token is entered
|
||||
)
|
||||
db.add(config)
|
||||
restored["github_backup"] += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
|
||||
|
||||
@@ -38,6 +38,7 @@ async def init_db():
|
||||
archive,
|
||||
external_link,
|
||||
filament,
|
||||
github_backup,
|
||||
kprofile_note,
|
||||
library,
|
||||
maintenance,
|
||||
|
||||
@@ -182,6 +182,7 @@ from backend.app.api.routes import (
|
||||
external_links,
|
||||
filaments,
|
||||
firmware,
|
||||
github_backup,
|
||||
kprofiles,
|
||||
library,
|
||||
maintenance,
|
||||
@@ -209,6 +210,7 @@ from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
from backend.app.services.notification_service import notification_service
|
||||
@@ -2383,6 +2385,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start the notification digest scheduler
|
||||
notification_service.start_digest_scheduler()
|
||||
|
||||
# Start the GitHub backup scheduler
|
||||
await github_backup_service.start_scheduler()
|
||||
|
||||
# Start AMS history recording
|
||||
start_ams_history_recording()
|
||||
|
||||
@@ -2422,6 +2427,7 @@ async def lifespan(app: FastAPI):
|
||||
print_scheduler.stop()
|
||||
smart_plug_manager.stop_scheduler()
|
||||
notification_service.stop_digest_scheduler()
|
||||
github_backup_service.stop_scheduler()
|
||||
stop_ams_history_recording()
|
||||
stop_runtime_tracking()
|
||||
printer_manager.disconnect_all()
|
||||
@@ -2468,6 +2474,7 @@ app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(discovery.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(pending_uploads.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(firmware.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(github_backup.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
# Serve static files (React build)
|
||||
|
||||
@@ -2,6 +2,7 @@ from backend.app.models.ams_history import AMSSensorHistory
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.kprofile_note import KProfileNote
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
@@ -33,4 +34,6 @@ __all__ = [
|
||||
"LibraryFolder",
|
||||
"LibraryFile",
|
||||
"User",
|
||||
"GitHubBackupConfig",
|
||||
"GitHubBackupLog",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""GitHub backup configuration and log models."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class GitHubBackupConfig(Base):
|
||||
"""Configuration for GitHub profile backup."""
|
||||
|
||||
__tablename__ = "github_backup_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
repository_url: Mapped[str] = mapped_column(String(500)) # Full GitHub URL
|
||||
access_token: Mapped[str] = mapped_column(Text) # Personal Access Token
|
||||
branch: Mapped[str] = mapped_column(String(100), default="main")
|
||||
|
||||
# Schedule configuration
|
||||
schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
schedule_type: Mapped[str] = mapped_column(String(20), default="daily") # hourly/daily/weekly
|
||||
schedule_cron: Mapped[str | None] = mapped_column(String(100), nullable=True) # For future cron support
|
||||
|
||||
# What to backup
|
||||
backup_kprofiles: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
backup_cloud_profiles: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
backup_settings: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Status tracking
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_backup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_backup_status: Mapped[str | None] = mapped_column(String(20), nullable=True) # success/failed/skipped
|
||||
last_backup_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_backup_commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
next_scheduled_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
logs: Mapped[list["GitHubBackupLog"]] = relationship(back_populates="config", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class GitHubBackupLog(Base):
|
||||
"""Log entry for GitHub backup runs."""
|
||||
|
||||
__tablename__ = "github_backup_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
config_id: Mapped[int] = mapped_column(ForeignKey("github_backup_config.id", ondelete="CASCADE"))
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20)) # running/success/failed/skipped
|
||||
trigger: Mapped[str] = mapped_column(String(20)) # manual/scheduled
|
||||
|
||||
commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
files_changed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
config: Mapped["GitHubBackupConfig"] = relationship(back_populates="logs")
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Pydantic schemas for GitHub backup configuration."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ScheduleType(str, Enum):
|
||||
"""Backup schedule types."""
|
||||
|
||||
HOURLY = "hourly"
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
|
||||
|
||||
class GitHubBackupConfigCreate(BaseModel):
|
||||
"""Schema for creating/updating GitHub backup config."""
|
||||
|
||||
repository_url: str = Field(..., min_length=1, max_length=500, description="GitHub repository URL")
|
||||
access_token: str = Field(..., min_length=1, description="Personal Access Token")
|
||||
branch: str = Field(default="main", max_length=100, description="Branch to push to")
|
||||
|
||||
schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
|
||||
schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
|
||||
|
||||
backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
|
||||
backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
|
||||
backup_settings: bool = Field(default=False, description="Backup app settings")
|
||||
|
||||
enabled: bool = Field(default=True, description="Enable backup feature")
|
||||
|
||||
@field_validator("repository_url")
|
||||
@classmethod
|
||||
def validate_repo_url(cls, v: str) -> str:
|
||||
"""Validate GitHub repository URL format."""
|
||||
# Accept various GitHub URL formats
|
||||
patterns = [
|
||||
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
]
|
||||
v = v.strip().rstrip("/")
|
||||
if not any(re.match(p, v) for p in patterns):
|
||||
raise ValueError("Invalid GitHub repository URL. Expected format: https://github.com/owner/repo")
|
||||
return v
|
||||
|
||||
|
||||
class GitHubBackupConfigUpdate(BaseModel):
|
||||
"""Schema for updating GitHub backup config (all fields optional)."""
|
||||
|
||||
repository_url: str | None = Field(default=None, max_length=500)
|
||||
access_token: str | None = Field(default=None)
|
||||
branch: str | None = Field(default=None, max_length=100)
|
||||
|
||||
schedule_enabled: bool | None = None
|
||||
schedule_type: ScheduleType | None = None
|
||||
|
||||
backup_kprofiles: bool | None = None
|
||||
backup_cloud_profiles: bool | None = None
|
||||
backup_settings: bool | None = None
|
||||
|
||||
enabled: bool | None = None
|
||||
|
||||
@field_validator("repository_url")
|
||||
@classmethod
|
||||
def validate_repo_url(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
patterns = [
|
||||
r"^https://github\.com/[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
r"^git@github\.com:[\w.-]+/[\w.-]+(?:\.git)?$",
|
||||
]
|
||||
v = v.strip().rstrip("/")
|
||||
if not any(re.match(p, v) for p in patterns):
|
||||
raise ValueError("Invalid GitHub repository URL")
|
||||
return v
|
||||
|
||||
|
||||
class GitHubBackupConfigResponse(BaseModel):
|
||||
"""Schema for GitHub backup config API response."""
|
||||
|
||||
id: int
|
||||
repository_url: str
|
||||
has_token: bool = Field(description="Whether an access token is configured")
|
||||
branch: str
|
||||
|
||||
schedule_enabled: bool
|
||||
schedule_type: str
|
||||
|
||||
backup_kprofiles: bool
|
||||
backup_cloud_profiles: bool
|
||||
backup_settings: bool
|
||||
|
||||
enabled: bool
|
||||
last_backup_at: datetime | None
|
||||
last_backup_status: str | None
|
||||
last_backup_message: str | None
|
||||
last_backup_commit_sha: str | None
|
||||
next_scheduled_run: datetime | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GitHubBackupLogResponse(BaseModel):
|
||||
"""Schema for backup log API response."""
|
||||
|
||||
id: int
|
||||
config_id: int
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
status: str
|
||||
trigger: str
|
||||
commit_sha: str | None
|
||||
files_changed: int
|
||||
error_message: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GitHubBackupStatus(BaseModel):
|
||||
"""Schema for current backup status."""
|
||||
|
||||
configured: bool = Field(description="Whether backup is configured")
|
||||
enabled: bool = Field(description="Whether backup is enabled")
|
||||
is_running: bool = Field(description="Whether a backup is currently running")
|
||||
progress: str | None = Field(default=None, description="Current backup progress message")
|
||||
last_backup_at: datetime | None
|
||||
last_backup_status: str | None
|
||||
next_scheduled_run: datetime | None
|
||||
|
||||
|
||||
class GitHubTestConnectionResponse(BaseModel):
|
||||
"""Schema for test connection response."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
repo_name: str | None = None
|
||||
permissions: dict | None = None
|
||||
|
||||
|
||||
class GitHubBackupTriggerResponse(BaseModel):
|
||||
"""Schema for manual backup trigger response."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
log_id: int | None = None
|
||||
commit_sha: str | None = None
|
||||
files_changed: int = 0
|
||||
@@ -0,0 +1,734 @@
|
||||
"""GitHub backup service for printer profiles.
|
||||
|
||||
Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.bambu_cloud import get_cloud_service
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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 as e:
|
||||
logger.error(f"Error in GitHub backup scheduler: {e}")
|
||||
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(UTC)
|
||||
for config in configs:
|
||||
if config.next_scheduled_run and config.next_scheduled_run <= now:
|
||||
logger.info(f"Running scheduled backup for config {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(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) -> dict:
|
||||
"""Test GitHub connection and permissions.
|
||||
|
||||
Args:
|
||||
repo_url: GitHub repository URL
|
||||
token: Personal Access Token
|
||||
|
||||
Returns:
|
||||
dict with success, message, repo_name, permissions
|
||||
"""
|
||||
try:
|
||||
owner, repo = self._parse_repo_url(repo_url)
|
||||
client = await self._get_client()
|
||||
|
||||
# Test API access
|
||||
response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
|
||||
|
||||
if response.status_code == 404:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Repository not found. Check URL and token permissions.",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"GitHub API error: {response.status_code}",
|
||||
"repo_name": None,
|
||||
"permissions": None,
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
permissions = data.get("permissions", {})
|
||||
|
||||
# Check for push permission
|
||||
if not permissions.get("push", False):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Token does not have push permission to this repository",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Connection successful",
|
||||
"repo_name": data.get("full_name"),
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GitHub connection test failed: {e}")
|
||||
return {"success": False, "message": str(e), "repo_name": None, "permissions": None}
|
||||
|
||||
def _parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Parse owner and repo from GitHub URL."""
|
||||
# Handle HTTPS URLs
|
||||
match = re.match(r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
# Handle SSH URLs
|
||||
match = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
raise ValueError(f"Invalid GitHub URL: {url}")
|
||||
|
||||
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(UTC)
|
||||
log.error_message = "No data to backup"
|
||||
config.last_backup_at = datetime.now(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,
|
||||
}
|
||||
|
||||
# Push to GitHub
|
||||
self._backup_progress = "Pushing to GitHub..."
|
||||
push_result = await self._push_to_github(config, backup_data)
|
||||
|
||||
# Update log and config
|
||||
log.status = push_result["status"]
|
||||
log.completed_at = datetime.now(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(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.error(f"Backup failed: {e}")
|
||||
log.status = "failed"
|
||||
log.completed_at = datetime.now(UTC)
|
||||
log.error_message = str(e)
|
||||
|
||||
config.last_backup_at = datetime.now(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] = {}
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Metadata file
|
||||
metadata = {
|
||||
"version": "1.0",
|
||||
"backup_type": "bambuddy_profiles",
|
||||
"created_at": now.isoformat(),
|
||||
"contents": {
|
||||
"kprofiles": config.backup_kprofiles,
|
||||
"cloud_profiles": config.backup_cloud_profiles,
|
||||
"settings": config.backup_settings,
|
||||
},
|
||||
}
|
||||
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)
|
||||
|
||||
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,
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"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(f"Failed to get K-profiles for printer {serial} nozzle {nozzle}: {e}")
|
||||
|
||||
if printer_profiles:
|
||||
logger.info(f"Collected K-profiles for {serial}: {printer_profiles}")
|
||||
|
||||
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
|
||||
"""Collect Bambu Cloud profiles if authenticated."""
|
||||
# Check if cloud is authenticated
|
||||
cloud = get_cloud_service()
|
||||
|
||||
# Try to restore token from DB
|
||||
result = await db.execute(select(Settings).where(Settings.key == "bambu_cloud_token"))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value:
|
||||
cloud.set_token(setting.value)
|
||||
|
||||
if not cloud.is_authenticated:
|
||||
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",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": filament_settings,
|
||||
}
|
||||
|
||||
if printer_settings:
|
||||
files["cloud_profiles/printer.json"] = {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": printer_settings,
|
||||
}
|
||||
|
||||
if process_settings:
|
||||
files["cloud_profiles/process.json"] = {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"profiles": process_settings,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Collected cloud profiles: {len(filament_settings)} filament, "
|
||||
f"{len(printer_settings)} printer, {len(process_settings)} process"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to collect cloud profiles: {e}")
|
||||
|
||||
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",
|
||||
"exported_at": datetime.now(UTC).isoformat(),
|
||||
"settings": settings_data,
|
||||
}
|
||||
|
||||
async def _push_to_github(self, config: GitHubBackupConfig, files: dict) -> dict:
|
||||
"""Push files to GitHub using the GitHub API.
|
||||
|
||||
Uses the Git Data API to create blobs, tree, and commit.
|
||||
|
||||
Returns:
|
||||
dict with status, message, commit_sha, files_changed
|
||||
"""
|
||||
try:
|
||||
owner, repo = self._parse_repo_url(config.repository_url)
|
||||
branch = config.branch
|
||||
client = await self._get_client()
|
||||
headers = {
|
||||
"Authorization": f"token {config.access_token}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "Bambuddy-Backup",
|
||||
}
|
||||
|
||||
# Get current branch reference
|
||||
ref_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers
|
||||
)
|
||||
|
||||
if ref_response.status_code == 404:
|
||||
# Branch doesn't exist, need to create it from default branch
|
||||
return await self._create_branch_and_push(client, headers, owner, repo, branch, files)
|
||||
|
||||
if ref_response.status_code != 200:
|
||||
return {
|
||||
"status": "failed",
|
||||
"message": f"Failed to get branch ref: {ref_response.status_code}",
|
||||
"error": ref_response.text,
|
||||
}
|
||||
|
||||
ref_data = ref_response.json()
|
||||
current_commit_sha = ref_data["object"]["sha"]
|
||||
|
||||
# Get the current tree
|
||||
commit_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers
|
||||
)
|
||||
if commit_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get current commit"}
|
||||
|
||||
current_tree_sha = commit_response.json()["tree"]["sha"]
|
||||
|
||||
# Get existing files to check for changes
|
||||
tree_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers
|
||||
)
|
||||
existing_files = {}
|
||||
if tree_response.status_code == 200:
|
||||
for item in tree_response.json().get("tree", []):
|
||||
if item["type"] == "blob":
|
||||
existing_files[item["path"]] = item["sha"]
|
||||
|
||||
# Create blobs for changed files
|
||||
tree_items = []
|
||||
files_changed = 0
|
||||
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
content_bytes = content_str.encode("utf-8")
|
||||
content_sha = hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes).hexdigest()
|
||||
|
||||
# Skip if file hasn't changed
|
||||
if path in existing_files and existing_files[path] == content_sha:
|
||||
continue
|
||||
|
||||
# Create blob
|
||||
blob_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"},
|
||||
)
|
||||
|
||||
if blob_response.status_code != 201:
|
||||
logger.error(f"Failed to create blob for {path}: {blob_response.text}")
|
||||
continue
|
||||
|
||||
blob_sha = blob_response.json()["sha"]
|
||||
tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_sha})
|
||||
files_changed += 1
|
||||
|
||||
if not tree_items:
|
||||
return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0}
|
||||
|
||||
# Create new tree
|
||||
tree_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"base_tree": current_tree_sha, "tree": tree_items},
|
||||
)
|
||||
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create tree: {tree_response.text}"}
|
||||
|
||||
new_tree_sha = tree_response.json()["sha"]
|
||||
|
||||
# Create commit
|
||||
commit_message = f"Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||
commit_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]},
|
||||
)
|
||||
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create commit: {commit_response.text}"}
|
||||
|
||||
new_commit_sha = commit_response.json()["sha"]
|
||||
|
||||
# Update branch reference
|
||||
ref_update = await client.patch(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{branch}",
|
||||
headers=headers,
|
||||
json={"sha": new_commit_sha},
|
||||
)
|
||||
|
||||
if ref_update.status_code != 200:
|
||||
return {"status": "failed", "message": f"Failed to update branch: {ref_update.text}"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Backup successful - {files_changed} files updated",
|
||||
"commit_sha": new_commit_sha,
|
||||
"files_changed": files_changed,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Push to GitHub failed: {e}")
|
||||
return {"status": "failed", "message": str(e), "error": str(e)}
|
||||
|
||||
async def _create_branch_and_push(
|
||||
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
|
||||
) -> dict:
|
||||
"""Create a new branch and push files when branch doesn't exist."""
|
||||
try:
|
||||
# Get default branch
|
||||
repo_response = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
||||
if repo_response.status_code != 200:
|
||||
return {"status": "failed", "message": "Failed to get repo info"}
|
||||
|
||||
default_branch = repo_response.json().get("default_branch", "main")
|
||||
|
||||
# Get default branch ref
|
||||
ref_response = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers
|
||||
)
|
||||
if ref_response.status_code != 200:
|
||||
# Empty repo - create initial commit
|
||||
return await self._create_initial_commit(client, headers, owner, repo, branch, files)
|
||||
|
||||
base_sha = ref_response.json()["object"]["sha"]
|
||||
|
||||
# Create new branch
|
||||
create_ref = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": base_sha},
|
||||
)
|
||||
|
||||
if create_ref.status_code != 201:
|
||||
return {"status": "failed", "message": f"Failed to create branch: {create_ref.text}"}
|
||||
|
||||
# Now push to the new branch (recursive call will find the branch)
|
||||
return await self._push_to_github(
|
||||
type(
|
||||
"Config",
|
||||
(),
|
||||
{
|
||||
"repository_url": f"https://github.com/{owner}/{repo}",
|
||||
"access_token": headers["Authorization"].replace("token ", ""),
|
||||
"branch": branch,
|
||||
},
|
||||
)(),
|
||||
files,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
async def _create_initial_commit(
|
||||
self, client: httpx.AsyncClient, headers: dict, owner: str, repo: str, branch: str, files: dict
|
||||
) -> dict:
|
||||
"""Create initial commit in an empty repository."""
|
||||
try:
|
||||
# Create blobs
|
||||
tree_items = []
|
||||
for path, content in files.items():
|
||||
content_str = json.dumps(content, indent=2, default=str)
|
||||
blob_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/blobs",
|
||||
headers=headers,
|
||||
json={"content": base64.b64encode(content_str.encode()).decode(), "encoding": "base64"},
|
||||
)
|
||||
if blob_response.status_code == 201:
|
||||
tree_items.append(
|
||||
{"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}
|
||||
)
|
||||
|
||||
# Create tree
|
||||
tree_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/trees",
|
||||
headers=headers,
|
||||
json={"tree": tree_items},
|
||||
)
|
||||
if tree_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create tree"}
|
||||
|
||||
tree_sha = tree_response.json()["sha"]
|
||||
|
||||
# Create commit (no parents for initial)
|
||||
commit_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/commits",
|
||||
headers=headers,
|
||||
json={
|
||||
"message": f"Initial Bambuddy backup - {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
||||
"tree": tree_sha,
|
||||
},
|
||||
)
|
||||
if commit_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create commit"}
|
||||
|
||||
commit_sha = commit_response.json()["sha"]
|
||||
|
||||
# Create branch ref
|
||||
ref_response = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/git/refs",
|
||||
headers=headers,
|
||||
json={"ref": f"refs/heads/{branch}", "sha": commit_sha},
|
||||
)
|
||||
if ref_response.status_code != 201:
|
||||
return {"status": "failed", "message": "Failed to create branch ref"}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Initial backup created - {len(files)} files",
|
||||
"commit_sha": commit_sha,
|
||||
"files_changed": len(files),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "failed", "message": str(e)}
|
||||
|
||||
@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()
|
||||
Reference in New Issue
Block a user