Files
bambuddy/backend/app/schemas/github_backup.py
T
maziggy 455a9e4ba7 fix(backup): collect cloud profiles from every connected account (#2717)
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.

The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.

It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.

Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.

Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:

    cloud_profiles/bambu/user-3/{filament,printer,process}.json
    cloud_profiles/orca/user-3/{filament,printer,process}.json

Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.

Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.

Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.

The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.

The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.

One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.

Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
2026-07-31 16:59:33 +02:00

207 lines
7.0 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")
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