mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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.
309 lines
10 KiB
Python
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)
|