pipeline-service/pipeline_service/deploy_capability.py

200 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""部署环境能力 — 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 记录对象转成 dictsqlor 行是 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 {}