"""部署环境能力 — sd_deploy_envs 的状态机语义化迁移(通用、产线无关)。 定位:环境 CRUD 走 xls2ui 生成的端点;本模块只做「验证状态流转」—— 每个迁移 CAS 原子(防并发/越权)+ 租户隔离 + 审计。 环境的状态机/流转规则在 deploy-env skill 里(LLM 读 skill 判断合法性);本模块只固化操作原语。 发布本体(部署动作)复用 task 状态机(deploy 角色);本模块管「环境」这个可追踪实体。 角色规范:role 参数用 agent.{role}(无前缀自动补 agent.);人角色用 {orgtype}.{role}。 scope 约定:sd_deploy_envs 用 project_id 做范围校验(CAS 的 WHERE 里带 project_id)。 """ import logging from datetime import datetime from sqlor.dbpools import DBPools from appPublic.uniqueID import getID from .audit import record_audit DBNAME = "pipeline" logger = logging.getLogger("pipeline.deploy_capability") TABLE = "sd_deploy_envs" # 部署环境状态(SDLC 默认,状态机语义见 deploy-env skill) S_CONFIGURED = "configured" # 已配置 S_VERIFIED = "verified" # 已验证 S_FAILED = "failed" # 验证失败 def _get_db(): db = DBPools() if not db.databases: from appPublic.jsonConfig import getConfig config = getConfig() if config.databases: db.databases = config.databases return db, DBNAME def _normalize_role(role): """角色规范:agent 角色补 agent. 前缀;人角色 {orgtype}.{role} 保留原样。""" role = (role or "").strip() if not role: return "" if "." in role: return role return f"agent.{role}" async def _transition(env_id, project_id, from_states, to_state, action, extra_updates=None, who=None, agent_id=None, detail=None): """CAS 状态迁移(支持多 from 状态 + 额外字段更新)+ 审计。返回 (ok, message)。""" if not env_id or not project_id: return False, "缺少 env_id 或 project_id" if isinstance(from_states, str): from_states = [from_states] from_in = ",".join(f"${{s{i}}}$" for i in range(len(from_states))) params = {"to": to_state, "eid": env_id, "pid": project_id} for i, s in enumerate(from_states): params[f"s{i}"] = s extra_sets = "" for col, val in (extra_updates or {}).items(): key = col params[key] = val extra_sets += f", {col}=${{{key}}}$" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: await sor.sqlExe( f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW(){extra_sets} " f"WHERE id=${{eid}}$ AND project_id=${{pid}}$ AND status IN ({from_in})", params) recs = await sor.R(TABLE, {'id': env_id}) await sor.sqlExe("COMMIT", {}) if not recs: return False, "环境不存在" cur = getattr(recs[0], 'status', '') if cur != to_state: return False, f"状态迁移失败(CAS): 期望 from={from_states} 实际 status={cur}" await record_audit(project_id, TABLE, env_id, action, from_state="|".join(from_states), to_state=to_state, who=who, agent_id=agent_id, detail=detail, sor=sor) return True, to_state async def configure_env(project_id, env_type, host, user, deploy_path, port=22, ssh_key_path="", sudo_enabled="", python_path="", db_host="", db_port=3306, db_name="", db_user="", db_password="", who=None, agent_id=None): """配置环境:新建记录,status=configured。返回 (ok, env_id_or_message)。""" if not project_id: return False, "缺少 project_id" if not env_type or not env_type.strip(): return False, "缺少 env_type" if not host or not host.strip(): return False, "缺少 host" if not user or not user.strip(): return False, "缺少 user" if not deploy_path or not deploy_path.strip(): return False, "缺少 deploy_path" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: try: port = int(port) except (TypeError, ValueError): port = 22 try: db_port = int(db_port) except (TypeError, ValueError): db_port = 3306 # sudo_enabled 归一化:字段 VARCHAR(1),'false'/'0'/'no'/'n' → 'N','true'/'1'/'yes'/'y' → 'Y' _se = str(sudo_enabled).strip().lower() if sudo_enabled else '' se_val = 'Y' if _se in ('1', 'true', 'yes', 'y', '是') else 'N' data = { 'project_id': project_id, 'env_type': env_type.strip(), 'host': host.strip(), 'port': port, 'user': user.strip(), 'ssh_key_path': ssh_key_path or '', 'sudo_enabled': se_val, 'deploy_path': deploy_path.strip(), 'python_path': python_path or '', 'db_host': db_host or '', 'db_port': db_port, 'db_name': db_name or '', 'db_user': db_user or '', 'db_password': db_password or '', 'status': S_CONFIGURED, } # upsert:按 (project_id, env_type) 查已存在(UNIQUE 约束),存在则 UPDATE,否则 INSERT existing = await sor.sqlExe( "SELECT id FROM sd_deploy_envs WHERE project_id=${pid}$ AND env_type=${et}$", {"pid": project_id, "et": env_type.strip()}) await sor.sqlExe("COMMIT", {}) if existing: eid = existing[0].id data['id'] = eid await sor.U(TABLE, data) else: eid = getID() data['id'] = eid await sor.C(TABLE, data) await record_audit(project_id, TABLE, eid, 'configure', to_state=S_CONFIGURED, who=_normalize_role(who), agent_id=agent_id, sor=sor) logger.info("configure_env: %s project=%s type=%s (upsert)", eid, project_id, env_type.strip()) return True, eid async def verify_env(env_id, project_id, who=None, agent_id=None): """验证环境通过:configured/failed → verified(记录验证时间)。""" return await _transition(env_id, project_id, [S_CONFIGURED, S_FAILED], S_VERIFIED, 'verify', extra_updates={"verified_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S')}, who=who, agent_id=agent_id) async def fail_env(env_id, project_id, reason="", who=None, agent_id=None): """验证环境失败:configured/verified → failed(附失败原因)。""" return await _transition(env_id, project_id, [S_CONFIGURED, S_VERIFIED], S_FAILED, 'fail', who=who, agent_id=agent_id, detail=reason) async def set_env_state(env_id, project_id, from_state, to_state, who=None, agent_id=None, detail=None): """通用 CAS 状态迁移兜底(跨产线自定义状态机用)。""" return await _transition(env_id, project_id, [from_state], to_state, 'set_state', who=who, agent_id=agent_id, detail=detail) async def list_envs(project_id, env_type=None, status=None, limit=50) -> list: """列出环境(可按类型/状态过滤)。""" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: conditions = ["project_id=${pid}$"] params = {"pid": project_id} if env_type: conditions.append("env_type=${et}$") params["et"] = env_type if status: conditions.append("status=${status}$") params["status"] = status where = " AND ".join(conditions) try: limit = int(limit) except (TypeError, ValueError): limit = 50 sql = (f"SELECT * FROM {TABLE} WHERE {where} " f"ORDER BY created_at ASC LIMIT {limit}") recs = await sor.sqlExe(sql, params) # 释放 SELECT 元数据锁 await sor.sqlExe("COMMIT", {}) result = [] for rec in (recs or []): result.append(_rec_to_dict(rec)) return result def _rec_to_dict(rec): """把 sqlor 记录对象转成 dict(sqlor 行是 DictObject,必须 dict(rec) 取列)。""" if isinstance(rec, dict): return dict(rec) try: return dict(rec) except (TypeError, ValueError): if hasattr(rec, 'to_dict'): try: return rec.to_dict() except Exception: pass return {}