github_restore.py 60 KB

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