github_backup.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Pydantic schemas for GitHub backup configuration."""
  2. import re
  3. from datetime import datetime
  4. from pydantic import BaseModel, Field, model_validator
  5. from backend.app.core.compat import StrEnum
  6. class ScheduleType(StrEnum):
  7. """Backup schedule types."""
  8. HOURLY = "hourly"
  9. DAILY = "daily"
  10. WEEKLY = "weekly"
  11. class ProviderType(StrEnum):
  12. """Git hosting provider types."""
  13. GITHUB = "github"
  14. GITLAB = "gitlab"
  15. GITEA = "gitea"
  16. FORGEJO = "forgejo"
  17. class GitHubBackupConfigCreate(BaseModel):
  18. """Schema for creating/updating GitHub backup config."""
  19. repository_url: str = Field(..., min_length=1, max_length=500, description="Git repository URL")
  20. access_token: str = Field(..., min_length=1, description="Personal Access Token")
  21. branch: str = Field(default="main", max_length=100, description="Branch to push to")
  22. provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git hosting provider")
  23. schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
  24. schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
  25. backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
  26. backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
  27. backup_settings: bool = Field(default=False, description="Backup app settings")
  28. backup_spools: bool = Field(default=False, description="Backup spool inventory")
  29. backup_archives: bool = Field(default=False, description="Backup print archive history")
  30. allow_insecure_http: bool = Field(default=False, description="Allow HTTP (non-TLS) repository URLs")
  31. enabled: bool = Field(default=True, description="Enable backup feature")
  32. @model_validator(mode="after")
  33. def validate_repo_url(self) -> "GitHubBackupConfigCreate":
  34. url = self.repository_url.strip().rstrip("/")
  35. self.repository_url = url
  36. https_or_ssh = [
  37. r"^https://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
  38. r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
  39. ]
  40. http_pattern = r"^http://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$"
  41. if any(re.match(p, url) for p in https_or_ssh):
  42. return self
  43. if re.match(http_pattern, url):
  44. if not self.allow_insecure_http:
  45. raise ValueError(
  46. "This URL uses HTTP instead of HTTPS. "
  47. "Enable 'Allow insecure HTTP' if your instance does not use TLS."
  48. )
  49. return self
  50. raise ValueError(
  51. "Invalid Git repository URL. Expected: https://host/owner/repo, "
  52. "http://host/owner/repo (with 'Allow insecure HTTP' enabled), or git@host:owner/repo"
  53. )
  54. class GitHubBackupConfigUpdate(BaseModel):
  55. """Schema for updating GitHub backup config (all fields optional)."""
  56. repository_url: str | None = Field(default=None, max_length=500)
  57. access_token: str | None = Field(default=None)
  58. branch: str | None = Field(default=None, max_length=100)
  59. provider: ProviderType | None = None
  60. schedule_enabled: bool | None = None
  61. schedule_type: ScheduleType | None = None
  62. backup_kprofiles: bool | None = None
  63. backup_cloud_profiles: bool | None = None
  64. backup_settings: bool | None = None
  65. backup_spools: bool | None = None
  66. backup_archives: bool | None = None
  67. allow_insecure_http: bool | None = None
  68. enabled: bool | None = None
  69. @model_validator(mode="after")
  70. def validate_repo_url(self) -> "GitHubBackupConfigUpdate":
  71. if self.repository_url is None:
  72. return self
  73. url = self.repository_url.strip().rstrip("/")
  74. self.repository_url = url
  75. valid_patterns = [
  76. r"^https?://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
  77. r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
  78. ]
  79. if not any(re.match(p, url) for p in valid_patterns):
  80. raise ValueError(
  81. "Invalid repository URL. Expected: https://host/owner/repo, "
  82. "http://host/owner/repo, or git@host:owner/repo"
  83. )
  84. return self
  85. class GitHubBackupConfigResponse(BaseModel):
  86. """Schema for GitHub backup config API response."""
  87. id: int
  88. repository_url: str
  89. has_token: bool = Field(description="Whether an access token is configured")
  90. branch: str
  91. provider: str
  92. allow_insecure_http: bool
  93. schedule_enabled: bool
  94. schedule_type: str
  95. backup_kprofiles: bool
  96. backup_cloud_profiles: bool
  97. backup_settings: bool
  98. backup_spools: bool
  99. backup_archives: bool
  100. enabled: bool
  101. last_backup_at: datetime | None
  102. last_backup_status: str | None
  103. last_backup_message: str | None
  104. last_backup_commit_sha: str | None
  105. next_scheduled_run: datetime | None
  106. created_at: datetime
  107. updated_at: datetime
  108. class Config:
  109. from_attributes = True
  110. class GitHubBackupLogResponse(BaseModel):
  111. """Schema for backup log API response."""
  112. id: int
  113. config_id: int
  114. started_at: datetime
  115. completed_at: datetime | None
  116. status: str
  117. trigger: str
  118. commit_sha: str | None
  119. files_changed: int
  120. error_message: str | None
  121. class Config:
  122. from_attributes = True
  123. class CloudAccountCounts(BaseModel):
  124. """How many connected cloud accounts a backup would collect presets from.
  125. Counts only, never identities: with auth enabled these are other users'
  126. accounts, and whoever administers the backup has no business learning who
  127. signed in to what. The number is enough to answer the only question the UI
  128. asks — is the Cloud Profiles category worth offering at all (#2717).
  129. """
  130. bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
  131. orca: int = Field(default=0, description="Connected Orca Cloud accounts")
  132. class GitHubBackupStatus(BaseModel):
  133. """Schema for current backup status."""
  134. configured: bool = Field(description="Whether backup is configured")
  135. enabled: bool = Field(description="Whether backup is enabled")
  136. is_running: bool = Field(description="Whether a backup is currently running")
  137. restore_running: bool = Field(default=False, description="Whether a restore is currently running")
  138. progress: str | None = Field(default=None, description="Current backup progress message")
  139. last_backup_at: datetime | None
  140. last_backup_status: str | None
  141. next_scheduled_run: datetime | None
  142. class GitHubTestConnectionResponse(BaseModel):
  143. """Schema for test connection response."""
  144. success: bool
  145. message: str
  146. repo_name: str | None = None
  147. permissions: dict | None = None
  148. # True = confirmed private. False = confirmed public (or non-private such
  149. # as GitLab "internal"). None = could not be determined (older self-hosted
  150. # API, non-2xx response). The backup config endpoints refuse anything that
  151. # isn't an explicit True.
  152. is_private: bool | None = None
  153. class GitHubBackupTriggerResponse(BaseModel):
  154. """Schema for manual backup trigger response."""
  155. success: bool
  156. message: str
  157. log_id: int | None = None
  158. commit_sha: str | None = None
  159. files_changed: int = 0
  160. # --- Restore (issue #2656) --------------------------------------------------
  161. # "HEAD" means "whatever the branch tip is right now"; the service resolves it
  162. # to a concrete SHA before reading anything so preview and apply can't straddle
  163. # two different commits. Anything else must look like a git object name.
  164. REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
  165. class RestoreCategory(StrEnum):
  166. """Backup categories that can be restored.
  167. Cloud profiles are deliberately absent: the backup collector never actually
  168. writes ``cloud_profiles/*.json`` (it reads a "setting" list key the Bambu
  169. Cloud API does not return), and the preset list it would collect carries no
  170. setting payload to restore from. Tracked separately from #2656.
  171. """
  172. KPROFILES = "kprofiles"
  173. SETTINGS = "settings"
  174. SPOOLS = "spools"
  175. ARCHIVES = "archives"
  176. class GitHubCommitInfo(BaseModel):
  177. """One commit in the backup repository."""
  178. sha: str
  179. message: str
  180. author: str
  181. date: str
  182. class GitHubCommitListResponse(BaseModel):
  183. """Schema for the commit picker."""
  184. success: bool
  185. message: str
  186. branch: str
  187. commits: list[GitHubCommitInfo] = Field(default_factory=list)
  188. class GitHubRestorePreviewCategory(BaseModel):
  189. """What a single category looks like inside one backup commit."""
  190. category: RestoreCategory
  191. available: bool = Field(description="Whether this category is present in the commit")
  192. item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
  193. detail: str | None = Field(default=None, description="Why unavailable, or extra context")
  194. class GitHubRestorePreview(BaseModel):
  195. """Schema for inspecting a commit before restoring from it."""
  196. success: bool
  197. message: str
  198. ref: str = Field(description="The concrete commit SHA that was inspected")
  199. commit: GitHubCommitInfo | None = None
  200. metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
  201. categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
  202. class GitHubRestoreRequest(BaseModel):
  203. """Schema for triggering a restore."""
  204. ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
  205. categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
  206. overwrite_existing: bool = Field(
  207. default=False,
  208. description="Update rows that already exist locally. When false, only missing rows are inserted.",
  209. )
  210. @model_validator(mode="after")
  211. def deduplicate_categories(self) -> "GitHubRestoreRequest":
  212. # Same category twice would double-count the result totals.
  213. seen: list[RestoreCategory] = []
  214. for category in self.categories:
  215. if category not in seen:
  216. seen.append(category)
  217. self.categories = seen
  218. return self
  219. class GitHubRestoreCategoryResult(BaseModel):
  220. """Per-category outcome of a restore."""
  221. restored: int = 0
  222. skipped: int = 0
  223. failed: int = 0
  224. notes: list[str] = Field(default_factory=list)
  225. class GitHubRestoreResponse(BaseModel):
  226. """Schema for the restore result."""
  227. success: bool
  228. message: str
  229. log_id: int | None = None
  230. ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
  231. results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)