github_restore.py 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
  1. """Restore Bambuddy data from a Git provider backup (issue #2656).
  2. The backup side (``github_backup.py``) is push-only: it collects a handful of
  3. JSON documents and commits them. This module is the read side — it walks the
  4. backup repository's history, lets a caller inspect what a given commit contains,
  5. and applies selected categories back into the local database (or, for
  6. K-profiles, back onto the printers).
  7. Design notes worth knowing before editing:
  8. * **A restore never reuses the backup's primary keys.** ``spool.id`` and
  9. ``print_archives.id`` are bare autoincrement columns, so the ids in a backup
  10. taken weeks ago very likely belong to unrelated rows today. Rows are matched
  11. on natural keys instead, inserted without an explicit id, and an
  12. ``old_id -> new_id`` map is threaded through so foreign keys in dependent
  13. tables (spool usage history) still line up.
  14. The printer-side ``cali_idx`` behaves the same way and gets the same
  15. treatment. Editing a K-profile in Bambuddy is a delete-then-add on a
  16. single-nozzle printer, which re-keys it, and ``extrusion_cali_set`` aimed at a
  17. slot that no longer exists is silently dropped — so the live index is read
  18. back and matched before writing, never taken from the backup.
  19. * **Categories are applied archives -> spools -> settings -> kprofiles.**
  20. Archives first because spool usage history references ``archive_id``;
  21. K-profiles last because they leave the database and talk to hardware.
  22. * **Cloud profiles are not restorable.** The backup collector never actually
  23. writes ``cloud_profiles/*.json``, and the preset list it would write carries
  24. no setting payload. Tracked separately from #2656.
  25. """
  26. import asyncio
  27. import json
  28. import logging
  29. import re
  30. from datetime import datetime, timezone
  31. import httpx
  32. from sqlalchemy import select
  33. from sqlalchemy.ext.asyncio import AsyncSession
  34. from backend.app.core.database import async_session
  35. from backend.app.models.archive import PrintArchive
  36. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  37. from backend.app.models.printer import Printer
  38. from backend.app.models.project import Project
  39. from backend.app.models.settings import Settings
  40. from backend.app.models.spool import Spool
  41. from backend.app.models.spool_usage_history import SpoolUsageHistory
  42. from backend.app.schemas.github_backup import RestoreCategory
  43. from backend.app.services.git_providers.factory import get_provider_backend
  44. from backend.app.services.printer_manager import printer_manager
  45. logger = logging.getLogger(__name__)
  46. METADATA_PATH = "backup_metadata.json"
  47. SETTINGS_PATH = "settings/app_settings.json"
  48. SPOOLS_PATH = "spools/inventory.json"
  49. SPOOL_USAGE_PATH = "spools/usage_history.json"
  50. ARCHIVES_PATH = "archives/print_history.json"
  51. # kprofiles/{printer_serial}/{nozzle_diameter}.json
  52. _KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
  53. # Settings keys the backup collector already refuses to write. Applied again on
  54. # the read side because a backup taken before that denylist existed can still
  55. # contain them, and a restore must not resurrect a stale credential.
  56. _SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
  57. # Belt-and-braces for the same reason: any key that looks like a secret is
  58. # skipped even if it isn't in the explicit denylist above.
  59. _SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
  60. # Settings the MQTT relay reads only when it is (re)configured, so restoring the
  61. # rows is not enough on its own. Mirrors the set the settings PUT handler
  62. # watches. mqtt_password is in here for the configure() payload's sake — the
  63. # credential blocklist means a restore never writes it.
  64. _MQTT_SETTING_KEYS = {
  65. "mqtt_enabled",
  66. "mqtt_broker",
  67. "mqtt_port",
  68. "mqtt_username",
  69. "mqtt_password",
  70. "mqtt_topic_prefix",
  71. "mqtt_use_tls",
  72. }
  73. # Keys that decide *who can reach the instance* rather than how it behaves. The
  74. # backup collector writes them like any other Settings row, so a backup taken
  75. # before auth was turned on carries auth_enabled=false — and a restore reaches
  76. # the table directly, so honouring them would:
  77. #
  78. # * disable authentication outright. ``set_auth_enabled`` pairs its write with
  79. # ``invalidate_auth_enabled_cache()``; we cannot, so the 30 s TTL in
  80. # core.auth is the only thing between the write and an open instance. That
  81. # cache is built to fail closed — writing the stored value behind its back
  82. # is what would make it fail open.
  83. # * bypass the lockout refusals ``update_settings`` enforces (a
  84. # ``local_login_enabled=false`` with no enabled OIDC provider, or with no
  85. # OIDC link on the caller, is a 400 there — #1589).
  86. # * cross a permission boundary: /github-backup/restore is gated on
  87. # GITHUB_RESTORE alone, so this would be a way to rewrite auth config
  88. # without SETTINGS_UPDATE.
  89. #
  90. # Auth is reconfigured through the auth UI, which has the guards. Restoring it
  91. # from a snapshot has no safe reading.
  92. _PROTECTED_SETTING_KEYS = {
  93. "auth_enabled",
  94. "advanced_auth_enabled",
  95. "local_login_enabled",
  96. "setup_completed",
  97. }
  98. # Nozzle diameters the backup collector iterates. A path outside this set means
  99. # the backup was written by a newer version, so accept it rather than dropping
  100. # data, but keep the list for validation messages.
  101. _KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
  102. def _parse_dt(value) -> datetime | None:
  103. """Best-effort parse of a datetime the backup wrote via ``str(...)``."""
  104. if not value or not isinstance(value, str):
  105. return None
  106. try:
  107. return datetime.fromisoformat(value)
  108. except ValueError:
  109. return None
  110. def _is_blocked_setting_key(key: str) -> bool:
  111. lowered = key.lower()
  112. return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
  113. def _is_protected_setting_key(key: str) -> bool:
  114. return key in _PROTECTED_SETTING_KEYS
  115. def _is_skipped_setting_key(key: str) -> bool:
  116. """True for any key ``_restore_settings`` refuses to write, for either reason."""
  117. return _is_blocked_setting_key(key) or _is_protected_setting_key(key)
  118. class _CategoryTally:
  119. """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
  120. def __init__(self) -> None:
  121. self.restored = 0
  122. self.skipped = 0
  123. self.failed = 0
  124. self.notes: list[str] = []
  125. def note(self, message: str) -> None:
  126. # Notes are surfaced verbatim in the UI, so keep the list bounded rather
  127. # than emitting one line per row for a large backup.
  128. if message not in self.notes and len(self.notes) < 20:
  129. self.notes.append(message)
  130. def as_dict(self) -> dict:
  131. return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
  132. class GitHubRestoreService:
  133. """Reads a backup repository and applies selected categories locally."""
  134. def __init__(self) -> None:
  135. self._running_restore: bool = False
  136. self._progress: str | None = None
  137. self._http_client: httpx.AsyncClient | None = None
  138. # Guards the check-then-set on ``_running_restore``. Without it two
  139. # concurrent POSTs can both observe False before either sets it.
  140. self._lock = asyncio.Lock()
  141. async def _get_client(self) -> httpx.AsyncClient:
  142. if self._http_client is None or self._http_client.is_closed:
  143. self._http_client = httpx.AsyncClient(timeout=60.0)
  144. return self._http_client
  145. @property
  146. def is_running(self) -> bool:
  147. return self._running_restore
  148. @property
  149. def progress(self) -> str | None:
  150. return self._progress
  151. # --- Repository reads --------------------------------------------------
  152. async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
  153. """List recent commits on the configured branch."""
  154. backend = get_provider_backend(config.provider)
  155. client = await self._get_client()
  156. result = await backend.list_commits(
  157. repo_url=config.repository_url,
  158. token=config.access_token,
  159. branch=config.branch,
  160. client=client,
  161. limit=limit,
  162. )
  163. result["branch"] = config.branch
  164. return result
  165. async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str]:
  166. """Turn ``HEAD`` into a concrete commit SHA.
  167. Done once up front so a preview and the restore that follows it act on
  168. the same commit even if a scheduled backup lands in between.
  169. """
  170. if ref and ref.upper() != "HEAD":
  171. return ref, ""
  172. result = await self.list_commits(config, limit=1)
  173. if not result.get("success"):
  174. return None, result.get("message") or "Could not read the backup repository"
  175. commits = result.get("commits") or []
  176. if not commits:
  177. return None, f"Branch '{config.branch}' has no commits to restore from"
  178. return commits[0]["sha"], ""
  179. def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
  180. """Return the paths in ``available`` that belong to ``category``."""
  181. if category == RestoreCategory.SETTINGS:
  182. return [p for p in (SETTINGS_PATH,) if p in available]
  183. if category == RestoreCategory.SPOOLS:
  184. return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
  185. if category == RestoreCategory.ARCHIVES:
  186. return [p for p in (ARCHIVES_PATH,) if p in available]
  187. if category == RestoreCategory.KPROFILES:
  188. return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
  189. return []
  190. @staticmethod
  191. def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
  192. """Parse each fetched file, collecting paths that failed to parse."""
  193. parsed: dict[str, object] = {}
  194. bad: list[str] = []
  195. for path, text in raw.items():
  196. try:
  197. parsed[path] = json.loads(text)
  198. except (ValueError, TypeError):
  199. bad.append(path)
  200. return parsed, bad
  201. async def preview(self, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
  202. """Report which categories a commit contains, and how much is in each."""
  203. resolved, error = await self._resolve_ref(config, ref)
  204. if resolved is None:
  205. return {"success": False, "message": error, "ref": ref, "categories": []}
  206. backend = get_provider_backend(config.provider)
  207. client = await self._get_client()
  208. tree = await backend.list_tree(
  209. repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
  210. )
  211. if not tree.get("success"):
  212. return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
  213. available: list[str] = tree.get("paths") or []
  214. # One batched read covers metadata plus every category payload.
  215. wanted = [METADATA_PATH] if METADATA_PATH in available else []
  216. for category in RestoreCategory:
  217. wanted.extend(self._category_paths(category, available))
  218. fetched = await backend.fetch_files(
  219. repo_url=config.repository_url, token=config.access_token, ref=resolved, paths=wanted, client=client
  220. )
  221. if not fetched.get("success"):
  222. return {
  223. "success": False,
  224. "message": fetched.get("message") or "Could not read the commit contents",
  225. "ref": resolved,
  226. }
  227. parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
  228. metadata = parsed.get(METADATA_PATH)
  229. metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
  230. categories = []
  231. for category in RestoreCategory:
  232. paths = self._category_paths(category, available)
  233. if not paths:
  234. categories.append(
  235. {
  236. "category": category,
  237. "available": False,
  238. "item_count": 0,
  239. "detail": "Not present in this backup commit",
  240. }
  241. )
  242. continue
  243. unreadable = [p for p in paths if p in bad_paths]
  244. if unreadable:
  245. categories.append(
  246. {
  247. "category": category,
  248. "available": False,
  249. "item_count": 0,
  250. "detail": f"Unreadable JSON: {', '.join(unreadable)}",
  251. }
  252. )
  253. continue
  254. count, detail = self._count_items(category, parsed)
  255. categories.append({"category": category, "available": True, "item_count": count, "detail": detail})
  256. commit_info = None
  257. commits = (await self.list_commits(config, limit=20)).get("commits") or []
  258. for entry in commits:
  259. if entry["sha"] == resolved:
  260. commit_info = entry
  261. break
  262. return {
  263. "success": True,
  264. "message": "OK",
  265. "ref": resolved,
  266. "commit": commit_info,
  267. "metadata_version": metadata_version,
  268. "categories": categories,
  269. }
  270. @staticmethod
  271. def _count_items(category: RestoreCategory, parsed: dict) -> tuple[int, str | None]:
  272. """Count restorable items for ``category`` and describe any caveat."""
  273. if category == RestoreCategory.SETTINGS:
  274. payload = parsed.get(SETTINGS_PATH)
  275. values = payload.get("settings") if isinstance(payload, dict) else None
  276. if not isinstance(values, dict):
  277. return 0, "No settings in payload"
  278. # Both refusals are counted the same way here so the preview's item
  279. # count matches what the restore actually writes; the wording only
  280. # calls out the credential ones, which are what a user might expect
  281. # to come back.
  282. blocked = sum(1 for key in values if _is_blocked_setting_key(key))
  283. skipped = sum(1 for key in values if _is_skipped_setting_key(key))
  284. detail = f"{blocked} credential-like keys will be skipped" if blocked else None
  285. return len(values) - skipped, detail
  286. if category == RestoreCategory.SPOOLS:
  287. payload = parsed.get(SPOOLS_PATH)
  288. spools = payload.get("spools") if isinstance(payload, dict) else None
  289. usage_payload = parsed.get(SPOOL_USAGE_PATH)
  290. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  291. count = len(spools) if isinstance(spools, list) else 0
  292. detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None
  293. return count, detail
  294. if category == RestoreCategory.ARCHIVES:
  295. payload = parsed.get(ARCHIVES_PATH)
  296. archives = payload.get("archives") if isinstance(payload, dict) else None
  297. count = len(archives) if isinstance(archives, list) else 0
  298. return count, "Metadata only — 3MF files and thumbnails are not in a Git backup"
  299. if category == RestoreCategory.KPROFILES:
  300. total = 0
  301. serials = set()
  302. for path, payload in parsed.items():
  303. match = _KPROFILE_PATH_RE.match(path)
  304. if not match or not isinstance(payload, dict):
  305. continue
  306. serials.add(match.group(1))
  307. profiles = payload.get("profiles")
  308. if isinstance(profiles, list):
  309. total += len(profiles)
  310. detail = f"across {len(serials)} printer(s)" if serials else None
  311. return total, detail
  312. return 0, None
  313. # --- Restore -----------------------------------------------------------
  314. async def run_restore(
  315. self,
  316. config_id: int,
  317. ref: str,
  318. categories: list[RestoreCategory],
  319. overwrite_existing: bool = False,
  320. ) -> dict:
  321. """Apply selected categories from one backup commit."""
  322. # Import locally to avoid a module-level cycle: the backup service takes
  323. # the mirror-image lock against us.
  324. from backend.app.services.github_backup import github_backup_service
  325. # The lock serialises two concurrent restores; the backup side has no
  326. # lock of its own, and relies on this region staying await-free after the
  327. # acquisition. Both flags are plain bools on one event loop, so with no
  328. # suspension point between the two reads and the write, the loop cannot
  329. # slip github_backup.run_backup's mirror-image check in between. Adding an
  330. # `await` below the acquisition and above `self._running_restore = True`
  331. # would let a backup and a restore run at once.
  332. async with self._lock:
  333. if self._running_restore:
  334. return {"success": False, "message": "A restore is already running", "results": {}}
  335. if github_backup_service.is_running:
  336. return {
  337. "success": False,
  338. "message": "A backup is currently running. Wait for it to finish before restoring.",
  339. "results": {},
  340. }
  341. self._running_restore = True
  342. log_id = None
  343. try:
  344. async with async_session() as db:
  345. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  346. config = result.scalar_one_or_none()
  347. if not config:
  348. return {"success": False, "message": "Configuration not found", "results": {}}
  349. self._progress = "Resolving commit..."
  350. resolved, error = await self._resolve_ref(config, ref)
  351. if resolved is None:
  352. return {"success": False, "message": error, "results": {}}
  353. log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
  354. db.add(log)
  355. await db.commit()
  356. await db.refresh(log)
  357. log_id = log.id
  358. try:
  359. payload, error = await self._read_categories(config, resolved, categories)
  360. if error:
  361. raise RuntimeError(error)
  362. settings_keys_written: set[str] = set()
  363. results = await self._apply(db, payload, categories, overwrite_existing, settings_keys_written)
  364. await db.commit()
  365. # After the commit: this reconnects the relay, which is not
  366. # something to do on values that could still roll back.
  367. settings_tally = results.get(RestoreCategory.SETTINGS.value)
  368. if settings_tally is not None:
  369. self._progress = "Reconnecting the MQTT relay..."
  370. await self._reconfigure_mqtt_relay(db, settings_keys_written, settings_tally)
  371. total_restored = sum(tally.restored for tally in results.values())
  372. any_failed = any(tally.failed for tally in results.values())
  373. log.status = "failed" if any_failed and total_restored == 0 else "success"
  374. log.completed_at = datetime.now(timezone.utc)
  375. log.files_changed = total_restored
  376. if any_failed:
  377. log.error_message = "Some items could not be restored — see the restore result for detail"
  378. await db.commit()
  379. return {
  380. "success": True,
  381. "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
  382. "log_id": log_id,
  383. "ref": resolved,
  384. "results": {name: tally.as_dict() for name, tally in results.items()},
  385. }
  386. except Exception as e:
  387. # Rolls back whatever is still uncommitted. That is every
  388. # database category unless K-profiles were also selected, in
  389. # which case _apply has already committed them before talking
  390. # to the printers — see the comment there.
  391. logger.exception("Restore failed for config %s ref %s", config_id, resolved)
  392. await db.rollback()
  393. log.status = "failed"
  394. log.completed_at = datetime.now(timezone.utc)
  395. log.error_message = str(e)[:1000]
  396. await db.commit()
  397. return {"success": False, "message": str(e), "log_id": log_id, "ref": resolved, "results": {}}
  398. finally:
  399. self._running_restore = False
  400. self._progress = None
  401. async def _read_categories(
  402. self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
  403. ) -> tuple[dict, str]:
  404. """Fetch and parse just the files the requested categories need."""
  405. backend = get_provider_backend(config.provider)
  406. client = await self._get_client()
  407. self._progress = "Listing backup contents..."
  408. tree = await backend.list_tree(
  409. repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
  410. )
  411. if not tree.get("success"):
  412. return {}, tree.get("message") or "Could not list the commit"
  413. available: list[str] = tree.get("paths") or []
  414. wanted: list[str] = []
  415. for category in categories:
  416. wanted.extend(self._category_paths(category, available))
  417. if not wanted:
  418. return {}, "None of the selected categories are present in that commit"
  419. self._progress = "Downloading backup files..."
  420. fetched = await backend.fetch_files(
  421. repo_url=config.repository_url, token=config.access_token, ref=ref, paths=wanted, client=client
  422. )
  423. if not fetched.get("success"):
  424. return {}, fetched.get("message") or "Could not read the commit contents"
  425. parsed, bad = self._parse_json_files(fetched.get("files") or {})
  426. if bad:
  427. return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
  428. return parsed, ""
  429. async def _apply(
  430. self,
  431. db: AsyncSession,
  432. payload: dict,
  433. categories: list[RestoreCategory],
  434. overwrite: bool,
  435. settings_keys_written: set[str] | None = None,
  436. ) -> dict[str, _CategoryTally]:
  437. """Apply categories in dependency order and return per-category tallies.
  438. ``settings_keys_written``, if given, collects the setting keys actually
  439. written, for the caller's post-commit side effects (see
  440. ``_reconfigure_mqtt_relay``).
  441. """
  442. results: dict[str, _CategoryTally] = {}
  443. archive_id_map: dict[int, int] = {}
  444. # Archives first: spool usage history references archive_id.
  445. if RestoreCategory.ARCHIVES in categories:
  446. self._progress = "Restoring print archives..."
  447. tally = _CategoryTally()
  448. await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
  449. results[RestoreCategory.ARCHIVES.value] = tally
  450. if RestoreCategory.SPOOLS in categories:
  451. self._progress = "Restoring spool inventory..."
  452. tally = _CategoryTally()
  453. await self._restore_spools(
  454. db,
  455. payload.get(SPOOLS_PATH),
  456. payload.get(SPOOL_USAGE_PATH),
  457. overwrite,
  458. tally,
  459. archive_id_map,
  460. )
  461. results[RestoreCategory.SPOOLS.value] = tally
  462. if RestoreCategory.SETTINGS in categories:
  463. self._progress = "Restoring app settings..."
  464. tally = _CategoryTally()
  465. await self._restore_settings(
  466. db, payload.get(SETTINGS_PATH), overwrite, tally, keys_written=settings_keys_written
  467. )
  468. results[RestoreCategory.SETTINGS.value] = tally
  469. # Last, because it leaves the database and publishes over MQTT.
  470. if RestoreCategory.KPROFILES in categories:
  471. # Commit the database categories FIRST, and not just for tidiness.
  472. # Everything above has already autoflushed its INSERTs, so SQLite is
  473. # holding the single write transaction — and _restore_kprofiles then
  474. # awaits get_kprofiles per printer per nozzle, which is
  475. # timeout=5.0 * max_retries=3, i.e. up to ~15 s each against an
  476. # unresponsive printer. busy_timeout is 15 s (core/database.py), so a
  477. # farm with a couple of sulking printers would hold the writer past
  478. # it and every concurrent writer in the app would fail with
  479. # "database is locked".
  480. #
  481. # The cost is that a K-profile failure no longer rolls back the
  482. # categories that already succeeded. That is the correct trade
  483. # anyway: extrusion_cali_set has left for the printer by then and
  484. # cannot be rolled back either, so a rollback would only have made
  485. # the database disagree with the hardware.
  486. await db.commit()
  487. self._progress = "Sending K-profiles to printers..."
  488. tally = _CategoryTally()
  489. await self._restore_kprofiles(db, payload, tally)
  490. results[RestoreCategory.KPROFILES.value] = tally
  491. return results
  492. # --- Per-category appliers --------------------------------------------
  493. async def _restore_archives(
  494. self,
  495. db: AsyncSession,
  496. payload,
  497. overwrite: bool,
  498. tally: _CategoryTally,
  499. id_map: dict[int, int],
  500. ) -> None:
  501. archives = payload.get("archives") if isinstance(payload, dict) else None
  502. if not isinstance(archives, list):
  503. tally.note("No archive data in this backup")
  504. return
  505. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  506. valid_projects = set((await db.execute(select(Project.id))).scalars().all())
  507. # Only metadata is backed up, never the 3MF/thumbnail bytes, and
  508. # print_archives.file_path is NOT NULL — so inserted rows get an empty
  509. # path and are history-only. Say so once rather than per row.
  510. warned_files = False
  511. for entry in archives:
  512. if not isinstance(entry, dict):
  513. tally.failed += 1
  514. continue
  515. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  516. started_at = _parse_dt(entry.get("started_at"))
  517. existing = await self._find_archive(db, entry, started_at)
  518. fields = {
  519. "print_name": entry.get("print_name"),
  520. "print_time_seconds": entry.get("print_time_seconds"),
  521. "filament_used_grams": entry.get("filament_used_grams"),
  522. "filament_type": entry.get("filament_type"),
  523. "filament_color": entry.get("filament_color"),
  524. "layer_height": entry.get("layer_height"),
  525. "total_layers": entry.get("total_layers"),
  526. "nozzle_diameter": entry.get("nozzle_diameter"),
  527. "bed_temperature": entry.get("bed_temperature"),
  528. "nozzle_temperature": entry.get("nozzle_temperature"),
  529. "sliced_for_model": entry.get("sliced_for_model"),
  530. "status": entry.get("status") or "completed",
  531. "started_at": started_at,
  532. "completed_at": _parse_dt(entry.get("completed_at")),
  533. "makerworld_url": entry.get("makerworld_url"),
  534. "designer": entry.get("designer"),
  535. "external_url": entry.get("external_url"),
  536. "is_favorite": bool(entry.get("is_favorite")),
  537. "tags": entry.get("tags"),
  538. "notes": entry.get("notes"),
  539. "cost": entry.get("cost"),
  540. "failure_reason": entry.get("failure_reason"),
  541. "quantity": entry.get("quantity") or 1,
  542. "energy_kwh": entry.get("energy_kwh"),
  543. "energy_cost": entry.get("energy_cost"),
  544. # A soft-deleted archive is still in the backup (its row is kept
  545. # so stats keep counting it), so carry the flag across or the
  546. # restore turns something the user deleted back into a visible
  547. # archive. Backups written before this key existed have no
  548. # deleted_at, and those rows can only come back live.
  549. "deleted_at": _parse_dt(entry.get("deleted_at")),
  550. }
  551. printer_id = entry.get("printer_id")
  552. if printer_id is not None and printer_id not in valid_printers:
  553. tally.note("Some archives referenced printers that no longer exist — link cleared")
  554. printer_id = None
  555. project_id = entry.get("project_id")
  556. if project_id is not None and project_id not in valid_projects:
  557. tally.note("Some archives referenced projects that no longer exist — link cleared")
  558. project_id = None
  559. fields["printer_id"] = printer_id
  560. fields["project_id"] = project_id
  561. if existing is not None:
  562. if old_id is not None:
  563. id_map[old_id] = existing.id
  564. if not overwrite:
  565. tally.skipped += 1
  566. continue
  567. # Overwrite means "make the local row match the backup", which
  568. # includes un-deleting one the user deleted after the backup was
  569. # taken. Legitimate, but not obvious from a restored/skipped
  570. # count, so say it.
  571. if existing.deleted_at is not None and fields["deleted_at"] is None:
  572. tally.note("Archive(s) deleted since the backup are visible again — overwrite was on")
  573. for key, value in fields.items():
  574. setattr(existing, key, value)
  575. tally.restored += 1
  576. continue
  577. if not warned_files:
  578. tally.note(
  579. "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup"
  580. )
  581. warned_files = True
  582. row = PrintArchive(
  583. filename=entry.get("filename") or "restored-from-backup",
  584. file_path="",
  585. file_size=entry.get("file_size") or 0,
  586. content_hash=entry.get("content_hash"),
  587. **fields,
  588. )
  589. created_at = _parse_dt(entry.get("created_at"))
  590. if created_at is not None:
  591. row.created_at = created_at
  592. db.add(row)
  593. await db.flush()
  594. if old_id is not None:
  595. id_map[old_id] = row.id
  596. tally.restored += 1
  597. async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
  598. """Match a backed-up archive to a local row by natural key.
  599. ``started_at`` is nullable and genuinely NULL for a whole class of rows —
  600. the re-slice path in ``library.py`` constructs ``PrintArchive`` without
  601. one — so it cannot be *required* by the key. It narrows the match instead:
  602. a backed-up row with no ``started_at`` matches a local row that has none
  603. either. Requiring it meant those archives never matched, so each restore
  604. re-inserted them as duplicates and overwrite mode could never update them.
  605. ``content_hash`` identifies the sliced file on its own, which is why it is
  606. the branch allowed to run without a ``started_at``; ``filename`` is too
  607. weak for that (re-slices share it) and still requires one. Two backed-up
  608. rows sharing a hash *and* having no ``started_at`` are indistinguishable
  609. in the backup, so they collapse onto one local row — better than
  610. duplicating both on every restore.
  611. Soft-deleted rows are matched deliberately: there is no ``deleted_at``
  612. filter here because the row still exists, and matching it is what stops a
  613. restore inserting a live duplicate of an archive the user has deleted.
  614. """
  615. started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
  616. content_hash = entry.get("content_hash")
  617. if content_hash:
  618. result = await db.execute(
  619. select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
  620. )
  621. row = result.scalars().first()
  622. if row is not None:
  623. return row
  624. filename = entry.get("filename")
  625. if filename and started_at:
  626. result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
  627. return result.scalars().first()
  628. return None
  629. async def _restore_spools(
  630. self,
  631. db: AsyncSession,
  632. inventory,
  633. usage_payload,
  634. overwrite: bool,
  635. tally: _CategoryTally,
  636. archive_id_map: dict[int, int],
  637. ) -> None:
  638. spools = inventory.get("spools") if isinstance(inventory, dict) else None
  639. if not isinstance(spools, list):
  640. tally.note("No spool data in this backup")
  641. return
  642. spool_id_map: dict[int, int] = {}
  643. for entry in spools:
  644. if not isinstance(entry, dict):
  645. tally.failed += 1
  646. continue
  647. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  648. existing = await self._find_spool(db, entry)
  649. fields = {
  650. "material": entry.get("material") or "PLA",
  651. "subtype": entry.get("subtype"),
  652. "color_name": entry.get("color_name"),
  653. "rgba": entry.get("rgba"),
  654. "brand": entry.get("brand"),
  655. "label_weight": entry.get("label_weight") or 1000,
  656. "core_weight": entry.get("core_weight") or 250,
  657. "weight_used": entry.get("weight_used") or 0,
  658. "weight_locked": bool(entry.get("weight_locked")),
  659. "slicer_filament": entry.get("slicer_filament"),
  660. "slicer_filament_name": entry.get("slicer_filament_name"),
  661. "nozzle_temp_min": entry.get("nozzle_temp_min"),
  662. "nozzle_temp_max": entry.get("nozzle_temp_max"),
  663. "note": entry.get("note"),
  664. "cost_per_kg": entry.get("cost_per_kg"),
  665. "tag_uid": entry.get("tag_uid"),
  666. "tray_uuid": entry.get("tray_uuid"),
  667. "data_origin": entry.get("data_origin"),
  668. "tag_type": entry.get("tag_type"),
  669. "archived_at": _parse_dt(entry.get("archived_at")),
  670. }
  671. if existing is not None:
  672. if old_id is not None:
  673. spool_id_map[old_id] = existing.id
  674. if not overwrite:
  675. tally.skipped += 1
  676. continue
  677. for key, value in fields.items():
  678. setattr(existing, key, value)
  679. tally.restored += 1
  680. continue
  681. row = Spool(**fields)
  682. # Carry the original created_at across. Without it the row would be
  683. # stamped "now", and the composite fallback in _find_spool (which
  684. # keys on created_at) would miss on a second restore and insert a
  685. # duplicate instead of matching.
  686. created_at = _parse_dt(entry.get("created_at"))
  687. if created_at is not None:
  688. row.created_at = created_at
  689. db.add(row)
  690. await db.flush()
  691. if old_id is not None:
  692. spool_id_map[old_id] = row.id
  693. tally.restored += 1
  694. await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
  695. async def _find_spool(self, db: AsyncSession, entry: dict) -> Spool | None:
  696. """Match a backed-up spool to a local row.
  697. Physical identity first (an RFID/Bambu tag is the spool), then a
  698. descriptive composite including ``created_at`` so two otherwise
  699. identical spools added at different times stay distinct.
  700. """
  701. tag_uid = entry.get("tag_uid")
  702. if tag_uid:
  703. result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
  704. row = result.scalars().first()
  705. if row is not None:
  706. return row
  707. tray_uuid = entry.get("tray_uuid")
  708. if tray_uuid:
  709. result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
  710. row = result.scalars().first()
  711. if row is not None:
  712. return row
  713. created_at = _parse_dt(entry.get("created_at"))
  714. if created_at is None:
  715. return None
  716. result = await db.execute(
  717. select(Spool).where(
  718. Spool.created_at == created_at,
  719. Spool.material == (entry.get("material") or "PLA"),
  720. Spool.brand == entry.get("brand"),
  721. Spool.subtype == entry.get("subtype"),
  722. Spool.color_name == entry.get("color_name"),
  723. )
  724. )
  725. return result.scalars().first()
  726. async def _restore_spool_usage(
  727. self,
  728. db: AsyncSession,
  729. usage_payload,
  730. tally: _CategoryTally,
  731. spool_id_map: dict[int, int],
  732. archive_id_map: dict[int, int],
  733. ) -> None:
  734. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  735. if not isinstance(usage, list) or not usage:
  736. return
  737. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  738. unresolved = 0
  739. for entry in usage:
  740. if not isinstance(entry, dict):
  741. tally.failed += 1
  742. continue
  743. old_spool_id = entry.get("spool_id")
  744. spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
  745. if spool_id is None:
  746. # The parent spool never made it into the map: the backup's spool
  747. # list didn't include it, or its entry carried no integer id. A
  748. # spool that was merely *skipped* (matched locally, overwrite off)
  749. # is mapped a few lines up in _restore_spools, so it never lands
  750. # here — which is why the note below offers no remedy.
  751. unresolved += 1
  752. tally.skipped += 1
  753. continue
  754. created_at = _parse_dt(entry.get("created_at"))
  755. # Usage history has no natural key of its own, so dedupe on the
  756. # tuple that makes a consumption event unique in practice.
  757. existing = await db.execute(
  758. select(SpoolUsageHistory).where(
  759. SpoolUsageHistory.spool_id == spool_id,
  760. SpoolUsageHistory.created_at == created_at,
  761. SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
  762. SpoolUsageHistory.print_name == entry.get("print_name"),
  763. )
  764. )
  765. if existing.scalars().first() is not None:
  766. tally.skipped += 1
  767. continue
  768. printer_id = entry.get("printer_id")
  769. if printer_id is not None and printer_id not in valid_printers:
  770. printer_id = None
  771. old_archive_id = entry.get("archive_id")
  772. archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
  773. row = SpoolUsageHistory(
  774. spool_id=spool_id,
  775. printer_id=printer_id,
  776. print_name=entry.get("print_name"),
  777. archive_id=archive_id,
  778. weight_used=entry.get("weight_used") or 0,
  779. percent_used=entry.get("percent_used") or 0,
  780. status=entry.get("status") or "completed",
  781. cost=entry.get("cost"),
  782. )
  783. if created_at is not None:
  784. row.created_at = created_at
  785. db.add(row)
  786. tally.restored += 1
  787. if unresolved:
  788. tally.note(
  789. f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
  790. "spool list, so there is nothing to attach them to."
  791. )
  792. async def _restore_settings(
  793. self,
  794. db: AsyncSession,
  795. payload,
  796. overwrite: bool,
  797. tally: _CategoryTally,
  798. keys_written: set[str] | None = None,
  799. ) -> None:
  800. values = payload.get("settings") if isinstance(payload, dict) else None
  801. if not isinstance(values, dict):
  802. tally.note("No settings data in this backup")
  803. return
  804. blocked = 0
  805. protected = 0
  806. for key, value in values.items():
  807. if not isinstance(key, str) or not key:
  808. tally.failed += 1
  809. continue
  810. if _is_blocked_setting_key(key):
  811. blocked += 1
  812. tally.skipped += 1
  813. continue
  814. if _is_protected_setting_key(key):
  815. protected += 1
  816. tally.skipped += 1
  817. continue
  818. if value is None:
  819. tally.skipped += 1
  820. continue
  821. result = await db.execute(select(Settings).where(Settings.key == key))
  822. existing = result.scalar_one_or_none()
  823. if existing is not None:
  824. if not overwrite:
  825. tally.skipped += 1
  826. continue
  827. existing.value = str(value)
  828. tally.restored += 1
  829. if keys_written is not None:
  830. keys_written.add(key)
  831. continue
  832. db.add(Settings(key=key, value=str(value)))
  833. tally.restored += 1
  834. if keys_written is not None:
  835. keys_written.add(key)
  836. if blocked:
  837. tally.note(f"{blocked} credential-like key(s) skipped — re-enter secrets manually")
  838. if protected:
  839. tally.note(
  840. f"{protected} authentication setting(s) skipped — change those in Settings > "
  841. "Authentication so the lockout checks still run"
  842. )
  843. async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
  844. """Push restored mqtt_* settings into the live relay.
  845. The relay reads its broker config once, at configure() time — the
  846. settings PUT handler reconfigures it for exactly this reason
  847. (api/routes/settings.py). Writing the rows alone left the relay on the
  848. pre-restore broker until the next backend restart while the UI showed
  849. the restored values, which is the one way a restore could look applied
  850. and not be.
  851. Called after the commit, never before: configure() tears the connection
  852. down and rebuilds it, so it must not run against values a later failure
  853. could roll back. Only mqtt_password can't come back this way (the
  854. credential blocklist skips it) — the row already in the database is
  855. reused, so an unchanged broker keeps working.
  856. """
  857. if not _MQTT_SETTING_KEYS & keys_written:
  858. return
  859. try:
  860. from backend.app.services.mqtt_relay import mqtt_relay
  861. rows = await db.execute(select(Settings).where(Settings.key.in_(_MQTT_SETTING_KEYS)))
  862. stored = {s.key: s.value for s in rows.scalars().all()}
  863. # Same shape and defaults the settings PUT handler builds.
  864. await mqtt_relay.configure(
  865. {
  866. "mqtt_enabled": (stored.get("mqtt_enabled") or "false") == "true",
  867. "mqtt_broker": stored.get("mqtt_broker") or "",
  868. "mqtt_port": int(stored.get("mqtt_port") or "1883"),
  869. "mqtt_username": stored.get("mqtt_username") or "",
  870. "mqtt_password": stored.get("mqtt_password") or "",
  871. "mqtt_topic_prefix": stored.get("mqtt_topic_prefix") or "bambuddy",
  872. "mqtt_use_tls": (stored.get("mqtt_use_tls") or "false") == "true",
  873. }
  874. )
  875. except Exception:
  876. # Same call is best-effort in the settings PUT handler: the rows are
  877. # committed either way, and a broker that refuses the new config
  878. # must not turn a successful restore into a failed one. Noted rather
  879. # than swallowed silently, so the user knows to restart.
  880. logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
  881. tally.note("MQTT settings restored, but the relay could not be reconnected — restart Bambuddy")
  882. async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
  883. by_serial: dict[str, list[tuple[str, dict]]] = {}
  884. for path, content in payload.items():
  885. match = _KPROFILE_PATH_RE.match(path)
  886. if not match or not isinstance(content, dict):
  887. continue
  888. by_serial.setdefault(match.group(1), []).append((match.group(2), content))
  889. if not by_serial:
  890. tally.note("No K-profile data in this backup")
  891. return
  892. result = await db.execute(select(Printer))
  893. printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
  894. # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
  895. # the profile occupying a slot, so writing is always an overwrite on the
  896. # printer side.
  897. tally.note("K-profiles always overwrite the matching slot on the printer")
  898. tally.note("The printer's acknowledgement is not reliable — verify the profiles on the printer")
  899. for serial, entries in sorted(by_serial.items()):
  900. profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
  901. printer = printers.get(serial)
  902. if printer is None:
  903. tally.skipped += profile_total
  904. tally.note(f"No printer with serial {serial} — skipped")
  905. continue
  906. client = printer_manager.get_client(printer.id)
  907. if not client or not client.state.connected:
  908. tally.skipped += profile_total
  909. tally.note(f"{printer.name} ({serial}) is not connected — skipped")
  910. continue
  911. for nozzle, content in sorted(entries):
  912. profiles = content.get("profiles")
  913. if not isinstance(profiles, list) or not profiles:
  914. continue
  915. if nozzle not in _KNOWN_NOZZLES:
  916. tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is")
  917. # The backup's slot_id is a cali_idx, and cali_idx is as
  918. # unstable as the autoincrement ids we already refuse to reuse
  919. # for spools and archives: editing a profile in Bambuddy is a
  920. # delete-then-add on a single-nozzle printer, which re-keys it.
  921. # Addressing extrusion_cali_set at a slot that no longer exists
  922. # is a silent no-op — the printer drops it and we would still
  923. # report the profile restored. So resolve the live index first.
  924. current = await self._current_kprofile_index(client, nozzle, serial)
  925. profile_dicts = []
  926. unmatched = 0
  927. for p in profiles:
  928. if not isinstance(p, dict):
  929. continue
  930. match = self._match_kprofile(p, current)
  931. if match is None:
  932. unmatched += 1
  933. profile_dicts.append(
  934. {
  935. "filament_id": p.get("filament_id", ""),
  936. "name": p.get("name", ""),
  937. "k_value": p.get("k_value", "0.020000"),
  938. "nozzle_id": p.get("nozzle_id"),
  939. "extruder_id": p.get("extruder_id", 0),
  940. # Prefer the live setting_id when we matched: it is
  941. # what the printer currently associates with the slot.
  942. "setting_id": (match.setting_id if match else None) or p.get("setting_id"),
  943. # cali_idx -1 tells the printer to add a new profile
  944. # rather than address a slot that isn't there.
  945. "cali_idx": match.slot_id if match else -1,
  946. # Only consulted for the generated-setting_id
  947. # fallback; cali_idx above takes precedence.
  948. "slot_id": 0,
  949. }
  950. )
  951. if not profile_dicts:
  952. continue
  953. if unmatched:
  954. tally.note(
  955. f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
  956. "— added as new profiles"
  957. )
  958. try:
  959. sent = client.set_kprofiles_batch(profile_dicts, nozzle)
  960. except Exception as e:
  961. logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
  962. sent = False
  963. if sent:
  964. tally.restored += len(profile_dicts)
  965. else:
  966. tally.failed += len(profile_dicts)
  967. tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})")
  968. @staticmethod
  969. async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:
  970. """Read the printer's live profiles for one nozzle.
  971. Best-effort: a read failure degrades to "nothing matched", which makes
  972. every profile an add rather than aborting the restore.
  973. """
  974. try:
  975. return list(await client.get_kprofiles(nozzle_diameter=nozzle) or [])
  976. except Exception as e:
  977. logger.warning("Could not read live K-profiles for %s nozzle %s: %s", serial, nozzle, e)
  978. return []
  979. @staticmethod
  980. def _match_kprofile(entry: dict, current: list):
  981. """Find the live profile a backed-up entry corresponds to.
  982. ``setting_id`` is the filament preset the profile was calibrated for and
  983. is the strongest signal; a delete-then-add edit regenerates it, so fall
  984. back to the display name, which Bambuddy's own editor preserves.
  985. Both are scoped by ``filament_id`` — the same preset on a different
  986. filament is a different profile.
  987. """
  988. filament_id = entry.get("filament_id")
  989. if not filament_id:
  990. return None
  991. candidates = [c for c in current if c.filament_id == filament_id]
  992. if not candidates:
  993. return None
  994. setting_id = entry.get("setting_id")
  995. if setting_id:
  996. for c in candidates:
  997. if c.setting_id == setting_id:
  998. return c
  999. name = entry.get("name")
  1000. if name:
  1001. for c in candidates:
  1002. if c.name == name:
  1003. return c
  1004. # Exactly one profile for this filament and no better discriminator:
  1005. # treat it as the same profile rather than duplicating it.
  1006. return candidates[0] if len(candidates) == 1 else None
  1007. # Singleton instance
  1008. github_restore_service = GitHubRestoreService()