592 lines
26 KiB
Python
Executable File
592 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import html
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
|
||
BASE_DIR = Path(os.environ.get("ASLAN_DEPLOY_BASE", "/opt/aslan-test"))
|
||
RUN_DIR = Path(os.environ.get("ASLAN_OPS_RUN_DIR", str(BASE_DIR / "ops" / "runs")))
|
||
DEPLOY_SCRIPT = Path(os.environ.get("ASLAN_DEPLOY_SCRIPT", str(BASE_DIR / "deploy-likei-services.sh")))
|
||
SOURCE_DIR = Path(os.environ.get("ASLAN_SOURCE_DIR", str(BASE_DIR / "source" / "aslan-server")))
|
||
COMPOSE_FILE = Path(os.environ.get("ASLAN_COMPOSE_FILE", str(BASE_DIR / "docker-compose.yml")))
|
||
ENV_FILE = Path(os.environ.get("ASLAN_ENV_FILE", str(BASE_DIR / "config" / ".env")))
|
||
GIT_REF = os.environ.get("ASLAN_GIT_REF", "aslan_test")
|
||
REMOTE_URL = os.environ.get("ASLAN_GIT_REMOTE_URL", "git@gitea.haiyihy.com:hy/aslan-server.git")
|
||
HOST = os.environ.get("ASLAN_OPS_HOST", "127.0.0.1")
|
||
PORT = int(os.environ.get("ASLAN_OPS_PORT", "18081"))
|
||
USERNAME = os.environ.get("ASLAN_OPS_USER", "admin")
|
||
PASSWORD = os.environ.get("ASLAN_OPS_PASSWORD", "")
|
||
SESSION_SECRET = os.environ.get("ASLAN_OPS_SESSION_SECRET", PASSWORD or secrets.token_hex(32))
|
||
COOKIE_PATH = os.environ.get("ASLAN_OPS_COOKIE_PATH", "/ops")
|
||
ALLOWED_BRANCHES = {
|
||
branch.strip()
|
||
for branch in os.environ.get("ASLAN_ALLOWED_BRANCHES", GIT_REF).split(",")
|
||
if branch.strip()
|
||
}
|
||
|
||
SERVICES = {
|
||
"auth": {"label": "Auth", "port": "", "health": ""},
|
||
"gateway": {"label": "Gateway", "port": "9000", "health": "/"},
|
||
"external": {"label": "External", "port": "", "health": ""},
|
||
"wallet": {"label": "Wallet", "port": "", "health": ""},
|
||
"order": {"label": "Order", "port": "", "health": ""},
|
||
"live": {"label": "Live", "port": "", "health": ""},
|
||
"other": {"label": "Other", "port": "", "health": ""},
|
||
"console": {"label": "Console", "port": "2700", "health": "/console/"},
|
||
}
|
||
|
||
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,80}$")
|
||
JOB_RE = re.compile(r"^[A-Za-z0-9-]+$")
|
||
|
||
job_lock = threading.Lock()
|
||
active_job = None
|
||
login_failures = {}
|
||
|
||
|
||
def now_ts():
|
||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def json_response(handler, status, payload):
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("X-Frame-Options", "DENY")
|
||
handler.send_header("Content-Length", str(len(body)))
|
||
handler.end_headers()
|
||
handler.wfile.write(body)
|
||
|
||
|
||
def text_response(handler, status, body, content_type="text/plain; charset=utf-8"):
|
||
data = body.encode("utf-8")
|
||
handler.send_response(status)
|
||
handler.send_header("Content-Type", content_type)
|
||
handler.send_header("Cache-Control", "no-store")
|
||
handler.send_header("X-Frame-Options", "DENY")
|
||
handler.send_header("Content-Length", str(len(data)))
|
||
handler.end_headers()
|
||
handler.wfile.write(data)
|
||
|
||
|
||
def redirect_response(handler, location, cookies=None):
|
||
handler.send_response(302)
|
||
for cookie in cookies or []:
|
||
handler.send_header("Set-Cookie", cookie)
|
||
handler.send_header("Location", location)
|
||
handler.send_header("Content-Length", "0")
|
||
handler.end_headers()
|
||
|
||
|
||
def sign_session(value):
|
||
mac = hmac.new(SESSION_SECRET.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
return f"{value}.{mac}"
|
||
|
||
|
||
def verify_session(signed):
|
||
if not signed or "." not in signed:
|
||
return False
|
||
value, mac = signed.rsplit(".", 1)
|
||
expected = hmac.new(SESSION_SECRET.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(mac, expected):
|
||
return False
|
||
try:
|
||
username, issued = value.split(":", 1)
|
||
return username == USERNAME and time.time() - int(issued) < 86400
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
def parse_cookies(header):
|
||
cookies = {}
|
||
for part in (header or "").split(";"):
|
||
if "=" not in part:
|
||
continue
|
||
key, value = part.strip().split("=", 1)
|
||
cookies[key] = value
|
||
return cookies
|
||
|
||
|
||
def too_many_login_failures(ip):
|
||
now = time.time()
|
||
window = [ts for ts in login_failures.get(ip, []) if now - ts < 300]
|
||
login_failures[ip] = window
|
||
return len(window) >= 5
|
||
|
||
|
||
def record_login_failure(ip):
|
||
window = login_failures.get(ip, [])
|
||
window.append(time.time())
|
||
login_failures[ip] = window[-10:]
|
||
|
||
|
||
def run_text(cmd, timeout=20):
|
||
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
||
if result.returncode != 0:
|
||
return (result.stderr.strip() or result.stdout.strip())
|
||
return result.stdout.strip()
|
||
|
||
|
||
def run_checked(cmd, timeout=30):
|
||
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
||
if result.returncode != 0:
|
||
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"command failed: {cmd[0]}")
|
||
return result.stdout
|
||
|
||
|
||
def compose_cmd(*args):
|
||
return ["docker", "compose", "-f", str(COMPOSE_FILE), "--env-file", str(ENV_FILE), *args]
|
||
|
||
|
||
def parse_compose_json_lines(text):
|
||
rows = []
|
||
for line in text.splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
rows.append(json.loads(line))
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return rows
|
||
|
||
|
||
def compose_status():
|
||
output = run_checked(compose_cmd("ps", "--all", "--format", "json"), timeout=20)
|
||
rows = parse_compose_json_lines(output)
|
||
by_service = {row.get("Service"): row for row in rows}
|
||
items = []
|
||
for name, meta in SERVICES.items():
|
||
row = by_service.get(name, {})
|
||
state = row.get("State") or "missing"
|
||
health = row.get("Health") or ""
|
||
status = row.get("Status") or ""
|
||
image = row.get("Image") or f"aslan-test/{name}:latest"
|
||
items.append({
|
||
"name": name,
|
||
"label": meta["label"],
|
||
"state": state,
|
||
"status": status,
|
||
"image": image,
|
||
"ports": row.get("Ports", ""),
|
||
"healthy": state == "running" and health not in {"unhealthy", "starting"},
|
||
"port": meta["port"],
|
||
"health": meta["health"],
|
||
})
|
||
return items
|
||
|
||
|
||
def git_status():
|
||
head = run_text(["git", "-C", str(SOURCE_DIR), "rev-parse", "HEAD"])
|
||
branch = run_text(["git", "-C", str(SOURCE_DIR), "branch", "--show-current"])
|
||
short = run_text(["git", "-C", str(SOURCE_DIR), "status", "--short", "--branch"])
|
||
last = run_text(["git", "-C", str(SOURCE_DIR), "log", "-1", "--oneline", "--decorate"])
|
||
return {"head": head, "branch": branch, "status": short, "last": last}
|
||
|
||
|
||
def list_jobs():
|
||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
||
jobs = []
|
||
for meta_path in sorted(RUN_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:30]:
|
||
try:
|
||
jobs.append(json.loads(meta_path.read_text()))
|
||
except json.JSONDecodeError:
|
||
continue
|
||
return jobs
|
||
|
||
|
||
def save_job(job):
|
||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
||
(RUN_DIR / f"{job['id']}.json").write_text(json.dumps(job, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def stream_process(job, cmd, env):
|
||
log_path = RUN_DIR / f"{job['id']}.log"
|
||
try:
|
||
with log_path.open("a", buffering=1) as log:
|
||
log.write(f"[{now_ts()}] start {' '.join(cmd)}\n")
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
cwd=str(BASE_DIR),
|
||
env=env,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
bufsize=1,
|
||
)
|
||
job["pid"] = process.pid
|
||
job["status"] = "running"
|
||
save_job(job)
|
||
for line in process.stdout:
|
||
log.write(line)
|
||
rc = process.wait()
|
||
job["status"] = "success" if rc == 0 else "failed"
|
||
job["returnCode"] = rc
|
||
job["finishedAt"] = now_ts()
|
||
save_job(job)
|
||
log.write(f"[{now_ts()}] finished rc={rc}\n")
|
||
except Exception as exc:
|
||
job["status"] = "failed"
|
||
job["returnCode"] = -1
|
||
job["finishedAt"] = now_ts()
|
||
job["error"] = str(exc)
|
||
save_job(job)
|
||
with log_path.open("a", buffering=1) as log:
|
||
log.write(f"[{now_ts()}] runner error: {exc}\n")
|
||
finally:
|
||
global active_job
|
||
with job_lock:
|
||
active_job = None
|
||
|
||
|
||
def start_deploy(services, branch, fast):
|
||
global active_job
|
||
invalid = [svc for svc in services if svc not in SERVICES]
|
||
if invalid:
|
||
raise ValueError(f"unsupported services: {', '.join(invalid)}")
|
||
if not services:
|
||
raise ValueError("select at least one service")
|
||
if not BRANCH_RE.match(branch):
|
||
raise ValueError("invalid branch name")
|
||
if branch not in ALLOWED_BRANCHES:
|
||
raise ValueError(f"branch is not allowed: {branch}")
|
||
if not DEPLOY_SCRIPT.exists():
|
||
raise ValueError(f"missing deploy script: {DEPLOY_SCRIPT}")
|
||
|
||
with job_lock:
|
||
if active_job:
|
||
raise RuntimeError(f"deploy already running: {active_job['id']}")
|
||
job_id = time.strftime("%Y%m%d%H%M%S") + "-" + secrets.token_hex(3)
|
||
job = {
|
||
"id": job_id,
|
||
"services": services,
|
||
"branch": branch,
|
||
"fast": fast,
|
||
"status": "queued",
|
||
"createdAt": now_ts(),
|
||
"finishedAt": "",
|
||
"returnCode": None,
|
||
}
|
||
active_job = job
|
||
save_job(job)
|
||
|
||
env = os.environ.copy()
|
||
env.update({
|
||
"BASE_DIR": str(BASE_DIR),
|
||
"SRC_DIR": str(SOURCE_DIR),
|
||
"COMPOSE_FILE": str(COMPOSE_FILE),
|
||
"ENV_FILE": str(ENV_FILE),
|
||
"GIT_REF": branch,
|
||
"ALLOWED_GIT_REFS": " ".join(sorted(ALLOWED_BRANCHES)),
|
||
"GIT_REMOTE_URL": REMOTE_URL,
|
||
"MODE": "compose",
|
||
"HOME": str(Path.home()),
|
||
})
|
||
if fast:
|
||
# Fast mode keeps Maven target caches and is enough for small Java-only updates.
|
||
env["MAVEN_GOALS"] = "package"
|
||
cmd = [str(DEPLOY_SCRIPT), *services]
|
||
thread = threading.Thread(target=stream_process, args=(job, cmd, env), daemon=True)
|
||
thread.start()
|
||
return job
|
||
|
||
|
||
def service_log(svc):
|
||
if svc not in SERVICES:
|
||
raise ValueError(f"unsupported service: {svc}")
|
||
return run_checked(compose_cmd("logs", "--tail=300", svc), timeout=20)
|
||
|
||
|
||
def login_page(error=""):
|
||
message = f"<div class='error'>{html.escape(error)}</div>" if error else ""
|
||
return f"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Aslan Test Deploy</title>
|
||
<style>
|
||
:root {{ --ink:#172026; --muted:#65727f; --line:#d9e1e7; --brand:#0f766e; --bg:#f6f8fa; --danger:#b42318; }}
|
||
* {{ box-sizing:border-box; }}
|
||
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; background:var(--bg); color:var(--ink); }}
|
||
main {{ min-height:100vh; display:grid; place-items:center; padding:24px; }}
|
||
form {{ width:min(360px,100%); background:#fff; border:1px solid var(--line); border-radius:8px; padding:24px; box-shadow:0 10px 28px rgba(16,24,40,.08); }}
|
||
h1 {{ margin:0 0 20px; font-size:22px; letter-spacing:0; }}
|
||
label {{ display:block; margin:12px 0 6px; color:var(--muted); font-size:13px; }}
|
||
input {{ width:100%; height:40px; border:1px solid var(--line); border-radius:6px; padding:0 10px; font-size:15px; }}
|
||
button {{ width:100%; height:40px; border:0; border-radius:6px; background:var(--brand); color:white; font-weight:600; margin-top:18px; cursor:pointer; }}
|
||
.error {{ color:var(--danger); font-size:13px; margin-bottom:8px; }}
|
||
</style>
|
||
</head>
|
||
<body><main><form method="post" action="login"><h1>Aslan 测试部署</h1>{message}<label>账号</label><input name="username" autocomplete="username" autofocus><label>密码</label><input name="password" type="password" autocomplete="current-password"><button type="submit">登录</button></form></main></body>
|
||
</html>"""
|
||
|
||
|
||
INDEX_HTML = """<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Aslan Test Deploy</title>
|
||
<style>
|
||
:root { color-scheme: light; --bg:#f4f7f8; --panel:#fff; --ink:#14212b; --muted:#647382; --line:#d8e2e8; --brand:#0f766e; --brand-strong:#0b5f59; --warn:#b54708; --ok:#067647; --bad:#b42318; --chip:#e6f4f1; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; background:var(--bg); color:var(--ink); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; letter-spacing:0; }
|
||
header { height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 24px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:1; }
|
||
h1 { margin:0; font-size:18px; }
|
||
header nav { display:flex; gap:12px; align-items:center; }
|
||
button, .link { height:34px; border-radius:6px; border:1px solid var(--line); background:#fff; color:var(--ink); padding:0 12px; cursor:pointer; font-weight:600; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
|
||
button.primary { background:var(--brand); border-color:var(--brand); color:white; }
|
||
button.primary:hover { background:var(--brand-strong); }
|
||
button:disabled { opacity:.55; cursor:not-allowed; }
|
||
main { max-width:1220px; margin:0 auto; padding:24px; display:grid; gap:16px; }
|
||
section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; }
|
||
.bar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; }
|
||
.bar h2 { margin:0; font-size:16px; }
|
||
.muted { color:var(--muted); font-size:13px; }
|
||
.grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
|
||
.svc { border:1px solid var(--line); border-radius:8px; padding:14px; display:grid; gap:10px; min-height:154px; }
|
||
.svc-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||
.svc h3 { margin:0; font-size:16px; }
|
||
.badge { display:inline-flex; align-items:center; height:24px; border-radius:999px; padding:0 9px; font-size:12px; background:var(--chip); color:var(--brand-strong); white-space:nowrap; }
|
||
.badge.warn { background:#fff1e7; color:var(--warn); }
|
||
.badge.bad { background:#fee4e2; color:var(--bad); }
|
||
.meta { display:grid; grid-template-columns:64px 1fr; gap:6px; font-size:13px; }
|
||
.meta span:nth-child(odd) { color:var(--muted); }
|
||
.image { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
||
.deploy-row { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
|
||
.checks { display:flex; flex-wrap:wrap; gap:12px; }
|
||
label.check { display:inline-flex; align-items:center; gap:6px; color:var(--ink); font-size:14px; }
|
||
input[type="checkbox"] { width:16px; height:16px; accent-color:var(--brand); }
|
||
input.branch { width:180px; height:34px; border:1px solid var(--line); border-radius:6px; padding:0 10px; }
|
||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||
th, td { text-align:left; padding:10px; border-top:1px solid var(--line); vertical-align:top; }
|
||
th { color:var(--muted); font-weight:600; }
|
||
pre { margin:0; height:420px; overflow:auto; background:#101820; color:#e6edf3; padding:14px; border-radius:8px; font-size:12px; line-height:1.45; white-space:pre-wrap; }
|
||
@media (max-width: 1100px) { .grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||
@media (max-width: 720px) { header { padding:0 14px; } main { padding:14px; } .grid { grid-template-columns:1fr; } .bar { align-items:flex-start; flex-direction:column; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Aslan 测试部署</h1>
|
||
<nav><span class="muted" id="git"></span><button id="refresh">刷新</button><a class="link" href="logout">退出</a></nav>
|
||
</header>
|
||
<main>
|
||
<section>
|
||
<div class="bar"><h2>服务状态</h2><span class="muted" id="updated"></span></div>
|
||
<div id="services" class="grid"></div>
|
||
</section>
|
||
<section>
|
||
<div class="bar"><h2>更新部署</h2><span class="muted">拉取 aslan_test,构建本地镜像,docker compose 替换容器</span></div>
|
||
<div class="deploy-row">
|
||
<div class="checks" id="serviceChecks"></div>
|
||
<label class="check">快速构建 <input id="fast" type="checkbox" checked></label>
|
||
<input id="branch" class="branch" value="aslan_test">
|
||
<button class="primary" id="deploy">部署选中服务</button>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<div class="bar"><h2>部署日志</h2><span class="muted" id="jobState">无运行任务</span></div>
|
||
<pre id="log"></pre>
|
||
</section>
|
||
<section>
|
||
<div class="bar"><h2>最近任务</h2></div>
|
||
<table><thead><tr><th>ID</th><th>服务</th><th>分支</th><th>状态</th><th>时间</th></tr></thead><tbody id="jobs"></tbody></table>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const serviceNames = ["auth", "gateway", "external", "wallet", "order", "live", "other", "console"];
|
||
let currentJob = "";
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
function badge(ok, text) { return `<span class="badge ${ok ? "" : "bad"}">${esc(text)}</span>`; }
|
||
async function api(path, opts) {
|
||
const res = await fetch(path, Object.assign({headers:{'Content-Type':'application/json','X-Aslan-Ops':'1'}}, opts || {}));
|
||
if (!res.ok) throw new Error((await res.text()) || res.statusText);
|
||
return await res.json();
|
||
}
|
||
function renderChecks() {
|
||
document.getElementById("serviceChecks").innerHTML = serviceNames.map(s => `<label class="check"><input type="checkbox" value="${s}" checked> ${s}</label>`).join("");
|
||
}
|
||
async function loadStatus() {
|
||
const data = await api("api/status");
|
||
document.getElementById("updated").textContent = new Date().toLocaleString();
|
||
document.getElementById("git").textContent = `${data.git.branch || "unknown"} ${String(data.git.head || "").slice(0,8)}`;
|
||
document.getElementById("services").innerHTML = data.services.map(s => `
|
||
<article class="svc">
|
||
<div class="svc-head"><h3>${esc(s.label)}</h3>${badge(s.healthy, s.state || "missing")}</div>
|
||
<div class="meta">
|
||
<span>状态</span><span>${esc(s.status)}</span>
|
||
<span>端口</span><span>${esc(s.ports || s.port || "-")}</span>
|
||
<span>镜像</span><span class="image" title="${esc(s.image)}">${esc(s.image)}</span>
|
||
</div>
|
||
<div class="deploy-row"><button data-service="${esc(s.name)}">部署</button><button data-log="${esc(s.name)}">日志</button></div>
|
||
</article>`).join("");
|
||
document.querySelectorAll("[data-service]").forEach(btn => btn.onclick = () => deploy([btn.dataset.service]));
|
||
document.querySelectorAll("[data-log]").forEach(btn => btn.onclick = () => loadServiceLog(btn.dataset.log));
|
||
document.getElementById("jobs").innerHTML = data.jobs.map(j => `<tr><td><button data-job="${esc(j.id)}">${esc(j.id)}</button></td><td>${esc((j.services || []).join(","))}</td><td>${esc(j.branch)}</td><td>${esc(j.status)}</td><td>${esc(j.createdAt)}</td></tr>`).join("");
|
||
document.querySelectorAll("[data-job]").forEach(btn => btn.onclick = () => { currentJob = btn.dataset.job; loadLog(); });
|
||
const running = data.jobs.find(j => j.status === "running" || j.status === "queued");
|
||
if (running && !currentJob) currentJob = running.id;
|
||
if (currentJob) loadLog();
|
||
}
|
||
async function deploy(services) {
|
||
const selected = services || Array.from(document.querySelectorAll("#serviceChecks input:checked")).map(i => i.value);
|
||
document.getElementById("deploy").disabled = true;
|
||
try {
|
||
const data = await api("api/deploy", {method:"POST", body:JSON.stringify({services:selected, branch:document.getElementById("branch").value, fast:document.getElementById("fast").checked})});
|
||
currentJob = data.job.id;
|
||
await loadStatus();
|
||
await loadLog();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
} finally {
|
||
document.getElementById("deploy").disabled = false;
|
||
}
|
||
}
|
||
async function loadLog() {
|
||
if (!currentJob) return;
|
||
const data = await api(`api/jobs/${currentJob}/log`);
|
||
document.getElementById("jobState").textContent = `${currentJob} ${data.job.status}`;
|
||
const log = document.getElementById("log");
|
||
log.textContent = data.log || "";
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
async function loadServiceLog(service) {
|
||
const data = await api(`api/services/${service}/log`);
|
||
currentJob = "";
|
||
document.getElementById("jobState").textContent = `${service} 容器日志`;
|
||
const log = document.getElementById("log");
|
||
log.textContent = data.log || "";
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
renderChecks();
|
||
document.getElementById("refresh").onclick = loadStatus;
|
||
document.getElementById("deploy").onclick = () => deploy();
|
||
loadStatus().catch(err => document.getElementById("log").textContent = err.message);
|
||
setInterval(() => loadStatus().catch(() => {}), 5000);
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "AslanTestOps/1.0"
|
||
|
||
def authenticated(self):
|
||
cookies = parse_cookies(self.headers.get("Cookie"))
|
||
return verify_session(cookies.get("aslan_ops"))
|
||
|
||
def require_auth(self):
|
||
if self.authenticated():
|
||
return True
|
||
redirect_response(self, "login")
|
||
return False
|
||
|
||
def read_body(self, max_bytes=65536):
|
||
size = int(self.headers.get("Content-Length", "0"))
|
||
if size > max_bytes:
|
||
raise ValueError("request body too large")
|
||
return self.rfile.read(size).decode("utf-8")
|
||
|
||
def do_GET(self):
|
||
parsed = urlparse(self.path)
|
||
if parsed.path == "/login":
|
||
return text_response(self, 200, login_page(), "text/html; charset=utf-8")
|
||
if parsed.path == "/logout":
|
||
redirect_response(self, "login", [f"aslan_ops=; Max-Age=0; HttpOnly; SameSite=Lax; Path={COOKIE_PATH}"])
|
||
return
|
||
if not self.require_auth():
|
||
return
|
||
if parsed.path in ("", "/"):
|
||
return text_response(self, 200, INDEX_HTML, "text/html; charset=utf-8")
|
||
if parsed.path == "/api/status":
|
||
try:
|
||
return json_response(self, 200, {
|
||
"services": compose_status(),
|
||
"git": git_status(),
|
||
"jobs": list_jobs(),
|
||
})
|
||
except Exception as exc:
|
||
return json_response(self, 500, {"error": str(exc)})
|
||
match = re.match(r"^/api/jobs/([A-Za-z0-9-]+)/log$", parsed.path)
|
||
if match:
|
||
job_id = match.group(1)
|
||
if not JOB_RE.match(job_id):
|
||
return json_response(self, 400, {"error": "invalid job id"})
|
||
meta_path = RUN_DIR / f"{job_id}.json"
|
||
log_path = RUN_DIR / f"{job_id}.log"
|
||
if not meta_path.exists():
|
||
return json_response(self, 404, {"error": "job not found"})
|
||
job = json.loads(meta_path.read_text())
|
||
log = log_path.read_text(errors="replace")[-160000:] if log_path.exists() else ""
|
||
return json_response(self, 200, {"job": job, "log": log})
|
||
match = re.match(r"^/api/services/([A-Za-z0-9_-]+)/log$", parsed.path)
|
||
if match:
|
||
try:
|
||
return json_response(self, 200, {"service": match.group(1), "log": service_log(match.group(1))})
|
||
except Exception as exc:
|
||
return json_response(self, 400, {"error": str(exc)})
|
||
return text_response(self, 404, "not found")
|
||
|
||
def do_POST(self):
|
||
parsed = urlparse(self.path)
|
||
if parsed.path == "/login":
|
||
if too_many_login_failures(self.client_address[0]):
|
||
return text_response(self, 429, login_page("登录失败次数过多,稍后再试"), "text/html; charset=utf-8")
|
||
try:
|
||
form = parse_qs(self.read_body(max_bytes=4096))
|
||
except ValueError as exc:
|
||
return text_response(self, 413, str(exc))
|
||
username = form.get("username", [""])[0]
|
||
password = form.get("password", [""])[0]
|
||
if hmac.compare_digest(username, USERNAME) and hmac.compare_digest(password, PASSWORD):
|
||
login_failures.pop(self.client_address[0], None)
|
||
session = sign_session(f"{USERNAME}:{int(time.time())}")
|
||
redirect_response(self, ".", [f"aslan_ops={session}; HttpOnly; SameSite=Lax; Path={COOKIE_PATH}"])
|
||
return
|
||
record_login_failure(self.client_address[0])
|
||
return text_response(self, 401, login_page("账号或密码错误"), "text/html; charset=utf-8")
|
||
if not self.require_auth():
|
||
return
|
||
if parsed.path == "/api/deploy":
|
||
try:
|
||
if self.headers.get("X-Aslan-Ops") != "1":
|
||
return json_response(self, 403, {"error": "missing deploy header"})
|
||
payload = json.loads(self.read_body(max_bytes=4096) or "{}")
|
||
services = payload.get("services") or []
|
||
branch = payload.get("branch") or GIT_REF
|
||
fast = bool(payload.get("fast", True))
|
||
job = start_deploy(services, branch, fast)
|
||
return json_response(self, 202, {"job": job})
|
||
except Exception as exc:
|
||
return json_response(self, 400, {"error": str(exc)})
|
||
return text_response(self, 404, "not found")
|
||
|
||
def log_message(self, fmt, *args):
|
||
print(f"[{now_ts()}] {self.address_string()} {fmt % args}", flush=True)
|
||
|
||
|
||
def main():
|
||
if not PASSWORD:
|
||
raise SystemExit("ASLAN_OPS_PASSWORD is required")
|
||
RUN_DIR.mkdir(parents=True, exist_ok=True)
|
||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||
print(f"aslan test ops listening on {HOST}:{PORT}", flush=True)
|
||
server.serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|