Files
bambuddy/backend/app/services/git_providers/base.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

137 lines
4.8 KiB
Python

"""Abstract base class for Git hosting provider backends."""
import hashlib
from abc import ABC, abstractmethod
import httpx
class GitProviderBackend(ABC):
"""Abstract base for Git hosting provider API backends."""
@staticmethod
def _blob_sha(content_bytes: bytes) -> str:
"""Compute the git blob SHA for content_bytes (sha1("blob {len}\\0" + data))."""
return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
@staticmethod
def _truncated_response_text(response: httpx.Response, max_length: int = 200) -> str:
"""Return a bounded response body for errors surfaced to logs/UI."""
text = response.text
if len(text) <= max_length:
return text
return f"{text[: max_length - 3]}..."
@staticmethod
def _read_sha(response: httpx.Response, *path: str) -> tuple[str | None, str | None]:
"""Walk a JSON path to a string SHA value.
Returns ``(sha, None)`` on success, ``(None, reason)`` if the body is
not JSON, the path is missing, or the leaf is not a string. Callers
use the reason to build a clear failure message instead of letting
``KeyError``/``JSONDecodeError`` bubble to the outer catch-all (which
surfaces cryptic one-word strings like ``"'object'"`` to operators).
"""
try:
data = response.json()
except ValueError:
return None, "non-JSON response body"
for key in path:
if not isinstance(data, dict):
return None, f"unexpected shape at key {key!r}"
if key not in data:
return None, f"missing key {key!r}"
data = data[key]
if not isinstance(data, str):
return None, f"value at {'.'.join(path)} is not a string"
return data, None
def get_headers(self, token: str) -> dict:
"""Return HTTP headers for authenticated API requests."""
return {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3+json",
"User-Agent": "Bambuddy-Backup",
}
@abstractmethod
def parse_repo_url(self, url: str) -> tuple[str, str]:
"""Return (owner, repo) extracted from the repository URL."""
@abstractmethod
def get_api_base(self, repo_url: str) -> str:
"""Return the API base URL for this provider instance."""
@abstractmethod
async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
"""Test API connectivity and push permissions. Returns success/message/repo_name/permissions."""
@abstractmethod
async def push_files(
self,
repo_url: str,
token: str,
branch: str,
files: dict,
client: httpx.AsyncClient,
) -> dict:
"""Push files to the repository. Returns status/message/commit_sha/files_changed."""
# --- Read side (restore, issue #2656) ---------------------------------
# The backup path only ever writes. Restore needs to walk history, list a
# snapshot and read individual blobs back, so these three mirror the
# ``{"success": bool, "message": str, ...}`` convention ``test_connection``
# already uses rather than raising.
@abstractmethod
async def list_commits(
self,
repo_url: str,
token: str,
branch: str,
client: httpx.AsyncClient,
limit: int = 20,
) -> dict:
"""List recent commits on ``branch``, newest first.
Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
"""
@abstractmethod
async def list_tree(
self,
repo_url: str,
token: str,
ref: str,
client: httpx.AsyncClient,
) -> dict:
"""List every blob path present at ``ref``.
``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
via :meth:`list_commits` first, so the snapshot being previewed and the
one being restored are provably the same commit even if a scheduled
backup lands in between.
Returns ``{"success", "message", "paths": [str]}``.
"""
@abstractmethod
async def fetch_files(
self,
repo_url: str,
token: str,
ref: str,
paths: list[str],
client: httpx.AsyncClient,
) -> dict:
"""Read several files' decoded UTF-8 text at ``ref``.
Batched rather than one-file-at-a-time so providers that need a tree
listing to map path -> blob SHA can do that lookup once for the whole
restore instead of per file.
Returns ``{"success", "message", "files": {path: text}}``. Paths absent
from the commit are simply missing from ``files`` — that is not an error,
since which categories a given backup contains varies by config.
"""