Files

245 lines
10 KiB
Python
Raw Permalink 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.
"""项目级仓库解析:把项目的 repo_url 变成 worker 可派发的本地检出。
零第三方依赖,只用 Python 标准库;不改 host.py 的 worktree 逻辑,
不存任何仓库凭证、不推远端。
- validate_repo_url:API(POST /projects)与 worker 共用的同一套
校验,绝不另写第二份校验逻辑让两者漂移。只接受:
https:// 开头(且不含 userinfo,即不含用户名/密码),或
git@ 开头(git@host:path 形)。file://、ssh://、本地路径、
带密码的 URL 一律拒绝。
- checkout_dir:按 project_id 的安全字符命名,防路径穿越。
- resolve_repo:目录不存在时 git clone;已存在时校验它是 git
仓库且 remote origin 与 repo_url 一致(不一致时报错,绝不强制
改 remote),一致时 git fetch origin(失败明确报错,不静默用
旧代码);fetch 成功后不自动 checkout/pull、不 git reset/clean,
工作树脏不脏由 host.py 的 worktree 逻辑处理。
- 私有仓库 clone 失败时错误文本写明:需要仓库访问凭证,控制面
不存仓库凭证——绝不尝试用任何 key 去 clone。
- check_repo_access:建项目/建任务前的快速预检,只跑
`git ls-remote <url> HEAD`,不 clone、不写盘、不改库;
失败时返回 (False, 原因),成功时返回 (True, HEAD 哈希)。
本机 https 到 GitHub 常被出口策略挡、ssh 才通(2026-10-30
实测),预检失败的 https 私有仓应先改走 git@ 形再测,
不要等任务派发后在 worker 里花几分钟超时才发现。
CLI:`python3 -m cloud.repos check <url>`,退出码 0/1,
只打印结果与原因,绝不打印任何凭证。
"""
import os
import re
import subprocess
import sys
from urllib.parse import urlparse
__all__ = [
"RepoError",
"validate_repo_url",
"checkout_dir",
"resolve_repo",
"check_repo_access",
"check_repo_access_many",
]
class RepoError(ValueError):
"""仓库解析失败(校验、clone、remote 不一致、fetch 失败)。"""
_GIT_SCP_RE = re.compile(r"^git@[^/:@\s]+:[^@\s]+$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]")
def validate_repo_url(repo_url):
"""校验 repo_url,合法时返回原值,否则抛 ValueError。
拒绝:非字符串、空串、含空白/控制字符、非 https:// 与 git@ 开头、
https:// 带 userinfo(username 或 password 任一存在即拒绝,
含 https://user@host/ 这种只有用户名无密码的也拒绝,因为
userinfo 本身就是凭证载体)、https:// 无 host、git@ 形不合规。
"""
if not isinstance(repo_url, str):
raise ValueError("repo_url 必须是非空字符串")
# 含空白/控制字符的 URL 直接拒绝,不静默 strip:strip 会把
# “https://…\n”这种畸形输入洗成合法 URL 存进库,git 调用时
# 再出难以定位的问题;调用方应传干净的 URL。
if not repo_url or any(ch.isspace() for ch in repo_url):
raise ValueError("repo_url 必须是非空字符串且不得含空白字符")
url = repo_url
if url.startswith("https://"):
parsed = urlparse(url)
if parsed.username is not None or parsed.password is not None:
raise ValueError(
"repo_url 不得含 userinfo(用户名/密码): %s" % url)
if not parsed.hostname:
raise ValueError("repo_url 缺少 host: %s" % url)
return url
if url.startswith("git@"):
if not _GIT_SCP_RE.match(url):
raise ValueError("git@ 形 repo_url 不合法: %s" % url)
return url
raise ValueError(
"repo_url 只接受 https:// 或 git@ 开头,"
"不接受 file://、ssh://、本地路径: %s" % url)
def checkout_dir(repos_dir, project_id):
"""按 project_id 的安全字符命名检出目录,防路径穿越。
project_id 本身是 hex,仍做安全字符过滤;过滤后为空时报错。
返回的绝对路径一定在 repos_dir 之下,否则报错(防御性双检)。
"""
raw = str(project_id)
safe = _SAFE_NAME_RE.sub("-", raw).strip(".")
if not safe or safe in (".", ".."):
raise RepoError("project_id 无法映射为安全的检出目录名: %r" % raw)
base = os.path.abspath(repos_dir)
path = os.path.join(base, safe)
if os.path.commonpath([base, path]) != base:
raise RepoError("检出目录越出 repos_dir,拒绝: %s" % path)
return path
def _default_runner(cmd, timeout=None):
return subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout)
def _run(runner, cmd):
"""调 runner:优先只传 cmd,假 runner 只接受 cmd 时即走此路;
接受 (cmd, timeout) 的 runner 也兼容(TypeError 时回退)。"""
try:
return runner(cmd)
except TypeError:
return runner(cmd, timeout=None)
def resolve_repo(repo_url, repos_dir, project_id, runner=None):
"""把 repo_url 解析为本地检出路径,返回该路径。
目录不存在:git clone repo_url 到该目录;clone 失败抛 RepoError,
文本含“需要仓库访问凭证,控制面不存仓库凭证”的提示(私有仓
库、仓库不存在、无权访问都走同一文案,不猜测、不尝试凭证)。
目录已存在:须是 git 仓库(.git 目录或文件存在)且 remote
origin 的 URL 与 repo_url 一致;不是 git 仓库、取不到 origin、
origin 不一致时都抛 RepoError,绝不强制改 remote、不覆盖。
一致时 git fetch origin:失败抛 RepoError,不静默用旧代码;
成功后不 checkout/pull/reset/clean,工作树由 host.py 处理。
"""
url = validate_repo_url(repo_url)
run = runner if runner is not None else _default_runner
target = checkout_dir(repos_dir, project_id)
os.makedirs(repos_dir, exist_ok=True)
if not os.path.exists(target):
result = _run(run, ["git", "clone", url, target])
if getattr(result, "returncode", 1) != 0:
err = (getattr(result, "stderr", "") or "").strip()
raise RepoError(
"git clone 失败: %s;若为私有仓库,需要仓库访问凭证,"
"控制面不存仓库凭证,请先在可访问该仓库的机器上完成"
"检出或改用公开仓库" % (err or url))
return target
git_path = os.path.join(target, ".git")
if not (os.path.isdir(git_path) or os.path.isfile(git_path)):
raise RepoError(
"检出目录已存在但不是 git 仓库,拒绝覆盖: %s" % target)
result = _run(run, ["git", "-C", target, "remote", "get-url", "origin"])
if getattr(result, "returncode", 1) != 0:
raise RepoError(
"检出目录 %s 取不到 remote origin,拒绝派发" % target)
origin = (getattr(result, "stdout", "") or "").strip()
if origin != url:
raise RepoError(
"检出目录 %s 的 remote origin(%s)与项目 repo_url(%s)"
"不一致,拒绝派发(绝不强制改 remote)"
% (target, origin, url))
fetched = _run(run, ["git", "-C", target, "fetch", "origin"])
if getattr(fetched, "returncode", 1) != 0:
err = (getattr(fetched, "stderr", "") or "").strip()
raise RepoError(
"git fetch origin 失败,拒绝用旧代码派发: %s"
% (err or target))
return target
def check_repo_access(repo_url, runner=None, timeout=30):
"""预检仓库是否可读:只跑 `git ls-remote <url> HEAD`。
返回 (ok, detail):ok 时 detail 为 HEAD 哈希;失败时
detail 为原因(不含凭证)。runner 缺省走真实 git,超时
由 subprocess 的 timeout 控(假 runner 不接受 timeout 时
_run 已回退);runner 抛 TimeoutExpired 时同样返回失败,
不抛异常——预检失败绝不能炸掉调用方。
https 形在主机上失败时 detail 末尾追加 ssh 形提示,
因为本机 https 到 GitHub 常被出口策略挡、ssh 才通。
"""
try:
url = validate_repo_url(repo_url)
except ValueError as exc:
return (False, str(exc))
run = runner if runner is not None else _default_runner
def _ls_remote(cmd):
try:
return run(cmd, timeout=timeout)
except TypeError:
return run(cmd)
try:
result = _ls_remote(["git", "ls-remote", url, "HEAD"])
except Exception as exc: # noqa: BLE001 - 超时、缺 git 等一律失败
detail = "git ls-remote 失败: %s" % (exc,)
if url.startswith("https://"):
detail += ";本机 https 到 GitHub 常被出口策略挡,"
detail += "请改走 git@ 形再测"
return (False, detail)
if getattr(result, "returncode", 1) != 0:
err = (getattr(result, "stderr", "") or "").strip()
detail = "git ls-remote 失败: %s" % (err or url)
if url.startswith("https://"):
detail += ";私有仓库请改走 git@ 形并给 codexsvc 配"
detail += "只读 deploy key,控制面不存仓库凭证"
return (False, detail)
head = (getattr(result, "stdout", "") or "").split()
return (True, head[0] if head else "")
def check_repo_access_many(repo_urls, runner=None, timeout=30):
"""批量预检:逐个调 check_repo_access,返回 {url: (ok, detail)}。
顺序与输入一致;单个失败不中断其余(批量预检的意义就是一次
看清哪些仓通、哪些不通)。空列表返回空 dict。
"""
out = {}
for url in repo_urls or []:
out[url] = check_repo_access(url, runner=runner, timeout=timeout)
return out
def _main(argv):
if len(argv) >= 3 and argv[1] == "check-many":
results = check_repo_access_many(argv[2:])
failed = 0
for url, (ok, detail) in results.items():
if ok:
print("ok %s HEAD=%s" % (url, detail))
else:
failed += 1
print("fail %s: %s" % (url, detail))
return 1 if failed else 0
if len(argv) != 3 or argv[1] != "check":
sys.stderr.write(
"用法: python3 -m cloud.repos check <repo_url>\n"
" python3 -m cloud.repos check-many <repo_url>...\n")
return 2
ok, detail = check_repo_access(argv[2])
if ok:
print("ok HEAD=%s" % detail)
return 0
print("fail: %s" % detail)
return 1
if __name__ == "__main__":
raise SystemExit(_main(sys.argv))