CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
140 lines
5.0 KiB
Python
140 lines
5.0 KiB
Python
"""Đánh số phiên bản và chuyển đổi cấu hình — R02-T06.
|
|
|
|
Hôm nay ``config.json`` không có số phiên bản. Nghĩa là không có cách nào biết
|
|
file trên đĩa thuộc thời nào, và mọi thay đổi hình dạng phải xử lý bằng cách
|
|
đoán — ``config.py::_migrate_connectors()`` chính là một ví dụ: nó đoán "có
|
|
khoá ``office`` nghĩa là file cũ".
|
|
|
|
Ở đây đặt luật rõ:
|
|
|
|
* File có ``schema_version``. Thiếu ⇒ coi là **1** (mọi file đang tồn tại).
|
|
* Mỗi bước nâng cấp là một hàm ``v1 -> v2``, chạy tuần tự, không nhảy cóc.
|
|
* **Sao lưu trước khi nâng cấp.** Người dùng lùi về bản app cũ thì bản cũ đọc
|
|
file mới có thể hỏng — phải còn đường về.
|
|
* Chỉ nâng, không hạ. File mới hơn app thì báo và dùng nguyên trạng, không cố
|
|
đoán ngược.
|
|
|
|
Bước v1→v2 đầu tiên đi kèm R02-T05: gỡ ``api_key`` khỏi đĩa, đẩy vào
|
|
``SecretStore``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
import shutil
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict
|
|
|
|
from ..secrets.secret_store import SecretStore, provider_key
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
#: Phiên bản app hiện đang ghi ra.
|
|
CURRENT_VERSION = 2
|
|
|
|
#: Thiếu ``schema_version`` ⇒ file có từ trước khi đánh số.
|
|
ASSUMED_VERSION = 1
|
|
|
|
|
|
def read_version(data: Dict[str, Any]) -> int:
|
|
"""Phiên bản schema của một dict cấu hình.
|
|
|
|
File cũ chưa có trường này thì coi như ``ASSUMED_VERSION`` — đó chính là
|
|
phiên bản trước khi trường được thêm vào.
|
|
"""
|
|
try:
|
|
return int(data.get("schema_version", ASSUMED_VERSION))
|
|
except (TypeError, ValueError):
|
|
return ASSUMED_VERSION
|
|
|
|
|
|
def _v1_to_v2(data: Dict[str, Any], secrets: SecretStore | None) -> Dict[str, Any]:
|
|
"""Chuyển API key từ file sang kho bí mật — R02-T05.
|
|
|
|
Không có kho bí mật thì **không chuyển**: thà để khoá nằm nguyên trong file
|
|
còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. File giữ
|
|
nguyên phiên bản 1, lần chạy sau trên máy có keyring sẽ chuyển.
|
|
"""
|
|
if secrets is None or not getattr(secrets, "available", True):
|
|
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
|
|
return data
|
|
|
|
out = copy.deepcopy(data)
|
|
moved = []
|
|
for name, conf in (out.get("providers") or {}).items():
|
|
if not isinstance(conf, dict):
|
|
continue
|
|
key = (conf.get("api_key") or "").strip()
|
|
# "ollama" là giá trị bù nhìn — Ollama đòi có api_key nhưng bỏ qua nội
|
|
# dung. Đẩy nó vào keyring chỉ tổ rác.
|
|
if not key or key == "ollama":
|
|
continue
|
|
secrets.set(provider_key(name), key)
|
|
conf["api_key"] = ""
|
|
moved.append(name)
|
|
|
|
out["schema_version"] = 2
|
|
if moved:
|
|
log.info("đã chuyển API key sang kho bí mật: %s", ", ".join(moved))
|
|
return out
|
|
|
|
|
|
#: {phiên bản nguồn: hàm nâng lên phiên bản kế tiếp}
|
|
STEPS: Dict[int, Callable[[Dict[str, Any], SecretStore | None], Dict[str, Any]]] = {
|
|
1: _v1_to_v2,
|
|
}
|
|
|
|
|
|
def backup(path: Path) -> Path | None:
|
|
"""Chép file trước khi nâng cấp. Trả về đường dẫn bản sao."""
|
|
if not path.exists():
|
|
return None
|
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
target = path.with_suffix(path.suffix + f".v{stamp}.bak")
|
|
try:
|
|
shutil.copy2(path, target)
|
|
return target
|
|
except OSError as exc:
|
|
log.warning("không sao lưu được %s: %s", path, exc)
|
|
return None
|
|
|
|
|
|
def migrate(data: Dict[str, Any], *, secrets: SecretStore | None = None,
|
|
path: Path | None = None) -> tuple[Dict[str, Any], bool]:
|
|
"""Nâng ``data`` lên :data:`CURRENT_VERSION`.
|
|
|
|
Trả về ``(dữ_liệu, có_đổi_không)``. ``có_đổi_không`` là False thì chỗ gọi
|
|
khỏi phải ghi lại đĩa.
|
|
"""
|
|
version = read_version(data)
|
|
|
|
if version > CURRENT_VERSION:
|
|
# App cũ gặp file mới. Đoán ngược là cách nhanh nhất để mất dữ liệu.
|
|
log.warning("config phiên bản %s mới hơn app (%s) — dùng nguyên trạng",
|
|
version, CURRENT_VERSION)
|
|
return data, False
|
|
|
|
if version == CURRENT_VERSION:
|
|
return data, False
|
|
|
|
if path is not None:
|
|
backup(path)
|
|
|
|
changed = False
|
|
while version < CURRENT_VERSION:
|
|
step = STEPS.get(version)
|
|
if step is None:
|
|
log.warning("thiếu bước nâng cấp từ phiên bản %s — dừng", version)
|
|
break
|
|
data = step(data, secrets)
|
|
new_version = read_version(data)
|
|
if new_version <= version:
|
|
# Bước không nâng được phiên bản (ví dụ v1→v2 bỏ qua vì chưa có
|
|
# keyring). Dừng, đừng lặp vô hạn.
|
|
break
|
|
version = new_version
|
|
changed = True
|
|
|
|
return data, changed
|