lockbot 2.5.4__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {lockbot-2.5.4/python/lockbot.egg-info → lockbot-2.6.0}/PKG-INFO +3 -1
  2. {lockbot-2.5.4 → lockbot-2.6.0}/pyproject.toml +3 -1
  3. lockbot-2.6.0/python/lockbot/backend/app/backup/router.py +96 -0
  4. lockbot-2.6.0/python/lockbot/backend/app/backup/scheduler.py +139 -0
  5. lockbot-2.6.0/python/lockbot/backend/app/backup/service.py +191 -0
  6. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/models.py +1 -0
  7. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/router.py +205 -30
  8. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/schemas.py +25 -0
  9. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/main.py +67 -2
  10. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/base_bot.py +5 -0
  11. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/config.py +5 -0
  12. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/device_bot.py +48 -0
  13. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/handler.py +1 -1
  14. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/i18n/en.py +11 -2
  15. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/i18n/zh.py +13 -2
  16. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/node_bot.py +46 -0
  17. lockbot-2.6.0/python/lockbot/core/platforms/__init__.py +0 -0
  18. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/queue_bot.py +42 -0
  19. {lockbot-2.5.4 → lockbot-2.6.0/python/lockbot.egg-info}/PKG-INFO +3 -1
  20. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/SOURCES.txt +4 -0
  21. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/requires.txt +2 -0
  22. {lockbot-2.5.4 → lockbot-2.6.0}/LICENSE +0 -0
  23. {lockbot-2.5.4 → lockbot-2.6.0}/MANIFEST.in +0 -0
  24. {lockbot-2.5.4 → lockbot-2.6.0}/README.md +0 -0
  25. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/__init__.py +0 -0
  26. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/__init__.py +0 -0
  27. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/__init__.py +0 -0
  28. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/admin/__init__.py +0 -0
  29. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/admin/router.py +0 -0
  30. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/__init__.py +0 -0
  31. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/models.py +0 -0
  32. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/router.py +0 -0
  33. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/audit/service.py +0 -0
  34. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/__init__.py +0 -0
  35. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/dependencies.py +0 -0
  36. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/models.py +0 -0
  37. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/router.py +0 -0
  38. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/auth/schemas.py +0 -0
  39. {lockbot-2.5.4/python/lockbot/backend/app/bots → lockbot-2.6.0/python/lockbot/backend/app/backup}/__init__.py +0 -0
  40. {lockbot-2.5.4/python/lockbot/backend/app/logs → lockbot-2.6.0/python/lockbot/backend/app/bots}/__init__.py +0 -0
  41. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/encryption.py +0 -0
  42. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/manager.py +0 -0
  43. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
  44. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/config.py +0 -0
  45. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/database.py +0 -0
  46. {lockbot-2.5.4/python/lockbot/backend/app/settings → lockbot-2.6.0/python/lockbot/backend/app/logs}/__init__.py +0 -0
  47. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/rate_limit.py +0 -0
  48. {lockbot-2.5.4/python/lockbot/core → lockbot-2.6.0/python/lockbot/backend/app/settings}/__init__.py +0 -0
  49. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/settings/models.py +0 -0
  50. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/backend/app/settings/router.py +0 -0
  51. {lockbot-2.5.4/python/lockbot/core/platforms → lockbot-2.6.0/python/lockbot/core}/__init__.py +0 -0
  52. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/bot_instance.py +0 -0
  53. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/device_usage_alert.py +0 -0
  54. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/device_usage_utils.py +0 -0
  55. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/entry.py +0 -0
  56. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/env.py +0 -0
  57. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/i18n/__init__.py +0 -0
  58. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/io.py +0 -0
  59. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/message_adapter.py +0 -0
  60. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/msg_utils.py +0 -0
  61. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/platforms/infoflow.py +0 -0
  62. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/request.py +0 -0
  63. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/scheduler.py +0 -0
  64. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot/core/utils.py +0 -0
  65. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/dependency_links.txt +0 -0
  66. {lockbot-2.5.4 → lockbot-2.6.0}/python/lockbot.egg-info/top_level.txt +0 -0
  67. {lockbot-2.5.4 → lockbot-2.6.0}/setup.cfg +0 -0
  68. {lockbot-2.5.4 → lockbot-2.6.0}/tools/create_super_admin.py +0 -0
  69. {lockbot-2.5.4 → lockbot-2.6.0}/tools/gen_keys.py +0 -0
  70. {lockbot-2.5.4 → lockbot-2.6.0}/tools/reset_super_admin_password.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockbot
