github_restore.py 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900
  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.** Restoring a preset means writing to a
  23. Bambu or Orca Cloud account, which is a different operation from everything
  24. else here — every other category lands in the local database or, for
  25. K-profiles, on a printer the instance already owns. Tracked separately from
  26. #2656. (The collector does write ``cloud_profiles/*.json`` as of #2717; the
  27. earlier claim that it did not is no longer true.)
  28. """
  29. import asyncio
  30. import json
  31. import logging
  32. import os
  33. import re
  34. from dataclasses import dataclass, field as dataclasses_field
  35. from datetime import datetime, timezone
  36. import httpx
  37. from sqlalchemy import select
  38. from sqlalchemy.ext.asyncio import AsyncSession
  39. from backend.app.core.database import async_session
  40. from backend.app.models.archive import PrintArchive
  41. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  42. from backend.app.models.printer import Printer
  43. from backend.app.models.project import Project
  44. from backend.app.models.settings import Settings
  45. from backend.app.models.spool import Spool
  46. from backend.app.models.spool_usage_history import SpoolUsageHistory
  47. from backend.app.models.user import User
  48. from backend.app.schemas.github_backup import RestoreCategory
  49. from backend.app.services.git_providers.factory import get_provider_backend
  50. from backend.app.services.printer_manager import printer_manager
  51. logger = logging.getLogger(__name__)
  52. METADATA_PATH = "backup_metadata.json"
  53. SETTINGS_PATH = "settings/app_settings.json"
  54. SPOOLS_PATH = "spools/inventory.json"
  55. SPOOL_USAGE_PATH = "spools/usage_history.json"
  56. ARCHIVES_PATH = "archives/print_history.json"
  57. # kprofiles/{printer_serial}/{nozzle_diameter}.json
  58. _KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
  59. # Settings keys the backup collector already refuses to write. Applied again on
  60. # the read side because a backup taken before that denylist existed can still
  61. # contain them, and a restore must not resurrect a stale credential.
  62. _SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
  63. # The primary refusal, not a backstop for the set above. The collector filters
  64. # exactly bambu_cloud_token and auth_secret_key, so every other credential —
  65. # mqtt_password, ldap_bind_password, ha_token, prometheus_token — is present in
  66. # a current backup and is skipped only because its key matches a hint here.
  67. # _COMPANION_CREDENTIALS sits downstream of that: it withholds a toggle when the
  68. # credential it needs was refused, so shortening this tuple would both write a
  69. # stale credential and quietly make that rule inert.
  70. _SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
  71. # Settings the MQTT relay reads only when it is (re)configured, so restoring the
  72. # rows is not enough on its own. Mirrors the set the settings PUT handler
  73. # watches. mqtt_password is in here for the configure() payload's sake — the
  74. # credential blocklist means a restore never writes it.
  75. _MQTT_SETTING_KEYS = {
  76. "mqtt_enabled",
  77. "mqtt_broker",
  78. "mqtt_port",
  79. "mqtt_username",
  80. "mqtt_password",
  81. "mqtt_topic_prefix",
  82. "mqtt_use_tls",
  83. }
  84. # Keys that decide *who can reach the instance* rather than how it behaves. The
  85. # backup collector writes them like any other Settings row, so a backup taken
  86. # before auth was turned on carries auth_enabled=false — and a restore reaches
  87. # the table directly, so honouring them would:
  88. #
  89. # * disable authentication outright. ``set_auth_enabled`` pairs its write with
  90. # ``invalidate_auth_enabled_cache()``; we cannot, so the 30 s TTL in
  91. # core.auth is the only thing between the write and an open instance. That
  92. # cache is built to fail closed — writing the stored value behind its back
  93. # is what would make it fail open.
  94. # * bypass the lockout refusals ``update_settings`` enforces (a
  95. # ``local_login_enabled=false`` with no enabled OIDC provider, or with no
  96. # OIDC link on the caller, is a 400 there — #1589).
  97. # * cross a permission boundary: /github-backup/restore is gated on
  98. # GITHUB_RESTORE alone, so this would be a way to rewrite auth config
  99. # without SETTINGS_UPDATE.
  100. #
  101. # Auth is reconfigured through the auth UI, which has the guards. Restoring it
  102. # from a snapshot has no safe reading.
  103. _PROTECTED_SETTING_KEYS = {
  104. "auth_enabled",
  105. "advanced_auth_enabled",
  106. "local_login_enabled",
  107. "setup_completed",
  108. }
  109. # Nozzle diameters the backup collector iterates. A path outside this set means
  110. # the backup was written by a newer version, so accept it rather than dropping
  111. # data, but keep the list for validation messages.
  112. _KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
  113. def _parse_dt(value) -> datetime | None:
  114. """Best-effort parse of a datetime the backup wrote via ``str(...)``.
  115. Normalised to naive UTC, because that is what every ``DateTime`` column
  116. here holds: the models write ``datetime.now(timezone.utc)`` into naive
  117. columns and both dialects drop the offset on the way in. Carrying an aware
  118. value through would store the wrong wall clock, and comparing one against a
  119. value read back out of a naive column raises ``TypeError``. The collector
  120. only ever writes naive strings, so this is a guard on hand-edited or
  121. foreign backups rather than a path Bambuddy takes itself.
  122. """
  123. if not value or not isinstance(value, str):
  124. return None
  125. try:
  126. parsed = datetime.fromisoformat(value)
  127. except ValueError:
  128. return None
  129. if parsed.tzinfo is not None:
  130. parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
  131. return parsed
  132. def _created_at_matches(row, created_at: datetime | None) -> bool:
  133. """Does ``row.created_at`` equal a timestamp read out of a backup?
  134. Compared in Python, not in SQL, and that is the whole point. Every
  135. ``created_at`` these callers dedupe on is ``server_default=func.now()``, so
  136. SQLite fills it from ``CURRENT_TIMESTAMP``, which has second precision and
  137. stores ``'2026-08-02 11:28:41'``. SQLAlchemy binds a Python datetime as
  138. ``'2026-08-02 11:28:41.000000'``, and SQLite compares the two as strings —
  139. so ``Model.created_at == created_at`` never matches a row the application
  140. itself created, not even when handed that row's own value straight back.
  141. Every dedupe keyed on it misses, and the restore inserts a duplicate of
  142. everything instead of recognising what is already there.
  143. Reading the candidates back and comparing the parsed datetimes sidesteps
  144. the bind format entirely, and is equally correct on PostgreSQL (where the
  145. column keeps microseconds and the SQL comparison happened to work).
  146. """
  147. return created_at is not None and row.created_at == created_at
  148. def _is_blocked_setting_key(key: str) -> bool:
  149. lowered = key.lower()
  150. return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
  151. def _is_protected_setting_key(key: str) -> bool:
  152. return key in _PROTECTED_SETTING_KEYS
  153. # There used to be an ``_is_skipped_setting_key`` here, the union of the two
  154. # predicates above, shared by the preview and the restore so neither could drift
  155. # from the other. It is gone because a name is no longer enough to decide: the
  156. # third refusal below depends on the payload's *other* values and on local
  157. # database state. ``_plan_settings`` is the shared classifier now, and it covers
  158. # all three reasons.
  159. # Toggles whose *safety* depends on a companion credential that the blocklist
  160. # above refuses to restore. Writing the toggle alone is not a partial restore,
  161. # it is a downgrade:
  162. #
  163. # * prometheus_enabled with no token opens /api/v1/metrics. The route is on
  164. # PUBLIC_API_ROUTES and its own gate is ``if token:`` (api/routes/metrics.py),
  165. # so an empty or absent token means no authentication at all — a full,
  166. # unauthenticated dump of the instance to anyone who can reach the port. On
  167. # an instance that never enabled Prometheus there is no token row, so
  168. # overwrite-off alone is enough to do it.
  169. # * the other four switch an integration on with no way to authenticate to it,
  170. # which breaks the login path (LDAP) or the connection (MQTT, HA).
  171. #
  172. # virtual_printer_enabled is largely vestigial post-migration — core/database.py
  173. # copies the rows into the virtual_printers table — but it is the same shape, and
  174. # refusing a vestigial toggle is a harmless no-op.
  175. _COMPANION_CREDENTIALS = {
  176. "prometheus_enabled": "prometheus_token",
  177. "ldap_enabled": "ldap_bind_password",
  178. "mqtt_enabled": "mqtt_password",
  179. "ha_enabled": "ha_token",
  180. "virtual_printer_enabled": "virtual_printer_access_code",
  181. }
  182. # Companion credentials a reader takes from the environment rather than from a
  183. # Settings row. ha_token is the only one: get_homeassistant_settings prefers
  184. # HA_TOKEN over the row, and auto-enables ha_enabled when HA_URL and HA_TOKEN are
  185. # both set, so an env-configured instance has a usable credential and no row.
  186. _COMPANION_CREDENTIAL_ENV = {"ha_token": "HA_TOKEN"}
  187. # The pairs above divide into two classes, because "did the *backup* carry a
  188. # usable credential?" does not mean the same thing for both.
  189. #
  190. # For the availability pairs it is the condition that stops the rule
  191. # over-refusing. An anonymous MQTT broker and an anonymous LDAP bind are working
  192. # configs, so a backup with an empty credential is describing something that
  193. # works, and refusing its toggle would be a false positive. Those pairs only
  194. # matter when the restore would produce a config weaker than *both* the backup
  195. # and the local instance.
  196. #
  197. # For the exposure pair it does not transfer. An empty prometheus_token removes
  198. # /api/v1/metrics' only gate (the route is on PUBLIC_API_ROUTES and its own
  199. # check is ``if token:``), so the exposure is a property of the toggle itself,
  200. # not of a downgrade relative to the backup: a backup taken on an instance that
  201. # enabled Prometheus *without* a token — the field is optional and defaults to
  202. # "" — is the more likely source of one, not the less. So an exposure toggle
  203. # skips this condition and is judged on local state alone.
  204. _COMPANION_EXPOSURE_TOGGLES = frozenset({"prometheus_enabled"})
  205. def _setting_value_is_true(value: object) -> bool:
  206. """True if a settings *payload* value would be stored as "on".
  207. Deliberately as narrow as ``api.routes.settings.setting_is_true``: a restore
  208. writes ``str(value)`` verbatim and no reader in the codebase treats "1",
  209. "on" or "yes" as on, so restoring one of those cannot switch anything on.
  210. Bool-tolerant because a backup's JSON can carry a real boolean.
  211. """
  212. if isinstance(value, bool):
  213. return value
  214. if value is None:
  215. return False
  216. return str(value).strip().lower() == "true"
  217. def _is_usable_credential(value: object) -> bool:
  218. """True if a credential value is present and not blank.
  219. A present-but-*blank* ``prometheus_token`` row counts as unusable, because an
  220. empty token is exactly the ``if token:`` hole the companion rule exists to
  221. stop a restore from opening.
  222. """
  223. return value is not None and bool(str(value).strip())
  224. @dataclass(frozen=True)
  225. class _SettingsPlan:
  226. """Which keys of a settings payload will not be written, and why.
  227. Built once, before anything is added to the session, and shared by the
  228. preview and the restore so the two cannot disagree about what a commit will
  229. change. The companion bucket is why this needs a session at all: unlike the
  230. two name-based buckets it depends on local database state.
  231. The three buckets are disjoint — a key is classified once, in order.
  232. """
  233. blocked: tuple[str, ...] = ()
  234. protected: tuple[str, ...] = ()
  235. companion: tuple[str, ...] = ()
  236. @property
  237. def refused(self) -> frozenset[str]:
  238. return frozenset(self.blocked) | frozenset(self.protected) | frozenset(self.companion)
  239. @property
  240. def refused_count(self) -> int:
  241. return len(self.blocked) + len(self.protected) + len(self.companion)
  242. @dataclass(frozen=True)
  243. class _Detail:
  244. """A preview caveat, as a translation code plus its English rendering.
  245. Same contract as a note: the client translates ``code`` with ``params`` and
  246. falls back to ``message``.
  247. """
  248. code: str
  249. message: str
  250. params: dict[str, str | int] = dataclasses_field(default_factory=dict)
  251. class _CategoryTally:
  252. """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
  253. def __init__(self) -> None:
  254. self.restored = 0
  255. self.skipped = 0
  256. self.failed = 0
  257. self.notes: list[dict] = []
  258. def note(self, code: str, message: str, **params) -> None:
  259. """Record a note as a translation code, its params and an English fallback.
  260. Deduped on ``(code, params)`` rather than on the rendered text, which is
  261. the same thing today but keeps two notes that differ only in a printer
  262. name from collapsing into one. Bounded for the reason it always was: the
  263. UI renders every note, so a large backup must not emit one per row.
  264. """
  265. if any(existing["code"] == code and existing["params"] == params for existing in self.notes):
  266. return
  267. if len(self.notes) >= 20:
  268. return
  269. self.notes.append({"code": code, "params": params, "message": message})
  270. def as_dict(self) -> dict:
  271. return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
  272. class GitHubRestoreService:
  273. """Reads a backup repository and applies selected categories locally."""
  274. def __init__(self) -> None:
  275. self._running_restore: bool = False
  276. self._progress: str | None = None
  277. self._http_client: httpx.AsyncClient | None = None
  278. # Guards the check-then-set on ``_running_restore``. Without it two
  279. # concurrent POSTs can both observe False before either sets it.
  280. self._lock = asyncio.Lock()
  281. async def _get_client(self) -> httpx.AsyncClient:
  282. if self._http_client is None or self._http_client.is_closed:
  283. self._http_client = httpx.AsyncClient(timeout=60.0)
  284. return self._http_client
  285. @property
  286. def is_running(self) -> bool:
  287. return self._running_restore
  288. @property
  289. def progress(self) -> str | None:
  290. return self._progress
  291. # --- Repository reads --------------------------------------------------
  292. async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
  293. """List recent commits on the configured branch."""
  294. backend = get_provider_backend(config.provider)
  295. client = await self._get_client()
  296. result = await backend.list_commits(
  297. repo_url=config.repository_url,
  298. token=config.access_token,
  299. branch=config.branch,
  300. client=client,
  301. limit=limit,
  302. )
  303. result["branch"] = config.branch
  304. return result
  305. async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str, dict | None]:
  306. """Turn ``HEAD`` into a concrete commit SHA.
  307. Done once up front so a preview and the restore that follows it act on
  308. the same commit even if a scheduled backup lands in between.
  309. The third element is the commit entry, when resolving already fetched
  310. one. ``preview`` displays it, and taking it from here means the ``HEAD``
  311. case — by far the common one — costs one ``list_commits`` call rather
  312. than two.
  313. """
  314. if ref and ref.upper() != "HEAD":
  315. return ref, "", None
  316. result = await self.list_commits(config, limit=1)
  317. if not result.get("success"):
  318. return None, result.get("message") or "Could not read the backup repository", None
  319. commits = result.get("commits") or []
  320. if not commits:
  321. return None, f"Branch '{config.branch}' has no commits to restore from", None
  322. return commits[0]["sha"], "", commits[0]
  323. async def _describe_commit(self, config: GitHubBackupConfig, resolved: str) -> dict | None:
  324. """Find the display metadata for one commit SHA.
  325. Two things used to leave ``commit: null`` in a preview, and the second is
  326. the one that bit in practice:
  327. * the commit is older than the 20 the picker lists, so it is not in the
  328. scan at all — that is what ``get_commit`` is for;
  329. * ``REF_PATTERN`` accepts a 7-character ref while providers return the
  330. full 40, so an exact ``==`` never matched an abbreviated SHA *even when
  331. the commit was in the window*. Hence the prefix comparison.
  332. Best-effort throughout: this is a subject line and a date, so a failure
  333. returns None and the preview renders without them rather than failing.
  334. """
  335. commits = (await self.list_commits(config, limit=20)).get("commits") or []
  336. for entry in commits:
  337. sha = entry.get("sha") or ""
  338. if sha == resolved or sha.startswith(resolved) or resolved.startswith(sha):
  339. return entry
  340. backend = get_provider_backend(config.provider)
  341. client = await self._get_client()
  342. result = await backend.get_commit(
  343. repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
  344. )
  345. return result.get("commit") if result.get("success") else None
  346. def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
  347. """Return the paths in ``available`` that belong to ``category``."""
  348. if category == RestoreCategory.SETTINGS:
  349. return [p for p in (SETTINGS_PATH,) if p in available]
  350. if category == RestoreCategory.SPOOLS:
  351. return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
  352. if category == RestoreCategory.ARCHIVES:
  353. return [p for p in (ARCHIVES_PATH,) if p in available]
  354. if category == RestoreCategory.KPROFILES:
  355. return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
  356. return []
  357. @staticmethod
  358. def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
  359. """Parse each fetched file, collecting paths that failed to parse."""
  360. parsed: dict[str, object] = {}
  361. bad: list[str] = []
  362. for path, text in raw.items():
  363. try:
  364. parsed[path] = json.loads(text)
  365. except (ValueError, TypeError):
  366. bad.append(path)
  367. return parsed, bad
  368. @staticmethod
  369. async def _plan_settings(db: AsyncSession, values: dict) -> _SettingsPlan:
  370. """Classify every key of a settings payload into its refusal bucket.
  371. Keys with an unusable name land in no bucket: they are the restore's
  372. ``failed``, not a refusal, and the preview counts them because the run
  373. will still report on them.
  374. Reads local state, so it must run before anything is added to the
  375. session — otherwise "does this instance already have a credential" would
  376. see the restore's own writes.
  377. """
  378. blocked: list[str] = []
  379. protected: list[str] = []
  380. # Toggle -> credential for the pairs that survived the payload-only
  381. # conditions and still need local state to judge.
  382. candidates: dict[str, str] = {}
  383. for key, value in values.items():
  384. if not isinstance(key, str) or not key:
  385. continue
  386. if _is_blocked_setting_key(key):
  387. blocked.append(key)
  388. continue
  389. if _is_protected_setting_key(key):
  390. protected.append(key)
  391. continue
  392. credential = _COMPANION_CREDENTIALS.get(key)
  393. if credential is None:
  394. continue
  395. # Turning something *off* is always safe to write.
  396. if not _setting_value_is_true(value):
  397. continue
  398. # Expressed as the predicate rather than assumed, so the map cannot
  399. # go quietly inert if _SECRET_KEY_HINTS is ever edited: a credential
  400. # the restore is willing to write travels with its toggle.
  401. if not _is_blocked_setting_key(credential):
  402. continue
  403. # The backup itself carried no credential here. For an availability
  404. # pair that describes a working config — an anonymous MQTT broker and
  405. # an anonymous LDAP bind both are (mqtt_relay.py and ldap_service.py
  406. # pass empty credentials straight through) — so refusing the toggle
  407. # would be a false positive. For an exposure pair a blank credential
  408. # is the hole itself, so the condition is skipped and only local
  409. # state decides. See _COMPANION_EXPOSURE_TOGGLES.
  410. if key not in _COMPANION_EXPOSURE_TOGGLES and not _is_usable_credential(values.get(credential)):
  411. continue
  412. candidates[key] = credential
  413. if not candidates:
  414. return _SettingsPlan(blocked=tuple(blocked), protected=tuple(protected))
  415. # One SELECT covering both halves of every candidate pair.
  416. wanted = set(candidates) | set(candidates.values())
  417. rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
  418. local = {row.key: row.value for row in rows.scalars().all()}
  419. companion: list[str] = []
  420. for toggle, credential in candidates.items():
  421. if _is_usable_credential(local.get(credential)):
  422. continue
  423. env_name = _COMPANION_CREDENTIAL_ENV.get(credential)
  424. if env_name and _is_usable_credential(os.environ.get(env_name)):
  425. continue
  426. # Already on locally with no credential: the exposure pre-dates this
  427. # restore, so refusing changes nothing and "left switched off" would
  428. # be a lie.
  429. if _setting_value_is_true(local.get(toggle)):
  430. continue
  431. companion.append(toggle)
  432. return _SettingsPlan(
  433. blocked=tuple(blocked),
  434. protected=tuple(protected),
  435. companion=tuple(companion),
  436. )
  437. async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
  438. """Report which categories a commit contains, and how much is in each.
  439. Takes a session because the settings count depends on local state — see
  440. ``_plan_settings``. ``ref`` stays keyword-friendly for callers.
  441. """
  442. resolved, error, commit_info = await self._resolve_ref(config, ref)
  443. if resolved is None:
  444. return {"success": False, "message": error, "ref": ref, "categories": []}
  445. backend = get_provider_backend(config.provider)
  446. client = await self._get_client()
  447. tree = await backend.list_tree(
  448. repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
  449. )
  450. if not tree.get("success"):
  451. return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
  452. available: list[str] = tree.get("paths") or []
  453. # One batched read covers metadata plus every category payload.
  454. wanted = [METADATA_PATH] if METADATA_PATH in available else []
  455. for category in RestoreCategory:
  456. wanted.extend(self._category_paths(category, available))
  457. fetched = await backend.fetch_files(
  458. repo_url=config.repository_url,
  459. token=config.access_token,
  460. ref=resolved,
  461. paths=wanted,
  462. client=client,
  463. # The listing above already built this map; without it the GitHub
  464. # family would GET the same recursive tree a second time.
  465. blob_shas=tree.get("blob_shas") or None,
  466. )
  467. if not fetched.get("success"):
  468. return {
  469. "success": False,
  470. "message": fetched.get("message") or "Could not read the commit contents",
  471. "ref": resolved,
  472. }
  473. parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
  474. metadata = parsed.get(METADATA_PATH)
  475. metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
  476. categories = []
  477. for category in RestoreCategory:
  478. paths = self._category_paths(category, available)
  479. if not paths:
  480. categories.append(
  481. self._category_entry(category, False, 0, _Detail("notPresent", "Not present in this backup commit"))
  482. )
  483. continue
  484. unreadable = [p for p in paths if p in bad_paths]
  485. if unreadable:
  486. joined = ", ".join(unreadable)
  487. categories.append(
  488. self._category_entry(
  489. category,
  490. False,
  491. 0,
  492. _Detail("unreadableJson", f"Unreadable JSON: {joined}", {"paths": joined}),
  493. )
  494. )
  495. continue
  496. count, detail = await self._count_items(db, category, parsed)
  497. categories.append(self._category_entry(category, True, count, detail))
  498. if commit_info is None:
  499. commit_info = await self._describe_commit(config, resolved)
  500. return {
  501. "success": True,
  502. "message": "OK",
  503. "ref": resolved,
  504. "commit": commit_info,
  505. "metadata_version": metadata_version,
  506. "categories": categories,
  507. }
  508. @staticmethod
  509. def _category_entry(category: RestoreCategory, available: bool, item_count: int, detail: _Detail | None) -> dict:
  510. """Shape one ``GitHubRestorePreviewCategory``, translated detail included."""
  511. return {
  512. "category": category,
  513. "available": available,
  514. "item_count": item_count,
  515. "detail": detail.message if detail else None,
  516. "detail_code": detail.code if detail else None,
  517. "detail_params": detail.params if detail else {},
  518. }
  519. async def _count_items(
  520. self, db: AsyncSession, category: RestoreCategory, parsed: dict
  521. ) -> tuple[int, _Detail | None]:
  522. """Count restorable items for ``category`` and describe any caveat."""
  523. if category == RestoreCategory.SETTINGS:
  524. payload = parsed.get(SETTINGS_PATH)
  525. values = payload.get("settings") if isinstance(payload, dict) else None
  526. if not isinstance(values, dict):
  527. return 0, _Detail("settingsNoPayload", "No settings in payload")
  528. # Every refusal is subtracted so the count matches what the restore
  529. # actually writes. The wording calls out the credential ones (what a
  530. # user might expect to come back) and the companion ones (a
  531. # behaviour change worth explaining before it happens); the auth
  532. # policy keys stay unmentioned on purpose.
  533. plan = await self._plan_settings(db, values)
  534. detail = None
  535. if plan.companion and not plan.blocked:
  536. # An exposure toggle becomes a candidate whether or not the
  537. # backup carried its credential, so this commit can refuse a
  538. # switch without having a single credential-like key to skip —
  539. # "0 credential-like key(s) will be skipped" would read as noise.
  540. detail = _Detail(
  541. "settingsCompanionOnlyWillSkip",
  542. f"{len(plan.companion)} switch(es) will be left off — the credential each one needs "
  543. "cannot be restored from a backup",
  544. {"companion": len(plan.companion)},
  545. )
  546. elif plan.companion:
  547. detail = _Detail(
  548. "settingsCompanionWillSkip",
  549. f"{len(plan.blocked)} credential-like key(s) will be skipped, and "
  550. f"{len(plan.companion)} switch(es) that depend on them will be left off",
  551. {"count": len(plan.blocked), "companion": len(plan.companion)},
  552. )
  553. elif plan.blocked:
  554. detail = _Detail(
  555. "settingsCredentialsWillSkip",
  556. f"{len(plan.blocked)} credential-like keys will be skipped",
  557. {"count": len(plan.blocked)},
  558. )
  559. return len(values) - plan.refused_count, detail
  560. if category == RestoreCategory.SPOOLS:
  561. payload = parsed.get(SPOOLS_PATH)
  562. spools = payload.get("spools") if isinstance(payload, dict) else None
  563. usage_payload = parsed.get(SPOOL_USAGE_PATH)
  564. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  565. # Usage records are counted here, not just described in the detail:
  566. # _restore_spool_usage increments this category's tally, so counting
  567. # only the spools broke restored + skipped + failed == item_count —
  568. # the invariant the settings count is careful to hold. The detail
  569. # breaks the total down rather than adding to it.
  570. count = len(spools) if isinstance(spools, list) else 0
  571. detail = None
  572. if isinstance(usage, list) and usage:
  573. count += len(usage)
  574. detail = _Detail("spoolsUsageCount", f"including {len(usage)} usage records", {"count": len(usage)})
  575. return count, detail
  576. if category == RestoreCategory.ARCHIVES:
  577. payload = parsed.get(ARCHIVES_PATH)
  578. archives = payload.get("archives") if isinstance(payload, dict) else None
  579. count = len(archives) if isinstance(archives, list) else 0
  580. return count, _Detail(
  581. "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup"
  582. )
  583. if category == RestoreCategory.KPROFILES:
  584. total = 0
  585. serials = set()
  586. for path, payload in parsed.items():
  587. match = _KPROFILE_PATH_RE.match(path)
  588. if not match or not isinstance(payload, dict):
  589. continue
  590. serials.add(match.group(1))
  591. profiles = payload.get("profiles")
  592. if isinstance(profiles, list):
  593. total += len(profiles)
  594. detail = None
  595. if serials:
  596. detail = _Detail("kprofilesPrinterCount", f"across {len(serials)} printer(s)", {"count": len(serials)})
  597. return total, detail
  598. return 0, None
  599. # --- Restore -----------------------------------------------------------
  600. async def run_restore(
  601. self,
  602. config_id: int,
  603. ref: str,
  604. categories: list[RestoreCategory],
  605. overwrite_existing: bool = False,
  606. ) -> dict:
  607. """Apply selected categories from one backup commit."""
  608. # Import locally to avoid a module-level cycle: the backup service takes
  609. # the mirror-image lock against us.
  610. from backend.app.services.github_backup import github_backup_service
  611. # The lock serialises two concurrent restores; the backup side has no
  612. # lock of its own, and relies on this region staying await-free after the
  613. # acquisition. Both flags are plain bools on one event loop, so with no
  614. # suspension point between the two reads and the write, the loop cannot
  615. # slip github_backup.run_backup's mirror-image check in between. Adding an
  616. # `await` below the acquisition and above `self._running_restore = True`
  617. # would let a backup and a restore run at once.
  618. async with self._lock:
  619. if self._running_restore:
  620. return {"success": False, "message": "A restore is already running", "results": {}}
  621. if github_backup_service.is_running:
  622. return {
  623. "success": False,
  624. "message": "A backup is currently running. Wait for it to finish before restoring.",
  625. "results": {},
  626. }
  627. self._running_restore = True
  628. log_id = None
  629. try:
  630. async with async_session() as db:
  631. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  632. config = result.scalar_one_or_none()
  633. if not config:
  634. return {"success": False, "message": "Configuration not found", "results": {}}
  635. self._progress = "Resolving commit..."
  636. resolved, error, _ = await self._resolve_ref(config, ref)
  637. if resolved is None:
  638. return {"success": False, "message": error, "results": {}}
  639. log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
  640. db.add(log)
  641. await db.commit()
  642. await db.refresh(log)
  643. log_id = log.id
  644. # Owned here rather than by _apply so the failure path can see
  645. # the categories that were already committed when the raise
  646. # happened. _apply records a tally only after its category's
  647. # commit, so every entry present is on disk.
  648. results: dict[str, _CategoryTally] = {}
  649. try:
  650. payload, error = await self._read_categories(config, resolved, categories)
  651. if error:
  652. raise RuntimeError(error)
  653. settings_keys_written: set[str] = set()
  654. await self._apply(
  655. db, payload, categories, overwrite_existing, settings_keys_written, results=results
  656. )
  657. await db.commit()
  658. # After the commit: this reconnects the relay, which is not
  659. # something to do on values that could still roll back.
  660. settings_tally = results.get(RestoreCategory.SETTINGS.value)
  661. if settings_tally is not None:
  662. self._progress = "Reconnecting the MQTT relay..."
  663. await self._reconfigure_mqtt_relay(db, settings_keys_written, settings_tally)
  664. total_restored = sum(tally.restored for tally in results.values())
  665. any_failed = any(tally.failed for tally in results.values())
  666. log.status = "failed" if any_failed and total_restored == 0 else "success"
  667. log.completed_at = datetime.now(timezone.utc)
  668. log.files_changed = total_restored
  669. if any_failed:
  670. log.error_message = "Some items could not be restored — see the restore result for detail"
  671. await db.commit()
  672. return {
  673. "success": True,
  674. "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
  675. "log_id": log_id,
  676. "ref": resolved,
  677. "results": {name: tally.as_dict() for name, tally in results.items()},
  678. }
  679. except Exception as e:
  680. # Rolls back the category that was mid-flight. Every category
  681. # already in ``results`` committed as it finished (see
  682. # _apply), so those rows survive this — and reporting an
  683. # empty result over them would tell the user nothing was
  684. # restored while their archives and spools are on disk.
  685. logger.exception("Restore failed for config %s ref %s", config_id, resolved)
  686. await db.rollback()
  687. committed = sum(tally.restored for tally in results.values())
  688. log.status = "failed"
  689. log.completed_at = datetime.now(timezone.utc)
  690. log.files_changed = committed
  691. log.error_message = str(e)[:1000]
  692. await db.commit()
  693. return {
  694. "success": False,
  695. "message": str(e),
  696. "log_id": log_id,
  697. "ref": resolved,
  698. "results": {name: tally.as_dict() for name, tally in results.items()},
  699. }
  700. finally:
  701. self._running_restore = False
  702. self._progress = None
  703. async def _read_categories(
  704. self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
  705. ) -> tuple[dict, str]:
  706. """Fetch and parse just the files the requested categories need."""
  707. backend = get_provider_backend(config.provider)
  708. client = await self._get_client()
  709. self._progress = "Listing backup contents..."
  710. tree = await backend.list_tree(
  711. repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
  712. )
  713. if not tree.get("success"):
  714. return {}, tree.get("message") or "Could not list the commit"
  715. available: list[str] = tree.get("paths") or []
  716. wanted: list[str] = []
  717. for category in categories:
  718. wanted.extend(self._category_paths(category, available))
  719. if not wanted:
  720. return {}, "None of the selected categories are present in that commit"
  721. self._progress = "Downloading backup files..."
  722. fetched = await backend.fetch_files(
  723. repo_url=config.repository_url,
  724. token=config.access_token,
  725. ref=ref,
  726. paths=wanted,
  727. client=client,
  728. blob_shas=tree.get("blob_shas") or None,
  729. )
  730. if not fetched.get("success"):
  731. return {}, fetched.get("message") or "Could not read the commit contents"
  732. parsed, bad = self._parse_json_files(fetched.get("files") or {})
  733. if bad:
  734. return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
  735. return parsed, ""
  736. async def _apply(
  737. self,
  738. db: AsyncSession,
  739. payload: dict,
  740. categories: list[RestoreCategory],
  741. overwrite: bool,
  742. settings_keys_written: set[str] | None = None,
  743. results: dict[str, _CategoryTally] | None = None,
  744. ) -> dict[str, _CategoryTally]:
  745. """Apply categories in dependency order and return per-category tallies.
  746. ``settings_keys_written``, if given, collects the setting keys actually
  747. written, for the caller's post-commit side effects (see
  748. ``_reconfigure_mqtt_relay``).
  749. ``results``, if given, is the caller's own dict rather than a fresh one.
  750. Each category is committed before it is recorded there, so on a raise
  751. the caller can report exactly what is already on disk — see the
  752. per-category commit below.
  753. """
  754. results = {} if results is None else results
  755. archive_id_map: dict[int, int] = {}
  756. # Every database category commits before the next one starts, and only
  757. # then is its tally recorded. Two reasons:
  758. #
  759. # * SQLite has one writer. Each category is a long run of one SELECT per
  760. # row or per key — _find_archive, _find_spool, the usage dedupe,
  761. # _restore_settings — interleaved with autoflushed INSERTs, all inside
  762. # the open write transaction. A few thousand archives plus a full
  763. # usage history plausibly passes the 15 s busy_timeout
  764. # (core/database.py), at which point every concurrent writer in the
  765. # app fails with "database is locked". This is the same hold the
  766. # K-profile phase had, arriving by volume rather than by awaiting a
  767. # sulking printer.
  768. # * The ordering tolerates it: the only cross-category state is
  769. # archive_id_map and spool_id_map, both plain dicts in memory, and
  770. # the session is expire_on_commit=False so nothing reloads.
  771. #
  772. # The cost is that a later failure no longer rolls back an earlier
  773. # category — which is why the tally is recorded after the commit, so
  774. # run_restore's failure path reports the rows that really landed instead
  775. # of claiming nothing was restored.
  776. # Archives first: spool usage history references archive_id.
  777. if RestoreCategory.ARCHIVES in categories:
  778. self._progress = "Restoring print archives..."
  779. tally = _CategoryTally()
  780. await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
  781. await db.commit()
  782. results[RestoreCategory.ARCHIVES.value] = tally
  783. if RestoreCategory.SPOOLS in categories:
  784. self._progress = "Restoring spool inventory..."
  785. tally = _CategoryTally()
  786. await self._restore_spools(
  787. db,
  788. payload.get(SPOOLS_PATH),
  789. payload.get(SPOOL_USAGE_PATH),
  790. overwrite,
  791. tally,
  792. archive_id_map,
  793. )
  794. await db.commit()
  795. results[RestoreCategory.SPOOLS.value] = tally
  796. if RestoreCategory.SETTINGS in categories:
  797. self._progress = "Restoring app settings..."
  798. tally = _CategoryTally()
  799. await self._restore_settings(
  800. db, payload.get(SETTINGS_PATH), overwrite, tally, keys_written=settings_keys_written
  801. )
  802. await db.commit()
  803. results[RestoreCategory.SETTINGS.value] = tally
  804. # Last, because it leaves the database and publishes over MQTT.
  805. if RestoreCategory.KPROFILES in categories:
  806. # The database categories are already committed by the loop above,
  807. # and that is load-bearing here rather than tidiness:
  808. # _restore_kprofiles awaits get_kprofiles per printer per nozzle,
  809. # which is timeout=5.0 * max_retries=3, i.e. up to ~15 s each against
  810. # an unresponsive printer. Holding SQLite's writer across that would
  811. # pass the 15 s busy_timeout on a farm with a couple of sulking
  812. # printers.
  813. #
  814. # The cost is that a K-profile failure no longer rolls back the
  815. # categories that already succeeded. That is the correct trade
  816. # anyway: extrusion_cali_set has left for the printer by then and
  817. # cannot be rolled back either, so a rollback would only have made
  818. # the database disagree with the hardware.
  819. self._progress = "Sending K-profiles to printers..."
  820. tally = _CategoryTally()
  821. try:
  822. await self._restore_kprofiles(db, payload, tally)
  823. except Exception as e:
  824. # Everything above is committed and cannot be un-committed, so
  825. # letting this reach run_restore's handler would report
  826. # "nothing was restored" over durable archive, spool and
  827. # settings rows — and skip the post-commit MQTT reconfigure,
  828. # leaving the relay on the pre-restore broker. The K-profile
  829. # phase is the last thing that runs, so containing it here is
  830. # what keeps the result honest about what actually landed.
  831. logger.exception("The K-profile step failed after the database categories were committed")
  832. # Discards the phase's own read transaction. The rows above went
  833. # in at the commit two statements up; this only stops a session
  834. # left in a failed state by a database error from turning the
  835. # caller's commit into that same false report.
  836. await db.rollback()
  837. outstanding = self._kprofile_profile_count(
  838. content for path, content in payload.items() if _KPROFILE_PATH_RE.match(path)
  839. )
  840. outstanding -= tally.restored + tally.skipped + tally.failed
  841. tally.failed += max(outstanding, 0)
  842. tally.note(
  843. "kprofilesStepFailed",
  844. f"The K-profile step could not be completed: {e}",
  845. reason=str(e)[:200],
  846. )
  847. results[RestoreCategory.KPROFILES.value] = tally
  848. return results
  849. # --- Per-category appliers --------------------------------------------
  850. async def _restore_archives(
  851. self,
  852. db: AsyncSession,
  853. payload,
  854. overwrite: bool,
  855. tally: _CategoryTally,
  856. id_map: dict[int, int],
  857. ) -> None:
  858. archives = payload.get("archives") if isinstance(payload, dict) else None
  859. if not isinstance(archives, list):
  860. tally.note("noData", "No data of this kind in this backup")
  861. return
  862. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  863. valid_projects = set((await db.execute(select(Project.id))).scalars().all())
  864. # Ownership decides visibility, not just attribution: an archive with a
  865. # NULL created_by_id is a 404 to every caller without archives:read_all
  866. # (_ensure_archive_visible fails closed on it) and never appears in the
  867. # ownership-scoped list queries. Hoisted like the two above.
  868. #
  869. # username is the natural key and wins, per the module's rule at the top
  870. # of the file; created_by_id is the fallback for a pre-#2656 commit that
  871. # carries no username. That ordering is what makes restoring onto a
  872. # rebuilt instance safe: the users table renumbers there, so a live id
  873. # can land on a different person, and the id path alone cannot tell that
  874. # from a correct match. Resolving on the name instead means the one case
  875. # it cannot resolve — a user renamed since the backup — falls through to
  876. # ownerless-with-a-note below rather than misattributing in silence.
  877. users = (await db.execute(select(User.id, User.username))).all()
  878. valid_users = {user_id for user_id, _ in users}
  879. users_by_name = {username: user_id for user_id, username in users}
  880. # Only metadata is backed up, never the 3MF/thumbnail bytes, and
  881. # print_archives.file_path is NOT NULL — so inserted rows get an empty
  882. # path and are history-only. Say so once rather than per row.
  883. warned_files = False
  884. for entry in archives:
  885. if not isinstance(entry, dict):
  886. tally.failed += 1
  887. continue
  888. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  889. started_at = _parse_dt(entry.get("started_at"))
  890. existing = await self._find_archive(db, entry, started_at)
  891. fields = {
  892. "print_name": entry.get("print_name"),
  893. "print_time_seconds": entry.get("print_time_seconds"),
  894. "filament_used_grams": entry.get("filament_used_grams"),
  895. "filament_type": entry.get("filament_type"),
  896. "filament_color": entry.get("filament_color"),
  897. "layer_height": entry.get("layer_height"),
  898. "total_layers": entry.get("total_layers"),
  899. "nozzle_diameter": entry.get("nozzle_diameter"),
  900. "bed_temperature": entry.get("bed_temperature"),
  901. "nozzle_temperature": entry.get("nozzle_temperature"),
  902. "sliced_for_model": entry.get("sliced_for_model"),
  903. "status": entry.get("status") or "completed",
  904. "started_at": started_at,
  905. "completed_at": _parse_dt(entry.get("completed_at")),
  906. "makerworld_url": entry.get("makerworld_url"),
  907. "designer": entry.get("designer"),
  908. "external_url": entry.get("external_url"),
  909. "is_favorite": bool(entry.get("is_favorite")),
  910. "tags": entry.get("tags"),
  911. "notes": entry.get("notes"),
  912. "cost": entry.get("cost"),
  913. "failure_reason": entry.get("failure_reason"),
  914. "quantity": entry.get("quantity") or 1,
  915. "energy_kwh": entry.get("energy_kwh"),
  916. "energy_cost": entry.get("energy_cost"),
  917. }
  918. printer_id = entry.get("printer_id")
  919. if printer_id is not None and printer_id not in valid_printers:
  920. tally.note(
  921. "archivesPrinterMissing", "Some archives referenced printers that no longer exist — link cleared"
  922. )
  923. printer_id = None
  924. project_id = entry.get("project_id")
  925. if project_id is not None and project_id not in valid_projects:
  926. tally.note(
  927. "archivesProjectMissing", "Some archives referenced projects that no longer exist — link cleared"
  928. )
  929. project_id = None
  930. fields["printer_id"] = printer_id
  931. fields["project_id"] = project_id
  932. # The ownership pair and deleted_at are the late arrivals — a backup
  933. # commit taken before the collector wrote them carries neither key.
  934. # Absent is NOT the same as null here, because the overwrite branch
  935. # below is a blanket setattr: treating a missing key as None would
  936. # write NULL over a live owner (_ensure_archive_visible then 404s the
  937. # archive for the very user who owns it — the failure carrying the
  938. # column was added to fix) and silently un-delete a row the user
  939. # deleted. So only carry a column the backup actually knows about;
  940. # on insert, an absent key just takes the model default.
  941. owner_cleared = False
  942. backup_username = entry.get("created_by_username")
  943. if isinstance(backup_username, str) and backup_username:
  944. # The natural-key path. A miss here is a user renamed or deleted
  945. # since the backup, and there is nothing else to resolve on: the
  946. # id alongside it is from the source instance's numbering, so
  947. # trusting it is exactly the misattribution the name is here to
  948. # prevent. Cleared rather than failing the row — the archive is
  949. # still worth having, and an admin can reassign it — but said out
  950. # loud, because a cleared owner is not silent-safe.
  951. created_by_id = users_by_name.get(backup_username)
  952. if created_by_id is None:
  953. tally.note(
  954. "archivesOwnerUnmatched",
  955. "Some archives name an owner this instance does not have — owner cleared rather than "
  956. "guessed from the backup's user id, so they are visible only to users with the "
  957. "archives:read_all permission until an admin reassigns them",
  958. )
  959. owner_cleared = True
  960. fields["created_by_id"] = created_by_id
  961. elif "created_by_id" in entry:
  962. # Fallback for a commit taken before the collector recorded the
  963. # username. Validated rather than trusted, so a *stale* id clears
  964. # instead of pointing somewhere wrong; a live id belonging to a
  965. # different person on a rebuilt instance is the case this path
  966. # cannot see, and is why the branch above exists.
  967. created_by_id = entry.get("created_by_id")
  968. if created_by_id is not None and created_by_id not in valid_users:
  969. tally.note(
  970. "archivesOwnerCleared",
  971. "Some archives referenced users that no longer exist — owner cleared, so they are "
  972. "visible only to users with the archives:read_all permission until an admin reassigns them",
  973. )
  974. created_by_id = None
  975. owner_cleared = True
  976. fields["created_by_id"] = created_by_id
  977. if "deleted_at" in entry:
  978. # A soft-deleted archive is still in the backup (its row is kept
  979. # so stats keep counting it), so carry the flag across or the
  980. # restore turns something the user deleted back into a visible
  981. # archive.
  982. fields["deleted_at"] = _parse_dt(entry.get("deleted_at"))
  983. if existing is not None:
  984. if old_id is not None:
  985. id_map[old_id] = existing.id
  986. if not overwrite:
  987. tally.skipped += 1
  988. continue
  989. # Overwrite means "make the local row match the backup", which
  990. # includes un-deleting one the user deleted after the backup was
  991. # taken. Legitimate, but not obvious from a restored/skipped
  992. # count, so say it.
  993. if existing.deleted_at is not None and "deleted_at" in fields and fields["deleted_at"] is None:
  994. tally.note(
  995. "archivesUndeleted",
  996. "Archive(s) deleted since the backup are visible again — overwrite was on",
  997. )
  998. for key, value in fields.items():
  999. setattr(existing, key, value)
  1000. tally.restored += 1
  1001. continue
  1002. if not warned_files:
  1003. tally.note(
  1004. "archivesMetadataOnly",
  1005. "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup",
  1006. )
  1007. warned_files = True
  1008. # Insert-only, and the mirror of the "absent is not null" rule above:
  1009. # on overwrite an unknown owner correctly leaves the local one alone,
  1010. # but there is no local row here to fall back on, so the archive
  1011. # lands ownerless — a 404 for everyone without archives:read_all.
  1012. # Two ways to get here: a commit taken before the collector recorded
  1013. # the column (every pre-#2656 backup), or an archive that genuinely
  1014. # had no owner on the source instance. Both restore fine and both
  1015. # were silent, so the tally said "N archives restored" while the user
  1016. # who asked for them saw none. The stale-id case above already said
  1017. # its piece; don't say it twice for the same row.
  1018. if fields.get("created_by_id") is None and not owner_cleared:
  1019. tally.note(
  1020. "archivesOwnerUnknown",
  1021. "Some archives were restored without an owner — this backup does not record one, so they "
  1022. "are visible only to users with the archives:read_all permission until an admin reassigns "
  1023. "them",
  1024. )
  1025. row = PrintArchive(
  1026. filename=entry.get("filename") or "restored-from-backup",
  1027. file_path="",
  1028. file_size=entry.get("file_size") or 0,
  1029. content_hash=entry.get("content_hash"),
  1030. **fields,
  1031. )
  1032. created_at = _parse_dt(entry.get("created_at"))
  1033. if created_at is not None:
  1034. row.created_at = created_at
  1035. db.add(row)
  1036. await db.flush()
  1037. if old_id is not None:
  1038. id_map[old_id] = row.id
  1039. tally.restored += 1
  1040. async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
  1041. """Match a backed-up archive to a local row by natural key.
  1042. ``started_at`` is nullable and genuinely NULL for a whole class of rows —
  1043. the re-slice path in ``library.py`` constructs ``PrintArchive`` without
  1044. one — so it cannot be *required* by the key. It narrows the match instead:
  1045. a backed-up row with no ``started_at`` matches a local row that has none
  1046. either. Requiring it meant those archives never matched, so each restore
  1047. re-inserted them as duplicates and overwrite mode could never update them.
  1048. ``content_hash`` identifies the sliced file on its own, which is why it is
  1049. the branch allowed to run without a ``started_at``; ``filename`` is too
  1050. weak for that (re-slices share it) and still requires one. Two backed-up
  1051. rows sharing a hash *and* having no ``started_at`` are indistinguishable
  1052. in the backup, so they collapse onto one local row — better than
  1053. duplicating both on every restore.
  1054. Soft-deleted rows are matched deliberately: there is no ``deleted_at``
  1055. filter here because the row still exists, and matching it is what stops a
  1056. restore inserting a live duplicate of an archive the user has deleted.
  1057. """
  1058. started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
  1059. content_hash = entry.get("content_hash")
  1060. if content_hash:
  1061. result = await db.execute(
  1062. select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
  1063. )
  1064. row = result.scalars().first()
  1065. if row is not None:
  1066. return row
  1067. filename = entry.get("filename")
  1068. if filename and started_at:
  1069. result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
  1070. return result.scalars().first()
  1071. return None
  1072. async def _restore_spools(
  1073. self,
  1074. db: AsyncSession,
  1075. inventory,
  1076. usage_payload,
  1077. overwrite: bool,
  1078. tally: _CategoryTally,
  1079. archive_id_map: dict[int, int],
  1080. ) -> None:
  1081. spools = inventory.get("spools") if isinstance(inventory, dict) else None
  1082. if not isinstance(spools, list):
  1083. tally.note("noData", "No data of this kind in this backup")
  1084. return
  1085. spool_id_map: dict[int, int] = {}
  1086. tags_kept = 0
  1087. for entry in spools:
  1088. if not isinstance(entry, dict):
  1089. tally.failed += 1
  1090. continue
  1091. old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
  1092. existing, matched_on = await self._find_spool(db, entry)
  1093. fields = {
  1094. "material": entry.get("material") or "PLA",
  1095. "subtype": entry.get("subtype"),
  1096. "color_name": entry.get("color_name"),
  1097. "rgba": entry.get("rgba"),
  1098. "brand": entry.get("brand"),
  1099. "label_weight": entry.get("label_weight") or 1000,
  1100. "core_weight": entry.get("core_weight") or 250,
  1101. "weight_used": entry.get("weight_used") or 0,
  1102. "weight_locked": bool(entry.get("weight_locked")),
  1103. "slicer_filament": entry.get("slicer_filament"),
  1104. "slicer_filament_name": entry.get("slicer_filament_name"),
  1105. "nozzle_temp_min": entry.get("nozzle_temp_min"),
  1106. "nozzle_temp_max": entry.get("nozzle_temp_max"),
  1107. "note": entry.get("note"),
  1108. "cost_per_kg": entry.get("cost_per_kg"),
  1109. "tag_uid": entry.get("tag_uid"),
  1110. "tray_uuid": entry.get("tray_uuid"),
  1111. "data_origin": entry.get("data_origin"),
  1112. "tag_type": entry.get("tag_type"),
  1113. "archived_at": _parse_dt(entry.get("archived_at")),
  1114. }
  1115. if existing is not None:
  1116. if old_id is not None:
  1117. spool_id_map[old_id] = existing.id
  1118. if not overwrite:
  1119. tally.skipped += 1
  1120. continue
  1121. tags_kept += await self._guard_tag_overwrite(db, existing, fields, matched_on)
  1122. for key, value in fields.items():
  1123. setattr(existing, key, value)
  1124. tally.restored += 1
  1125. continue
  1126. row = Spool(**fields)
  1127. # Carry the original created_at across. Without it the row would be
  1128. # stamped "now", and the composite fallback in _find_spool (which
  1129. # keys on created_at) would miss on a second restore and insert a
  1130. # duplicate instead of matching.
  1131. created_at = _parse_dt(entry.get("created_at"))
  1132. if created_at is not None:
  1133. row.created_at = created_at
  1134. db.add(row)
  1135. await db.flush()
  1136. if old_id is not None:
  1137. spool_id_map[old_id] = row.id
  1138. tally.restored += 1
  1139. if tags_kept:
  1140. tally.note(
  1141. "spoolTagKept",
  1142. f"{tags_kept} spool tag(s) left as they are — the backup would have cleared a tag that "
  1143. "has since been scanned, or moved one onto a second spool.",
  1144. count=tags_kept,
  1145. )
  1146. await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
  1147. async def _find_spool(self, db: AsyncSession, entry: dict) -> tuple[Spool | None, str | None]:
  1148. """Match a backed-up spool to a local row, and say which key matched.
  1149. Physical identity first (an RFID/Bambu tag is the spool), then a
  1150. descriptive composite including ``created_at`` so two otherwise
  1151. identical spools added at different times stay distinct.
  1152. The second element names the column that matched — ``"tag_uid"``,
  1153. ``"tray_uuid"`` or ``None`` for the composite. ``_guard_tag_overwrite``
  1154. needs it: the matched column holds the incoming value by definition, so
  1155. it is the *other* one that overwrite can corrupt.
  1156. """
  1157. tag_uid = entry.get("tag_uid")
  1158. if tag_uid:
  1159. result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
  1160. row = result.scalars().first()
  1161. if row is not None:
  1162. return row, "tag_uid"
  1163. tray_uuid = entry.get("tray_uuid")
  1164. if tray_uuid:
  1165. result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
  1166. row = result.scalars().first()
  1167. if row is not None:
  1168. return row, "tray_uuid"
  1169. created_at = _parse_dt(entry.get("created_at"))
  1170. if created_at is None:
  1171. return None, None
  1172. # created_at is filtered in Python, not here — see _created_at_matches.
  1173. result = await db.execute(
  1174. select(Spool).where(
  1175. Spool.material == (entry.get("material") or "PLA"),
  1176. Spool.brand == entry.get("brand"),
  1177. Spool.subtype == entry.get("subtype"),
  1178. Spool.color_name == entry.get("color_name"),
  1179. )
  1180. )
  1181. for row in result.scalars():
  1182. if _created_at_matches(row, created_at):
  1183. return row, None
  1184. return None, None
  1185. @staticmethod
  1186. async def _guard_tag_overwrite(db: AsyncSession, existing: Spool, fields: dict, matched_on: str | None) -> int:
  1187. """Remove tag columns from ``fields`` that an overwrite would corrupt.
  1188. ``tag_uid`` and ``tray_uuid`` are both in ``fields`` and overwrite is a
  1189. blanket ``setattr`` loop, so a spool matched on one key gets the backup's
  1190. *other* key written onto it. Neither column has a unique constraint
  1191. (``models/spool.py``, and no unique index in the migrations), so nothing
  1192. errors — a duplicate tag simply appears, after which ``_find_spool``'s
  1193. ``.first()`` is non-deterministic and an AMS tag lookup resolves to an
  1194. arbitrary one of the two spools. The same loop can also *clear* a tag the
  1195. user has scanned since the backup was taken, when the backup entry holds
  1196. ``None``.
  1197. Two refusals, and the row is otherwise overwritten as normal:
  1198. * the incoming value is empty and the local row has one — the backup
  1199. predates the scan, so the local tag is the newer fact;
  1200. * the incoming value is already held by a different local spool — writing
  1201. it would create the duplicate described above.
  1202. Returns how many columns were left alone, so the caller can say so in the
  1203. tally rather than doing it silently.
  1204. """
  1205. kept = 0
  1206. for column in ("tag_uid", "tray_uuid"):
  1207. # The column we matched on already holds the incoming value.
  1208. if column == matched_on:
  1209. continue
  1210. incoming = fields.get(column)
  1211. current = getattr(existing, column)
  1212. if incoming == current:
  1213. continue
  1214. if not incoming:
  1215. if current:
  1216. fields.pop(column)
  1217. kept += 1
  1218. continue
  1219. clash = await db.execute(
  1220. select(Spool.id).where(getattr(Spool, column) == incoming, Spool.id != existing.id)
  1221. )
  1222. if clash.scalars().first() is not None:
  1223. fields.pop(column)
  1224. kept += 1
  1225. return kept
  1226. async def _restore_spool_usage(
  1227. self,
  1228. db: AsyncSession,
  1229. usage_payload,
  1230. tally: _CategoryTally,
  1231. spool_id_map: dict[int, int],
  1232. archive_id_map: dict[int, int],
  1233. ) -> None:
  1234. usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
  1235. if not isinstance(usage, list) or not usage:
  1236. return
  1237. valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
  1238. unresolved = 0
  1239. unlinked_archives = 0
  1240. for entry in usage:
  1241. if not isinstance(entry, dict):
  1242. tally.failed += 1
  1243. continue
  1244. old_spool_id = entry.get("spool_id")
  1245. spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
  1246. if spool_id is None:
  1247. # The parent spool never made it into the map: the backup's spool
  1248. # list didn't include it, or its entry carried no integer id. A
  1249. # spool that was merely *skipped* (matched locally, overwrite off)
  1250. # is mapped a few lines up in _restore_spools, so it never lands
  1251. # here — which is why the note below offers no remedy.
  1252. unresolved += 1
  1253. tally.skipped += 1
  1254. continue
  1255. created_at = _parse_dt(entry.get("created_at"))
  1256. # Usage history has no natural key of its own, so dedupe on the
  1257. # tuple that makes a consumption event unique in practice. As in
  1258. # _find_spool, created_at is compared in Python — see
  1259. # _created_at_matches. An entry carrying no created_at at all
  1260. # cannot be recognised and is re-inserted, which is what the
  1261. # IS NULL comparison this replaced did too: the column is
  1262. # non-nullable, so it never matched either.
  1263. existing = await db.execute(
  1264. select(SpoolUsageHistory).where(
  1265. SpoolUsageHistory.spool_id == spool_id,
  1266. SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
  1267. SpoolUsageHistory.print_name == entry.get("print_name"),
  1268. )
  1269. )
  1270. if any(_created_at_matches(row, created_at) for row in existing.scalars()):
  1271. tally.skipped += 1
  1272. continue
  1273. printer_id = entry.get("printer_id")
  1274. if printer_id is not None and printer_id not in valid_printers:
  1275. printer_id = None
  1276. old_archive_id = entry.get("archive_id")
  1277. archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
  1278. if archive_id is None and isinstance(old_archive_id, int):
  1279. # Restoring spools without archives leaves archive_id_map empty,
  1280. # so every "this print consumed that spool" link is dropped — the
  1281. # local archive may well exist, but its payload wasn't fetched,
  1282. # so there is no natural key here to match it on. Nor is it
  1283. # repairable by a later archives-only restore: the dedupe key
  1284. # above doesn't include archive_id, so these rows are recognised
  1285. # as already-present and skipped. Worth telling the user while
  1286. # they can still redo the run with both categories ticked.
  1287. unlinked_archives += 1
  1288. row = SpoolUsageHistory(
  1289. spool_id=spool_id,
  1290. printer_id=printer_id,
  1291. print_name=entry.get("print_name"),
  1292. archive_id=archive_id,
  1293. weight_used=entry.get("weight_used") or 0,
  1294. percent_used=entry.get("percent_used") or 0,
  1295. status=entry.get("status") or "completed",
  1296. cost=entry.get("cost"),
  1297. )
  1298. if created_at is not None:
  1299. row.created_at = created_at
  1300. db.add(row)
  1301. tally.restored += 1
  1302. if unresolved:
  1303. tally.note(
  1304. "spoolUsageUnresolved",
  1305. f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
  1306. "spool list, so there is nothing to attach them to.",
  1307. count=unresolved,
  1308. )
  1309. if unlinked_archives:
  1310. tally.note(
  1311. "spoolUsageUnlinked",
  1312. f"{unlinked_archives} usage record(s) restored without their print-history link — "
  1313. "select Print archives alongside Spool inventory to keep it.",
  1314. count=unlinked_archives,
  1315. )
  1316. async def _restore_settings(
  1317. self,
  1318. db: AsyncSession,
  1319. payload,
  1320. overwrite: bool,
  1321. tally: _CategoryTally,
  1322. keys_written: set[str] | None = None,
  1323. ) -> None:
  1324. values = payload.get("settings") if isinstance(payload, dict) else None
  1325. if not isinstance(values, dict):
  1326. tally.note("noData", "No data of this kind in this backup")
  1327. return
  1328. # Planned before the first write, so the companion rule reads genuinely
  1329. # pre-restore local state, and so the preview and this run classify the
  1330. # payload identically.
  1331. plan = await self._plan_settings(db, values)
  1332. refused = plan.refused
  1333. for key, value in values.items():
  1334. if not isinstance(key, str) or not key:
  1335. tally.failed += 1
  1336. continue
  1337. if key in refused:
  1338. # Refusals are reported in the notes and nowhere else. They are
  1339. # already outside the preview's item count, and the preview is
  1340. # the number the user was shown, so counting them here would
  1341. # make restored + skipped + failed exceed it. The two skips
  1342. # below stay counted because they depend on this run's flags,
  1343. # which the preview cannot see.
  1344. continue
  1345. if value is None:
  1346. tally.skipped += 1
  1347. continue
  1348. result = await db.execute(select(Settings).where(Settings.key == key))
  1349. existing = result.scalar_one_or_none()
  1350. if existing is not None:
  1351. if not overwrite:
  1352. tally.skipped += 1
  1353. continue
  1354. existing.value = str(value)
  1355. tally.restored += 1
  1356. if keys_written is not None:
  1357. keys_written.add(key)
  1358. continue
  1359. db.add(Settings(key=key, value=str(value)))
  1360. tally.restored += 1
  1361. if keys_written is not None:
  1362. keys_written.add(key)
  1363. if plan.blocked:
  1364. tally.note(
  1365. "settingsCredentialsSkipped",
  1366. f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually",
  1367. count=len(plan.blocked),
  1368. )
  1369. if plan.protected:
  1370. tally.note(
  1371. "settingsAuthSkipped",
  1372. f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > "
  1373. "Authentication so the lockout checks still run",
  1374. count=len(plan.protected),
  1375. )
  1376. if plan.companion:
  1377. keys = ", ".join(sorted(plan.companion))
  1378. tally.note(
  1379. "settingsCompanionSkipped",
  1380. f"{keys} left switched off — the credential each one needs cannot be restored from a "
  1381. "backup and this instance has none stored, so switching them on would leave the "
  1382. "integration unauthenticated",
  1383. keys=keys,
  1384. count=len(plan.companion),
  1385. )
  1386. async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
  1387. """Push restored mqtt_* settings into the live relay.
  1388. The relay reads its broker config once, at configure() time — the
  1389. settings PUT handler reconfigures it for exactly this reason
  1390. (api/routes/settings.py). Writing the rows alone left the relay on the
  1391. pre-restore broker until the next backend restart while the UI showed
  1392. the restored values, which is the one way a restore could look applied
  1393. and not be.
  1394. Called after the commit, never before: configure() tears the connection
  1395. down and rebuilds it, so it must not run against values a later failure
  1396. could roll back. Only mqtt_password can't come back this way (the
  1397. credential blocklist skips it) — the row already in the database is
  1398. reused, so an unchanged broker keeps working.
  1399. """
  1400. if not _MQTT_SETTING_KEYS & keys_written:
  1401. return
  1402. try:
  1403. from backend.app.services.mqtt_relay import mqtt_relay
  1404. rows = await db.execute(select(Settings).where(Settings.key.in_(_MQTT_SETTING_KEYS)))
  1405. stored = {s.key: s.value for s in rows.scalars().all()}
  1406. # Same shape and defaults the settings PUT handler builds.
  1407. await mqtt_relay.configure(
  1408. {
  1409. "mqtt_enabled": (stored.get("mqtt_enabled") or "false") == "true",
  1410. "mqtt_broker": stored.get("mqtt_broker") or "",
  1411. "mqtt_port": int(stored.get("mqtt_port") or "1883"),
  1412. "mqtt_username": stored.get("mqtt_username") or "",
  1413. "mqtt_password": stored.get("mqtt_password") or "",
  1414. "mqtt_topic_prefix": stored.get("mqtt_topic_prefix") or "bambuddy",
  1415. "mqtt_use_tls": (stored.get("mqtt_use_tls") or "false") == "true",
  1416. }
  1417. )
  1418. except Exception:
  1419. # Same call is best-effort in the settings PUT handler: the rows are
  1420. # committed either way, and a broker that refuses the new config
  1421. # must not turn a successful restore into a failed one. Noted rather
  1422. # than swallowed silently, so the user knows to restart.
  1423. logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
  1424. tally.note(
  1425. "settingsMqttRelayFailed",
  1426. "MQTT settings restored, but the relay could not be reconnected — restart Bambuddy",
  1427. )
  1428. async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
  1429. by_serial: dict[str, list[tuple[str, dict]]] = {}
  1430. for path, content in payload.items():
  1431. match = _KPROFILE_PATH_RE.match(path)
  1432. if not match or not isinstance(content, dict):
  1433. continue
  1434. by_serial.setdefault(match.group(1), []).append((match.group(2), content))
  1435. if not by_serial:
  1436. tally.note("noData", "No data of this kind in this backup")
  1437. return
  1438. result = await db.execute(select(Printer))
  1439. printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
  1440. # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
  1441. # the profile occupying a slot, so writing is always an overwrite on the
  1442. # printer side.
  1443. tally.note("kprofilesAlwaysOverwrite", "K-profiles always overwrite the matching slot on the printer")
  1444. # A refusal is now believed and counted failed (#2718 made the ack worth
  1445. # reading), but silence still counts restored, so the caveat stands —
  1446. # narrowed to what is actually left uncertain.
  1447. tally.note(
  1448. "kprofilesAckUnreliable",
  1449. "A printer that does not answer still counts as restored — verify the profiles on the printer",
  1450. )
  1451. for serial, entries in sorted(by_serial.items()):
  1452. profile_total = self._kprofile_profile_count(c for _, c in entries)
  1453. printer = printers.get(serial)
  1454. if printer is None:
  1455. tally.skipped += profile_total
  1456. tally.note("kprofilesPrinterMissing", f"No printer with serial {serial} — skipped", serial=serial)
  1457. continue
  1458. client = printer_manager.get_client(printer.id)
  1459. if not client or not client.state.connected:
  1460. tally.skipped += profile_total
  1461. tally.note(
  1462. "kprofilesPrinterOffline",
  1463. f"{printer.name} ({serial}) is not connected — skipped",
  1464. printer=printer.name,
  1465. serial=serial,
  1466. )
  1467. continue
  1468. for nozzle, content in sorted(entries):
  1469. profiles = content.get("profiles")
  1470. if not isinstance(profiles, list) or not profiles:
  1471. continue
  1472. if nozzle not in _KNOWN_NOZZLES:
  1473. tally.note(
  1474. "kprofilesUnknownNozzle",
  1475. f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is",
  1476. nozzle=nozzle,
  1477. serial=serial,
  1478. )
  1479. # The backup's slot_id is a cali_idx, and cali_idx is as
  1480. # unstable as the autoincrement ids we already refuse to reuse
  1481. # for spools and archives: editing a profile in Bambuddy is a
  1482. # delete-then-add on a single-nozzle printer, which re-keys it.
  1483. # Addressing extrusion_cali_set at a slot that no longer exists
  1484. # is a silent no-op — the printer drops it and we would still
  1485. # report the profile restored. So resolve the live index first.
  1486. current = await self._current_kprofile_index(client, nozzle, serial)
  1487. profile_dicts = []
  1488. unmatched = 0
  1489. # A live profile can only stand in for one backed-up entry. Two
  1490. # entries resolving to the same cali_idx both go into the batch,
  1491. # the second overwrites the first on the printer, and the tally
  1492. # counts two restored where one landed.
  1493. claimed: set[int] = set()
  1494. for p in profiles:
  1495. if not isinstance(p, dict):
  1496. # Counted, not dropped. _kprofile_profile_count includes
  1497. # it, so the offline and printer-missing paths already
  1498. # count the same entry skipped and the failure path
  1499. # counts it outstanding — leaving the tally here was the
  1500. # one place a profile could vanish from
  1501. # restored + skipped + failed entirely.
  1502. tally.failed += 1
  1503. continue
  1504. match = self._match_kprofile(p, current, claimed)
  1505. if match is None:
  1506. unmatched += 1
  1507. else:
  1508. claimed.add(match.slot_id)
  1509. entry = {
  1510. "filament_id": p.get("filament_id", ""),
  1511. "name": p.get("name", ""),
  1512. "k_value": p.get("k_value", "0.020000"),
  1513. "extruder_id": p.get("extruder_id", 0),
  1514. # Prefer the live setting_id when we matched: it is
  1515. # what the printer currently associates with the slot.
  1516. "setting_id": (match.setting_id if match else None) or p.get("setting_id"),
  1517. # cali_idx -1 tells the printer to add a new profile
  1518. # rather than address a slot that isn't there.
  1519. "cali_idx": match.slot_id if match else -1,
  1520. # Only consulted for the generated-setting_id
  1521. # fallback; cali_idx above takes precedence.
  1522. "slot_id": 0,
  1523. }
  1524. # Same precedence as setting_id, and set only when known.
  1525. # nozzle_id encodes the fitted nozzle's type and diameter
  1526. # ("HS00-0.4"), so the live value beats the backup's: the
  1527. # user may have swapped the nozzle since. When neither knows,
  1528. # the key has to be *absent* — set_kprofiles_batch supplies
  1529. # HS00-{diameter} via p.get(..., default), which a key
  1530. # present-and-None defeats, publishing a null nozzle_id.
  1531. # Printers that omit it are the reason the default is there
  1532. # (#1748), so it has to be reachable.
  1533. nozzle_id = (getattr(match, "nozzle_id", None) if match else None) or p.get("nozzle_id")
  1534. if nozzle_id:
  1535. entry["nozzle_id"] = nozzle_id
  1536. profile_dicts.append(entry)
  1537. if not profile_dicts:
  1538. continue
  1539. if unmatched:
  1540. tally.note(
  1541. "kprofilesUnmatched",
  1542. f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
  1543. "— added as new profiles",
  1544. count=unmatched,
  1545. nozzle=nozzle,
  1546. printer=printer.name,
  1547. )
  1548. try:
  1549. seq = client.set_kprofiles_batch(profile_dicts, nozzle)
  1550. except Exception as e:
  1551. logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
  1552. seq = None
  1553. if not seq:
  1554. tally.failed += len(profile_dicts)
  1555. tally.note(
  1556. "kprofilesSendFailed",
  1557. f"Failed to send {nozzle} profiles to {printer.name} ({serial})",
  1558. nozzle=nozzle,
  1559. printer=printer.name,
  1560. serial=serial,
  1561. )
  1562. continue
  1563. # What came back is the sequence_id the command was published
  1564. # under, not a verdict (#2718) — a truthy string only means the
  1565. # command left the building. The printer answers separately, and
  1566. # every other caller of this API now reads that answer; without
  1567. # this the restore would be the one path left that reports a
  1568. # refused write as saved.
  1569. ok, detail = await self._kprofile_ack(client, seq, serial, nozzle)
  1570. if ok:
  1571. tally.restored += len(profile_dicts)
  1572. else:
  1573. tally.failed += len(profile_dicts)
  1574. tally.note(
  1575. "kprofilesRefused",
  1576. f"{printer.name} ({serial}) refused the {nozzle} profiles: {detail}",
  1577. nozzle=nozzle,
  1578. printer=printer.name,
  1579. serial=serial,
  1580. reason=detail,
  1581. )
  1582. @staticmethod
  1583. def _kprofile_profile_count(contents) -> int:
  1584. """Count the profiles across parsed K-profile files.
  1585. Defensive on purpose. A hand-edited or truncated backup can carry a
  1586. ``profiles`` value that is not a list, and this count runs *before* the
  1587. per-call guards in the loop below — after ``_apply`` has already
  1588. committed the database categories. A malformed file has to be a skipped
  1589. category, not an exception thrown over committed rows.
  1590. """
  1591. total = 0
  1592. for content in contents:
  1593. profiles = content.get("profiles") if isinstance(content, dict) else None
  1594. if isinstance(profiles, list):
  1595. total += len(profiles)
  1596. return total
  1597. @staticmethod
  1598. async def _kprofile_ack(client, seq: str, serial: str, nozzle: str) -> tuple[bool, str]:
  1599. """Read the printer's verdict on one batch write.
  1600. ``await_cali_ack`` already treats silence as success — no answer is not
  1601. evidence of refusal, and firmware that predates the ack never answers at
  1602. all. An exception reading it is the same situation one layer up, so it
  1603. degrades the same way rather than turning a write that most likely
  1604. landed into a reported failure.
  1605. """
  1606. try:
  1607. ok, detail = await client.await_cali_ack(seq)
  1608. return bool(ok), str(detail or "")
  1609. except Exception as e:
  1610. logger.warning("Could not read the K-profile ack for %s nozzle %s: %s", serial, nozzle, e)
  1611. return True, ""
  1612. @staticmethod
  1613. async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:
  1614. """Read the printer's live profiles for one nozzle.
  1615. Best-effort: a read failure degrades to "nothing matched", which makes
  1616. every profile an add rather than aborting the restore.
  1617. """
  1618. try:
  1619. return list(await client.get_kprofiles(nozzle_diameter=nozzle) or [])
  1620. except Exception as e:
  1621. logger.warning("Could not read live K-profiles for %s nozzle %s: %s", serial, nozzle, e)
  1622. return []
  1623. @staticmethod
  1624. def _match_kprofile(entry: dict, current: list, claimed: set[int]):
  1625. """Find the live profile a backed-up entry corresponds to.
  1626. ``setting_id`` is the filament preset the profile was calibrated for and
  1627. is the strongest signal; a delete-then-add edit regenerates it, so fall
  1628. back to the display name, which Bambuddy's own editor preserves.
  1629. Both are scoped by ``filament_id`` — the same preset on a different
  1630. filament is a different profile — and by ``extruder_id``, because on a
  1631. dual-nozzle printer the same preset on the other extruder is a different
  1632. profile too.
  1633. ``claimed`` holds the slot ids already taken by earlier entries in this
  1634. nozzle's loop, and no live profile may be claimed twice. Without it, two
  1635. backed-up entries sharing a ``filament_id`` and matching on neither
  1636. ``setting_id`` nor ``name`` both fell through to the single-candidate
  1637. arm and both took the same slot — reachable whenever the user has since
  1638. deleted one of a pair, because the delete-then-add re-key is what strips
  1639. the ``setting_id`` match. Returning None for the displaced entry means
  1640. ``cali_idx: -1``, i.e. add-as-new, which is the safe outcome.
  1641. """
  1642. filament_id = entry.get("filament_id")
  1643. if not filament_id:
  1644. return None
  1645. candidates = [c for c in current if c.filament_id == filament_id]
  1646. # The live index is read per nozzle *diameter*, so on an H2D both
  1647. # extruders' profiles come back together. With the same filament
  1648. # calibrated on both — the ordinary case on a dual-nozzle printer, not an
  1649. # exotic one — filament_id alone lets extruder 0's backed-up entry match
  1650. # extruder 1's live profile, and the batch then carries
  1651. # {extruder_id: 0, cali_idx: <extruder-1 slot>}: one extruder's
  1652. # calibration written over the other's, counted restored.
  1653. #
  1654. # Conditional on both sides saying which extruder they mean. A pre-#2656
  1655. # backup carries no extruder_id, and a live index that reports none must
  1656. # not turn every entry into an add.
  1657. extruder_id = entry.get("extruder_id")
  1658. if isinstance(extruder_id, int) and any(getattr(c, "extruder_id", None) is not None for c in candidates):
  1659. candidates = [c for c in candidates if getattr(c, "extruder_id", None) == extruder_id]
  1660. available = [c for c in candidates if c.slot_id not in claimed]
  1661. if not available:
  1662. return None
  1663. setting_id = entry.get("setting_id")
  1664. if setting_id:
  1665. for c in available:
  1666. if c.setting_id == setting_id:
  1667. return c
  1668. name = entry.get("name")
  1669. if name:
  1670. for c in available:
  1671. if c.name == name:
  1672. return c
  1673. # Exactly one profile for this filament and no better discriminator:
  1674. # treat it as the same profile rather than duplicating it. Judged
  1675. # against every candidate rather than the unclaimed ones, because two
  1676. # live profiles for one filament are ambiguous whether or not another
  1677. # entry has already taken one of them.
  1678. return available[0] if len(candidates) == 1 else None
  1679. # Singleton instance
  1680. github_restore_service = GitHubRestoreService()