Files
bambuddy/backend/app/schemas/github_backup.py
T
jmoore-skild 6a239314dc feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.

Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.

The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.

Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.

Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.

Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.

Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.

Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.

Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
2026-08-04 08:22:57 -04:00

309 lines
10 KiB
Python

"""Pydantic schemas for GitHub backup configuration."""
import re
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
from backend.app.core.compat import StrEnum
class ScheduleType(StrEnum):
"""Backup schedule types."""
HOURLY = "hourly"
DAILY = "daily"
WEEKLY = "weekly"
class ProviderType(StrEnum):
"""Git hosting provider types."""
GITHUB = "github"
GITLAB = "gitlab"
GITEA = "gitea"
FORGEJO = "forgejo"
class GitHubBackupConfigCreate(BaseModel):
"""Schema for creating/updating GitHub backup config."""
repository_url: str = Field(..., min_length=1, max_length=500, description="Git 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")
provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git hosting provider")
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")
backup_spools: bool = Field(default=False, description="Backup spool inventory")
backup_archives: bool = Field(default=False, description="Backup print archive history")
allow_insecure_http: bool = Field(default=False, description="Allow HTTP (non-TLS) repository URLs")
enabled: bool = Field(default=True, description="Enable backup feature")
@model_validator(mode="after")
def validate_repo_url(self) -> "GitHubBackupConfigCreate":
url = self.repository_url.strip().rstrip("/")
self.repository_url = url
https_or_ssh = [
r"^https://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
]
http_pattern = r"^http://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$"
if any(re.match(p, url) for p in https_or_ssh):
return self
if re.match(http_pattern, url):
if not self.allow_insecure_http:
raise ValueError(
"This URL uses HTTP instead of HTTPS. "
"Enable 'Allow insecure HTTP' if your instance does not use TLS."
)
return self
raise ValueError(
"Invalid Git repository URL. Expected: https://host/owner/repo, "
"http://host/owner/repo (with 'Allow insecure HTTP' enabled), or git@host:owner/repo"
)
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)
provider: ProviderType | None = None
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
backup_spools: bool | None = None
backup_archives: bool | None = None
allow_insecure_http: bool | None = None
enabled: bool | None = None
@model_validator(mode="after")
def validate_repo_url(self) -> "GitHubBackupConfigUpdate":
if self.repository_url is None:
return self
url = self.repository_url.strip().rstrip("/")
self.repository_url = url
valid_patterns = [
r"^https?://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
]
if not any(re.match(p, url) for p in valid_patterns):
raise ValueError(
"Invalid repository URL. Expected: https://host/owner/repo, "
"http://host/owner/repo, or git@host:owner/repo"
)
return self
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
provider: str
allow_insecure_http: bool
schedule_enabled: bool
schedule_type: str
backup_kprofiles: bool
backup_cloud_profiles: bool
backup_settings: bool
backup_spools: bool
backup_archives: 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 CloudAccountCounts(BaseModel):
"""How many connected cloud accounts a backup would collect presets from.
Counts only, never identities: with auth enabled these are other users'
accounts, and whoever administers the backup has no business learning who
signed in to what. The number is enough to answer the only question the UI
asks — is the Cloud Profiles category worth offering at all (#2717).
"""
bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
orca: int = Field(default=0, description="Connected Orca Cloud accounts")
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")
restore_running: bool = Field(default=False, description="Whether a restore 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
# True = confirmed private. False = confirmed public (or non-private such
# as GitLab "internal"). None = could not be determined (older self-hosted
# API, non-2xx response). The backup config endpoints refuse anything that
# isn't an explicit True.
is_private: bool | 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
# --- Restore (issue #2656) --------------------------------------------------
# "HEAD" means "whatever the branch tip is right now"; the service resolves it
# to a concrete SHA before reading anything so preview and apply can't straddle
# two different commits. Anything else must look like a git object name.
REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
class RestoreCategory(StrEnum):
"""Backup categories that can be restored.
Cloud profiles are deliberately absent: the backup collector never actually
writes ``cloud_profiles/*.json`` (it reads a "setting" list key the Bambu
Cloud API does not return), and the preset list it would collect carries no
setting payload to restore from. Tracked separately from #2656.
"""
KPROFILES = "kprofiles"
SETTINGS = "settings"
SPOOLS = "spools"
ARCHIVES = "archives"
class GitHubCommitInfo(BaseModel):
"""One commit in the backup repository."""
sha: str
message: str
author: str
date: str
class GitHubCommitListResponse(BaseModel):
"""Schema for the commit picker."""
success: bool
message: str
branch: str
commits: list[GitHubCommitInfo] = Field(default_factory=list)
class GitHubRestorePreviewCategory(BaseModel):
"""What a single category looks like inside one backup commit."""
category: RestoreCategory
available: bool = Field(description="Whether this category is present in the commit")
item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
detail: str | None = Field(default=None, description="Why unavailable, or extra context")
class GitHubRestorePreview(BaseModel):
"""Schema for inspecting a commit before restoring from it."""
success: bool
message: str
ref: str = Field(description="The concrete commit SHA that was inspected")
commit: GitHubCommitInfo | None = None
metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
class GitHubRestoreRequest(BaseModel):
"""Schema for triggering a restore."""
ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
overwrite_existing: bool = Field(
default=False,
description="Update rows that already exist locally. When false, only missing rows are inserted.",
)
@model_validator(mode="after")
def deduplicate_categories(self) -> "GitHubRestoreRequest":
# Same category twice would double-count the result totals.
seen: list[RestoreCategory] = []
for category in self.categories:
if category not in seen:
seen.append(category)
self.categories = seen
return self
class GitHubRestoreCategoryResult(BaseModel):
"""Per-category outcome of a restore."""
restored: int = 0
skipped: int = 0
failed: int = 0
notes: list[str] = Field(default_factory=list)
class GitHubRestoreResponse(BaseModel):
"""Schema for the restore result."""
success: bool
message: str
log_id: int | None = None
ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)