Files
aaPanel/class_v2/git_tools_v2.py
T
2026-08-27 12:18:36 +08:00

1755 lines
70 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.
# coding: utf-8
"""Git manager v2: public/SSH/token auth, revisions, deployment and rollback."""
import json
import os
import re
import shlex
import shutil
import stat
import subprocess
import tempfile
import time
import urllib.parse
import uuid
from contextlib import contextmanager
from datetime import datetime
import public
from git_auth import decrypt_git_token, encrypt_git_token
AUTH_TYPES = ("public", "ssh_key", "token")
GIT_CACHE_ROOT = "/www/server/panel/data/git_cache"
DEFAULT_IGNORE_PATHS = ["/.env", "/storage", "/bootstrap/cache"]
def _request_data(get):
if isinstance(get, dict):
return dict(get)
if hasattr(get, "get_items"):
return dict(get.get_items())
return dict(get.__dict__)
def _has_field(get, name):
if isinstance(get, dict):
return name in get
if hasattr(get, "get_items"):
return name in get.get_items()
return name in getattr(get, "__dict__", {})
def _auth_api(value):
value = str(value or "").strip().lower()
return "ssh_key" if value in ("ssh", "ssh_key") else (value or "public")
def _auth_db(value):
value = _auth_api(value)
return "ssh" if value == "ssh_key" else value
def _transport(repository):
repository = str(repository or "").strip()
if re.match(r"^[A-Za-z0-9._-]+@[^\s:/]+:.+$", repository):
return "ssh"
parsed = urllib.parse.urlsplit(repository)
if parsed.scheme == "ssh" and parsed.hostname:
return "ssh"
if parsed.scheme in ("http", "https") and parsed.netloc:
return parsed.scheme
return "unknown"
def _validate_key(key_path):
key_path = os.path.realpath(str(key_path or "").strip())
ssh_root = os.path.realpath("/root/.ssh")
try:
inside_root = os.path.commonpath((ssh_root, key_path)) == ssh_root
except ValueError:
inside_root = False
if not key_path or not inside_root or key_path.endswith(".pub"):
raise ValueError("Git SSH key must be a private key under /root/.ssh")
if not os.path.isfile(key_path) or not os.path.isfile(key_path + ".pub"):
raise ValueError("Git SSH private key does not exist")
public_key = str(public.readFile(key_path + ".pub") or "").strip()
if not public_key.startswith("ssh-ed25519 "):
raise ValueError("Only ED25519 SSH keys are supported")
return key_path
def _normalize(data, stored=None, token_required=True):
data = dict(data or {})
stored = dict(stored or {})
repository = str(
data.get("repo_url", data.get("repo", stored.get("repo", "")))
).strip()
raw_auth_type = data.get("auth_type", stored.get("auth_type", ""))
if not raw_auth_type and data.get("key_path"):
raw_auth_type = "ssh_key"
elif not raw_auth_type and (data.get("username") or data.get("token")):
raw_auth_type = "token"
auth_type = _auth_api(raw_auth_type)
branch = str(data.get("branch", stored.get("branch", ""))).strip()
if not repository or any(char.isspace() for char in repository):
raise ValueError("Git repository URL is invalid")
if auth_type not in AUTH_TYPES:
raise ValueError("Unsupported Git authentication type")
transport = _transport(repository)
parsed = urllib.parse.urlsplit(repository) if "://" in repository else None
# SSH 传输中 user@ 是连接用户名(如 ssh://git@host:port/path),属正常格式;
# 仅 HTTP/HTTPS URL 内嵌用户名/密码才是凭据泄漏,需拦截。
if parsed and transport in ("http", "https") and (
parsed.username is not None or parsed.password is not None
):
raise ValueError("Do not include credentials in the Git repository URL")
config = {"repo": repository, "branch": branch, "auth_type": auth_type}
if auth_type == "public":
if transport not in ("http", "https"):
raise ValueError("Public repositories must use HTTP or HTTPS")
elif auth_type == "ssh_key":
if transport != "ssh":
# 兼容旧版历史数据: 仓库为 HTTPS 却绑定了 SSH key。
# SSH key 对 HTTPS 传输无效,降级到public访问
config["auth_type"] = "public"
else:
config["key_path"] = _validate_key(
data.get("key_path", stored.get("key_path", ""))
)
else:
if transport != "https":
raise ValueError("Token authentication requires an HTTPS repository URL")
username = str(data.get("username", stored.get("username", ""))).strip()
token = str(data.get("token", ""))
if not token and stored.get("oauth_access_token"):
try:
token = decrypt_git_token(stored["oauth_access_token"])
except Exception:
raise ValueError("The saved Git personal access token cannot be decrypted")
if not username:
raise ValueError("Git username is required")
if token_required and not token:
raise ValueError("Git personal access token is required")
config.update({"username": username, "token": token})
return config
def _site_config(site_id):
record = public.M("git_sites_auth").where("site_id=?", (int(site_id),)).find()
if not record:
raise ValueError("This website is not bound to a Git repository")
return _normalize({}, record), record
def _site_ignore_paths(site_id):
"""读取网站的忽略路径列表;未配置时返回默认忽略路径。"""
try:
raw = public.M("git_sites_auth").where(
"site_id=?", (int(site_id),)
).getField("ignore_paths")
if not raw:
return list(DEFAULT_IGNORE_PATHS)
parsed = json.loads(raw)
if not isinstance(parsed, list):
return list(DEFAULT_IGNORE_PATHS)
return [str(item).strip() for item in parsed if str(item).strip()]
except Exception:
return list(DEFAULT_IGNORE_PATHS)
def _ignore_static_prefix(pattern):
"""取 ignore pattern 的非通配静态前缀,用于部署保留与可写判断。
'docs/**' -> 'docs''/.env' -> '.env''storage/' -> 'storage''bootstrap/cache' -> 'bootstrap/cache'
"""
pattern = str(pattern or "").strip().strip('/')
if not pattern:
return ""
prefix = []
for seg in pattern.split('/'):
if any(ch in seg for ch in '*?['):
break
prefix.append(seg)
return '/'.join(prefix)
@contextmanager
def _environment(config):
environment = os.environ.copy()
environment["GIT_TERMINAL_PROMPT"] = "0"
# 网站目录 git 仓库可能属于 www 用户,面板进程执行 git 会触发 dubious ownership
# 统一信任所有目录,避免部署/拉取时 safe.directory 报错
environment["GIT_SAFE_DIRECTORY"] = "*"
# git 对 HTTP 传输默认无超时,慢/断流时可能无限等待。限制低速率连接快速失败。
environment["GIT_HTTP_LOW_SPEED_LIMIT"] = "1000"
environment["GIT_HTTP_LOW_SPEED_TIME"] = "60"
auth_dir = ""
if config["auth_type"] == "ssh_key":
environment["HOME"] = "/root"
environment["GIT_SSH_COMMAND"] = (
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes "
"-o StrictHostKeyChecking=accept-new "
"-o UserKnownHostsFile=/root/.ssh/known_hosts"
).format(shlex.quote(config["key_path"]))
elif config["auth_type"] == "token":
auth_dir = tempfile.mkdtemp(prefix="aapanel_git_auth_")
os.chmod(auth_dir, 0o700)
username_file = os.path.join(auth_dir, "username")
token_file = os.path.join(auth_dir, "token")
askpass_file = os.path.join(auth_dir, "askpass.sh")
for path, value in (
(username_file, config["username"]),
(token_file, config["token"]),
):
with open(path, "w", encoding="utf-8") as handle:
handle.write(value)
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
with open(askpass_file, "w", encoding="utf-8") as handle:
handle.write(
"#!/bin/sh\ncase \"$1\" in *sername*) cat ''{}'';; *) cat ''{}'';; esac\n".format(
username_file, token_file
)
)
os.chmod(askpass_file, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
environment["GIT_ASKPASS"] = askpass_file
environment["GIT_ASKPASS_REQUIRE"] = "force"
try:
yield environment
finally:
if auth_dir:
shutil.rmtree(auth_dir, ignore_errors=True)
def _redact(text, config):
result = str(text or "")
token = str(config.get("token", ""))
if token:
result = result.replace(token, "***")
return result
def _run(args, config, cwd=None, timeout=180, check=True):
command = ["git"]
# 网站目录 git 仓库可能属于 www 用户,面板进程执行 git 会触发 dubious ownership
command.extend(["-c", "safe.directory=*"])
if _transport(config.get("repo")) in ("http", "https"):
command.extend(["-c", "http.version=HTTP/1.1"])
command.extend([str(item) for item in args])
with _environment(config) as environment:
try:
process = subprocess.run(
command, cwd=cwd, env=environment,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
# subprocess.run 超时后已 kill 主进程;给出清晰错误而非永久等待
raise ValueError(
"Git command timed out after {} seconds: {}".format(
timeout, " ".join(str(item) for item in args)[-2000:]
)
)
stdout = _redact(process.stdout, config)
stderr = _redact(process.stderr, config)
if check and process.returncode != 0:
error_text = stderr.strip() or stdout.strip() or "Git command failed"
raise ValueError(error_text[-4000:])
return process.returncode, stdout, stderr
def _remote_refs(config):
# 超时时间改30s
_, stdout, _ = _run(
["ls-remote", "--symref", config["repo"], "HEAD", "refs/heads/*"],
config, timeout=30,
)
default_branch = ""
branches = []
for line in stdout.splitlines():
if line.startswith("ref: refs/heads/") and line.endswith("\tHEAD"):
default_branch = line.split("refs/heads/", 1)[1].split("\t", 1)[0]
elif "refs/heads/" in line:
branch = line.split("refs/heads/", 1)[1].strip()
if branch and branch not in branches:
branches.append(branch)
branches.sort()
return default_branch, branches
def _clone(config, branch, parent_dir, shallow=False):
target = os.path.join(parent_dir, "repository")
clone_args = [
"clone", "--no-checkout", "--single-branch", "--branch", branch,
]
if shallow:
clone_args.extend(["--depth", "1"])
clone_args.extend([config["repo"], target])
_run(clone_args, config, timeout=600)
if not os.path.isdir(os.path.join(target, ".git")):
raise ValueError("Git clone did not create a repository")
return target
def _resolve(repo_path, config, branch, commit_hash="", remote_ref=None):
remote_ref = remote_ref or "refs/remotes/origin/{}".format(branch)
target = str(commit_hash or "").strip() or remote_ref
_, stdout, _ = _run(
["rev-parse", "{}^{{commit}}".format(target)], config, cwd=repo_path
)
full_commit = stdout.strip()
if not re.match(r"^[0-9a-fA-F]{40}$", full_commit):
raise ValueError("Unable to resolve the selected commit")
if commit_hash:
return_code, _, _ = _run(
["merge-base", "--is-ancestor", full_commit, remote_ref],
config, cwd=repo_path, check=False,
)
if return_code != 0:
raise ValueError(
"The selected commit does not belong to origin/{}".format(branch)
)
return full_commit
def _commit_info(repo_path, config, commit_hash):
fmt = "%H%x1f%h%x1f%an%x1f%ae%x1f%ct%x1f%s"
_, stdout, _ = _run(
["show", "-s", "--format={}".format(fmt), commit_hash],
config, cwd=repo_path,
)
parts = stdout.strip().split("\x1f", 5)
if len(parts) != 6:
raise ValueError("Failed to read Git commit information")
return {
"commit_hash": parts[0], "commit_hash_short": parts[1],
"author_name": parts[2], "author_email": parts[3],
"committed_time": datetime.fromtimestamp(int(parts[4])).strftime(
"%Y-%m-%d %H:%M:%S"
),
"message": parts[5].strip(),
}
def _clone_mirror(self, mirror, config, branch):
"""clone 一份裸仓库;失败时清理目录并抛错。"""
try:
# 用 --bare 而非 --mirrorref 平铺到 refs/heads/<branch>
# 配合 --single-branch + --filter 在真实 HTTPS/protocol v2 下行为一致。
_run(
[
"clone", "--bare", "--single-branch", "--branch", branch,
"--filter=blob:none", config["repo"], mirror,
],
config, timeout=600,
)
except Exception:
shutil.rmtree(mirror, ignore_errors=True)
raise
def _mirror_has_ref(self, mirror, config, branch_ref):
"""校验 mirror 中目标 ref 是否存在(不存在视为坏缓存)。"""
try:
_, stdout, _ = _run(
["rev-parse", "--verify", "--quiet", "{}^{{commit}}".format(branch_ref)],
config, cwd=mirror, check=False,
)
except Exception:
return False
return bool(stdout.strip())
def _mirror_repository(self, site_id, config, branch, refresh=True):
"""返回持久化的裸仓库 mirror(用于提交列表/详情查询),按需 fetch 增量更新。
缓存目录:{GIT_CACHE_ROOT}/<site_id>/<branch>.git
refs 布局:mirror 仓库中远程分支位于 refs/heads/<branch>。
自愈:目录无效 / fetch 失败 / 目标 ref 缺失时删除重建,避免坏缓存导致查询报错。
"""
cache_dir = os.path.join(GIT_CACHE_ROOT, str(int(site_id)))
mirror = os.path.join(cache_dir, "{}.git".format(branch.replace("/", "__")))
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
import fcntl
lock_handle = open(
"/tmp/aapanel_git_cache_{}_{}.lock".format(
int(site_id), branch.replace("/", "__")
),
"a+",
)
try:
fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX)
branch_ref = "refs/heads/{}".format(branch)
# 有效标志是存在 objects/(真正的裸仓库);仅 isdir(mirror) 可能命中坏缓存
if not os.path.isdir(os.path.join(mirror, "objects")):
shutil.rmtree(mirror, ignore_errors=True)
_clone_mirror(self, mirror, config, branch)
else:
if refresh:
try:
# 完整 refspec 而非短名,避免 --prune 误删本地 ref
_run(
[
"fetch", "--filter=blob:none", "origin",
"{}:{}".format(branch_ref, branch_ref), "--prune",
],
config, cwd=mirror, timeout=300,
)
except Exception:
# fetch 失败:缓存可能损坏,删掉重 clone
shutil.rmtree(mirror, ignore_errors=True)
_clone_mirror(self, mirror, config, branch)
# 校验目标 ref 存在;缺失则重建(覆盖历史坏缓存)
if not _mirror_has_ref(self, mirror, config, branch_ref):
shutil.rmtree(mirror, ignore_errors=True)
_clone_mirror(self, mirror, config, branch)
finally:
try:
fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
finally:
lock_handle.close()
return mirror
def _restore_preserved_paths(backup_path, site_path, preserve_paths):
"""部署全量替换后,把旧网站目录中需要保留的路径复制回新网站目录。
支持 gitignore 风格 pattern(如 'docs/**'),按静态前缀保留整棵目录。
"""
seen = set()
for pattern in preserve_paths:
static = _ignore_static_prefix(pattern)
if not static or static in seen:
continue
seen.add(static)
old_path = os.path.join(backup_path, static)
new_path = os.path.join(site_path, static)
if os.path.isfile(old_path):
os.makedirs(os.path.dirname(new_path) or site_path, exist_ok=True)
shutil.copy2(old_path, new_path)
elif os.path.isdir(old_path):
if os.path.exists(new_path):
if os.path.isdir(new_path) and not os.path.islink(new_path):
shutil.rmtree(new_path)
else:
os.remove(new_path)
os.makedirs(os.path.dirname(new_path) or site_path, exist_ok=True)
shutil.copytree(old_path, new_path, dirs_exist_ok=True)
def _restore_existing_files(backup_path, site_path):
"""全量替换后,把旧网站目录中「新代码没有的」已有文件保留回新网站目录。
同名路径以新代码为准(旧文件不覆盖新代码),只补充缺失文件/目录,
避免连接部署时把用户在网站目录内手动放置的文件整体替换掉。
跳过 .git(保留新克隆仓库元数据)与 .user.ini(已单独恢复并锁定)。
"""
if not os.path.isdir(backup_path):
return
for root, dirs, files in os.walk(backup_path):
rel_root = os.path.relpath(root, backup_path)
if rel_root == ".":
rel_root = ""
dirs[:] = [d for d in dirs if d != ".git"]
if rel_root:
new_dir = os.path.join(site_path, rel_root)
if not os.path.exists(new_dir):
os.makedirs(new_dir, exist_ok=True)
for name in files:
if rel_root == "" and name == ".user.ini":
continue
old_file = os.path.join(root, name)
new_file = (
os.path.join(site_path, rel_root, name)
if rel_root else os.path.join(site_path, name)
)
if os.path.exists(new_file):
continue
os.makedirs(os.path.dirname(new_file) or site_path, exist_ok=True)
shutil.copy2(old_file, new_file)
def _merge_tree(src, dst, ignore_paths):
"""把 src 目录内容合并进 dst:覆盖同名/新增文件,不删除 dst 中已有文件。
- 跳过 .git(不覆盖网站已有 git 元数据)与 .user.ini
- 跳过 ignore_paths 前缀(本地配置/可写目录不被新代码覆盖)
- 优先 rsync(增量、保留软链接与权限);无 rsync 时回退 Python 遍历
- 软链接用 os.symlink 重建,不复制目标内容
合并失败(含磁盘满)时抛 OSError,dst 已有文件不受影响。
"""
os.makedirs(dst, exist_ok=True)
prefixes = [
_ignore_static_prefix(item) for item in (ignore_paths or [])
]
prefixes = [p for p in prefixes if p and p != "."]
def _ignored(rel):
for prefix in prefixes:
if rel == prefix or rel.startswith(prefix + "/"):
return True
return False
if shutil.which("rsync"):
cmd = ["rsync", "-a", "--exclude=.git", "--exclude=.user.ini"]
for prefix in prefixes:
# 带根级斜杠,与下方 Python 回退的根级前缀匹配保持一致
cmd.extend(["--exclude", "/" + prefix])
# src 尾 / 表示同步目录内容;不传 --delete,保留 dst 中多出的文件
cmd.extend([src.rstrip("/") + "/", dst.rstrip("/") + "/"])
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=1800,
)
if proc.returncode == 0:
return
raise OSError(
"Failed to merge code into the website directory: {}".format(
proc.stderr.strip() or "rsync exit {}".format(proc.returncode)
)
)
# Python 回退:遍历 src,覆盖同名/新增,跳过 .git/.user.ini/ignore_paths,软链接重建
for root, dirs, files in os.walk(src):
rel_root = os.path.relpath(root, src)
if rel_root == ".":
rel_root = ""
dirs[:] = [d for d in dirs if d not in (".git", ".user.ini")]
for name in files:
if name == ".user.ini":
continue
src_file = os.path.join(root, name)
dst_file = (
os.path.join(dst, rel_root, name)
if rel_root else os.path.join(dst, name)
)
rel = (os.path.join(rel_root, name) if rel_root else name).replace(os.sep, "/")
if _ignored(rel):
continue
os.makedirs(os.path.dirname(dst_file) or dst, exist_ok=True)
if os.path.islink(src_file):
if os.path.islink(dst_file) or os.path.exists(dst_file):
try:
os.remove(dst_file)
except OSError:
pass
os.symlink(os.readlink(src_file), dst_file)
else:
shutil.copy2(src_file, dst_file)
def get_remote_branches(self, get):
try:
site_id = int(get.get("site_id", 0) or 0)
stored = {}
if site_id:
stored = public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).find()
if not stored:
raise ValueError("This website is not bound to a Git repository")
config = _normalize(_request_data(get), stored)
default_branch, branches = _remote_refs(config)
public.set_module_logs("Git-Tools", "get_remote_branches")
return public.return_message(0, 0, {
"auth_type": config["auth_type"],
"default_branch": default_branch,
"branches": branches,
})
except Exception as exc:
return public.return_message(
-1, 0, "Connection to GIT failed: {}".format(exc)
)
def _current_branch(self, site_path):
if not site_path or not os.path.isdir(os.path.join(site_path, ".git")):
public.print_log("111")
return ""
try:
# 网站目录 git 仓库可能属于 www 用户,面板以 root 执行会触发
# dubious ownership;用 -c safe.directory 单次注入,不改全局配置。
_, stdout, _ = _run(
[
"-c", "safe.directory={}".format(site_path),
"rev-parse", "--abbrev-ref", "HEAD",
],
{"repo": "", "auth_type": "public"}, cwd=site_path,
)
public.print_log(" stdout ---{}".format(stdout))
return "" if stdout.strip() == "HEAD" else stdout.strip()
except Exception as exc:
public.print_log("222 {}".format(exc))
return ""
def get_remote_commits(self, get):
try:
site_id = int(get.get("site_id", 0) or 0)
branch = str(get.get("branch", "")).strip()
if not site_id or not branch:
raise ValueError("site_id and branch are required")
page = max(1, int(get.get("p", 1) or 1))
limit = min(100, max(1, int(get.get("limit", 20) or 20)))
refresh = str(get.get("refresh", "1") or "1").lower() in ("1", "true", "yes")
config, _ = _site_config(site_id)
repo_path = _mirror_repository(self, site_id, config, branch, refresh)
remote_ref = "refs/heads/{}".format(branch)
_, count_stdout, _ = _run(
["rev-list", "--count", remote_ref], config, cwd=repo_path
)
fmt = "%H%x1f%h%x1f%an%x1f%ae%x1f%ct%x1f%s%x1e"
_, log_stdout, _ = _run(
[
"log", remote_ref,
"--skip={}".format((page - 1) * limit),
"--max-count={}".format(limit),
"--format={}".format(fmt),
],
config, cwd=repo_path,
)
site_path = public.M("sites").where(
"id=?", (site_id,)
).getField("path")
current_commit = ""
if site_path and os.path.isdir(os.path.join(site_path, ".git")):
_, current_stdout, _ = _run(
["rev-parse", "HEAD"],
{"repo": "", "auth_type": "public"},
cwd=site_path, check=False,
)
current_commit = current_stdout.strip()
commits = []
for record in log_stdout.split("\x1e"):
parts = record.strip().split("\x1f", 5)
if len(parts) != 6:
continue
commits.append({
"commit_hash": parts[0],
"commit_hash_short": parts[1],
"author_name": parts[2],
"author_email": parts[3],
"committed_time": datetime.fromtimestamp(
int(parts[4])
).strftime("%Y-%m-%d %H:%M:%S"),
"message": parts[5].strip(),
"is_deployed": parts[0] == current_commit,
})
public.set_module_logs("Git-Tools", "get_remote_commits")
return public.return_message(0, 0, {
"branch": branch,
"remote_ref": "origin/{}".format(branch),
"current_branch": _current_branch(self, site_path),
"current_commit": current_commit,
"data": commits,
"page": {
"p": page, "limit": limit,
"total": int(count_stdout.strip() or 0),
},
})
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def get_commit_detail(self, get):
try:
site_id = int(get.get("site_id", 0) or 0)
branch = str(get.get("branch", "")).strip()
commit_hash = str(get.get("commit_hash", "")).strip()
if not site_id or not branch or not commit_hash:
raise ValueError("site_id, branch and commit_hash are required")
config, _ = _site_config(site_id)
repo_path = _mirror_repository(self, site_id, config, branch, refresh=False)
branch_ref = "refs/heads/{}".format(branch)
full_commit = _resolve(repo_path, config, branch, commit_hash, branch_ref)
info = _commit_info(repo_path, config, full_commit)
_, numstat, _ = _run(
["show", "--format=", "--numstat", full_commit],
config, cwd=repo_path,
)
_, name_status, _ = _run(
[
"diff-tree", "--root", "--no-commit-id",
"--name-status", "-r", full_commit,
],
config, cwd=repo_path,
)
status_names = {
"A": "added", "M": "modified", "D": "deleted",
"R": "renamed", "C": "copied", "T": "type_changed",
}
file_status = {}
for line in name_status.splitlines():
parts = line.split("\t")
if len(parts) < 2:
continue
code = parts[0][:1]
file_status[parts[-1]] = status_names.get(code, "modified")
files = []
additions = deletions = diff_used = 0
diff_limit = 512 * 1024
for line in numstat.splitlines():
parts = line.split("\t", 2)
if len(parts) != 3:
continue
add_value = int(parts[0]) if parts[0].isdigit() else 0
del_value = int(parts[1]) if parts[1].isdigit() else 0
additions += add_value
deletions += del_value
binary = parts[0] == "-" or parts[1] == "-"
patch = ""
if not binary and diff_used < diff_limit:
_, patch, _ = _run(
["show", "--format=", "--no-ext-diff", full_commit, "--", parts[2]],
config, cwd=repo_path,
)
patch = patch[:min(64 * 1024, diff_limit - diff_used)]
diff_used += len(patch)
files.append({
"path": parts[2],
"status": file_status.get(parts[2], "modified"),
"additions": add_value, "deletions": del_value,
"binary": binary, "patch": patch,
})
info.update({
"branch": branch,
"stats": {
"files_changed": len(files),
"additions": additions, "deletions": deletions,
},
"files": files,
"diff_truncated": diff_used >= diff_limit,
})
public.set_module_logs("Git-Tools", "get_commit_detail")
return public.return_message(0, 0, info)
except Exception as exc:
return public.return_message(-1, 0, str(exc))
@contextmanager
def _deploy_lock(site_id):
import fcntl
lock_handle = open(
"/tmp/aapanel_git_deploy_{}.lock".format(int(site_id)), "a+"
)
try:
try:
fcntl.flock(
lock_handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
)
except BlockingIOError:
raise ValueError(
"A Git deployment for this website is already running"
)
yield
finally:
try:
fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
finally:
lock_handle.close()
def _prune_records(site_id):
number_copies = public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).getField("number_copies") or 5
records = public.M("site_deploy_status").where(
"site_id=?", (site_id,)
).order("id DESC").select() or []
for record in records[int(number_copies):]:
if int(record.get("deploy_status", 0) or 0) == 1:
continue
for field in ("script_path", "log_path"):
path = record.get(field, "")
if path and os.path.isfile(path):
try:
os.remove(path)
except OSError:
pass
public.M("site_deploy_status").where("id=?", (record["id"],)).delete()
def _write_record(
site, status, branch, operation_type, rollback_from_id,
started_at, commit_info=None, error_message="", log_lines=None
):
commit_info = dict(commit_info or {})
deploy_time = datetime.fromtimestamp(started_at).strftime(
"%Y-%m-%d %H:%M:%S"
)
log_file = os.path.join(
"/www/wwwlogs",
"{}_{}_git_deploy.log".format(site["name"], uuid.uuid4().hex[:12]),
)
os.makedirs(os.path.dirname(log_file), mode=0o755, exist_ok=True)
public.writeFile(log_file, "\n".join(log_lines or []) + "\n")
record_id = public.M("site_deploy_status").add(
"site_id,status,deploy_status,script_path,log_path,deployment_time,"
"execut_time,commit_hash,commit_hash_short,msg,author_name,"
"committed_time,branch,operation_type,rollback_from_id,error_message",
(
site["id"], status, 0, "", log_file,
deploy_time, round(time.time() - started_at, 2),
commit_info.get("commit_hash", ""),
commit_info.get("commit_hash_short", ""),
commit_info.get("message", ""),
commit_info.get("author_name", ""),
commit_info.get("committed_time", ""),
branch, operation_type, int(rollback_from_id or 0), error_message,
),
)
if not isinstance(record_id, int):
raise ValueError("Failed to save the Git deployment record")
if status == 1:
active_result = public.M("site_deploy_status").execute(
"UPDATE site_deploy_status "
"SET deploy_status=CASE WHEN id=? THEN 1 ELSE 0 END "
"WHERE site_id=?",
(record_id, site["id"]),
)
if not isinstance(active_result, int):
public.M("site_deploy_status").where(
"id=?", (record_id,)
).delete()
raise ValueError("Failed to activate the Git deployment record")
try:
_prune_records(site["id"])
except Exception:
pass
return record_id
def _configure_repository(self, site_path, config):
safe_env = os.environ.copy()
safe_env["HOME"] = "/root"
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", site_path],
env=safe_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=30, check=True,
)
if config["auth_type"] == "ssh_key":
ssh_command = (
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes "
"-o StrictHostKeyChecking=accept-new "
"-o UserKnownHostsFile=/root/.ssh/known_hosts"
).format(shlex.quote(config["key_path"]))
_run(
["config", "core.sshCommand", ssh_command],
config, cwd=site_path,
)
else:
_run(
["config", "--unset-all", "core.sshCommand"],
config, cwd=site_path, check=False,
)
def _remove_site_tree(path):
if not path or not os.path.exists(path):
return
user_ini = os.path.join(path, ".user.ini")
chattr = shutil.which("chattr")
if chattr and os.path.isfile(user_ini):
subprocess.run(
[chattr, "-i", user_ini],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=15, check=False,
)
shutil.rmtree(path)
def _deploy_revision(
self, site_id, branch, commit_hash="", operation_type="git_latest",
rollback_from_id=0, config_override=None, preserve_existing=False
):
started_at = time.time()
site = public.M("sites").where("id=?", (int(site_id),)).find()
if not site:
raise ValueError("The website does not exist")
site_path = os.path.realpath(str(site.get("path", "") or ""))
if not site_path or site_path in ("/", "/www", "/www/wwwroot"):
raise ValueError("The website path is invalid")
config = config_override or _site_config(site_id)[0]
old_branch = public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).getField("branch") or ""
branch = str(branch or config.get("branch", "")).strip()
if not branch:
raise ValueError("Git branch is required")
parent_dir = os.path.dirname(site_path)
os.makedirs(parent_dir, exist_ok=True)
temporary_root = backup_path = ""
directory_swapped = False
commit_info = {}
log_lines = [
"=============Git Deployment START==============",
"site: {}".format(site.get("name", "")),
"path: {}".format(site_path),
"branch: {}".format(branch),
"operation: {}".format(operation_type),
]
try:
with _deploy_lock(site_id):
temporary_root = tempfile.mkdtemp(
prefix=".aapanel_git_deploy_", dir=parent_dir
)
shallow = not commit_hash
# 克隆项目
target_path = _clone(config, branch, temporary_root, shallow)
full_commit = _resolve(
target_path, config, branch, commit_hash
)
_run(["checkout", "--detach", full_commit], config, cwd=target_path)
_run(["reset", "--hard", full_commit], config, cwd=target_path)
commit_info = _commit_info(target_path, config, full_commit)
log_lines.append("commit: {}".format(full_commit))
backup_path = "{}.aapanel_git_backup_{}".format(
site_path, uuid.uuid4().hex[:12]
)
# 重命名网站目录为备份
if os.path.exists(site_path):
os.rename(site_path, backup_path)
try:
# 克隆项目重命名网站目录
os.rename(target_path, site_path)
directory_swapped = True
old_user_ini = os.path.join(backup_path, ".user.ini")
new_user_ini = os.path.join(site_path, ".user.ini")
if os.path.isfile(old_user_ini):
chattr = shutil.which("chattr")
if chattr:
subprocess.run(
[chattr, "-i", old_user_ini],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=15, check=False,
)
shutil.copy2(old_user_ini, new_user_ini)
if chattr:
subprocess.run(
[chattr, "+i", new_user_ini],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=15, check=False,
)
ignore_paths = _site_ignore_paths(site_id)
# 把旧网站目录中需要保留的路径复制回新网站目录
_restore_preserved_paths(backup_path, site_path, ignore_paths)
# 保留已有文件:补充新代码没有的旧文件,同名以新代码为准
if preserve_existing:
_restore_existing_files(backup_path, site_path)
_configure_repository(self, site_path, config)
# 修正权限 目录 755,文件 644
self._fix_file_permission(site_path, ignore_paths)
except Exception:
raise
log_lines.extend([
"status: success",
"=============Git Deployment END================",
])
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("branch", branch)
deploy_id = _write_record(
site, 1, branch, operation_type, rollback_from_id,
started_at, commit_info, "", log_lines,
)
if backup_path and os.path.exists(backup_path):
shutil.rmtree(backup_path, ignore_errors=True)
backup_path = ""
self._project_restart(site_id)
return {
"deploy_id": deploy_id, "site_id": int(site_id),
"branch": branch,
"commit_hash": commit_info["commit_hash"],
"commit_hash_short": commit_info["commit_hash_short"],
"operation_type": operation_type, "status": 1,
"execut_time": round(time.time() - started_at, 2),
}
except Exception as exc:
error_message = _redact(str(exc), config)
if backup_path and os.path.exists(backup_path):
if os.path.exists(site_path):
_remove_site_tree(site_path)
os.rename(backup_path, site_path)
restored_user_ini = os.path.join(site_path, ".user.ini")
chattr = shutil.which("chattr")
if chattr and os.path.isfile(restored_user_ini):
subprocess.run(
[chattr, "+i", restored_user_ini],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=15, check=False,
)
elif directory_swapped and os.path.exists(site_path):
_remove_site_tree(site_path)
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("branch", old_branch)
log_lines.extend([
"status: failed", "error: {}".format(error_message),
"=============Git Deployment END================",
])
try:
_write_record(
site, 0, branch, operation_type, rollback_from_id,
started_at, commit_info, error_message, log_lines,
)
except Exception:
pass
raise ValueError(error_message)
finally:
if temporary_root:
shutil.rmtree(temporary_root, ignore_errors=True)
def _deploy_merge(
self, site_id, branch, commit_hash="", operation_type="git_latest",
config_override=None,
):
"""克隆到临时目录后合并进网站目录
- 不删除网站目录已有文件(同名以新代码为准),合并失败不影响原有文件
- 跳过 ignore_paths(本地配置/可写目录)与 .user.ini
"""
started_at = time.time()
site = public.M("sites").where("id=?", (int(site_id),)).find()
if not site:
raise ValueError("The website does not exist")
site_path = os.path.realpath(str(site.get("path", "") or ""))
if not site_path or site_path in ("/", "/www", "/www/wwwroot"):
raise ValueError("The website path is invalid")
config = config_override or _site_config(site_id)[0]
branch = str(branch or config.get("branch", "")).strip()
if not branch:
raise ValueError("Git branch is required")
commit_hash = str(commit_hash or "").strip()
parent_dir = os.path.dirname(site_path)
os.makedirs(parent_dir, exist_ok=True)
temporary_root = ""
commit_info = {}
log_lines = [
"=============Git Deployment START==============",
"site: {}".format(site.get("name", "")),
"path: {}".format(site_path),
"branch: {}".format(branch),
"operation: {}".format(operation_type),
]
try:
with _deploy_lock(site_id):
temporary_root = tempfile.mkdtemp(
prefix=".aapanel_git_deploy_", dir=parent_dir
)
shallow = not commit_hash
target_path = _clone(config, branch, temporary_root, shallow)
full_commit = _resolve(
target_path, config, branch, commit_hash
)
_run(["checkout", "--detach", full_commit], config, cwd=target_path)
_run(["reset", "--hard", full_commit], config, cwd=target_path)
commit_info = _commit_info(target_path, config, full_commit)
log_lines.append("commit: {}".format(full_commit))
ignore_paths = _site_ignore_paths(site_id)
# 合并进网站目录:覆盖同名/新增,保留用户已有文件与忽略路径
_merge_tree(target_path, site_path, ignore_paths)
# 网站目录原本不是 git 仓库时注入 .git,后续可原地拉取部署
if not os.path.isdir(os.path.join(site_path, ".git")):
shutil.copytree(
os.path.join(target_path, ".git"),
os.path.join(site_path, ".git"),
)
_configure_repository(self, site_path, config)
# 修正权限 目录 755 文件 644,忽略路径可写,统一 www:www
self._fix_file_permission(site_path, ignore_paths)
log_lines.extend([
"status: success",
"=============Git Deployment END================",
])
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("branch", branch)
deploy_id = _write_record(
site, 1, branch, operation_type, 0,
started_at, commit_info, "", log_lines,
)
self._project_restart(site_id)
return {
"deploy_id": deploy_id, "site_id": int(site_id),
"branch": branch,
"commit_hash": commit_info["commit_hash"],
"commit_hash_short": commit_info["commit_hash_short"],
"operation_type": operation_type, "status": 1,
"execut_time": round(time.time() - started_at, 2),
}
except Exception as exc:
error_message = _redact(str(exc), config)
log_lines.extend([
"status: failed", "error: {}".format(error_message),
"=============Git Deployment END================",
])
try:
_write_record(
site, 0, branch, operation_type, 0,
started_at, commit_info, error_message, log_lines,
)
except Exception:
pass
raise ValueError(error_message)
finally:
if temporary_root:
shutil.rmtree(temporary_root, ignore_errors=True)
def _simple_deploy(self, site_id, branch, commit_hash, operation_type):
"""
旧版拉取部署:直接在网站目录内原地拉取,不做全量目录替换。
网站目录需已是 Git 仓库
"""
started_at = time.time()
site = public.M("sites").where("id=?", (int(site_id),)).find()
if not site:
raise ValueError("The website does not exist")
site_path = os.path.realpath(str(site.get("path", "") or ""))
if not site_path or site_path in ("/", "/www", "/www/wwwroot"):
raise ValueError("The website path is invalid")
config = _site_config(site_id)[0]
branch = str(branch or config.get("branch", "")).strip()
if not branch:
raise ValueError("Git branch is required")
if not os.path.isdir(os.path.join(site_path, ".git")):
raise ValueError(
"The website directory is not a Git repository, "
"please deploy with overwrite_pull=1 first"
)
commit_hash = str(commit_hash or "").strip()
log_lines = [
"=============Git Deployment START==============",
"site: {}".format(site.get("name", "")),
"path: {}".format(site_path),
"branch: {}".format(branch),
"operation: {}".format(operation_type),
]
try:
with _deploy_lock(site_id):
if commit_hash:
_run(
["fetch", "origin", branch, "--prune"],
config, cwd=site_path, timeout=300,
)
# 浅克隆只含分支最新提交,回滚到历史提交会报
# "reference is not a tree";先拉全历史再 checkout。
shallow_code, shallow_out, _ = _run(
["rev-parse", "--is-shallow-repository"],
config, cwd=site_path, check=False,
)
if shallow_code == 0 and shallow_out.strip() == "true":
_run(
["fetch", "--unshallow", "origin"],
config, cwd=site_path, timeout=600,
)
_run(
["checkout", "--detach", commit_hash],
config, cwd=site_path,
)
_run(["reset", "--hard", commit_hash], config, cwd=site_path)
full_commit = _resolve(
site_path, config, branch, commit_hash
)
else:
# 确保回到本地分支再 pull,避免 detached HEAD 下 pull 失败
_run(
["checkout", branch],
config, cwd=site_path, timeout=120, check=False,
)
_run(
["pull", "origin", branch],
config, cwd=site_path, timeout=300,
)
full_commit = _resolve(
site_path, config, branch, "",
"refs/remotes/origin/{}".format(branch),
)
commit_info = _commit_info(site_path, config, full_commit)
log_lines.append("commit: {}".format(full_commit))
self._fix_file_permission(site_path, _site_ignore_paths(site_id))
log_lines.extend([
"status: success",
"=============Git Deployment END================",
])
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("branch", branch)
deploy_id = _write_record(
site, 1, branch, operation_type, 0,
started_at, commit_info, "", log_lines,
)
self._project_restart(site_id)
return {
"deploy_id": deploy_id, "site_id": int(site_id),
"branch": branch,
"commit_hash": commit_info["commit_hash"],
"commit_hash_short": commit_info["commit_hash_short"],
"operation_type": operation_type, "status": 1,
"execut_time": round(time.time() - started_at, 2),
}
except Exception as exc:
error_message = _redact(str(exc), config)
log_lines.extend([
"status: failed", "error: {}".format(error_message),
"=============Git Deployment END================",
])
try:
_write_record(
site, 0, branch, operation_type, 0,
started_at, {}, error_message, log_lines,
)
except Exception:
pass
raise ValueError(error_message)
def deploy_git_code(self, get):
try:
site_id = int(get.get("site_id", 0) or 0)
branch = str(get.get("branch", "")).strip()
commit_hash = str(get.get("commit_hash", "")).strip()
if not site_id or not branch:
raise ValueError("site_id and branch are required")
operation_type = "git_commit" if commit_hash else "git_latest"
# overwrite_pull=1 走全量替换部署 旧文件整体替换, 默认走网站目录内简单原地拉取
overwrite_pull = str(get.get("overwrite_pull", "0") or "0").strip().lower()
if overwrite_pull in ("1", "true", "yes"):
result = _deploy_revision(self, site_id, branch, commit_hash, operation_type)
else:
result = _simple_deploy(self, site_id, branch, commit_hash, operation_type)
result["message"] = "Git code deployed successfully"
public.set_module_logs("Git-Tools", "deploy_git_code")
return public.return_message(0, 0, result)
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def _save_binding(site_id, config):
public.M("git_sites_auth").add(
"site_id,repo,branch,auth_type,key_path,username,"
"oauth_access_token,oauth_token_type",
(
site_id, config["repo"], config.get("branch", ""),
_auth_db(config["auth_type"]), config.get("key_path", ""),
config.get("username", ""),
encrypt_git_token(config.get("token", ""))
if config["auth_type"] == "token" else "",
"aes" if config["auth_type"] == "token" else "",
),
)
def connect_repository(self, get):
site_id = 0
binding_created = False
try:
site_id = int(get.get("site_id", 0) or 0)
branch = str(get.get("branch", "")).strip()
if not site_id or not branch:
raise ValueError("site_id and branch are required")
if public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).count():
raise ValueError(
"The current website already has git repository records"
)
config = _normalize(_request_data(get))
config["branch"] = branch
_, branches = _remote_refs(config)
if branch not in branches:
raise ValueError("The selected remote branch does not exist")
_save_binding(site_id, config)
binding_created = True
# 默认合并式部署
overwrite_pull = str(get.get("overwrite_pull", "0") or "0").strip().lower()
if overwrite_pull in ("1", "true", "yes"):
# 全量替换: overwrite_pull=1 旧文件整体替换,仅保留忽略路径;
result = _deploy_revision(
self, site_id, branch, "", "git_latest",
config_override=config,
)
else:
# 合并式部署: 克隆到临时目录合并进网站目录,不删除用户已有文件
result = _deploy_merge(
self, site_id, branch, "", "git_latest",
config_override=config,
)
result["message"] = "Repository connected and deployed successfully"
public.set_module_logs("Git-Tools", "connect_repository")
return public.return_message(0, 0, result)
except Exception as exc:
if binding_created:
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).delete()
return public.return_message(-1, 0, str(exc))
def import_existing_repository(self, get):
"""网站目录已有 .git 仓库时绑定 git 管理器,支持 public/ssh_key/token 三种鉴权。
传参 site_id/repo 必传 branch为空为默认分支
- public: 传 site_id/repo auth_type=public
- ssh_key: 传 key_path auth_type=ssh_key ,仓库须为 SSH 地址
- token: 传 auth_type=token username + token
"""
try:
site_id = int(get.get("site_id", 0) or 0)
if not site_id:
raise ValueError("site_id is required")
data = _request_data(get)
config = _normalize(data)
branch = str(config.get("branch", "") or "").strip()
site_path = public.M("sites").where("id=?", (site_id,)).getField("path")
if not site_path or not os.path.isdir(os.path.join(site_path, ".git")):
raise ValueError("This directory is not a valid Git repository (the .git file was not found)")
if public.M("git_sites_auth").where("site_id=?", (site_id,)).count():
raise ValueError("This website already has Git records. Please do not add them again.")
# 测试连接并拉取远端分支 支持三种鉴权
default_branch, branches = _remote_refs(config)
if not branch:
branch = default_branch
config["branch"] = branch
if branches and branch and branch not in branches:
raise ValueError("The selected remote branch does not exist")
# 绑定前对网站仓库做安全与权限处理 复用v1方法
self._add_safe_directory(site_path)
if config["auth_type"] == "ssh_key":
self._auto_configure_repo(site_path, config["key_path"])
self._fix_file_permission(site_path)
_save_binding(site_id, config)
project_type = str(data.get("project_type", "") or "").strip()
if project_type in ("node", "go", "python"):
public.set_module_logs("Git-Tools", "{}_git_create_website".format(project_type))
else:
public.set_module_logs("Git-Tools", "import_existing_repository")
public.set_module_logs("Git-Tools", "git_create_website")
return public.return_message(0, 0, public.lang("The repository was successfully added!"))
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def git_rollback(self, get):
try:
deploy_id = int(get.get("deploy_id", 0) or 0)
record = public.M("site_deploy_status").where(
"id=?", (deploy_id,)
).find()
if not record:
raise ValueError("The deployment record does not exist")
if not record.get("branch") or not record.get("commit_hash"):
raise ValueError("No commit record found for rollback")
result = _deploy_revision(
self, record["site_id"], record["branch"],
record["commit_hash"], "rollback",
rollback_from_id=record["id"],
)
result["rollback_from_id"] = record["id"]
result["message"] = "Rollback completed successfully"
public.set_module_logs("Git-Tools", "git_rollback")
return public.return_message(0, 0, result)
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def _find_webhook(site_name):
try:
from plugin.webhook.webhook_main import webhook_main
for hook in webhook_main().GetList(None):
if hook.get("title") == "{}_git_hook".format(site_name):
return hook
except Exception:
pass
return None
def _webhook_info(self, site):
script_id = public.M("site_deploy_script").where(
"site_id=? and is_webhook=1", (site["id"],)
).getField("id") or ""
hook = _find_webhook(site["name"])
if not hook:
# 首次访问时自动创建 hook,保持旧版 get_site_git_conf 默认返回 webhook_url 的语义
try:
self._set_webhook(site["name"], site["id"])
except Exception:
pass
hook = _find_webhook(site["name"])
webhook_url = ""
if hook:
webhook_url = "{}/hook?access_key={}&site_id={}".format(
public.getPanelAddr(), hook.get("access_key", ""),
site["id"],
)
return script_id, webhook_url
def get_site_git_conf(self, get):
"""
获取网站git配置 branch以网站实际分支为准
"""
try:
site_id = int(get.get("site_id", 0) or 0)
site = public.M("sites").where("id=?", (site_id,)).find()
site_git = public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).find()
if not site_git:
return public.return_message(0, 0, {})
script_id, webhook_url = _webhook_info(self, site)
webhook_script_name = ""
if script_id:
webhook_script_name = public.M("site_deploy_script").where(
"id=?", (script_id,)
).getField("title") or ""
auth_type = _auth_api(site_git.get("auth_type"))
key_path = (
site_git.get("key_path", "") if auth_type == "ssh_key" else ""
)
ssh_key = str(
public.readFile(key_path + ".pub") or ""
).strip() if key_path else ""
latest_record = public.M("site_deploy_status").where(
"site_id=?", (site_id,)
).order("id DESC").find()
# branch以网站目录HEAD实际分支为准
branch = str(site_git.get("branch", "") or "")
site_path = str(site.get("path", "") or "") if site else ""
actual_branch = _current_branch(self, site_path) if site_path else ""
if actual_branch and actual_branch != branch:
branch = actual_branch
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("branch", actual_branch)
# public.set_module_logs("Git-Tools", "get_site_git_conf")
return public.return_message(0, 0, {
"site_id": site_id, "deploy_id": site_git.get("id"),
"repo": site_git.get("repo", ""),
"branch": branch,
"auth_type": auth_type,
"deploy_type": site_git.get("auth_type", ""),
"username": site_git.get("username", "")
if auth_type == "token" else "",
"token_configured": bool(site_git.get("oauth_access_token"))
if auth_type == "token" else False,
"key_path": key_path, "ssh_key": ssh_key,
"number_copies": site_git.get("number_copies", 5),
"ignore_paths": _site_ignore_paths(site_id),
"webhook_enabled": bool(script_id),
"webhook_url": webhook_url,
"webhook_script": script_id,
"webhook_script_name": webhook_script_name,
"latest_deploy": latest_record or None,
})
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def _bind_webhook(self, site, script_id):
try:
from plugin.webhook.webhook_main import webhook_main
webhook_obj = webhook_main()
hook = next(
(
item for item in webhook_obj.GetList(None)
if item.get("title") == "{}_git_hook".format(site["name"])
),
None,
)
if script_id and not hook:
self._set_webhook(site["name"], site["id"])
hook = next(
(
item for item in webhook_obj.GetList(None)
if item.get("title") == "{}_git_hook".format(site["name"])
),
None,
)
if not script_id:
public.M("site_deploy_script").where(
"site_id=?", (site["id"],)
).setField("is_webhook", 0)
if hook:
webhook_path = os.path.join(
"/www/server/panel/plugin/webhook/script",
hook.get("access_key", ""),
)
if os.path.isfile(webhook_path):
public.writeFile(webhook_path, "#!/bin/bash\n")
return
script = public.M("site_deploy_script").where(
"id=? and site_id=?", (script_id, site["id"])
).find()
if not script:
raise ValueError("The script does not exist")
if not hook:
raise ValueError(
"The webhook configuration file of this website does not exist"
)
webhook_path = os.path.join(
"/www/server/panel/plugin/webhook/script",
hook.get("access_key", ""),
)
public.writeFile(webhook_path, "bash {}".format(script["script_path"]))
public.M("site_deploy_script").where(
"site_id=?", (site["id"],)
).setField("is_webhook", 0)
public.M("site_deploy_script").where(
"id=?", (script_id,)
).setField("is_webhook", 1)
except ImportError:
if script_id:
raise ValueError("The webhook plugin is not installed")
def save_site_git_conf(self, get):
try:
site_id = int(get.get("site_id", 0) or 0)
site = public.M("sites").where("id=?", (site_id,)).find()
stored = public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).find()
if not site or not stored:
raise ValueError("This website does not have a Git repository")
config_fields = (
"repo", "repo_url", "auth_type", "branch",
"key_path", "username", "token",
)
request_data = _request_data(get)
if any(key in request_data for key in config_fields):
config = _normalize(request_data, stored)
default_branch, branches = _remote_refs(config)
branch = config.get("branch") or default_branch
if branch not in branches:
raise ValueError("The selected remote branch does not exist")
token_cipher = stored.get("oauth_access_token", "")
if config["auth_type"] == "token" and config.get("token"):
token_cipher = encrypt_git_token(config["token"])
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).update({
"repo": config["repo"], "branch": branch,
"auth_type": _auth_db(config["auth_type"]),
"key_path": config.get("key_path", ""),
"username": config.get("username", ""),
"oauth_access_token": token_cipher
if config["auth_type"] == "token" else "",
"oauth_token_type": "aes"
if config["auth_type"] == "token" else "",
})
if _has_field(get, "ignore_paths"):
ignore_paths = request_data.get("ignore_paths", [])
# 如果是JSON 字符串 例 '["/.env", "/storage"]' 先解析
if isinstance(ignore_paths, str):
try:
parsed = json.loads(ignore_paths)
except (ValueError, TypeError):
raise ValueError(
"ignore_paths must be a list of path strings"
)
if not isinstance(parsed, list):
raise ValueError(
"ignore_paths must be a list of path strings"
)
ignore_paths = parsed
elif not isinstance(ignore_paths, list):
raise ValueError(
"ignore_paths must be a list of path strings"
)
cleaned = []
for item in ignore_paths:
item = str(item or "").strip()
if item and item not in (".", ".."):
cleaned.append(item)
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("ignore_paths", json.dumps(cleaned))
if _has_field(get, "number_copies"):
number_copies = int(get.get("number_copies", 5) or 5)
import PluginLoader
if PluginLoader.get_auth_state() < 1 and number_copies != 5:
raise ValueError(
"Sorry. Free version users are only supported to save "
"5 deployment records."
)
public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).setField("number_copies", number_copies)
_prune_records(site_id)
if _has_field(get, "script_id"):
_bind_webhook(self, site, int(get.get("script_id", 0) or 0))
public.set_module_logs("Git-Tools", "save_site_git_conf")
return public.return_message(
0, 0, public.lang("Saved successfully.")
)
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def get_deploy_records(self, get):
try:
site_id = int(get.get("site_id", 0) or 0)
if not site_id:
raise ValueError("site_id is required")
records = public.M("site_deploy_status").where(
"site_id=?", (site_id,)
).order("id DESC").select() or []
branch = str(get.get("branch", "")).strip()
status_filter = str(get.get("status", "")).strip()
if branch:
records = [
item for item in records if item.get("branch", "") == branch
]
if status_filter != "":
records = [
item for item in records
if str(item.get("status", "")) == status_filter
]
page = max(1, int(get.get("p", 1) or 1))
limit = min(100, max(1, int(get.get("limit", 20) or 20)))
total = len(records)
records = records[(page - 1) * limit:page * limit]
script_id = public.M("site_deploy_script").where(
"site_id=? and is_webhook=1", (site_id,)
).getField("id") or ""
# public.set_module_logs("Git-Tools", "get_deploy_records")
return public.return_message(0, 0, {
"auto_deploy": bool(script_id),
"webhook_enabled": bool(script_id),
"webhook_script": script_id,
"records": records,
"page": {"p": page, "limit": limit, "total": total},
})
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def manual_deploy_site(self, get):
"""脚本手动部署:执行站点绑定的部署脚本,记录 v2 格式(带 branch)部署记录。
覆写旧版 git_tools.py 的同名方法:旧版写记录时 branch 为空且操作副本
数量用 _del_deploy_record(有误删风险),导致 v2 get_deploy_records 按
branch 过滤时看不到脚本部署记录、且记录总数不断减少。
"""
try:
site_id = int(get.get("site_id", 0) or 0)
script_id = int(get.get("script_id", 0) or 0)
if not site_id or not script_id:
raise ValueError("site_id and script_id are required")
site = public.M("sites").where("id=?", (site_id,)).find()
site_git = public.M("git_sites_auth").where(
"site_id=?", (site_id,)
).find()
if not site_git or not site:
raise ValueError(
"This website does not have or has no git repository."
)
script = public.M("site_deploy_script").where(
"id=?", (script_id,)
).find()
if not script:
raise ValueError("The script does not exist.")
started_at = time.time()
site_path = str(site.get("path", "") or "")
branch = str(site_git.get("branch", "") or "").strip()
if not branch:
branch = _site_config(site_id)[0].get("branch", "") or "master"
deploy_script = str(script.get("script_path", "") or "")
if not os.path.exists(deploy_script):
raise ValueError("The deployment script does not exist!")
if not public.readFile(deploy_script.strip()):
public.writeFile(
deploy_script,
"cd {}\n".format(site_path)
+ 'echo " 🚀 Application deployed! "',
)
deploy_time = datetime.fromtimestamp(started_at).strftime(
"%Y-%m-%d %H:%M:%S"
)
log_lines = [
"=============Deployment START==============",
"time: {}".format(deploy_time),
"site: {}".format(site.get("name", "")),
"path: {}".format(site_path),
"branch: {}".format(branch),
"Git deployment script: {}".format(script.get("title", "")),
]
result = public.ExecShell("bash {}".format(deploy_script))
if result[0]:
log_lines.append("-----STDOUT-----")
log_lines.append(str(result[0]).strip())
if result[1]:
log_lines.append("-----STDERR-----")
log_lines.append(str(result[1]).strip())
ok = self._is_deploy_success(result[0], result[1])
status = 1 if ok else 0
# 修正权限
self._fix_file_permission(site_path, _site_ignore_paths(site_id))
# 当前提交信息(旧版 _get_now_info 为类方法,v2 继承直接可用)
commit_info = self._get_now_info(site_path)
if not commit_info:
raise ValueError(
"Failed to obtain the current submitted info. "
"Please check if the repository is correct!"
)
commit_date = str(commit_info.get("commit_date", "") or "")
try:
parsed_time = datetime.strptime(
commit_date, "%a %b %d %H:%M:%S %Y %z"
)
commit_date = parsed_time.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass
v2_commit_info = {
"commit_hash": commit_info.get("hash", ""),
"commit_hash_short": commit_info.get("short_hash", ""),
"author_name": commit_info.get("author", ""),
"committed_time": commit_date,
"message": commit_info.get("message", ""),
}
log_lines.append("commit: {}".format(v2_commit_info["commit_hash"]))
log_lines.append("=============Deployment END================")
# 用 v2 _write_record 写入,自带 branch + 激活当前记录 + 副本修剪
_write_record(
site, status, branch, "manual_deploy", 0,
started_at, v2_commit_info, "", log_lines,
)
self._project_restart(site_id)
public.set_module_logs("Git-Tools", "manual_deploy_site")
return public.return_message(
0, 0,
"Deployment successful: "
"Please visit the deployment log for details!",
)
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def install(cls):
methods = (
get_remote_branches, get_remote_commits, get_commit_detail,
deploy_git_code, connect_repository, git_rollback,
get_site_git_conf, save_site_git_conf, get_deploy_records,
manual_deploy_site, import_existing_repository,
)
for method in methods:
setattr(cls, method.__name__, method)