github_backup.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. """API routes for GitHub profile backup."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException, Query
  4. from sqlalchemy import delete, desc, select
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  7. from backend.app.core.database import get_db
  8. from backend.app.core.permissions import Permission
  9. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  10. from backend.app.models.user import User
  11. from backend.app.schemas.github_backup import (
  12. REF_PATTERN,
  13. CloudAccountCounts,
  14. GitHubBackupConfigCreate,
  15. GitHubBackupConfigResponse,
  16. GitHubBackupConfigUpdate,
  17. GitHubBackupLogResponse,
  18. GitHubBackupStatus,
  19. GitHubBackupTriggerResponse,
  20. GitHubCommitListResponse,
  21. GitHubRestorePreview,
  22. GitHubRestoreRequest,
  23. GitHubRestoreResponse,
  24. GitHubTestConnectionResponse,
  25. ProviderType,
  26. RestoreCategory,
  27. )
  28. from backend.app.services.github_backup import github_backup_service
  29. from backend.app.services.github_restore import github_restore_service
  30. logger = logging.getLogger(__name__)
  31. router = APIRouter(prefix="/github-backup", tags=["github-backup"])
  32. _PUBLIC_REPO_ERROR = (
  33. "Refusing to save: the target repository is not private. Bambuddy backups "
  34. "include MQTT credentials, Home Assistant tokens, Prometheus tokens, your "
  35. "Bambu Cloud email, the printer access codes via K-profiles, and other "
  36. "settings that must not be exposed publicly. Make the repository private "
  37. "in your provider's UI and try again."
  38. )
  39. _UNKNOWN_VISIBILITY_ERROR = (
  40. "Refusing to save: could not confirm the target repository is private. "
  41. "Bambuddy backups contain credentials and must never go to a public or "
  42. "internal-visibility repository. Verify the URL, the access token's scope, "
  43. "and that your provider exposes the 'private' / 'visibility' field on its "
  44. "repo API."
  45. )
  46. # The permission that owns each category's rows, required on top of
  47. # github:restore. Backup is its own permission group, so without this a role
  48. # holding only Backup writes — via a restore — rows it cannot write through the
  49. # endpoint that owns them.
  50. #
  51. # Each entry is the permission that endpoint actually gates its writes on:
  52. #
  53. # * SETTINGS → PUT /api/v1/settings/ (settings:update)
  54. # * SPOOLS → POST/PATCH /api/v1/inventory/spools (inventory:update). Spool
  55. # rows and their usage history both restore under this category.
  56. # * ARCHIVES → archives:update_all, not archives:create. A restore writes
  57. # rows owned by other users — that is the whole point of carrying
  58. # created_by_id — and update_all is the permission that means "may write an
  59. # archive that is not yours". create alone would let an operator with
  60. # archives:create_own-shaped access seed history onto someone else.
  61. # * KPROFILES → POST /api/v1/printers/{id}/kprofiles (kprofiles:update),
  62. # which is what the restore ultimately calls through set_kprofiles_batch.
  63. #
  64. # Cloud profiles are absent because they are not a restorable category
  65. # (RestoreCategory's docstring).
  66. _CATEGORY_WRITE_PERMISSION = {
  67. RestoreCategory.SETTINGS: Permission.SETTINGS_UPDATE,
  68. RestoreCategory.SPOOLS: Permission.INVENTORY_UPDATE,
  69. RestoreCategory.ARCHIVES: Permission.ARCHIVES_UPDATE_ALL,
  70. RestoreCategory.KPROFILES: Permission.KPROFILES_UPDATE,
  71. }
  72. async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> None:
  73. """Run a test_connection and refuse if the repo is not confirmed private.
  74. Used by POST and PATCH /config so a backup configuration can never be
  75. saved against a public repository.
  76. The URL is policy-checked first: the Gitea and Forgejo backends derive
  77. their API base from this value (``get_api_base``) and then request it with
  78. the supplied token, so an unchecked repository_url is an outbound fetch to
  79. an operator-supplied host. A self-hosted Gitea on the LAN is the normal
  80. case, so the LAN-service tier applies — this only rules out the targets
  81. that are wrong under any topology.
  82. """
  83. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  84. try:
  85. assert_safe_lan_service_url(repo_url, label="Repository URL")
  86. except ValueError as exc:
  87. raise HTTPException(status_code=422, detail=str(exc)) from exc
  88. result = await github_backup_service.test_connection(repo_url, token, provider=provider)
  89. if not result.get("success"):
  90. message = result.get("message") or "Connection test failed"
  91. raise HTTPException(status_code=400, detail=f"Cannot verify repository: {message}")
  92. is_private = result.get("is_private")
  93. if is_private is None:
  94. raise HTTPException(status_code=400, detail=_UNKNOWN_VISIBILITY_ERROR)
  95. if is_private is False:
  96. raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
  97. async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
  98. """How many Bambu / Orca accounts a backup would collect from.
  99. Asks the collector itself rather than re-deriving the rule, so the number
  100. the UI gates on can't drift from the number the backup actually uses
  101. (#2717). Counts only — never who.
  102. """
  103. try:
  104. bambu, orca = await github_backup_service.cloud_accounts(db)
  105. return len(bambu), len(orca)
  106. except Exception:
  107. # A settings page must still render when a credential store is
  108. # unreadable; the toggle simply shows as unavailable.
  109. logger.warning("Failed to count connected cloud accounts", exc_info=True)
  110. return 0, 0
  111. @router.get("/cloud-accounts", response_model=CloudAccountCounts)
  112. async def get_cloud_accounts(
  113. db: AsyncSession = Depends(get_db),
  114. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  115. ):
  116. """How many cloud accounts the Cloud Profiles category would collect from.
  117. Its own endpoint rather than a field on ``/config``, because the settings
  118. form needs this before any config exists — ``/config`` answers ``null``
  119. until the first save, which would leave the toggle disabled during the
  120. very setup it's part of.
  121. """
  122. bambu, orca = await _count_cloud_accounts(db)
  123. return CloudAccountCounts(bambu=bambu, orca=orca)
  124. def _config_to_response(config: GitHubBackupConfig) -> dict:
  125. """Convert config model to response dict."""
  126. return {
  127. "id": config.id,
  128. "repository_url": config.repository_url,
  129. "has_token": bool(config.access_token),
  130. "branch": config.branch,
  131. "provider": config.provider,
  132. "allow_insecure_http": config.allow_insecure_http,
  133. "schedule_enabled": config.schedule_enabled,
  134. "schedule_type": config.schedule_type,
  135. "backup_kprofiles": config.backup_kprofiles,
  136. "backup_cloud_profiles": config.backup_cloud_profiles,
  137. "backup_settings": config.backup_settings,
  138. "backup_spools": config.backup_spools,
  139. "backup_archives": config.backup_archives,
  140. "enabled": config.enabled,
  141. "last_backup_at": config.last_backup_at,
  142. "last_backup_status": config.last_backup_status,
  143. "last_backup_message": config.last_backup_message,
  144. "last_backup_commit_sha": config.last_backup_commit_sha,
  145. "next_scheduled_run": config.next_scheduled_run,
  146. "created_at": config.created_at,
  147. "updated_at": config.updated_at,
  148. }
  149. @router.get("/config", response_model=GitHubBackupConfigResponse | None)
  150. async def get_config(
  151. db: AsyncSession = Depends(get_db),
  152. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  153. ):
  154. """Get the current GitHub backup configuration."""
  155. result = await db.execute(select(GitHubBackupConfig).limit(1))
  156. config = result.scalar_one_or_none()
  157. if not config:
  158. return None
  159. return _config_to_response(config)
  160. @router.post("/config", response_model=GitHubBackupConfigResponse)
  161. async def save_config(
  162. config_data: GitHubBackupConfigCreate,
  163. db: AsyncSession = Depends(get_db),
  164. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  165. ):
  166. """Create or update GitHub backup configuration.
  167. Only one configuration is supported. If one exists, it will be updated.
  168. The target repository must be private — Bambuddy backups carry MQTT
  169. credentials, HA/Prometheus tokens, the Bambu Cloud email, and printer
  170. access codes (via K-profiles), so a public repo is a hard reject.
  171. """
  172. await _enforce_private_repo(
  173. config_data.repository_url,
  174. config_data.access_token,
  175. config_data.provider.value,
  176. )
  177. # Check for existing config
  178. result = await db.execute(select(GitHubBackupConfig).limit(1))
  179. config = result.scalar_one_or_none()
  180. if config:
  181. # Update existing
  182. config.repository_url = config_data.repository_url
  183. config.access_token = config_data.access_token
  184. config.branch = config_data.branch
  185. config.provider = config_data.provider.value
  186. config.schedule_enabled = config_data.schedule_enabled
  187. config.schedule_type = config_data.schedule_type.value
  188. config.backup_kprofiles = config_data.backup_kprofiles
  189. config.backup_cloud_profiles = config_data.backup_cloud_profiles
  190. config.backup_settings = config_data.backup_settings
  191. config.backup_spools = config_data.backup_spools
  192. config.backup_archives = config_data.backup_archives
  193. config.allow_insecure_http = config_data.allow_insecure_http
  194. config.enabled = config_data.enabled
  195. # Calculate next scheduled run if enabled
  196. if config.schedule_enabled:
  197. config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
  198. else:
  199. config.next_scheduled_run = None
  200. logger.info("Updated GitHub backup config: %s", config.repository_url)
  201. else:
  202. # Create new
  203. config = GitHubBackupConfig(
  204. repository_url=config_data.repository_url,
  205. access_token=config_data.access_token,
  206. branch=config_data.branch,
  207. provider=config_data.provider.value,
  208. schedule_enabled=config_data.schedule_enabled,
  209. schedule_type=config_data.schedule_type.value,
  210. backup_kprofiles=config_data.backup_kprofiles,
  211. backup_cloud_profiles=config_data.backup_cloud_profiles,
  212. backup_settings=config_data.backup_settings,
  213. backup_spools=config_data.backup_spools,
  214. backup_archives=config_data.backup_archives,
  215. allow_insecure_http=config_data.allow_insecure_http,
  216. enabled=config_data.enabled,
  217. )
  218. if config.schedule_enabled:
  219. config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
  220. db.add(config)
  221. logger.info("Created GitHub backup config: %s", config.repository_url)
  222. await db.commit()
  223. await db.refresh(config)
  224. return _config_to_response(config)
  225. @router.patch("/config", response_model=GitHubBackupConfigResponse)
  226. async def update_config(
  227. update_data: GitHubBackupConfigUpdate,
  228. db: AsyncSession = Depends(get_db),
  229. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  230. ):
  231. """Partially update GitHub backup configuration."""
  232. result = await db.execute(select(GitHubBackupConfig).limit(1))
  233. config = result.scalar_one_or_none()
  234. if not config:
  235. raise HTTPException(status_code=404, detail="No configuration found")
  236. update_dict = update_data.model_dump(exclude_unset=True)
  237. # Validate HTTP URL restriction when the URL policy is being changed. This avoids blocking unrelated autosaves
  238. # for legacy configs that already contain an HTTP URL.
  239. if "repository_url" in update_dict or "allow_insecure_http" in update_dict:
  240. url_to_check = update_dict.get("repository_url", config.repository_url)
  241. effective_allow_http = update_dict.get("allow_insecure_http", config.allow_insecure_http)
  242. if url_to_check and url_to_check.startswith("http://") and not effective_allow_http:
  243. raise HTTPException(
  244. status_code=422,
  245. detail="This URL uses HTTP instead of HTTPS. Enable 'Allow insecure HTTP' if your instance does not use TLS.",
  246. )
  247. # Re-verify the repo is private whenever the target changes — new URL,
  248. # new token, or new provider. We DON'T re-test on every unrelated PATCH
  249. # (e.g. toggling backup_archives) so flipping schedule settings doesn't
  250. # round-trip a live API call.
  251. target_changed = "repository_url" in update_dict or "access_token" in update_dict or "provider" in update_dict
  252. if target_changed:
  253. provider_value = update_dict.get("provider", config.provider)
  254. if hasattr(provider_value, "value"):
  255. provider_value = provider_value.value
  256. await _enforce_private_repo(
  257. update_dict.get("repository_url", config.repository_url),
  258. update_dict.get("access_token", config.access_token),
  259. provider_value,
  260. )
  261. for key, value in update_dict.items():
  262. if key in ("schedule_type", "provider") and value is not None:
  263. setattr(config, key, value.value)
  264. else:
  265. setattr(config, key, value)
  266. # Recalculate next scheduled run if schedule settings changed
  267. if "schedule_enabled" in update_dict or "schedule_type" in update_dict:
  268. if config.schedule_enabled:
  269. config.next_scheduled_run = github_backup_service.calculate_next_run(config.schedule_type)
  270. else:
  271. config.next_scheduled_run = None
  272. await db.commit()
  273. await db.refresh(config)
  274. logger.info("Updated GitHub backup config: %s", config.repository_url)
  275. return _config_to_response(config)
  276. @router.delete("/config")
  277. async def delete_config(
  278. db: AsyncSession = Depends(get_db),
  279. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  280. ):
  281. """Delete the GitHub backup configuration and all logs."""
  282. result = await db.execute(select(GitHubBackupConfig).limit(1))
  283. config = result.scalar_one_or_none()
  284. if not config:
  285. raise HTTPException(status_code=404, detail="No configuration found")
  286. await db.delete(config)
  287. await db.commit()
  288. logger.info("Deleted GitHub backup config")
  289. return {"message": "Configuration deleted"}
  290. @router.post("/test", response_model=GitHubTestConnectionResponse)
  291. async def test_connection(
  292. repo_url: str = Query(..., description="Repository URL"),
  293. token: str = Query(..., description="Personal Access Token"),
  294. provider: ProviderType = Query(default=ProviderType.GITHUB, description="Git provider key"),
  295. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  296. ):
  297. """Test Git provider connection with provided credentials."""
  298. result = await github_backup_service.test_connection(repo_url, token, provider=provider)
  299. return GitHubTestConnectionResponse(**result)
  300. @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
  301. async def test_stored_connection(
  302. db: AsyncSession = Depends(get_db),
  303. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  304. ):
  305. """Test GitHub connection using stored configuration."""
  306. result = await db.execute(select(GitHubBackupConfig).limit(1))
  307. config = result.scalar_one_or_none()
  308. if not config:
  309. raise HTTPException(status_code=404, detail="No configuration found")
  310. if not config.access_token:
  311. raise HTTPException(status_code=400, detail="No access token configured")
  312. test_result = await github_backup_service.test_connection(
  313. config.repository_url,
  314. config.access_token,
  315. provider=config.provider,
  316. )
  317. return GitHubTestConnectionResponse(**test_result)
  318. @router.post("/run", response_model=GitHubBackupTriggerResponse)
  319. async def trigger_backup(
  320. db: AsyncSession = Depends(get_db),
  321. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  322. ):
  323. """Manually trigger a backup."""
  324. result = await db.execute(select(GitHubBackupConfig).limit(1))
  325. config = result.scalar_one_or_none()
  326. if not config:
  327. raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
  328. if not config.enabled:
  329. raise HTTPException(status_code=400, detail="Backup is disabled")
  330. backup_result = await github_backup_service.run_backup(config.id, trigger="manual")
  331. return GitHubBackupTriggerResponse(**backup_result)
  332. @router.get("/status", response_model=GitHubBackupStatus)
  333. async def get_status(
  334. db: AsyncSession = Depends(get_db),
  335. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  336. ):
  337. """Get current backup status."""
  338. result = await db.execute(select(GitHubBackupConfig).limit(1))
  339. config = result.scalar_one_or_none()
  340. if not config:
  341. return GitHubBackupStatus(
  342. configured=False,
  343. enabled=False,
  344. is_running=False,
  345. progress=None,
  346. last_backup_at=None,
  347. last_backup_status=None,
  348. next_scheduled_run=None,
  349. )
  350. return GitHubBackupStatus(
  351. configured=True,
  352. enabled=config.enabled,
  353. is_running=github_backup_service.is_running,
  354. restore_running=github_restore_service.is_running,
  355. progress=github_backup_service.progress or github_restore_service.progress,
  356. last_backup_at=config.last_backup_at,
  357. last_backup_status=config.last_backup_status,
  358. next_scheduled_run=config.next_scheduled_run,
  359. )
  360. @router.get("/commits", response_model=GitHubCommitListResponse)
  361. async def list_commits(
  362. limit: int = Query(default=20, ge=1, le=100),
  363. db: AsyncSession = Depends(get_db),
  364. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
  365. ):
  366. """List recent backup commits so the user can pick one to restore from."""
  367. result = await db.execute(select(GitHubBackupConfig).limit(1))
  368. config = result.scalar_one_or_none()
  369. if not config:
  370. raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
  371. commit_result = await github_restore_service.list_commits(config, limit=limit)
  372. return GitHubCommitListResponse(**commit_result)
  373. @router.get("/restore/preview", response_model=GitHubRestorePreview)
  374. async def preview_restore(
  375. ref: str = Query(default="HEAD", pattern=REF_PATTERN),
  376. db: AsyncSession = Depends(get_db),
  377. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
  378. ):
  379. """Report which categories a given backup commit contains."""
  380. result = await db.execute(select(GitHubBackupConfig).limit(1))
  381. config = result.scalar_one_or_none()
  382. if not config:
  383. raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
  384. preview = await github_restore_service.preview(db, config, ref=ref)
  385. return GitHubRestorePreview(**preview)
  386. @router.post("/restore", response_model=GitHubRestoreResponse)
  387. async def restore_backup(
  388. request: GitHubRestoreRequest,
  389. db: AsyncSession = Depends(get_db),
  390. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
  391. ):
  392. """Restore selected categories from one backup commit.
  393. Note there is no private-repo gate here, unlike the config endpoints: that
  394. check exists to stop credentials leaving the instance, and this path only
  395. reads. A config can only be saved against a private repo anyway.
  396. Every category needs the permission that owns the rows it writes, on top of
  397. ``github:restore`` — see ``_CATEGORY_WRITE_PERMISSION`` and the check below.
  398. """
  399. if current_user is not None:
  400. # Each category rewrites rows some other endpoint already owns, and
  401. # Backup is its own permission group — so a role holding only Backup
  402. # could otherwise write, through a restore, what it cannot write through
  403. # the endpoint that owns them. This module already makes that argument;
  404. # it is why the four protected auth keys are refused outright.
  405. #
  406. # current_user is None only when auth is disabled: github:restore is in
  407. # _APIKEY_DENIED_PERMISSIONS, so an API key never gets past the
  408. # dependency to reach this line.
  409. missing = sorted(
  410. {
  411. permission.value
  412. for category, permission in _CATEGORY_WRITE_PERMISSION.items()
  413. if category in request.categories and not current_user.has_all_permissions(permission.value)
  414. }
  415. )
  416. if missing:
  417. raise HTTPException(
  418. status_code=403,
  419. detail=f"Missing required permissions: {', '.join(missing)}",
  420. )
  421. result = await db.execute(select(GitHubBackupConfig).limit(1))
  422. config = result.scalar_one_or_none()
  423. if not config:
  424. raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
  425. restore_result = await github_restore_service.run_restore(
  426. config.id,
  427. ref=request.ref,
  428. categories=request.categories,
  429. overwrite_existing=request.overwrite_existing,
  430. )
  431. return GitHubRestoreResponse(**restore_result)
  432. @router.get("/logs", response_model=list[GitHubBackupLogResponse])
  433. async def get_logs(
  434. limit: int = Query(default=50, ge=1, le=200),
  435. offset: int = Query(default=0, ge=0),
  436. db: AsyncSession = Depends(get_db),
  437. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  438. ):
  439. """Get backup logs."""
  440. result = await db.execute(select(GitHubBackupConfig).limit(1))
  441. config = result.scalar_one_or_none()
  442. if not config:
  443. return []
  444. logs_result = await db.execute(
  445. select(GitHubBackupLog)
  446. .where(GitHubBackupLog.config_id == config.id)
  447. .order_by(desc(GitHubBackupLog.started_at))
  448. .offset(offset)
  449. .limit(limit)
  450. )
  451. logs = logs_result.scalars().all()
  452. return [
  453. GitHubBackupLogResponse(
  454. id=log.id,
  455. config_id=log.config_id,
  456. started_at=log.started_at,
  457. completed_at=log.completed_at,
  458. status=log.status,
  459. trigger=log.trigger,
  460. commit_sha=log.commit_sha,
  461. files_changed=log.files_changed,
  462. error_message=log.error_message,
  463. )
  464. for log in logs
  465. ]
  466. @router.delete("/logs")
  467. async def clear_logs(
  468. keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
  469. db: AsyncSession = Depends(get_db),
  470. _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
  471. ):
  472. """Clear backup logs, optionally keeping the most recent entries."""
  473. result = await db.execute(select(GitHubBackupConfig).limit(1))
  474. config = result.scalar_one_or_none()
  475. if not config:
  476. return {"deleted": 0, "message": "No configuration found"}
  477. if keep_last > 0:
  478. # Get IDs to keep
  479. keep_result = await db.execute(
  480. select(GitHubBackupLog.id)
  481. .where(GitHubBackupLog.config_id == config.id)
  482. .order_by(desc(GitHubBackupLog.started_at))
  483. .limit(keep_last)
  484. )
  485. keep_ids = [row[0] for row in keep_result.fetchall()]
  486. if keep_ids:
  487. delete_result = await db.execute(
  488. delete(GitHubBackupLog).where(
  489. GitHubBackupLog.config_id == config.id, GitHubBackupLog.id.not_in(keep_ids)
  490. )
  491. )
  492. else:
  493. delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
  494. else:
  495. delete_result = await db.execute(delete(GitHubBackupLog).where(GitHubBackupLog.config_id == config.id))
  496. await db.commit()
  497. deleted_count = delete_result.rowcount
  498. logger.info("Deleted %s GitHub backup logs (kept %s)", deleted_count, keep_last)
  499. return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs"}