diff --git a/.gitignore b/.gitignore index dfa8fad..60afe16 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,17 @@ bin/ # Logs *.log +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +*.egg +.eggs/ +*.whl +.venv/ +venv/ +env/ + # Environment .env .env.local diff --git a/python-client-demo/app.py b/python-client-demo/app.py new file mode 100644 index 0000000..7854726 --- /dev/null +++ b/python-client-demo/app.py @@ -0,0 +1,72 @@ +import json +import logging +import os + +from flask import Flask +from kenaito_conf import Config, ConfigClient + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") +logger = logging.getLogger("demo") + +app = Flask(__name__) + +# Config defaults (overridden by remote config center) +config_values = { + "database.host": os.getenv("DATABASE_HOST", "192.168.1.1"), + "database.port": os.getenv("DATABASE_PORT", "8888"), + "database.name": os.getenv("DATABASE_NAME", "xxxx"), + "database.user": os.getenv("DATABASE_USER", "xxxx"), + "database.password": os.getenv("DATABASE_PASSWORD", "xxxxxxxx"), +} + +client: ConfigClient | None = None + + +def on_config_changed(changed: dict[str, str]) -> None: + for k, v in changed.items(): + if k in config_values: + config_values[k] = v + logger.info("config updated: %s = %s", k, v) + + +def create_client() -> ConfigClient: + cfg = Config( + base_url=os.getenv("KENAITO_BASE_URL", "http://localhost:8080"), + app_name=os.getenv("KENAITO_APP_NAME", "kenaito-springboot-demo"), + env=os.getenv("KENAITO_PROFILE", "daily"), + version=os.getenv("KENAITO_VERSION", "latest"), + token=os.getenv("KENAITO_TOKEN", ""), + sync_interval=int(os.getenv("KENAITO_SYNC_INTERVAL", "30")), + cache_dir=os.getenv("KENAITO_CACHE_DIR", "."), + ) + c = ConfigClient(cfg) + c.on_change(on_config_changed) + c.start() + return c + + +@app.route("/") +def index(): + return json.dumps(config_values, ensure_ascii=False, indent=2), 200, { + "Content-Type": "application/json; charset=utf-8", + } + + +@app.route("/health") +def health(): + return "OK" + + +if __name__ == "__main__": + try: + client = create_client() + remote = client.get_all() + for k in config_values: + if k in remote: + config_values[k] = remote[k] + logger.info("loaded %d configs from remote", len(remote)) + except Exception: + logger.exception("failed to init config client, using local defaults") + + port = int(os.getenv("SERVER_PORT", "8082")) + app.run(host="0.0.0.0", port=port) diff --git a/python-client-demo/requirements.txt b/python-client-demo/requirements.txt new file mode 100644 index 0000000..739be29 --- /dev/null +++ b/python-client-demo/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +kenaito-conf>=1.0.0 diff --git a/python-client/README.md b/python-client/README.md new file mode 100644 index 0000000..2a1a7f0 --- /dev/null +++ b/python-client/README.md @@ -0,0 +1,76 @@ +# Kenaito Config Center Python Client + +## 安装 + +```bash +cd python-client +pip3 install -e . +``` + +## 快速开始 + +```python +from kenaito_conf import Config, ConfigClient + +config = Config( + base_url="http://localhost:8080", + app_name="my-app", + env="daily", + sync_interval=30, +) + +client = ConfigClient(config) + +# 监听配置变更 +def on_change(changed): + for k, v in changed.items(): + print(f"config changed: {k} = {v}") + +client.on_change(on_change) + +# 启动客户端(拉取配置 + 开启定时同步) +client.start() + +# 获取配置 +value = client.get("database.host") +all_configs = client.get_all() + +# 停止 +client.stop() +``` + +## 配置项 + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `base_url` | 配置中心地址 | 必填 | +| `app_name` | 应用名称 | 必填 | +| `env` | 环境(dev/daily/prod) | `dev` | +| `version` | 配置版本 | `latest` | +| `token` | 认证令牌 | 空 | +| `sync_interval` | 同步间隔(秒) | `60` | +| `ignore_global` | 是否忽略全局配置 | `False` | +| `cache_dir` | 本地缓存目录 | `.` | + +## Demo + +```bash +cd python-client-demo +pip3 install -r requirements.txt +python3 app.py +``` + +Demo 默认监听 `8082` 端口,访问 `http://localhost:8082/` 查看配置。 + +环境变量覆盖: + +| 环境变量 | 说明 | 默认值 | +|----------|------|--------| +| `SERVER_PORT` | 服务端口 | `8082` | +| `KENAITO_BASE_URL` | 配置中心地址 | `http://localhost:8080` | +| `KENAITO_APP_NAME` | 应用名称 | `kenaito-springboot-demo` | +| `KENAITO_PROFILE` | 环境 | `daily` | +| `KENAITO_VERSION` | 配置版本 | `latest` | +| `KENAITO_TOKEN` | 认证令牌 | 空 | +| `KENAITO_SYNC_INTERVAL` | 同步间隔(秒) | `30` | +| `KENAITO_CACHE_DIR` | 缓存目录 | `.` | diff --git a/python-client/kenaito_conf/__init__.py b/python-client/kenaito_conf/__init__.py new file mode 100644 index 0000000..7d960a8 --- /dev/null +++ b/python-client/kenaito_conf/__init__.py @@ -0,0 +1,5 @@ +from kenaito_conf.client import ConfigClient +from kenaito_conf.config import Config + +__all__ = ["ConfigClient", "Config"] +__version__ = "1.0.0" diff --git a/python-client/kenaito_conf/cache.py b/python-client/kenaito_conf/cache.py new file mode 100644 index 0000000..e4b0e2f --- /dev/null +++ b/python-client/kenaito_conf/cache.py @@ -0,0 +1,22 @@ +import json +import os + +CACHE_FILE = "config-cache.json" + + +class Cache: + def __init__(self, cache_dir: str = "."): + self.cache_dir = cache_dir + + def write(self, data: dict) -> None: + path = os.path.join(self.cache_dir, CACHE_FILE) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + + def read(self) -> dict | None: + path = os.path.join(self.cache_dir, CACHE_FILE) + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None diff --git a/python-client/kenaito_conf/client.py b/python-client/kenaito_conf/client.py new file mode 100644 index 0000000..f999375 --- /dev/null +++ b/python-client/kenaito_conf/client.py @@ -0,0 +1,150 @@ +import logging +import threading +import time + +import requests + +from kenaito_conf.cache import Cache +from kenaito_conf.config import Config +from kenaito_conf.crypto import decrypt_config +from kenaito_conf.listener import Listener + +logger = logging.getLogger("kenaito_conf") + + +class ConfigClient: + def __init__(self, config: Config): + self._config = config + self._cache = Cache(config.cache_dir) + self._listener = Listener() + self._configs: dict[str, str] = {} + self._secret: str = "" + self._global_secret: str = "" + self._last_time: int = 0 + self._lock = threading.RLock() + self._stop_event = threading.Event() + self._started = False + + def on_change(self, handler) -> None: + self._listener.on_change(handler) + + def get(self, key: str) -> str | None: + with self._lock: + return self._configs.get(key) + + def get_all(self) -> dict[str, str]: + with self._lock: + return dict(self._configs) + + def start(self) -> None: + with self._lock: + if self._started: + return + self._started = True + + try: + self._fetch_all() + except Exception as e: + logger.warning("[ConfigClient] fetch all configs error: %s, trying cache", e) + cached = self._cache.read() + if cached is not None: + self._apply_configs(cached) + logger.info("[ConfigClient] loaded %d configs from cache", len(self._configs)) + else: + raise RuntimeError(f"fetch configs failed and no cache: {e}") from e + + t = threading.Thread(target=self._sync_loop, daemon=True) + t.start() + + def stop(self) -> None: + self._stop_event.set() + + def _fetch_all(self) -> None: + url = ( + f"{self._config.base_url}/api/fetchAllConfigs" + f"?appName={self._config.app_name}" + f"&env={self._config.env}" + f"&version={self._config.version}" + f"&ignoreGlobal={str(self._config.ignore_global).lower()}" + ) + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = resp.json() + + if data.get("code", 0) != 0: + raise RuntimeError(f"server error: {data.get('msg', 'unknown')}") + + secret = data.get("kenaito.config.encrypt-secret") + if secret: + self._secret = secret + + global_secret = data.get("kenaito.config.global-encrypt-secret") + if global_secret: + self._global_secret = global_secret + + ts = data.get("latest.fetch.timestamp") + if ts: + self._last_time = int(ts) + + self._cache.write(data) + self._apply_configs(data) + logger.info("[ConfigClient] fetched %d configs", len(self._configs)) + + def _fetch_changed(self) -> None: + url = f"{self._config.base_url}/api/fetchChangedConfigs" + payload = { + "appName": self._config.app_name, + "env": self._config.env, + "version": self._config.version, + "lastTime": self._last_time, + "ignoreGlobal": self._config.ignore_global, + } + resp = requests.post(url, json=payload, timeout=10) + resp.raise_for_status() + result = resp.json() + + if result.get("code", -1) != 0: + raise RuntimeError(f"server error code: {result.get('code')}") + + changed_data = result.get("data", {}) + + ts = changed_data.pop("latest.fetch.timestamp", None) + if ts: + self._last_time = int(ts) + + if changed_data: + changed = {} + for k, v in changed_data.items(): + decrypted = self._try_decrypt(v) + changed[k] = decrypted + + with self._lock: + self._configs.update(changed) + + self._listener.notify(changed) + logger.info("[ConfigClient] %d configs changed", len(changed)) + + def _apply_configs(self, data: dict) -> None: + with self._lock: + for k, v in data.items(): + if k.startswith("kenaito.") or k == "latest.fetch.timestamp": + continue + if not isinstance(v, str): + continue + self._configs[k] = self._try_decrypt(v) + + def _try_decrypt(self, value: str) -> str: + decrypted = decrypt_config(self._secret, value) + if decrypted == value and self._global_secret: + decrypted = decrypt_config(self._global_secret, value) + return decrypted + + def _sync_loop(self) -> None: + while not self._stop_event.is_set(): + self._stop_event.wait(self._config.sync_interval) + if self._stop_event.is_set(): + break + try: + self._fetch_changed() + except Exception: + logger.exception("[ConfigClient] sync error") diff --git a/python-client/kenaito_conf/config.py b/python-client/kenaito_conf/config.py new file mode 100644 index 0000000..11dc91e --- /dev/null +++ b/python-client/kenaito_conf/config.py @@ -0,0 +1,20 @@ +class Config: + def __init__( + self, + base_url: str = "", + app_name: str = "", + env: str = "dev", + version: str = "latest", + token: str = "", + sync_interval: int = 60, + ignore_global: bool = False, + cache_dir: str = ".", + ): + self.base_url = base_url + self.app_name = app_name + self.env = env + self.version = version + self.token = token + self.sync_interval = sync_interval + self.ignore_global = ignore_global + self.cache_dir = cache_dir diff --git a/python-client/kenaito_conf/crypto.py b/python-client/kenaito_conf/crypto.py new file mode 100644 index 0000000..a6c207e --- /dev/null +++ b/python-client/kenaito_conf/crypto.py @@ -0,0 +1,29 @@ +import base64 +import hashlib + +import pyaes + +CRYPT_PREFIX = "{Cipher}" + + +def decrypt_config(secret: str, data: str) -> str: + if not data.startswith(CRYPT_PREFIX): + return data + encrypted = data[len(CRYPT_PREFIX):] + return decrypt_with_aes(secret, encrypted) + + +def decrypt_with_aes(key: str, data: str) -> str: + md5_hash = hashlib.md5(key.encode()).hexdigest() + aes_key = md5_hash[16:].encode() + + cipher_text = base64.b64decode(data) + + iv = b"\0" * 16 + aes_cbc = pyaes.AESModeOfOperationCBC(aes_key, iv=iv) + plain_text = aes_cbc.decrypt(cipher_text) + + padding = plain_text[-1] + plain_text = plain_text[:-padding] + + return plain_text.decode() diff --git a/python-client/kenaito_conf/listener.py b/python-client/kenaito_conf/listener.py new file mode 100644 index 0000000..3ac73b2 --- /dev/null +++ b/python-client/kenaito_conf/listener.py @@ -0,0 +1,26 @@ +import logging +import threading +from typing import Callable + +ChangeHandler = Callable[[dict[str, str]], None] + +logger = logging.getLogger("kenaito_conf") + + +class Listener: + def __init__(self): + self._handlers: list[ChangeHandler] = [] + self._lock = threading.Lock() + + def on_change(self, handler: ChangeHandler) -> None: + with self._lock: + self._handlers.append(handler) + + def notify(self, changed: dict[str, str]) -> None: + with self._lock: + handlers = list(self._handlers) + for h in handlers: + try: + h(changed) + except Exception: + logger.exception("[ConfigClient] change handler error") diff --git a/python-client/pyproject.toml b/python-client/pyproject.toml new file mode 100644 index 0000000..b8e72c5 --- /dev/null +++ b/python-client/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "kenaito-conf" +version = "1.0.0" +description = "Kenaito Config Center Python Client" +requires-python = ">=3.10" +dependencies = [ + "requests>=2.28", + "pyaes>=1.6", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", +]