592 lines
26 KiB
Python
Executable File
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.

#!/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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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()