200 lines
7.8 KiB
Python
200 lines
7.8 KiB
Python
"""部署环境能力 — 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:
|
||
eid = getID()
|
||
try:
|
||
port = int(port)
|
||
except (TypeError, ValueError):
|
||
port = 22
|
||
try:
|
||
db_port = int(db_port)
|
||
except (TypeError, ValueError):
|
||
db_port = 3306
|
||
await sor.C(TABLE, {
|
||
'id': eid,
|
||
'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': sudo_enabled or '',
|
||
'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,
|
||
})
|
||
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", 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 {}
|