3
- Version: 2.5.4
3
+ Version: 2.6.0
4
4
  Summary: Cluster resource management bot for IM platforms
5
5
  Author-email: Jianbang Yang <yangjianbang112@gmail.com>
6
6
  License: MIT
@@ -34,6 +34,8 @@ Requires-Dist: bcrypt
34
34
  Requires-Dist: python-multipart
35
35
  Requires-Dist: httpx
36
36
  Requires-Dist: slowapi
37
+ Requires-Dist: pyzipper
38
+ Requires-Dist: bce-python-sdk
37
39
  Provides-Extra: dev
38
40
  Requires-Dist: pytest>=7.0; extra == "dev"
39
41
  Requires-Dist: ruff==0.15.10; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lockbot"
7
- version = "2.5.4"
7
+ version = "2.6.0"
8
8
  description = "Cluster resource management bot for IM platforms"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -42,6 +42,8 @@ dependencies = [
42
42
  "python-multipart",
43
43
  "httpx",
44
44
  "slowapi",
45
+ "pyzipper",
46
+ "bce-python-sdk",
45
47
  ]
46
48
 
47
49
  [project.optional-dependencies]
@@ -0,0 +1,96 @@
1
+ """
2
+ Backup API — configuration + trigger endpoints.
3
+ """
4
+
5
+ from fastapi import APIRouter, Depends, Request
6
+ from pydantic import BaseModel
7
+ from sqlalchemy.orm import Session
8
+
9
+ from lockbot.backend.app.audit.service import write_audit_log
10
+ from lockbot.backend.app.auth.dependencies import require_super_admin
11
+ from lockbot.backend.app.auth.models import User
12
+ from lockbot.backend.app.bots.encryption import mask
13
+ from lockbot.backend.app.database import get_db
14
+
15
+ from .scheduler import backup_scheduler
16
+ from .service import (
17
+ ENCRYPTED_KEYS,
18
+ get_backup_config,
19
+ run_backup,
20
+ save_backup_config,
21
+ test_bos_connection,
22
+ )
23
+
24
+ router = APIRouter(prefix="/api/admin/backup", tags=["backup"])
25
+
26
+
27
+ class BackupSettingsIn(BaseModel):
28
+ settings: dict[str, str]
29
+
30
+
31
+ @router.get("/settings")
32
+ def get_settings(
33
+ _admin: User = Depends(require_super_admin),
34
+ db: Session = Depends(get_db),
35
+ ):
36
+ """Return backup config with sensitive fields masked."""
37
+ config = get_backup_config(db)
38
+ # Mask secrets for display
39
+ for key in ENCRYPTED_KEYS:
40
+ if config.get(key):
41
+ config[key] = mask(config[key])
42
+ return config
43
+
44
+
45
+ @router.put("/settings")
46
+ def update_settings(
47
+ body: BackupSettingsIn,
48
+ _admin: User = Depends(require_super_admin),
49
+ db: Session = Depends(get_db),
50
+ ):
51
+ """Save backup configuration."""
52
+ save_backup_config(db, body.settings)
53
+ backup_scheduler.reload()
54
+ return {"ok": True}
55
+
56
+
57
+ @router.get("/scheduler/status")
58
+ def scheduler_status(
59
+ _admin: User = Depends(require_super_admin),
60
+ ):
61
+ """Return backup scheduler liveness status."""
62
+ return backup_scheduler.status()
63
+
64
+
65
+ @router.post("/run")
66
+ def trigger_backup(
67
+ request: Request,
68
+ _admin: User = Depends(require_super_admin),
69
+ db: Session = Depends(get_db),
70
+ ):
71
+ """Trigger an immediate backup to BOS."""
72
+ result = run_backup(db)
73
+ write_audit_log(
74
+ db,
75
+ _admin,
76
+ "admin.bos_backup",
77
+ detail={"object_key": result.get("object_key"), "size": result.get("size")},
78
+ ip=request.client.host if request.client else None,
79
+ result="success" if result["success"] else "failure",
80
+ )
81
+ db.commit()
82
+ return result
83
+
84
+
85
+ @router.post("/test-connection")
86
+ def test_connection(
87
+ _admin: User = Depends(require_super_admin),
88
+ db: Session = Depends(get_db),
89
+ ):
90
+ """Test BOS connectivity."""
91
+ config = get_backup_config(db)
92
+ try:
93
+ test_bos_connection(config)
94
+ return {"ok": True}
95
+ except Exception as e:
96
+ return {"ok": False, "error": str(e)}
@@ -0,0 +1,139 @@
1
+ """
2
+ Backup scheduler — daemon thread for periodic BOS backups.
3
+ """
4
+
5
+ import logging
6
+ import threading
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class BackupScheduler:
13
+ """Simple daemon-thread scheduler for periodic backups."""
14
+
15
+ def __init__(self):
16
+ self._thread: threading.Thread | None = None
17
+ self._stop_event = threading.Event()
18
+ self._reload_event = threading.Event()
19
+ self._started_at: datetime | None = None
20
+ self._last_heartbeat: datetime | None = None
21
+ self._last_error: str = ""
22
+ self._next_run_at: datetime | None = None
23
+
24
+ def start(self):
25
+ if self._thread and self._thread.is_alive():
26
+ return
27
+ self._stop_event.clear()
28
+ self._started_at = datetime.now(timezone.utc)
29
+ self._last_error = ""
30
+ self._thread = threading.Thread(target=self._run, daemon=True, name="backup-scheduler")
31
+ self._thread.start()
32
+ logger.info("Backup scheduler started")
33
+
34
+ def stop(self):
35
+ self._stop_event.set()
36
+ self._reload_event.set() # Wake up sleeping thread
37
+ if self._thread:
38
+ self._thread.join(timeout=5)
39
+ logger.info("Backup scheduler stopped")
40
+
41
+ def reload(self):
42
+ """Signal the scheduler to re-read config and recalculate next run."""
43
+ self._reload_event.set()
44
+
45
+ def status(self) -> dict:
46
+ """Return scheduler liveness information for admin monitoring."""
47
+ thread_alive = bool(self._thread and self._thread.is_alive())
48
+ return {
49
+ "running": thread_alive and not self._stop_event.is_set(),
50
+ "thread_alive": thread_alive,
51
+ "started_at": self._started_at.isoformat() if self._started_at else "",
52
+ "last_heartbeat": self._last_heartbeat.isoformat() if self._last_heartbeat else "",
53
+ "next_run_at": self._next_run_at.isoformat() if self._next_run_at else "",
54
+ "last_error": self._last_error,
55
+ }
56
+
57
+ def _run(self):
58
+ while not self._stop_event.is_set():
59
+ self._last_heartbeat = datetime.now(timezone.utc)
60
+ try:
61
+ seconds = self._seconds_until_next()
62
+ if seconds is not None:
63
+ self._next_run_at = datetime.now(timezone.utc) + timedelta(seconds=seconds)
64
+ else:
65
+ self._next_run_at = None
66
+ if seconds is None:
67
+ # Auto-backup disabled, sleep and wait for reload
68
+ self._reload_event.wait(timeout=300)
69
+ self._reload_event.clear()
70
+ continue
71
+
72
+ # Sleep until next fire time (interruptible by reload/stop)
73
+ logger.info("Next backup in %d seconds", seconds)
74
+ triggered = self._reload_event.wait(timeout=seconds)
75
+ self._reload_event.clear()
76
+ if triggered or self._stop_event.is_set():
77
+ continue # Config changed or shutting down, recalculate
78
+
79
+ # Time to backup
80
+ self._do_backup()
81
+ except Exception as exc:
82
+ self._last_error = str(exc)
83
+ logger.exception("Backup scheduler error")
84
+ # Sleep a bit before retry
85
+ self._stop_event.wait(timeout=60)
86
+
87
+ def _seconds_until_next(self) -> int | None:
88
+ """Calculate seconds until next backup. Returns None if disabled."""
89
+ from lockbot.backend.app.backup.service import get_backup_config
90
+ from lockbot.backend.app.database import SessionLocal
91
+
92
+ db = SessionLocal()
93
+ try:
94
+ config = get_backup_config(db)
95
+ finally:
96
+ db.close()
97
+
98
+ if config["backup_auto_enabled"] != "true":
99
+ return None
100
+ if config["backup_method"] != "bos":
101
+ return None
102
+
103
+ freq = config["backup_frequency"] # e.g. "daily:03:00"
104
+ if not freq.startswith("daily:"):
105
+ return None
106
+
107
+ time_str = freq[6:] # "HH:MM"
108
+ try:
109
+ hour, minute = int(time_str[:2]), int(time_str[3:5])
110
+ except (ValueError, IndexError):
111
+ return None
112
+
113
+ now = datetime.now(timezone.utc)
114
+ target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
115
+ if target <= now:
116
+ # Next day
117
+ from datetime import timedelta
118
+
119
+ target += timedelta(days=1)
120
+
121
+ return int((target - now).total_seconds())
122
+
123
+ def _do_backup(self):
124
+ """Execute backup using a fresh DB session."""
125
+ from lockbot.backend.app.backup.service import run_backup
126
+ from lockbot.backend.app.database import SessionLocal
127
+
128
+ db = SessionLocal()
129
+ try:
130
+ result = run_backup(db)
131
+ if result["success"]:
132
+ logger.info("Scheduled backup completed: %s", result["object_key"])
133
+ else:
134
+ logger.error("Scheduled backup failed: %s", result.get("error"))
135
+ finally:
136
+ db.close()
137
+
138
+
139
+ backup_scheduler = BackupScheduler()
@@ -0,0 +1,191 @@
1
+ """
2
+ BOS backup service — create archive and upload to Baidu Object Storage.
3
+ """
4
+
5
+ import logging
6
+ import sqlite3
7
+ import tempfile
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ import pyzipper
12
+ from baidubce.auth.bce_credentials import BceCredentials
13
+ from baidubce.bce_client_configuration import BceClientConfiguration
14
+ from baidubce.services.bos.bos_client import BosClient
15
+ from sqlalchemy.orm import Session
16
+
17
+ from lockbot.backend.app.bots.encryption import decrypt, encrypt
18
+ from lockbot.backend.app.config import DATA_DIR, DATABASE_URL
19
+ from lockbot.backend.app.settings.models import SiteSetting
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Setting keys
24
+ _KEYS = [
25
+ "backup_method",
26
+ "backup_bos_ak",
27
+ "backup_bos_sk",
28
+ "backup_bos_endpoint",
29
+ "backup_bos_bucket",
30
+ "backup_bos_prefix",
31
+ "backup_zip_password",
32
+ "backup_frequency",
33
+ "backup_auto_enabled",
34
+ "backup_last_time",
35
+ "backup_last_status",
36
+ "backup_total_count",
37
+ ]
38
+
39
+ ENCRYPTED_KEYS = {"backup_bos_sk"}
40
+
41
+
42
+ def get_backup_config(db: Session) -> dict:
43
+ """Read all backup_* settings, decrypting sensitive fields."""
44
+ rows = db.query(SiteSetting).filter(SiteSetting.key.in_(_KEYS)).all()
45
+ config = {k: "" for k in _KEYS}
46
+ for row in rows:
47
+ val = row.value or ""
48
+ if row.key in ENCRYPTED_KEYS and val:
49
+ try:
50
+ val = decrypt(val)
51
+ except Exception:
52
+ val = ""
53
+ config[row.key] = val
54
+ return config
55
+
56
+
57
+ def save_backup_config(db: Session, data: dict) -> None:
58
+ """Save backup settings, encrypting sensitive fields. Skips masked values."""
59
+ for key, value in data.items():
60
+ if key not in _KEYS:
61
+ continue
62
+ # Skip masked placeholder (frontend sends *** for unchanged secrets)
63
+ if key in ENCRYPTED_KEYS and value and value.startswith("***"):
64
+ continue
65
+ store_val = value
66
+ if key in ENCRYPTED_KEYS and value:
67
+ store_val = encrypt(value)
68
+ row = db.get(SiteSetting, key)
69
+ if row is None:
70
+ row = SiteSetting(key=key, value=store_val, updated_at=datetime.now(timezone.utc))
71
+ db.add(row)
72
+ else:
73
+ row.value = store_val
74
+ row.updated_at = datetime.now(timezone.utc)
75
+ db.commit()
76
+
77
+
78
+ def _update_backup_stats(db: Session, success: bool) -> None:
79
+ """Update last backup time, status, and increment count."""
80
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
81
+ updates = {
82
+ "backup_last_time": now,
83
+ "backup_last_status": "success" if success else "failed",
84
+ }
85
+ if success:
86
+ row = db.get(SiteSetting, "backup_total_count")
87
+ count = int(row.value) if row and row.value else 0
88
+ updates["backup_total_count"] = str(count + 1)
89
+
90
+ for key, val in updates.items():
91
+ row = db.get(SiteSetting, key)
92
+ if row is None:
93
+ row = SiteSetting(key=key, value=val, updated_at=datetime.now(timezone.utc))
94
+ db.add(row)
95
+ else:
96
+ row.value = val
97
+ row.updated_at = datetime.now(timezone.utc)
98
+ db.commit()
99
+
100
+
101
+ def create_backup_archive(password: str | None = None) -> Path:
102
+ """Create a zip archive of DB + bot state files. Returns path to temp zip."""
103
+ if not DATABASE_URL.startswith("sqlite:///"):
104
+ raise RuntimeError("Backup only supported for SQLite")
105
+
106
+ db_path = DATABASE_URL[len("sqlite:///") :]
107
+ tmp_dir = tempfile.mkdtemp(prefix="lockbot_backup_")
108
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
109
+ zip_path = Path(tmp_dir) / f"lockbot_backup_{ts}.zip"
110
+
111
+ # Safe SQLite copy
112
+ backup_db = Path(tmp_dir) / "lockbot.db"
113
+ src = sqlite3.connect(db_path)
114
+ dst = sqlite3.connect(str(backup_db))
115
+ src.backup(dst)
116
+ src.close()
117
+ dst.close()
118
+
119
+ # Collect bot state files
120
+ bots_dir = Path(DATA_DIR) / "bots"
121
+ state_files: list[tuple[Path, str]] = []
122
+ if bots_dir.exists():
123
+ for state_file in bots_dir.rglob("bot_state.json"):
124
+ arcname = str(state_file.relative_to(Path(DATA_DIR)))
125
+ state_files.append((state_file, arcname))
126
+
127
+ # Create zip (AES encrypted if password set)
128
+ if password:
129
+ with pyzipper.AESZipFile(
130
+ str(zip_path), "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
131
+ ) as zf:
132
+ zf.setpassword(password.encode())
133
+ zf.write(str(backup_db), "lockbot.db")
134
+ for fpath, arcname in state_files:
135
+ zf.write(str(fpath), arcname)
136
+ else:
137
+ with pyzipper.ZipFile(str(zip_path), "w", compression=pyzipper.ZIP_DEFLATED) as zf:
138
+ zf.write(str(backup_db), "lockbot.db")
139
+ for fpath, arcname in state_files:
140
+ zf.write(str(fpath), arcname)
141
+
142
+ # Cleanup temp db
143
+ backup_db.unlink(missing_ok=True)
144
+ return zip_path
145
+
146
+
147
+ def _build_bos_client(config: dict) -> BosClient:
148
+ """Create BOS client from config dict."""
149
+ return BosClient(
150
+ BceClientConfiguration(
151
+ credentials=BceCredentials(config["backup_bos_ak"], config["backup_bos_sk"]),
152
+ endpoint=config["backup_bos_endpoint"],
153
+ )
154
+ )
155
+
156
+
157
+ def upload_to_bos(filepath: Path, config: dict) -> str:
158
+ """Upload file to BOS. Returns the object key."""
159
+ client = _build_bos_client(config)
160
+ prefix = config["backup_bos_prefix"].strip("/")
161
+ object_key = f"{prefix}/{filepath.name}" if prefix else filepath.name
162
+ client.put_object_from_file(config["backup_bos_bucket"], object_key, str(filepath))
163
+ return object_key
164
+
165
+
166
+ def test_bos_connection(config: dict) -> bool:
167
+ """Test BOS connectivity by checking bucket existence."""
168
+ client = _build_bos_client(config)
169
+ client.list_objects(config["backup_bos_bucket"], max_keys=1)
170
+ return True
171
+
172
+
173
+ def run_backup(db: Session) -> dict:
174
+ """Execute full backup flow: archive → upload → update stats."""
175
+ config = get_backup_config(db)
176
+ zip_path = None
177
+ try:
178
+ password = config["backup_zip_password"] or None
179
+ zip_path = create_backup_archive(password)
180
+ object_key = upload_to_bos(zip_path, config)
181
+ size = zip_path.stat().st_size
182
+ _update_backup_stats(db, success=True)
183
+ return {"success": True, "object_key": object_key, "size": size}
184
+ except Exception as e:
185
+ logger.exception("Backup failed")
186
+ _update_backup_stats(db, success=False)
187
+ return {"success": False, "error": str(e)}
188
+ finally:
189
+ if zip_path and zip_path.exists():
190
+ zip_path.unlink(missing_ok=True)
191
+ zip_path.parent.rmdir()
@@ -34,6 +34,7 @@ class Bot(Base):
34
34
  consecutive_failures: Mapped[int] = mapped_column(Integer, default=0)
35
35
  last_request_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
36
36
  config_overrides: Mapped[str] = mapped_column(Text, default="{}") # JSON
37
+ api_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
37
38
 
38
39
  # Soft delete
39
40
  is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)