"""Agent 游戏内容仓库 CLI:按 committed 身份检出,或把新版本提交到对象存储。""" from __future__ import annotations import argparse import json from pathlib import Path from worker.game_artifact_storage_store import MySqlArtifactStorageStore from worker.game_artifact_store import ArtifactError from worker.game_content_repository import ( GameContentControlClient, GameContentError, GameContentRepository, ) def _infra_section(name: str, required: tuple[str, ...]) -> dict: """从项目内网配置读取完整分区;缺任一凭据即拒绝联网。""" from service import infra_config values = {key: infra_config.get(name, key, None) for key in required} missing = [key for key, value in values.items() if value in (None, "")] if missing: raise GameContentError(f"内网配置分区 {name} 缺字段:{','.join(missing)}") return values def _source_config() -> dict: """读取独立 source writer,只允许访问 game-sources。""" values = _infra_section( "game_source_minio", ("endpoint", "access_key", "secret_key", "source_bucket", "secure"), ) values.update({ "artifact_bucket": "game-artifacts", "evidence_bucket": "game-evidence", }) return values def _artifact_config() -> dict: """读取 artifact writer,只允许访问运行制品与证据桶。""" return _infra_section( "game_artifact_minio", ("endpoint", "access_key", "secret_key", "artifact_bucket", "evidence_bucket", "secure"), ) def _control_client() -> GameContentControlClient: """读取独立 HMAC 控制面配置;密钥缺失时在任何网络请求前 fail-closed。""" config = _infra_section( "game_content_control", ("endpoint", "secret", "timeout_s"), ) return GameContentControlClient( str(config["endpoint"]), str(config["secret"]), timeout_s=config["timeout_s"], ) def _connection_factory(section: str): """构造 game-cloud 业务库连接工厂,游标固定为字典行以保字段名边界。""" config = _infra_section(section, ("host", "port", "user", "password", "database")) def connect(): try: import pymysql except Exception as exc: # pragma: no cover - 部署依赖缺失时给出稳定错误 raise GameContentError(f"缺少 pymysql:{exc}") from exc return pymysql.connect( host=str(config["host"]), port=int(config["port"]), user=str(config["user"]), password=str(config["password"]), database=str(config["database"]), charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, autocommit=False, ) return connect def _repository(mysql_section: str) -> GameContentRepository: """创建统一仓库入口,调用方不能替换桶或对象 key。""" storage = MySqlArtifactStorageStore(_connection_factory(mysql_section)) return GameContentRepository( storage, control_client=_control_client(), source_config=_source_config(), artifact_config=_artifact_config(), ) def _positive(value: str) -> int: """argparse 正整数转换,阻止布尔/负数/零进入业务身份。""" parsed = int(value) if parsed <= 0: raise argparse.ArgumentTypeError("必须是正整数") return parsed def main(argv: list[str] | None = None) -> int: """执行 prepare、checkout、commit 或 activate,成功只向 stdout 输出结构化回执。""" parser = argparse.ArgumentParser( description="Agent 游戏内容仓库:prepare/committed checkout/commit/activate" ) parser.add_argument("--mysql-section", default="game_content_mysql", help="infra.yaml 中的 game-cloud 业务库分区") subparsers = parser.add_subparsers(dest="action", required=True) prepare = subparsers.add_parser("prepare", help="向 Project 权威预留或复用内容版本") prepare.add_argument("--tenant-id", type=_positive, required=True) prepare.add_argument("--game-id", type=_positive, required=True) prepare.add_argument("--revision-id", required=True) checkout = subparsers.add_parser("checkout", help="按 committed 版本检出 Agent 工作区") checkout.add_argument("--tenant-id", type=_positive, required=True) checkout.add_argument("--game-id", type=_positive, required=True) checkout.add_argument("--version-id", type=_positive, required=True) checkout.add_argument("--target", type=Path, required=True) commit = subparsers.add_parser("commit", help="上传并 CAS 提交新的不可变版本") commit.add_argument("--root", type=Path, required=True) commit.add_argument("--tenant-id", type=_positive, required=True) commit.add_argument("--game-id", type=_positive, required=True) commit.add_argument("--version-id", type=_positive, required=True) commit.add_argument("--revision-id", required=True) commit.add_argument("--engine", required=True) commit.add_argument("--package-type", choices=("phaser", "littlejs", "canvas"), required=True) commit.add_argument("--runtime-manifest", default="game-package.json") commit.add_argument("--creator", default="agent") activate = subparsers.add_parser("activate", help="浏览器验收后按 expected-current 激活版本") activate.add_argument("--tenant-id", type=_positive, required=True) activate.add_argument("--game-id", type=_positive, required=True) activate.add_argument("--version-id", type=_positive, required=True) activate.add_argument("--revision-id", required=True) activate.add_argument("--expected-current-version-id", type=_positive) args = parser.parse_args(argv) try: content = _repository(args.mysql_section) if args.action == "prepare": result = content.prepare( tenant_id=args.tenant_id, game_id=args.game_id, revision_id=args.revision_id, ) elif args.action == "checkout": result = content.checkout( tenant_id=args.tenant_id, game_id=args.game_id, version_id=args.version_id, target=args.target, ) elif args.action == "activate": result = content.activate( tenant_id=args.tenant_id, game_id=args.game_id, version_id=args.version_id, revision_id=args.revision_id, expected_current_version_id=args.expected_current_version_id, ) else: result = content.commit( root=args.root, tenant_id=args.tenant_id, game_id=args.game_id, version_id=args.version_id, revision_id=args.revision_id, engine=args.engine, package_type=args.package_type, runtime_manifest_path=args.runtime_manifest, creator=args.creator, ) print(json.dumps(result, ensure_ascii=False, sort_keys=True)) return 0 except (ArtifactError, ValueError) as exc: print(f"GAME-CONTENT-ERROR:{exc}") return 2 if __name__ == "__main__": raise SystemExit(main())