6100b4eb15
- main.py: PLATFORM_DB 挪到 <repo>/data/platform.db——data/ 是 compose bind mount,容器/镜像重建不再丢租户注册表;lifespan 对父目录兜底 mkdir(冷机 data/ 不存在,sqlite 不建父目录)。bootstrap 脚本 --db 默认值同步锚定 data/。 - 新测试 test_platform_db_defaults_into_data_dir: 常量形状钉死(回退 仓库根即红)+ 冷机形态下 lifespan 建库于 data/ 内(全量 603)。 - Dockerfile: corepack 钉实测通过的具体版本 0.36.0(不追 latest,构建 可复现);tzdata 注释照实改写(trixie slim 实测已自带,安装留作基 镜像漂移保险)。 - docker-compose: 头注释改准确(tenants/=租户库,data/=platform.db+ uploads);env_file 注记对象式写法需 Docker Compose >= 2.24。 - 实证: compose down/up 重建容器后宿主 ./data/platform.db 仍在, healthcheck healthy,/admin/ 200;docker compose build 复验通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
142 lines
6.1 KiB
Python
142 lines
6.1 KiB
Python
"""平台引导脚本(Phase 2 Task 11):生产装配(platform.db 源)的一键落地。
|
|
|
|
两步,全部幂等,可安全反复跑:
|
|
1. init:make_platform_engine + ensure_platform_schema + bootstrap_superuser
|
|
(凭据取 env/.env 的 ADMIN_USERNAME/ADMIN_PASSWORD,空表才建,与
|
|
main.lifespan 同一 bootstrap——谁先跑结果一致);
|
|
2. 导入:遍历出厂档案(--profiles,默认 auto jiancai)——
|
|
FileTenantSource.read_tenant(slug) 产出 LoadedTenant(配置组⊕wall⊕
|
|
prompts 同构快照,Task 4 起 registry 单一消费形态)→ tenants 表已有
|
|
该 slug 则跳过 → 写 Tenant+sites+domains+配置版本 1(版本写入走
|
|
db.write_config_version,与创建向导/配置 API 同一条 MAX+1 路径)。
|
|
|
|
**不建租户管理员**(计划裁定):导入是"存量迁移形态"——线上超管已由
|
|
init 步骤引导,各租户管理员按需走后台创建向导(Task 5)补,脚本不造
|
|
任何租户账号。
|
|
|
|
用法(Windows 须 PYTHONUTF8=1,输出含中文):
|
|
PYTHONUTF8=1 python scripts/bootstrap_platform.py [--db PATH]
|
|
[--profiles SLUG ...] [--seed DIR]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# 仓库根入 sys.path(照 extract_config.py 锚定纪律:脚本可从任意 CWD 跑)
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.engine.config import settings
|
|
from app.platform.db import (
|
|
Site,
|
|
SiteDomain,
|
|
Tenant,
|
|
bootstrap_superuser,
|
|
ensure_platform_schema,
|
|
make_platform_engine,
|
|
write_config_version,
|
|
)
|
|
# 域名归一与路由层同一事实源(registry._normalize_host;tenant_routes 的
|
|
# _normalize_domain 即它的 HTTP 包装)
|
|
from app.platform.registry import _normalize_host
|
|
from app.platform.tenant_source import FileTenantSource
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_PROFILES = ("auto", "jiancai")
|
|
|
|
|
|
async def _import_profile(engine, seed_dir: Path, slug: str) -> str:
|
|
"""单档案导入:已存在跳过(幂等),否则写 Tenant+sites+domains+版本1。
|
|
|
|
返回结果文案(导入明细/跳过原因);档案缺失 → LookupError 由调用方
|
|
转 SystemExit(拼错档案名静默空平台 = 全量 404 的部署事故,必须显性炸)。
|
|
"""
|
|
loaded = FileTenantSource(seed_dir).read_tenant(slug) # LookupError 显性抛
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
if (await session.execute(
|
|
select(Tenant.id).where(Tenant.slug == slug))).first() is not None:
|
|
return f"{slug}: 跳过(tenants 已存在)"
|
|
|
|
# profile 列 = 档案名(重置出厂按它取对应档案组;导入的租户即其
|
|
# 出厂档案的原生形态)
|
|
tenant = Tenant(slug=loaded.slug, name=loaded.name,
|
|
status=loaded.status, profile=slug)
|
|
session.add(tenant)
|
|
await session.flush()
|
|
domain_count = 0
|
|
for site_dict in loaded.sites:
|
|
site = Site(tenant_id=tenant.id, slug=site_dict["slug"],
|
|
name=site_dict.get("name") or site_dict["slug"],
|
|
brand_overlay=dict(site_dict.get("brand") or {}))
|
|
session.add(site)
|
|
await session.flush()
|
|
for domain in site_dict.get("domains") or []:
|
|
normalized = _normalize_host(domain)
|
|
if normalized: # 空串跳过(与域名索引同判)
|
|
session.add(SiteDomain(domain=normalized, site_id=site.id))
|
|
domain_count += 1
|
|
version = await write_config_version(
|
|
session, tenant.id, loaded.snapshot,
|
|
updated_by="bootstrap", comment=f"bootstrap 导入: profile={slug}")
|
|
assert version == 1 # 新租户无历史版本,必为 1
|
|
await session.commit()
|
|
return (f"{slug}: 导入(站点 {len(loaded.sites)},"
|
|
f"域名 {domain_count},版本 {version})")
|
|
|
|
|
|
async def run(args) -> None:
|
|
db_path = Path(args.db)
|
|
# sqlite 不建父目录(默认的 data/ 在冷机可能尚不存在,--db 指新目录
|
|
# 同炸——conftest build_app 同坑注记);照 registry._ensure_ready 纪律
|
|
# 先建目录(app/main.py 的 lifespan 对 PLATFORM_DB 同兜底)
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
engine = make_platform_engine(db_path)
|
|
try:
|
|
# ---- init(schema + 超管,均幂等)------------------------------------
|
|
await ensure_platform_schema(engine)
|
|
await bootstrap_superuser(
|
|
engine,
|
|
username=settings.ADMIN_USERNAME,
|
|
password=settings.ADMIN_PASSWORD)
|
|
print(f"platform.db: {db_path}")
|
|
print(f"超管: {settings.ADMIN_USERNAME}(空表才建,已存在则不动)")
|
|
|
|
# ---- 导入(逐档案;缺档案显性炸)-------------------------------------
|
|
seed_dir = Path(args.seed)
|
|
results = []
|
|
for slug in args.profiles:
|
|
try:
|
|
results.append(await _import_profile(engine, seed_dir, slug))
|
|
except LookupError as exc:
|
|
raise SystemExit(f"导入失败: 出厂档案缺失: {exc}") from exc
|
|
for line in results:
|
|
print(line)
|
|
finally:
|
|
await engine.dispose() # Windows 文件锁纪律
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--db", default=str(REPO_ROOT / "data" / "platform.db"),
|
|
help="platform.db 路径(默认 <repo>/data/,与 app/main.py 的 "
|
|
"PLATFORM_DB 同一锚定——data/ 是 compose bind mount,重建"
|
|
"容器不丢;测试/分环境可覆盖)")
|
|
parser.add_argument(
|
|
"--profiles", nargs="+", default=list(DEFAULT_PROFILES),
|
|
help=f"导入的出厂档案 slug 列表(默认 {' '.join(DEFAULT_PROFILES)})")
|
|
parser.add_argument(
|
|
"--seed", default=str(REPO_ROOT / "seed_profiles"),
|
|
help="出厂档案目录(默认仓库根 seed_profiles)")
|
|
args = parser.parse_args()
|
|
asyncio.run(run(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|