Team Ai
Apppublic

kingcap/openapi

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
prestart_migrate.py258 linesDownload Raw Back to docker
1#!/usr/bin/env python32"""3Pre-start compatibility bridge for migrating from seakee/cpa-manager to4seakee/cpa-manager-plus inside the HuggingFace Space image.5 6It runs before supervisord starts cpa-manager-plus, so the new manager can see7restored settings/admin credentials on its first bootstrap pass.8"""9 10import base6411import hashlib12import json13import os14import re15import secrets16import sqlite317import stat18import time19from pathlib import Path20 21try:22    import psycopg23except Exception:  # pragma: no cover - the image installs psycopg24    psycopg = None25 26 27DB_PATH = os.getenv("USAGE_DB_PATH", "/data/usage.sqlite")28PG_DSN = os.getenv("USAGE_SYNC_PG_DSN") or os.getenv("DATABASE_URL") or os.getenv("PGSTORE_DSN", "")29SCHEMA = os.getenv("USAGE_SYNC_SCHEMA", "cpa_manager_usage")30DATA_KEY_PATH = os.getenv("CPA_MANAGER_DATA_KEY_PATH", "/data/data.key")31ADMIN_KEY_FILE = os.getenv("CPA_MANAGER_ADMIN_KEY_FILE", "/data/cpamp_admin.key")32ADMIN_CREDENTIAL_KEY = "admin_credential_v1"33 34if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", SCHEMA):35    raise SystemExit(f"invalid USAGE_SYNC_SCHEMA: {SCHEMA}")36 37 38def log(message: str):39    print(f"[prestart-migrate] {message}", flush=True)40 41 42def now_ms() -> int:43    return int(time.time() * 1000)44 45 46def ensure_parent(path: str):47    parent = Path(path).parent48    parent.mkdir(parents=True, exist_ok=True)49 50 51def write_secret_file(path: str, value: str):52    ensure_parent(path)53    p = Path(path)54    fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)55    with os.fdopen(fd, "w", encoding="utf-8") as f:56        f.write(value.strip())57        f.write("\n")58    try:59        p.chmod(stat.S_IRUSR | stat.S_IWUSR)60    except Exception:61        pass62 63 64def file_has_value(path: str) -> bool:65    try:66        return bool(Path(path).read_text(encoding="utf-8").strip())67    except Exception:68        return False69 70 71def ensure_data_key():72    if os.getenv("CPA_MANAGER_DATA_KEY") or os.getenv("CPA_MANAGER_DATA_KEY_FILE"):73        log("已通过环境变量/secret 管理 CPA_MANAGER_DATA_KEY,跳过 data.key 生成")74        return75    if file_has_value(DATA_KEY_PATH):76        log(f"复用已有 data key: {DATA_KEY_PATH}")77        return78 79    if PG_DSN:80        seed = f"cpamp-data-key-v1\0{SCHEMA}\0{PG_DSN}".encode("utf-8")81        raw = hashlib.sha256(seed).digest()82        source = "derived-from-pg-dsn"83    else:84        raw = secrets.token_bytes(32)85        source = "generated-random"86 87    write_secret_file(DATA_KEY_PATH, base64.b64encode(raw).decode("ascii").rstrip("="))88    log(f"已初始化 CPA-Manager-Plus data key: {DATA_KEY_PATH} ({source})")89 90 91def sqlite_connect():92    ensure_parent(DB_PATH)93    conn = sqlite3.connect(DB_PATH, timeout=30)94    conn.row_factory = sqlite3.Row95    conn.execute("PRAGMA busy_timeout = 5000")96    conn.execute("PRAGMA journal_mode = WAL")97    return conn98 99 100def ensure_settings_table(conn):101    conn.execute(102        """103        CREATE TABLE IF NOT EXISTS settings (104            key text primary key,105            value text not null,106            updated_at_ms integer not null107        )108        """109    )110    conn.commit()111 112 113def sqlite_get_setting(conn, key: str) -> str:114    ensure_settings_table(conn)115    row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()116    return str(row["value"]) if row else ""117 118 119def sqlite_has_setting(conn, key: str) -> bool:120    ensure_settings_table(conn)121    row = conn.execute("SELECT 1 FROM settings WHERE key=?", (key,)).fetchone()122    return row is not None123 124 125def pg_connect():126    if not PG_DSN or psycopg is None:127        return None128    return psycopg.connect(PG_DSN, autocommit=True, prepare_threshold=None)129 130 131def restore_settings_from_pg(conn) -> int:132    if not PG_DSN:133        return 0134    try:135        pg = pg_connect()136        if pg is None:137            return 0138    except Exception as e:139        log(f"连接 PG 失败,跳过启动前 settings 恢复:{e}")140        return 0141 142    try:143        rows = pg.execute(144            f"""145            SELECT key, value_text, row_json, synced_at_ms146            FROM {SCHEMA}.settings147            ORDER BY key ASC148            """149        ).fetchall()150    except Exception as e:151        log(f"读取 PG settings 失败,可能是首次启动,已跳过:{e}")152        return 0153    finally:154        try:155            pg.close()156        except Exception:157            pass158 159    ensure_settings_table(conn)160    restored = 0161    for key, value_text, row_json, synced_at_ms in rows:162        key = str(key or "").strip()163        if not key:164            continue165 166        value = value_text167        updated_at_ms = int(synced_at_ms or now_ms())168 169        if isinstance(row_json, dict):170            value = row_json.get("value", value)171            try:172                updated_at_ms = int(row_json.get("updated_at_ms") or updated_at_ms)173            except Exception:174                pass175 176        if value is None:177            continue178 179        conn.execute(180            """181            INSERT INTO settings(key, value, updated_at_ms)182            VALUES(?, ?, ?)183            ON CONFLICT(key) DO UPDATE184            SET value=excluded.value,185                updated_at_ms=excluded.updated_at_ms186            """,187            (key, str(value), updated_at_ms),188        )189        restored += 1190 191    conn.commit()192    if restored:193        log(f"已从 PG 恢复 settings 到本地 SQLite:{restored} 条")194    return restored195 196 197def parse_json(raw: str):198    try:199        return json.loads(raw)200    except Exception:201        return None202 203 204def legacy_plain_management_key(conn) -> str:205    raw = sqlite_get_setting(conn, "setup")206    data = parse_json(raw)207    if not isinstance(data, dict):208        return ""209    key = str(data.get("managementKey") or data.get("ManagementKey") or "").strip()210    if key.startswith("enc:v1:"):211        return ""212    return key213 214 215def generate_admin_key() -> str:216    alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"217    return "cpamp_" + "".join(secrets.choice(alphabet) for _ in range(32))218 219 220def ensure_admin_key_file(conn):221    if os.getenv("CPA_MANAGER_ADMIN_KEY"):222        log("已通过 CPA_MANAGER_ADMIN_KEY 管理 Plus 管理员密钥")223        return224    if file_has_value(ADMIN_KEY_FILE):225        log(f"复用已有 Plus 管理员密钥文件: {ADMIN_KEY_FILE}")226        return227    if sqlite_has_setting(conn, ADMIN_CREDENTIAL_KEY):228        log("本地 SQLite 已有 Plus 管理员凭据,跳过管理员密钥文件生成")229        return230 231    legacy_key = legacy_plain_management_key(conn)232    if legacy_key:233        write_secret_file(ADMIN_KEY_FILE, legacy_key)234        log("已使用旧 /management.html 登录密码作为 CPA-Manager-Plus 管理员密钥")235        return236 237    generated = generate_admin_key()238    write_secret_file(ADMIN_KEY_FILE, generated)239    log(f"首次初始化 Plus 管理员密钥:{generated}")240    log(f"管理员密钥已写入 {ADMIN_KEY_FILE};后续重启会复用该文件")241 242 243def main():244    ensure_parent(DB_PATH)245    ensure_data_key()246 247    conn = sqlite_connect()248    try:249        ensure_settings_table(conn)250        restore_settings_from_pg(conn)251        ensure_admin_key_file(conn)252    finally:253        conn.close()254 255 256if __name__ == "__main__":257    main()258