fix(infra): .gitignore nuốt infrastructure/secrets/ — nhánh đỏ với mọi máy trừ máy tôi
Dòng 31 ghi `secrets/`. Mẫu không neo, nên git bỏ qua MỌI thư mục tên
secrets ở mọi độ sâu — kể cả infrastructure/secrets/ vốn là mã nguồn.
Ba file ở đó chưa bao giờ lên repo. Máy tôi vẫn 150 test xanh vì pytest
đọc đĩa chứ không đọc git; ai clone sạch thì đỏ 4 file ngay lúc thu thập:
ModuleNotFoundError: No module named
'cowork_local.infrastructure.secrets'
Hiệp phát hiện, không phải tôi. Đã dựng lại bằng clone sạch vào thư mục
đặt đúng tên cowork_local để tái hiện.
Neo mẫu thành /secrets/ và thêm tests/test_no_ignored_source.py — hỏi
thẳng git chứ không hỏi đĩa, nên lần sau lỗi cùng hình dạng sẽ đỏ ngay
trên máy người viết. Đã kiểm ngược: trả lại `secrets/` thì cả ba bài đỏ.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8be5ce1bab
commit
d74c052af3
+4
-1
@@ -28,7 +28,10 @@ bower_components/
|
||||
.env.preview
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ
|
||||
# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải
|
||||
# bí mật) khỏi repo suốt 21-22/08.
|
||||
/secrets/
|
||||
credentials.json
|
||||
.npmrc
|
||||
.yarnrc
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""SecretStore chạy trên OS Keyring — R02-T04.
|
||||
|
||||
Windows dùng Credential Manager, macOS dùng Keychain, Linux dùng Secret
|
||||
Service. Người dùng cuối không thấy gì khác, nhưng API key thôi nằm trong
|
||||
``config.json`` — đó là điều kiện để qua CASAN Check 1.
|
||||
|
||||
Không phải máy nào cũng có keyring dùng được: Linux chạy headless không có
|
||||
Secret Service, và CI thì gần như chắc chắn không. Nên adapter này **không bao
|
||||
giờ ném lỗi** — không dùng được thì tự báo ``available = False`` và trả về
|
||||
None, để tầng trên hiển thị "chưa lưu được khoá" thay vì sập cả app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: Tên "dịch vụ" trong keyring — mọi khoá của app nằm dưới đây.
|
||||
SERVICE = "cowork-local"
|
||||
|
||||
|
||||
class KeyringAdapter:
|
||||
"""Cài đặt :class:`SecretStore` bằng thư viện ``keyring``.
|
||||
|
||||
>>> store = KeyringAdapter()
|
||||
>>> if store.available:
|
||||
... store.set("provider:openai", "sk-...")
|
||||
"""
|
||||
|
||||
def __init__(self, service: str = SERVICE):
|
||||
self.service = service
|
||||
self._backend = None
|
||||
self._available = False
|
||||
try:
|
||||
import keyring
|
||||
from keyring.backends.fail import Keyring as FailKeyring
|
||||
|
||||
backend = keyring.get_keyring()
|
||||
# backend "fail" là cái keyring trả về khi không tìm được kho nào
|
||||
# dùng được — gọi vào chỉ tổ ném lỗi.
|
||||
if not isinstance(backend, FailKeyring):
|
||||
self._backend = keyring
|
||||
self._available = True
|
||||
else:
|
||||
log.info("keyring không có kho khả dụng trên máy này")
|
||||
except Exception as exc: # noqa: BLE001 — thiếu thư viện, thiếu DBus…
|
||||
log.info("keyring không dùng được: %s", exc)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Có kho bí mật dùng được không.
|
||||
|
||||
Tầng giao diện đọc cờ này để nói cho người dùng biết vì sao ô API key
|
||||
không lưu được, thay vì im lặng làm mất khoá họ vừa nhập.
|
||||
"""
|
||||
return self._available
|
||||
|
||||
# ---- SecretStore ----------------------------------------------------
|
||||
def get(self, key: str) -> str | None:
|
||||
if not self._available:
|
||||
return None
|
||||
try:
|
||||
return self._backend.get_password(self.service, key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("đọc khoá %r thất bại: %s", key, exc)
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
if not self._available:
|
||||
log.warning("không lưu được %r: máy này không có kho bí mật", key)
|
||||
return
|
||||
try:
|
||||
self._backend.set_password(self.service, key, value)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("lưu khoá %r thất bại: %s", key, exc)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
self._backend.delete_password(self.service, key)
|
||||
except Exception: # noqa: BLE001 — xoá cái không có: bỏ qua
|
||||
pass
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return self.get(key) is not None
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Nơi cất credential — interface, chưa phải cài đặt.
|
||||
|
||||
Hợp đồng số 1 của mục chung: chốt hôm nay để N2 và N3 code được ngay, không
|
||||
phải đợi bản Keyring thật (R02-T04, hạn 26/08).
|
||||
|
||||
Vì sao là interface chứ không phải hàm tiện ích: bản thật sẽ gọi OS Keyring —
|
||||
chậm, có thể ném lỗi, và trong test thì không được đụng vào keyring máy thật.
|
||||
Có interface thì test tiêm ``FakeSecretStore`` vào, chạy trong bộ nhớ.
|
||||
|
||||
Quy ước đặt key: ``"provider:<tên>"`` cho API key của provider, ví dụ
|
||||
``"provider:openai"``. Đặt sẵn để không mỗi người tự nghĩ một kiểu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
def provider_key(name: str) -> str:
|
||||
"""Key chuẩn cho API key của một provider."""
|
||||
return f"provider:{name}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretStore(Protocol):
|
||||
"""Đọc/ghi bí mật. Cài đặt thật: ``KeyringAdapter`` (R02-T04)."""
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
"""Giá trị của ``key``, hoặc None nếu chưa có.
|
||||
|
||||
Không được ném lỗi khi thiếu key — thiếu là chuyện bình thường (người
|
||||
dùng chưa nhập API key), không phải sự cố.
|
||||
"""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
"""Lưu ``value``. Ghi đè nếu key đã tồn tại."""
|
||||
...
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Xoá ``key``. Không có sẵn thì im lặng bỏ qua, không ném lỗi."""
|
||||
...
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
"""Có key này chưa — dùng cho màn Cài đặt hiển thị trạng thái mà không
|
||||
cần đọc chính giá trị bí mật ra."""
|
||||
...
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Không file mã nguồn nào được nằm ngoài repo vì `.gitignore`.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật, mất hai ngày mới lộ:
|
||||
|
||||
``.gitignore`` dòng 31 ghi ``secrets/`` — mẫu **không neo**, nên git bỏ qua
|
||||
mọi thư mục tên ``secrets`` ở mọi độ sâu, kể cả ``infrastructure/secrets/``
|
||||
vốn là **mã nguồn**. Ba file trong đó chưa bao giờ lên repo. Máy người viết
|
||||
vẫn chạy 150 test xanh, nhưng ai clone sạch về thì 4 file test đỏ ngay lúc
|
||||
thu thập.
|
||||
|
||||
Trên máy đã có file thì không cách nào nhận ra: ``pytest`` đọc đĩa, không đọc
|
||||
git. Nên phải hỏi thẳng git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: Thư mục chứa mã nguồn của ứng dụng — file .py ở đây bắt buộc phải vào repo.
|
||||
SOURCE_DIRS = ["domain", "application", "infrastructure", "presentation",
|
||||
"adapters", "core", "ui", "providers", "scripts", "tools", "tests"]
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
out = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
return out.stdout
|
||||
|
||||
|
||||
def test_khong_file_py_nao_bi_gitignore_nuot():
|
||||
"""File .py có trên đĩa nhưng git không thấy — vừa chưa theo dõi, vừa bị
|
||||
bỏ qua. Đó chính là hình dạng của lỗi ``secrets/``."""
|
||||
existing = []
|
||||
for d in SOURCE_DIRS:
|
||||
root = REPO / d
|
||||
if root.is_dir():
|
||||
existing.append(d)
|
||||
assert existing, "không thấy thư mục mã nguồn nào — kiểm lại SOURCE_DIRS"
|
||||
|
||||
ignored = _git("ls-files", "--others", "--ignored", "--exclude-standard",
|
||||
"--", *existing).splitlines()
|
||||
ignored_py = [p for p in ignored
|
||||
if p.endswith(".py") and "__pycache__" not in p]
|
||||
|
||||
assert not ignored_py, (
|
||||
"File mã nguồn bị .gitignore nuốt — clone sạch sẽ thiếu:\n "
|
||||
+ "\n ".join(ignored_py)
|
||||
+ "\nChạy `git check-ignore -v <file>` để biết dòng nào gây ra."
|
||||
)
|
||||
|
||||
|
||||
def test_khong_file_py_nao_bi_bo_quen_chua_theo_doi():
|
||||
"""Chưa bị ignore nhưng cũng chưa `git add` — quên, không phải cố ý."""
|
||||
untracked = _git("ls-files", "--others", "--exclude-standard").splitlines()
|
||||
forgotten = [p for p in untracked
|
||||
if p.endswith(".py")
|
||||
and p.split("/")[0] in SOURCE_DIRS
|
||||
and "__pycache__" not in p]
|
||||
|
||||
assert not forgotten, (
|
||||
"File mã nguồn chưa được git add — clone sạch sẽ thiếu:\n "
|
||||
+ "\n ".join(forgotten)
|
||||
)
|
||||
|
||||
|
||||
def test_moi_module_duoc_import_deu_co_trong_repo():
|
||||
"""Bắt theo hướng ngược: đi từ những gì code THỰC SỰ import.
|
||||
|
||||
Hai bài trên quét theo thư mục; bài này bắt cả trường hợp file nằm ngoài
|
||||
danh sách đó mà vẫn được import.
|
||||
"""
|
||||
tracked = set(_git("ls-files").splitlines())
|
||||
missing = []
|
||||
for d in ("domain", "application", "infrastructure", "adapters"):
|
||||
root = REPO / d
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for f in root.rglob("*.py"):
|
||||
rel = f.relative_to(REPO).as_posix()
|
||||
if "__pycache__" in rel:
|
||||
continue
|
||||
if rel not in tracked:
|
||||
missing.append(rel)
|
||||
|
||||
assert not missing, (
|
||||
"Module thuộc kiến trúc mới nhưng không có trong repo:\n "
|
||||
+ "\n ".join(missing)
|
||||
)
|
||||
Reference in New Issue
Block a user