feat: wire full-auto executor to server HTTP endpoints
GET /full-auto/status, GET /full-auto/config-plan, POST /full-auto/validate-action|merge-gate|ci-fix|release-plan| rollback-plan, POST /full-auto/execute (global admin only, FULL_AUTO_ENABLED + verified installation + branch protection verified via GitHub API, else 403 + audit), GET /full-auto/audit. Guards reused from cloud/full_auto.py; token only in Authorization header; audit via audit_record redaction into AuthStore audit_log.
This commit is contained in:
1 parent
b4a69e1e71
commit
d973e63b11
2 files changed
+849
-1
No files matched your search
+435
-1
@@ -56,6 +56,8 @@ from contextlib import contextmanager
|
||||
from decimal import Decimal
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from cloud.auth import ROLES as _MEMBER_ROLES
|
||||
from cloud.auth import AuthStore
|
||||
@@ -196,6 +198,7 @@ from cloud.github_app import (
|
||||
build_app_manifest,
|
||||
claim_fix_review,
|
||||
exchange_manifest_code,
|
||||
find_installation_for_repo,
|
||||
get_app_config,
|
||||
list_installations,
|
||||
parse_installation_event,
|
||||
@@ -215,6 +218,21 @@ from cloud.webhooks import (
|
||||
verify_github_signature_any,
|
||||
verify_slack_signature,
|
||||
)
|
||||
from cloud.full_auto import (
|
||||
FullAutoDenied,
|
||||
FullAutoError,
|
||||
audit_record,
|
||||
auto_config_plan,
|
||||
branch_protection_payload,
|
||||
ci_fix_decision,
|
||||
is_full_auto_enabled,
|
||||
merge_gate,
|
||||
release_plan,
|
||||
rollback_plan,
|
||||
run_plan,
|
||||
validate_action,
|
||||
)
|
||||
from cloud.github_app import resolve_app_token as _resolve_app_token
|
||||
|
||||
# PR 事件推送失败时记日志(systemd 下进 journal);handler 的
|
||||
# log_message 被重写为静默(测试输出干净),所以不用它。
|
||||
@@ -1615,6 +1633,130 @@ def _global_admin_ids():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
|
||||
|
||||
# -- 全自动执行器 HTTP 接线(cloud/full_auto.py 的薄端点层) --------
|
||||
# 只做接线,不发明新护栏:开关、动作白名单、分支保护、脱敏、
|
||||
# 限次修复全部复用 full_auto.py;本层只负责认证、管理员门、
|
||||
# installation 验证、token 解析与审计落盘。token 只进请求头,
|
||||
# 绝不进命令行、文件、审计记录与 HTTP 响应。
|
||||
def _full_auto_installation_verified(conn, repo_full_name=None):
|
||||
"""installation 已验证 = App 配置存在 + 安装记录存在。
|
||||
|
||||
repo 给出时查该仓库的安装记录(用户在 GitHub 侧安装 App
|
||||
后 webhook 落库的记录);repo 未给出时任一安装记录即算
|
||||
已验证(仅供 /full-auto/status 总览用,执行仍须按仓库查)。
|
||||
真实安装/重新接受只能由用户在 GitHub 侧完成,本层绝不
|
||||
伪造安装记录、绝不把“无记录”说成已验证。"""
|
||||
try:
|
||||
if get_app_config(conn) is None:
|
||||
return False
|
||||
if repo_full_name:
|
||||
return find_installation_for_repo(conn, repo_full_name) is not None
|
||||
rows = list_installations(conn)
|
||||
return bool(rows)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _full_auto_branch_protection_ok(protection, required_checks):
|
||||
"""分支保护是否达到 full_auto 的强制标准:与
|
||||
branch_protection_payload 的要求逐项核对,缺一项即不算
|
||||
配好;required_checks 须全部在 contexts 里(可多不可少)。
|
||||
protection 为非 dict 或 GET 失败时调用方已先行拒绝。"""
|
||||
if not isinstance(protection, dict):
|
||||
return False
|
||||
try:
|
||||
expected = branch_protection_payload(required_checks)
|
||||
except FullAutoError:
|
||||
return False
|
||||
checks = protection.get("required_status_checks") or {}
|
||||
if not isinstance(checks, dict) or not checks.get("strict"):
|
||||
return False
|
||||
contexts = checks.get("contexts") or []
|
||||
if not set(expected["required_status_checks"]["contexts"]) <= set(contexts):
|
||||
return False
|
||||
if not protection.get("enforce_admins"):
|
||||
return False
|
||||
if not isinstance(protection.get("required_pull_request_reviews"), dict):
|
||||
return False
|
||||
if protection.get("allow_force_pushes") is not False:
|
||||
return False
|
||||
if protection.get("allow_deletions") is not False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _full_auto_github_request(http_client, method, url, headers, body):
|
||||
"""full_auto.run_plan 用的 request_fn:注入的 fake 客户端
|
||||
优先(单测);生产走 urllib,token 只在 headers 里,响应
|
||||
只返回 (status, dict),绝不把 headers 写进返回值。"""
|
||||
if http_client is not None:
|
||||
result = http_client(method, url, headers, body)
|
||||
if not (isinstance(result, tuple) and len(result) == 2):
|
||||
raise FullAutoError("http_client must return (status, dict)",
|
||||
reason="auto_config_step_failed")
|
||||
return result
|
||||
request = urllib.request.Request(url, data=body, method=method,
|
||||
headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
status = exc.code
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
parsed = {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
return status, parsed
|
||||
|
||||
|
||||
def _full_auto_parse_plan_params(body, query):
|
||||
"""从 POST body 或 GET query 里解析 auto_config_plan 参数。
|
||||
|
||||
repo 必填且须含 /;required_checks 可为列表或逗号字符串;
|
||||
布尔字段接受 true/false/1/0 字符串与布尔值。非法返回
|
||||
(None, error_string)。"""
|
||||
def _get(name):
|
||||
if isinstance(body, dict) and name in body:
|
||||
return body[name]
|
||||
values = (query or {}).get(name)
|
||||
return values[0] if values else None
|
||||
|
||||
def _as_bool(value, default):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes")
|
||||
|
||||
repo = _get("repo") or _get("repo_full_name")
|
||||
if not isinstance(repo, str) or "/" not in repo:
|
||||
return None, "repo must be owner/repo"
|
||||
checks = _get("required_checks")
|
||||
if checks is None:
|
||||
checks = ("verify",)
|
||||
elif isinstance(checks, str):
|
||||
checks = tuple(c.strip() for c in checks.split(",") if c.strip())
|
||||
elif isinstance(checks, (list, tuple)):
|
||||
checks = tuple(str(c) for c in checks if str(c))
|
||||
else:
|
||||
return None, "required_checks must be a list or string"
|
||||
params = {
|
||||
"repo_full_name": repo,
|
||||
"default_branch": _get("default_branch") or "main",
|
||||
"org": _get("org"),
|
||||
"repo_exists": _as_bool(_get("repo_exists"), True),
|
||||
"ci_workflow_present": _as_bool(_get("ci_workflow_present"), False),
|
||||
"required_checks": checks,
|
||||
}
|
||||
if params["org"] is not None and not isinstance(params["org"], str):
|
||||
return None, "org must be a string"
|
||||
return params, None
|
||||
|
||||
|
||||
def _backfill_default_quotas(conn):
|
||||
"""给已有无行用户回填默认配额行(init_db 时调,幂等)。
|
||||
|
||||
@@ -2828,7 +2970,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
|
||||
if path == "/projects" and method == "POST":
|
||||
if path == "/full-auto/status" and method == "GET":
|
||||
self._full_auto_status(user_id, query)
|
||||
elif path == "/full-auto/config-plan" and method == "GET":
|
||||
self._full_auto_config_plan(user_id, query)
|
||||
elif path == "/full-auto/validate-action" and method == "POST":
|
||||
self._full_auto_validate_action(user_id)
|
||||
elif path == "/full-auto/merge-gate" and method == "POST":
|
||||
self._full_auto_merge_gate(user_id)
|
||||
elif path == "/full-auto/ci-fix" and method == "POST":
|
||||
self._full_auto_ci_fix(user_id)
|
||||
elif path == "/full-auto/release-plan" and method == "POST":
|
||||
self._full_auto_release_plan(user_id)
|
||||
elif path == "/full-auto/rollback-plan" and method == "POST":
|
||||
self._full_auto_rollback_plan(user_id)
|
||||
elif path == "/full-auto/execute" and method == "POST":
|
||||
self._full_auto_execute(user_id)
|
||||
elif path == "/full-auto/audit" and method == "GET":
|
||||
self._full_auto_audit_list(user_id, query)
|
||||
elif path == "/projects" and method == "POST":
|
||||
self._create_project(user_id)
|
||||
elif path == "/projects" and method == "GET":
|
||||
self._list_projects(user_id)
|
||||
@@ -9830,6 +9990,275 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
self._send_json(200, status)
|
||||
|
||||
# -- 全自动执行器 HTTP 端点(cloud/full_auto.py 的接线层) ------
|
||||
# 读类端点(status/config-plan)只查计划与状态、不执行、不
|
||||
# 碰 token;写类执行(execute)只限全局管理员,且须同时
|
||||
# 满足:FULL_AUTO_ENABLED 显式开启 + 该仓库 installation 已
|
||||
# 验证 + 主分支保护经 GitHub API 实测达标,三者缺一即
|
||||
# 403 拒绝并记审计,绝不静默放行。审计一律经
|
||||
# full_auto.audit_record 脱敏后落 AuthStore audit_log(action
|
||||
# 前缀 full_auto_),响应与审计里绝不出现 token 明文、
|
||||
# Bearer 明文或调用方塞进 detail 的敏感键。
|
||||
def _full_auto_status(self, user_id, query):
|
||||
repo = (query.get("repo") or [None])[0]
|
||||
conn = self._db()
|
||||
try:
|
||||
configured = get_app_config(conn) is not None
|
||||
verified = _full_auto_installation_verified(conn, repo)
|
||||
installations = list_installations(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
flag = str(os.environ.get("FULL_AUTO_ENABLED", "")
|
||||
).strip().lower() in ("1", "true", "yes")
|
||||
self._send_json(200, {
|
||||
"enabled": is_full_auto_enabled(os.environ, verified),
|
||||
"flag_enabled": flag,
|
||||
"installation_verified": verified,
|
||||
"app_configured": configured,
|
||||
"repo": repo,
|
||||
"installations": installations,
|
||||
})
|
||||
|
||||
def _full_auto_config_plan(self, user_id, query):
|
||||
params, err = _full_auto_parse_plan_params({}, query)
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
try:
|
||||
steps = auto_config_plan(**params)
|
||||
except FullAutoError as exc:
|
||||
self._send_json(400, {"error": str(exc),
|
||||
"reason": exc.reason})
|
||||
return
|
||||
self._send_json(200, {"repo": params["repo_full_name"],
|
||||
"steps": steps})
|
||||
|
||||
def _full_auto_validate_action(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
action = (body or {}).get("action")
|
||||
if not isinstance(action, str) or not action:
|
||||
self._send_json(400, {"error": "action required"})
|
||||
return
|
||||
try:
|
||||
validate_action(action)
|
||||
except FullAutoDenied as exc:
|
||||
self._send_json(403, {"error": "action denied",
|
||||
"reason": exc.reason,
|
||||
"action": action})
|
||||
return
|
||||
self._send_json(200, {"action": action, "allowed": True})
|
||||
|
||||
def _full_auto_merge_gate(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
passed, reasons = merge_gate(
|
||||
body.get("checks") or {},
|
||||
body.get("required_checks") or (),
|
||||
bool(body.get("directory_check_ok")),
|
||||
bool(body.get("contract_version_ok")),
|
||||
bool(body.get("coordinator_approved")))
|
||||
self._send_json(200, {"passed": passed, "reasons": reasons})
|
||||
|
||||
def _full_auto_ci_fix(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
try:
|
||||
attempt = int(body.get("attempt"))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {"error": "attempt must be an integer"})
|
||||
return
|
||||
self._send_json(200, ci_fix_decision(
|
||||
attempt, body.get("failures") or []))
|
||||
|
||||
def _full_auto_release_plan(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
if not isinstance(body.get("tag"), str) or not body.get("tag"):
|
||||
self._send_json(400, {"error": "tag required"})
|
||||
return
|
||||
self._send_json(200, release_plan(
|
||||
body["tag"], body.get("merged_prs") or []))
|
||||
|
||||
def _full_auto_rollback_plan(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
try:
|
||||
plan = rollback_plan(body.get("previous_tag"))
|
||||
except FullAutoError as exc:
|
||||
self._send_json(400, {"error": str(exc),
|
||||
"reason": exc.reason})
|
||||
return
|
||||
self._send_json(200, plan)
|
||||
|
||||
def _full_auto_audit_list(self, user_id, query):
|
||||
"""审计查询:只限全局管理员,只返回 full_auto_ 前缀行,
|
||||
且只返回已脱敏落盘的 detail(写时已脱敏,读时不再
|
||||
含 token——调用方若直接写库绕过本层,不在本端点
|
||||
的保证内,写路径见 _full_auto_execute)。"""
|
||||
if not self._is_global_admin(user_id):
|
||||
self._audit_admin(user_id, "full_auto_audit_denied",
|
||||
"audit list")
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
try:
|
||||
limit = int((query.get("limit") or ["100"])[0])
|
||||
except ValueError:
|
||||
limit = 100
|
||||
limit = max(1, min(limit, 100))
|
||||
conn = self._db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT user_id, action, detail, created_at FROM audit_log"
|
||||
" WHERE action LIKE 'full_auto_%'"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(limit,)).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {"audit": [
|
||||
{"user_id": r[0], "action": r[1], "detail": r[2],
|
||||
"created_at": r[3]} for r in rows]})
|
||||
|
||||
def _full_auto_execute(self, user_id):
|
||||
"""执行自动配置计划:按 full_auto.auto_config_plan 生成
|
||||
顺序计划,按 full_auto.run_plan 顺序执行,不跳步。
|
||||
|
||||
门禁顺序(任一步拒绝即停、记审计、不执行后续):
|
||||
1. 全局管理员;2. body.action 若给出须过 validate_action;
|
||||
3. FULL_AUTO_ENABLED 显式开 + 该仓库 installation 已验证;
|
||||
4. installation token 现解析现用(测试注入
|
||||
server.full_auto_token_resolver 仅供单测绕过 JWT 签发,
|
||||
生产为 None 时走 github_app.resolve_app_token);
|
||||
5. 主分支保护经 GitHub API 实测达标(带 token 查,
|
||||
私有仓无 token 查保护必 404,故 token 先于保护核查)。
|
||||
token 只进 Authorization 请求头;request_fn 注入点
|
||||
server.full_auto_http_client 仅供单测,生产走 urllib。"""
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
self._send_json(400, {"error": err})
|
||||
return
|
||||
body = body or {}
|
||||
params, perr = _full_auto_parse_plan_params(body, {})
|
||||
if perr:
|
||||
self._send_json(400, {"error": perr})
|
||||
return
|
||||
repo = params["repo_full_name"]
|
||||
branch = params["default_branch"]
|
||||
checks = params["required_checks"]
|
||||
|
||||
def _deny(reason, detail):
|
||||
record = audit_record("execute_denied", repo,
|
||||
"full-auto-executor", "denied",
|
||||
{"reason": reason, "detail": detail})
|
||||
self._audit_admin(user_id, "full_auto_execute_denied",
|
||||
json.dumps(record, ensure_ascii=False))
|
||||
self._send_json(403, {"error": "full-auto denied",
|
||||
"reason": reason})
|
||||
if not self._is_global_admin(user_id):
|
||||
_deny("not_global_admin", "execute requires global admin")
|
||||
return
|
||||
action = body.get("action")
|
||||
if action is not None:
|
||||
if not isinstance(action, str) or not action:
|
||||
self._send_json(400, {"error": "action must be a string"})
|
||||
return
|
||||
try:
|
||||
validate_action(action)
|
||||
except FullAutoDenied as exc:
|
||||
_deny(exc.reason, "action=%s" % action)
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
verified = _full_auto_installation_verified(conn, repo)
|
||||
finally:
|
||||
conn.close()
|
||||
if not is_full_auto_enabled(os.environ, verified):
|
||||
_deny("full_auto_disabled",
|
||||
"FULL_AUTO_ENABLED not set or installation unverified")
|
||||
return
|
||||
http_client = getattr(self.server, "full_auto_http_client", None)
|
||||
request_fn = lambda m, u, h, b: _full_auto_github_request( # noqa: E731
|
||||
http_client, m, u, h, b)
|
||||
resolver = getattr(self.server, "full_auto_token_resolver", None)
|
||||
if resolver is not None:
|
||||
token = resolver(self.server.db_path, repo)
|
||||
else:
|
||||
conn = self._db()
|
||||
try:
|
||||
token = _resolve_app_token(conn, repo)
|
||||
finally:
|
||||
conn.close()
|
||||
if not token:
|
||||
_deny("token_missing",
|
||||
"installation token unavailable for repo")
|
||||
return
|
||||
protection_url = ("https://api.github.com/repos/%s/branches/%s"
|
||||
"/protection" % (repo, branch))
|
||||
try:
|
||||
pstatus, protection = request_fn(
|
||||
"GET", protection_url,
|
||||
{"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "codex-app-server",
|
||||
"Authorization": "Bearer " + token}, None)
|
||||
except FullAutoError:
|
||||
_deny("branch_protection_unverified",
|
||||
"protection check request failed")
|
||||
return
|
||||
if pstatus != 200 or not _full_auto_branch_protection_ok(
|
||||
protection, checks):
|
||||
_deny("branch_protection_unverified",
|
||||
"branch protection incomplete or unverified")
|
||||
return
|
||||
try:
|
||||
steps = auto_config_plan(**params)
|
||||
except FullAutoError as exc:
|
||||
self._send_json(400, {"error": str(exc),
|
||||
"reason": exc.reason})
|
||||
return
|
||||
results = []
|
||||
try:
|
||||
results = run_plan(request_fn, token, steps)
|
||||
except FullAutoDenied as exc:
|
||||
_deny(exc.reason, "plan step denied")
|
||||
return
|
||||
except FullAutoError as exc:
|
||||
record = audit_record("auto_config", repo,
|
||||
"full-auto-executor", "failed",
|
||||
{"reason": exc.reason,
|
||||
"detail": body.get("detail")})
|
||||
self._audit_admin(user_id, "full_auto_auto_config",
|
||||
json.dumps(record, ensure_ascii=False))
|
||||
self._send_json(502, {"error": str(exc),
|
||||
"reason": exc.reason,
|
||||
"steps": results})
|
||||
return
|
||||
record = audit_record("auto_config", repo,
|
||||
"full-auto-executor", "ok",
|
||||
{"detail": body.get("detail"),
|
||||
"steps": [r["step"] for r in results]})
|
||||
self._audit_admin(user_id, "full_auto_auto_config",
|
||||
json.dumps(record, ensure_ascii=False))
|
||||
# 响应只含步骤名/状态码/ok,绝不回显请求体 detail、
|
||||
# headers 或 token——results 本身就是 run_plan 的
|
||||
# (step/status/ok) 三元组,无凭据字段。
|
||||
self._send_json(200, {"ok": True, "repo": repo, "steps": results})
|
||||
|
||||
# -- API keys 管理 ---------------------------------------------------
|
||||
def _user_keys_list(self):
|
||||
"""列出自己的 key:primary(id "legacy")+ 附加 key。
|
||||
@@ -10866,6 +11295,11 @@ def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
|
||||
server.pr_gh_runner = None
|
||||
server.pr_gh_which = None
|
||||
server.host_worktrees_dir = None
|
||||
# 全自动执行器注入点:生产为 None(走 urllib + 真实
|
||||
# installation token 解析);测试可注入 fake http_client
|
||||
# 与 token 解析器;绝不注入真实 token、真实 secret。
|
||||
server.full_auto_http_client = None
|
||||
server.full_auto_token_resolver = None
|
||||
return server
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""全自动执行器 HTTP 接线单测(cloud/server.py 的 /full-auto/* 端点)。
|
||||
|
||||
不跑真实模型、不联网、不碰生产与主机:GitHub API 一律走
|
||||
server.full_auto_http_client 的 fake 客户端,installation token
|
||||
一律走 server.full_auto_token_resolver 的 fake 解析(绕过 JWT
|
||||
签发,生产路径仍走 github_app.resolve_app_token)。测试里
|
||||
出现的 token/secret 一律是 fake 占位标记,断言目标正是:
|
||||
这些标记绝不出现在 HTTP 响应与审计记录里。
|
||||
|
||||
覆盖:默认关闭、未验证安装拒绝、非管理员拒绝、护栏拒绝
|
||||
(禁止/未知动作)、分支保护未验证拒绝、执行成功与审计
|
||||
脱敏、配置计划顺序、合并门/修复限次/发版回滚、审计查询权限。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.github_app import record_installation_repos, save_app_config
|
||||
from cloud.server import create_server
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.github_app import record_installation_repos, save_app_config
|
||||
from cloud.server import create_server
|
||||
|
||||
import tempfile
|
||||
|
||||
REPO = "owner/repo"
|
||||
FAKE_TOKEN = "fake-test-token"
|
||||
MARKER_SECRET = "marker-secret-value-12345"
|
||||
MARKER_BEARER = "marker-bearer-value-67890"
|
||||
|
||||
GOOD_PROTECTION = {
|
||||
"required_status_checks": {"strict": True, "contexts": ["verify"]},
|
||||
"enforce_admins": True,
|
||||
"required_pull_request_reviews": {"required_approving_review_count": 1},
|
||||
"allow_force_pushes": False,
|
||||
"allow_deletions": False,
|
||||
}
|
||||
|
||||
|
||||
def _fake_client_factory(calls, protection=None):
|
||||
def fake(method, url, headers, body):
|
||||
calls.append({"method": method, "url": url,
|
||||
"headers": dict(headers),
|
||||
"body": body})
|
||||
if url.endswith("/branches/main/protection") and method == "GET":
|
||||
return 200, dict(protection if protection is not None
|
||||
else GOOD_PROTECTION)
|
||||
if method in ("PATCH", "PUT"):
|
||||
return 200, {}
|
||||
return 404, {"message": "not found"}
|
||||
return fake
|
||||
|
||||
|
||||
class FullAutoApiTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._old_flag = os.environ.get("FULL_AUTO_ENABLED")
|
||||
self._old_admins = os.environ.get("CODEX_GLOBAL_ADMINS")
|
||||
os.environ.pop("FULL_AUTO_ENABLED", None)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.outsider_id, self.outsider_key = self.auth.create_user(
|
||||
"outsider")
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self.owner_id
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.rate_limit_per_minute = 10000
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
save_app_config(conn, {
|
||||
"app_id": "424242", "slug": "fake-codex-app",
|
||||
"client_id": "Iv1.fake",
|
||||
"webhook_secret": "fake-app-secret",
|
||||
"pem": "-----BEGIN RSA PRIVATE KEY-----\nfake-pem\n"
|
||||
"-----END RSA PRIVATE KEY-----\n",
|
||||
"html_url": "https://github.com/apps/fake-codex-app"})
|
||||
record_installation_repos(conn, "123", "owner", [REPO])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
if self._old_flag is None:
|
||||
os.environ.pop("FULL_AUTO_ENABLED", None)
|
||||
else:
|
||||
os.environ["FULL_AUTO_ENABLED"] = self._old_flag
|
||||
if self._old_admins is None:
|
||||
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
|
||||
else:
|
||||
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
try:
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8")) if raw else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return resp.status, parsed, raw.decode("utf-8", "replace")
|
||||
|
||||
def _audit_details(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT action, detail FROM audit_log"
|
||||
" WHERE action LIKE 'full_auto_%'").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
def _enable(self):
|
||||
os.environ["FULL_AUTO_ENABLED"] = "1"
|
||||
|
||||
def _arm_execute(self, protection=None):
|
||||
calls = []
|
||||
self.server.full_auto_http_client = _fake_client_factory(
|
||||
calls, protection)
|
||||
self.server.full_auto_token_resolver = (
|
||||
lambda db_path, repo: FAKE_TOKEN)
|
||||
return calls
|
||||
|
||||
# -- 状态:默认关闭 ----------------------------------------------
|
||||
def test_status_default_off_despite_verified_installation(self):
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/status?repo=" + REPO, key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertFalse(body["enabled"])
|
||||
self.assertFalse(body["flag_enabled"])
|
||||
self.assertTrue(body["installation_verified"])
|
||||
self.assertTrue(body["app_configured"])
|
||||
|
||||
def test_status_enabled_only_with_flag_and_verified(self):
|
||||
self._enable()
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/status?repo=" + REPO, key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["enabled"])
|
||||
|
||||
def test_status_unverified_repo_never_enabled(self):
|
||||
self._enable()
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/status?repo=other/repo",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertFalse(body["installation_verified"])
|
||||
self.assertFalse(body["enabled"])
|
||||
|
||||
def test_status_requires_auth_and_leaks_no_secret(self):
|
||||
status, body, raw = self._req(
|
||||
"GET", "/full-auto/status?repo=" + REPO)
|
||||
self.assertEqual(status, 401)
|
||||
status, body, raw = self._req(
|
||||
"GET", "/full-auto/status?repo=" + REPO, key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertNotIn("fake-app-secret", raw)
|
||||
self.assertNotIn("fake-pem", raw)
|
||||
|
||||
# -- 配置计划顺序 --------------------------------------------------
|
||||
def test_config_plan_order_and_branch_protection(self):
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/config-plan?repo=" + REPO
|
||||
+ "&repo_exists=false&org=myorg&ci_workflow_present=false",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
steps = [s["step"] for s in body["steps"]]
|
||||
self.assertEqual(steps, ["ensure_repo", "set_default_branch",
|
||||
"set_branch_protection",
|
||||
"add_ci_workflow", "verify_webhook"])
|
||||
protection = body["steps"][2]["payload_summary"]
|
||||
self.assertTrue(protection["enforce_admins"])
|
||||
self.assertTrue(protection["required_status_checks"]["strict"])
|
||||
self.assertFalse(protection["allow_force_pushes"])
|
||||
self.assertFalse(protection["allow_deletions"])
|
||||
self.assertIsNotNone(
|
||||
protection["required_pull_request_reviews"])
|
||||
|
||||
def test_config_plan_user_repo_without_org_is_user_once(self):
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/config-plan?repo=" + REPO
|
||||
+ "&repo_exists=false",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
first = body["steps"][0]
|
||||
self.assertEqual(first["manual"], "user_once")
|
||||
self.assertIsNone(first["api"])
|
||||
|
||||
def test_config_plan_existing_repo_skips_ensure(self):
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/config-plan?repo=" + REPO,
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertNotIn("ensure_repo",
|
||||
[s["step"] for s in body["steps"]])
|
||||
|
||||
def test_config_plan_bad_repo_rejected(self):
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/config-plan?repo=norepo",
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
# -- 护栏:动作校验 ------------------------------------------------
|
||||
def test_validate_action_forbidden_and_unknown_denied(self):
|
||||
for action, reason in (("delete_repo", "forbidden_action"),
|
||||
("force_push_main", "forbidden_action"),
|
||||
("change_visibility", "forbidden_action"),
|
||||
("read_secret", "forbidden_action"),
|
||||
("nonsense_action", "unknown_action")):
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/validate-action",
|
||||
{"action": action}, self.owner_key)
|
||||
self.assertEqual(status, 403, (action, body))
|
||||
self.assertEqual(body["reason"], reason)
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/validate-action",
|
||||
{"action": "create_pr"}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["allowed"])
|
||||
|
||||
# -- 执行门禁 ------------------------------------------------------
|
||||
def _execute_body(self, **extra):
|
||||
body = {"repo": REPO, "repo_exists": True,
|
||||
"ci_workflow_present": True}
|
||||
body.update(extra)
|
||||
return body
|
||||
|
||||
def test_execute_default_off_denied_without_github_call(self):
|
||||
calls = self._arm_execute()
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/execute", self._execute_body(),
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
self.assertEqual(body["reason"], "full_auto_disabled")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_execute_unverified_denied_without_github_call(self):
|
||||
self._enable()
|
||||
calls = self._arm_execute()
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/execute",
|
||||
self._execute_body(repo="other/repo"), self.owner_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
self.assertEqual(body["reason"], "full_auto_disabled")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_execute_non_admin_denied_without_github_call(self):
|
||||
self._enable()
|
||||
calls = self._arm_execute()
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/execute", self._execute_body(),
|
||||
self.outsider_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
self.assertEqual(body["reason"], "not_global_admin")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_execute_forbidden_action_denied_without_github_call(self):
|
||||
self._enable()
|
||||
calls = self._arm_execute()
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/execute",
|
||||
self._execute_body(action="delete_repo"), self.owner_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
self.assertEqual(body["reason"], "forbidden_action")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_execute_branch_protection_unverified_denied(self):
|
||||
self._enable()
|
||||
weak = dict(GOOD_PROTECTION)
|
||||
weak["allow_force_pushes"] = True
|
||||
calls = self._arm_execute(protection=weak)
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/execute", self._execute_body(),
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 403, body)
|
||||
self.assertEqual(body["reason"], "branch_protection_unverified")
|
||||
# 只查了保护、没执行任何计划步骤(无 PATCH/PUT)。
|
||||
self.assertTrue(all(c["method"] == "GET" for c in calls))
|
||||
self.assertTrue(calls)
|
||||
|
||||
def test_execute_success_token_only_in_header_and_audit_redacted(self):
|
||||
self._enable()
|
||||
calls = self._arm_execute()
|
||||
status, body, raw = self._req(
|
||||
"POST", "/full-auto/execute",
|
||||
self._execute_body(detail={
|
||||
"api_key": MARKER_SECRET,
|
||||
"note": "Bearer " + MARKER_BEARER}),
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["ok"])
|
||||
self.assertEqual([s["step"] for s in body["steps"]],
|
||||
["set_default_branch", "set_branch_protection",
|
||||
"verify_webhook"])
|
||||
self.assertTrue(all(s["ok"] for s in body["steps"]))
|
||||
# token 只进请求头:全部调用的 Authorization 头正确,
|
||||
# 且任何请求体里都没有 token;响应里也没有标记与 token。
|
||||
for call in calls:
|
||||
self.assertEqual(call["headers"].get("Authorization"),
|
||||
"Bearer " + FAKE_TOKEN)
|
||||
if call["body"]:
|
||||
self.assertNotIn(FAKE_TOKEN.encode(), call["body"])
|
||||
self.assertNotIn(FAKE_TOKEN, raw)
|
||||
self.assertNotIn(MARKER_SECRET, raw)
|
||||
self.assertNotIn(MARKER_BEARER, raw)
|
||||
# 审计脱敏:api_key 键被删、Bearer 尾被抹,标记全不在。
|
||||
rows = self._audit_details()
|
||||
self.assertTrue(any(a == "full_auto_auto_config"
|
||||
for a, _ in rows))
|
||||
blob = json.dumps(rows, ensure_ascii=False)
|
||||
self.assertNotIn(MARKER_SECRET, blob)
|
||||
self.assertNotIn(MARKER_BEARER, blob)
|
||||
self.assertNotIn(FAKE_TOKEN, blob)
|
||||
|
||||
# -- 纯判定端点:合并门 / 修复限次 / 发版回滚 ----------------------
|
||||
def test_merge_gate_endpoint(self):
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/merge-gate",
|
||||
{"checks": {"verify": "success"}, "required_checks": ["verify"],
|
||||
"directory_check_ok": True, "contract_version_ok": True,
|
||||
"coordinator_approved": False}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertFalse(body["passed"])
|
||||
self.assertTrue(body["reasons"])
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/merge-gate",
|
||||
{"checks": {"verify": "success"}, "required_checks": ["verify"],
|
||||
"directory_check_ok": True, "contract_version_ok": True,
|
||||
"coordinator_approved": True}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["passed"])
|
||||
self.assertEqual(body["reasons"], [])
|
||||
|
||||
def test_ci_fix_endpoint_limited_to_three(self):
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/ci-fix",
|
||||
{"attempt": 3, "failures": ["verify"]}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["action"], "fix_and_rerun")
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/ci-fix",
|
||||
{"attempt": 4, "failures": ["verify"]}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["action"], "escalate")
|
||||
|
||||
def test_release_and_rollback_plan_endpoints(self):
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/release-plan",
|
||||
{"tag": "v1", "merged_prs": [{"number": 1, "title": "x"}]},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIn("never force-push", body["rollback"])
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/rollback-plan",
|
||||
{"previous_tag": "v0"}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["previous_tag"], "v0")
|
||||
self.assertIn("no force-push", body["method"])
|
||||
status, body, _ = self._req(
|
||||
"POST", "/full-auto/rollback-plan", {}, self.owner_key)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
# -- 审计查询 ------------------------------------------------------
|
||||
def test_audit_list_admin_only(self):
|
||||
self._enable()
|
||||
self._arm_execute()
|
||||
status, _, _ = self._req(
|
||||
"POST", "/full-auto/execute", self._execute_body(),
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/audit", key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(any(a["action"] == "full_auto_auto_config"
|
||||
for a in body["audit"]))
|
||||
status, body, _ = self._req(
|
||||
"GET", "/full-auto/audit", key=self.outsider_key)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user