部署
This commit is contained in:
parent
1eba136810
commit
0edf38041e
@ -4,14 +4,19 @@ set -euo pipefail
|
|||||||
BASE_DIR="${BASE_DIR:-/opt/aslan-deploy}"
|
BASE_DIR="${BASE_DIR:-/opt/aslan-deploy}"
|
||||||
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/likei-services}"
|
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/likei-services}"
|
||||||
KUBECONFIG="${KUBECONFIG:-$BASE_DIR/kubeconfig}"
|
KUBECONFIG="${KUBECONFIG:-$BASE_DIR/kubeconfig}"
|
||||||
|
LOCK_FILE="${LOCK_FILE:-$BASE_DIR/deploy.lock}"
|
||||||
NAMESPACE="${NAMESPACE:-prod}"
|
NAMESPACE="${NAMESPACE:-prod}"
|
||||||
MODE="${MODE:-preload}"
|
MODE="${MODE:-preload}"
|
||||||
SKIP_BUILD="${SKIP_BUILD:-0}"
|
SKIP_BUILD="${SKIP_BUILD:-0}"
|
||||||
USE_GIT_SOURCE="${USE_GIT_SOURCE:-0}"
|
USE_GIT_SOURCE="${USE_GIT_SOURCE:-0}"
|
||||||
GIT_REMOTE_URL="${GIT_REMOTE_URL:-git@gitea.haiyihy.com:hy/aslan-server.git}"
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-git@gitea.haiyihy.com:hy/aslan-server.git}"
|
||||||
GIT_REF="${GIT_REF:-aslan_test}"
|
GIT_REF="${GIT_REF:-aslan_prod}"
|
||||||
|
MAVEN_GOALS="${MAVEN_GOALS:-clean package}"
|
||||||
|
MAVEN_PROFILE="${MAVEN_PROFILE:-prod}"
|
||||||
MAVEN_EXTRA_ARGS="${MAVEN_EXTRA_ARGS:-}"
|
MAVEN_EXTRA_ARGS="${MAVEN_EXTRA_ARGS:-}"
|
||||||
BUILD_TS="${BUILD_TS:-$(date +%Y%m%dv%H%M%S)}"
|
BUILD_TS="${BUILD_TS:-$(date +%Y%m%dv%H%M%S)}"
|
||||||
|
IMAGE_REGISTRY="${IMAGE_REGISTRY:-tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com}"
|
||||||
|
IMAGE_PROJECT="${IMAGE_PROJECT:-atyou-prod}"
|
||||||
BASE_IMAGE="tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/public_mirror_images/eclipse-temurin:17-jdk-jammy"
|
BASE_IMAGE="tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/public_mirror_images/eclipse-temurin:17-jdk-jammy"
|
||||||
FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
|
FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
|
||||||
|
|
||||||
@ -23,7 +28,7 @@ Usage:
|
|||||||
Examples:
|
Examples:
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh status
|
/opt/aslan-deploy/deploy-likei-services.sh status
|
||||||
/opt/aslan-deploy/deploy-likei-services.sh --mode preload other external console
|
/opt/aslan-deploy/deploy-likei-services.sh --mode preload other external console
|
||||||
USE_GIT_SOURCE=1 GIT_REF=aslan_test /opt/aslan-deploy/deploy-likei-services.sh --mode preload other
|
USE_GIT_SOURCE=1 GIT_REF=aslan_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other
|
||||||
MODE=push ALIYUN_USER=xxx ALIYUN_PASS=xxx /opt/aslan-deploy/deploy-likei-services.sh other
|
MODE=push ALIYUN_USER=xxx ALIYUN_PASS=xxx /opt/aslan-deploy/deploy-likei-services.sh other
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@ -44,6 +49,23 @@ need_cmd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
acquire_deploy_lock() {
|
||||||
|
need_cmd flock
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
# Only one deploy should mutate source, build images, preload nodes, and roll deployments at a time.
|
||||||
|
flock -n 9 || {
|
||||||
|
echo "another deploy is already running: $LOCK_FILE" >&2
|
||||||
|
exit 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_git_ref() {
|
||||||
|
if [[ ! "$GIT_REF" =~ ^[A-Za-z0-9._/-]{1,80}$ || "$GIT_REF" == -* || "$GIT_REF" == *..* ]]; then
|
||||||
|
echo "invalid GIT_REF: $GIT_REF" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
ensure_base_image() {
|
ensure_base_image() {
|
||||||
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
|
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
|
||||||
return
|
return
|
||||||
@ -62,6 +84,7 @@ update_source_from_git() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
need_cmd git
|
need_cmd git
|
||||||
|
validate_git_ref
|
||||||
mkdir -p "$(dirname "$SRC_DIR")"
|
mkdir -p "$(dirname "$SRC_DIR")"
|
||||||
if [[ ! -d "$SRC_DIR/.git" ]]; then
|
if [[ ! -d "$SRC_DIR/.git" ]]; then
|
||||||
rm -rf "$SRC_DIR"
|
rm -rf "$SRC_DIR"
|
||||||
@ -73,7 +96,7 @@ update_source_from_git() {
|
|||||||
log "git reset source to origin/$GIT_REF from $GIT_REMOTE_URL"
|
log "git reset source to origin/$GIT_REF from $GIT_REMOTE_URL"
|
||||||
git -C "$SRC_DIR" remote set-url origin "$GIT_REMOTE_URL"
|
git -C "$SRC_DIR" remote set-url origin "$GIT_REMOTE_URL"
|
||||||
git -C "$SRC_DIR" fetch origin "$GIT_REF"
|
git -C "$SRC_DIR" fetch origin "$GIT_REF"
|
||||||
git -C "$SRC_DIR" checkout "$GIT_REF"
|
git -C "$SRC_DIR" checkout -B "$GIT_REF" "origin/$GIT_REF"
|
||||||
git -C "$SRC_DIR" reset --hard "origin/$GIT_REF"
|
git -C "$SRC_DIR" reset --hard "origin/$GIT_REF"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +120,9 @@ service_dir() {
|
|||||||
|
|
||||||
image_repo() {
|
image_repo() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
other) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/other" ;;
|
other) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/other" ;;
|
||||||
external) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/external" ;;
|
external) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/external" ;;
|
||||||
console) echo "tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/atyou-prod/console" ;;
|
console) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/console" ;;
|
||||||
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@ -121,13 +144,14 @@ build_service() {
|
|||||||
if [[ "$SKIP_BUILD" != "1" ]]; then
|
if [[ "$SKIP_BUILD" != "1" ]]; then
|
||||||
log "maven package $svc ($module)"
|
log "maven package $svc ($module)"
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
mvn clean package -pl "$module" -am -P prod -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS
|
# MAVEN_GOALS=package keeps the existing target cache for code-only fast builds; use the default clean package for safer full builds.
|
||||||
|
mvn $MAVEN_GOALS -pl "$module" -am -P "$MAVEN_PROFILE" -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_base_image
|
ensure_base_image
|
||||||
log "docker build $image"
|
log "docker build $image"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg "SERVICE_VERSION=atyou-prod:${tag}" \
|
--build-arg "SERVICE_VERSION=${IMAGE_PROJECT}:${tag}" \
|
||||||
-f "$dir/Dockerfile" \
|
-f "$dir/Dockerfile" \
|
||||||
-t "$image" \
|
-t "$image" \
|
||||||
"$dir"
|
"$dir"
|
||||||
@ -274,6 +298,7 @@ main() {
|
|||||||
while [[ "$#" -gt 0 ]]; do
|
while [[ "$#" -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--mode)
|
--mode)
|
||||||
|
[[ -n "${2:-}" ]] || { echo "--mode requires an argument" >&2; exit 2; }
|
||||||
MODE="$2"
|
MODE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@ -304,6 +329,7 @@ main() {
|
|||||||
need_cmd kubectl
|
need_cmd kubectl
|
||||||
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
|
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
|
||||||
|
|
||||||
|
acquire_deploy_lock
|
||||||
update_source_from_git
|
update_source_from_git
|
||||||
|
|
||||||
local services=("$@")
|
local services=("$@")
|
||||||
21
.deploy/prod-deploy/ops/aslan-ops.service
Normal file
21
.deploy/prod-deploy/ops/aslan-ops.service
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Aslan deployment operations panel
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
Group=ubuntu
|
||||||
|
WorkingDirectory=/opt/aslan-deploy
|
||||||
|
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
Environment=HOME=/home/ubuntu
|
||||||
|
Environment=ASLAN_ALLOWED_BRANCHES=aslan_prod
|
||||||
|
EnvironmentFile=/etc/aslan-ops.env
|
||||||
|
ExecStart=/usr/bin/python3 /opt/aslan-deploy/ops/aslan_ops.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
607
.deploy/prod-deploy/ops/aslan_ops.py
Normal file
607
.deploy/prod-deploy/ops/aslan_ops.py
Normal file
@ -0,0 +1,607 @@
|
|||||||
|
#!/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-deploy"))
|
||||||
|
RUN_DIR = Path(os.environ.get("ASLAN_OPS_RUN_DIR", str(BASE_DIR / "ops" / "runs")))
|
||||||
|
KUBECONFIG = Path(os.environ.get("KUBECONFIG", str(BASE_DIR / "kubeconfig")))
|
||||||
|
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" / "likei-services")))
|
||||||
|
NAMESPACE = os.environ.get("ASLAN_NAMESPACE", "prod")
|
||||||
|
GIT_REF = os.environ.get("ASLAN_GIT_REF", "aslan_prod")
|
||||||
|
REMOTE_URL = os.environ.get("ASLAN_GIT_REMOTE_URL", "git@gitea.haiyihy.com:hy/aslan-server.git")
|
||||||
|
HOST = os.environ.get("ASLAN_OPS_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.environ.get("ASLAN_OPS_PORT", "18080"))
|
||||||
|
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))
|
||||||
|
ALLOWED_BRANCHES = {
|
||||||
|
branch.strip()
|
||||||
|
for branch in os.environ.get("ASLAN_ALLOWED_BRANCHES", GIT_REF).split(",")
|
||||||
|
if branch.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVICES = {
|
||||||
|
"other": {
|
||||||
|
"label": "Other",
|
||||||
|
"port": "5800",
|
||||||
|
"health": "/actuator/health",
|
||||||
|
},
|
||||||
|
"external": {
|
||||||
|
"label": "External",
|
||||||
|
"port": "5200",
|
||||||
|
"health": "/actuator/health",
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"label": "Console",
|
||||||
|
"port": "5300",
|
||||||
|
"health": "/console/actuator/health",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,80}$")
|
||||||
|
|
||||||
|
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_json(cmd, timeout=20):
|
||||||
|
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 json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(cmd, timeout=10):
|
||||||
|
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_status():
|
||||||
|
names = list(SERVICES)
|
||||||
|
payload = run_json([
|
||||||
|
"kubectl",
|
||||||
|
"--kubeconfig",
|
||||||
|
str(KUBECONFIG),
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"get",
|
||||||
|
"deploy",
|
||||||
|
*names,
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
])
|
||||||
|
items = []
|
||||||
|
for item in payload.get("items", []):
|
||||||
|
name = item["metadata"]["name"]
|
||||||
|
spec = item.get("spec", {})
|
||||||
|
status = item.get("status", {})
|
||||||
|
containers = item.get("spec", {}).get("template", {}).get("spec", {}).get("containers", [])
|
||||||
|
image = next((c.get("image", "") for c in containers if c.get("name") == name), containers[0].get("image", "") if containers else "")
|
||||||
|
conditions = {c.get("type"): c.get("status") for c in status.get("conditions", [])}
|
||||||
|
items.append({
|
||||||
|
"name": name,
|
||||||
|
"label": SERVICES.get(name, {}).get("label", name),
|
||||||
|
"ready": f"{status.get('readyReplicas', 0)}/{spec.get('replicas', 0)}",
|
||||||
|
"updated": status.get("updatedReplicas", 0),
|
||||||
|
"available": status.get("availableReplicas", 0),
|
||||||
|
"image": image,
|
||||||
|
"port": SERVICES.get(name, {}).get("port", ""),
|
||||||
|
"health": SERVICES.get(name, {}).get("health", ""),
|
||||||
|
"healthy": conditions.get("Available") == "True",
|
||||||
|
})
|
||||||
|
order = {name: idx for idx, name in enumerate(names)}
|
||||||
|
return sorted(items, key=lambda x: order.get(x["name"], 99))
|
||||||
|
|
||||||
|
|
||||||
|
def pod_status():
|
||||||
|
selector = "app in (other,external,console)"
|
||||||
|
payload = run_json([
|
||||||
|
"kubectl",
|
||||||
|
"--kubeconfig",
|
||||||
|
str(KUBECONFIG),
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"get",
|
||||||
|
"pods",
|
||||||
|
"-l",
|
||||||
|
selector,
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
])
|
||||||
|
pods = []
|
||||||
|
for item in payload.get("items", []):
|
||||||
|
status = item.get("status", {})
|
||||||
|
containers = status.get("containerStatuses", [])
|
||||||
|
ready_count = sum(1 for c in containers if c.get("ready"))
|
||||||
|
pods.append({
|
||||||
|
"name": item["metadata"]["name"],
|
||||||
|
"app": item["metadata"].get("labels", {}).get("app", ""),
|
||||||
|
"ready": f"{ready_count}/{len(containers)}",
|
||||||
|
"phase": status.get("phase", ""),
|
||||||
|
"restarts": sum(c.get("restartCount", 0) for c in containers),
|
||||||
|
"node": item.get("spec", {}).get("nodeName", ""),
|
||||||
|
"ip": status.get("podIP", ""),
|
||||||
|
"age": item["metadata"].get("creationTimestamp", ""),
|
||||||
|
})
|
||||||
|
return pods
|
||||||
|
|
||||||
|
|
||||||
|
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"])
|
||||||
|
return {"head": head, "branch": branch, "status": short}
|
||||||
|
|
||||||
|
|
||||||
|
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)[:20]:
|
||||||
|
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({
|
||||||
|
"USE_GIT_SOURCE": "1",
|
||||||
|
"GIT_REF": branch,
|
||||||
|
"GIT_REMOTE_URL": REMOTE_URL,
|
||||||
|
"MODE": "preload",
|
||||||
|
"KUBECONFIG": str(KUBECONFIG),
|
||||||
|
"NAMESPACE": NAMESPACE,
|
||||||
|
"ASLAN_NAMESPACE": NAMESPACE,
|
||||||
|
"HOME": str(Path.home()),
|
||||||
|
})
|
||||||
|
if fast:
|
||||||
|
# Fast mode avoids Maven clean so unchanged modules and already downloaded dependencies can reuse local target output.
|
||||||
|
env["MAVEN_GOALS"] = "package"
|
||||||
|
cmd = [str(DEPLOY_SCRIPT), "--mode", "preload", *services]
|
||||||
|
thread = threading.Thread(target=stream_process, args=(job, cmd, env), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
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 Ops</title>
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: light; --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 Ops</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 Ops</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(3,minmax(0,1fr)); gap:12px; }
|
||||||
|
.svc { border:1px solid var(--line); border-radius:8px; padding:14px; display:grid; gap:10px; min-height:172px; }
|
||||||
|
.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:90px 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:360px; overflow:auto; background:#101820; color:#e6edf3; padding:14px; border-radius:8px; font-size:12px; line-height:1.45; white-space:pre-wrap; }
|
||||||
|
.split { display:grid; grid-template-columns:2fr 1fr; gap:16px; }
|
||||||
|
@media (max-width: 900px) { header { padding:0 14px; } main { padding:14px; } .grid, .split { grid-template-columns:1fr; } .bar { align-items:flex-start; flex-direction:column; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Aslan Ops</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">固定 preload 到 TKE 节点,不走 registry push</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_prod">
|
||||||
|
<button class="primary" id="deploy">部署选中服务</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="split">
|
||||||
|
<section>
|
||||||
|
<div class="bar"><h2>部署日志</h2><span class="muted" id="jobState">无运行任务</span></div>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="bar"><h2>Pod</h2></div>
|
||||||
|
<table><thead><tr><th>名称</th><th>状态</th><th>节点</th></tr></thead><tbody id="pods"></tbody></table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<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 = ["other", "external", "console"];
|
||||||
|
let currentJob = "";
|
||||||
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
function badge(ok, text) { return `<span class="badge ${ok ? "" : "warn"}">${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.deployments.map(s => `
|
||||||
|
<article class="svc">
|
||||||
|
<div class="svc-head"><h3>${esc(s.label)}</h3>${badge(s.healthy, s.ready + " Ready")}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>端口</span><span>${esc(s.port)}</span>
|
||||||
|
<span>健康检查</span><span>${esc(s.health)}</span>
|
||||||
|
<span>镜像</span><span class="image" title="${esc(s.image)}">${esc(s.image)}</span>
|
||||||
|
</div>
|
||||||
|
<button data-service="${esc(s.name)}">部署 ${esc(s.name)}</button>
|
||||||
|
</article>`).join("");
|
||||||
|
document.querySelectorAll("[data-service]").forEach(btn => btn.onclick = () => deploy([btn.dataset.service]));
|
||||||
|
document.getElementById("pods").innerHTML = data.pods.map(p => `<tr><td>${esc(p.name)}<div class="muted">${esc(p.ip)}</div></td><td>${esc(p.ready)} ${esc(p.phase)}<div class="muted">restart ${esc(p.restarts)}</div></td><td>${esc(p.node)}</td></tr>`).join("");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 = "AslanOps/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", ["aslan_ops=; Max-Age=0; HttpOnly; SameSite=Lax; Path=/"])
|
||||||
|
return
|
||||||
|
if not self.require_auth():
|
||||||
|
return
|
||||||
|
if parsed.path == "/":
|
||||||
|
return text_response(self, 200, INDEX_HTML, "text/html; charset=utf-8")
|
||||||
|
if parsed.path == "/api/status":
|
||||||
|
try:
|
||||||
|
return json_response(self, 200, {
|
||||||
|
"deployments": deployment_status(),
|
||||||
|
"pods": pod_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)
|
||||||
|
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")[-120000:] if log_path.exists() else ""
|
||||||
|
return json_response(self, 200, {"job": job, "log": log})
|
||||||
|
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=/"])
|
||||||
|
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 ops listening on {HOST}:{PORT}", flush=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -15,12 +15,12 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
.deploy/aslan-deploy/sync-and-deploy.sh [remote deploy args...]
|
.deploy/prod-deploy/sync-and-deploy.sh [remote deploy args...]
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
.deploy/aslan-deploy/sync-and-deploy.sh status
|
.deploy/prod-deploy/sync-and-deploy.sh status
|
||||||
.deploy/aslan-deploy/sync-and-deploy.sh --mode preload other
|
.deploy/prod-deploy/sync-and-deploy.sh --mode preload other
|
||||||
.deploy/aslan-deploy/sync-and-deploy.sh --mode preload other external console
|
.deploy/prod-deploy/sync-and-deploy.sh --mode preload other external console
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
DEPLOY_HOST default 43.160.219.15
|
DEPLOY_HOST default 43.160.219.15
|
||||||
@ -29,7 +29,7 @@ Environment:
|
|||||||
|
|
||||||
Git source on deploy host:
|
Git source on deploy host:
|
||||||
ssh -i ~/.ssh/aslan-deploy-sg-ed25519 ubuntu@43.160.219.15 \
|
ssh -i ~/.ssh/aslan-deploy-sg-ed25519 ubuntu@43.160.219.15 \
|
||||||
'USE_GIT_SOURCE=1 GIT_REF=aslan_test /opt/aslan-deploy/deploy-likei-services.sh --mode preload other'
|
'USE_GIT_SOURCE=1 GIT_REF=aslan_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other'
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +58,10 @@ rsync -az \
|
|||||||
-e "$rsync_ssh" \
|
-e "$rsync_ssh" \
|
||||||
"$SCRIPT_DIR/deploy-likei-services.sh" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/deploy-likei-services.sh"
|
"$SCRIPT_DIR/deploy-likei-services.sh" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/deploy-likei-services.sh"
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "$rsync_ssh" \
|
||||||
|
"$SCRIPT_DIR/ops/" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/ops/"
|
||||||
|
|
||||||
for cache_group in $M2_CACHE_GROUPS; do
|
for cache_group in $M2_CACHE_GROUPS; do
|
||||||
local_cache_path="$LOCAL_M2_ROOT/$cache_group"
|
local_cache_path="$LOCAL_M2_ROOT/$cache_group"
|
||||||
remote_cache_parent="$REMOTE_M2_ROOT/$(dirname "$cache_group")"
|
remote_cache_parent="$REMOTE_M2_ROOT/$(dirname "$cache_group")"
|
||||||
345
.deploy/test-deploy/deploy-likei-services.sh
Executable file
345
.deploy/test-deploy/deploy-likei-services.sh
Executable file
@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_DIR="${BASE_DIR:-/opt/aslan-test-deploy}"
|
||||||
|
SRC_DIR="${SRC_DIR:-$BASE_DIR/source/likei-services}"
|
||||||
|
KUBECONFIG="${KUBECONFIG:-$BASE_DIR/kubeconfig}"
|
||||||
|
LOCK_FILE="${LOCK_FILE:-$BASE_DIR/deploy.lock}"
|
||||||
|
NAMESPACE="${NAMESPACE:-test}"
|
||||||
|
MODE="${MODE:-preload}"
|
||||||
|
SKIP_BUILD="${SKIP_BUILD:-0}"
|
||||||
|
USE_GIT_SOURCE="${USE_GIT_SOURCE:-0}"
|
||||||
|
GIT_REMOTE_URL="${GIT_REMOTE_URL:-git@gitea.haiyihy.com:hy/aslan-server.git}"
|
||||||
|
GIT_REF="${GIT_REF:-aslan_test}"
|
||||||
|
MAVEN_GOALS="${MAVEN_GOALS:-clean package}"
|
||||||
|
MAVEN_PROFILE="${MAVEN_PROFILE:-test}"
|
||||||
|
MAVEN_EXTRA_ARGS="${MAVEN_EXTRA_ARGS:-}"
|
||||||
|
BUILD_TS="${BUILD_TS:-$(date +%Y%m%dv%H%M%S)}"
|
||||||
|
IMAGE_REGISTRY="${IMAGE_REGISTRY:-tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com}"
|
||||||
|
IMAGE_PROJECT="${IMAGE_PROJECT:-atyou-test}"
|
||||||
|
BASE_IMAGE="tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com/public_mirror_images/eclipse-temurin:17-jdk-jammy"
|
||||||
|
FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [other|external|console ...]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
/opt/aslan-test-deploy/deploy-likei-services.sh status
|
||||||
|
/opt/aslan-test-deploy/deploy-likei-services.sh --mode preload other external console
|
||||||
|
USE_GIT_SOURCE=1 GIT_REF=aslan_test /opt/aslan-test-deploy/deploy-likei-services.sh --mode preload other
|
||||||
|
MODE=push ALIYUN_USER=xxx ALIYUN_PASS=xxx /opt/aslan-test-deploy/deploy-likei-services.sh other
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- preload mode builds the image on this server, imports it into each TKE node's containerd, then updates the deployment.
|
||||||
|
- push mode follows the original Jenkins flow and requires registry credentials in ALIYUN_USER/ALIYUN_PASS.
|
||||||
|
- USE_GIT_SOURCE=1 clones or resets source from GIT_REMOTE_URL/GIT_REF before building.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date '+%F %T')" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
need_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || {
|
||||||
|
echo "missing command: $1" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire_deploy_lock() {
|
||||||
|
need_cmd flock
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
# Only one deploy should mutate source, build images, preload nodes, and roll deployments at a time.
|
||||||
|
flock -n 9 || {
|
||||||
|
echo "another deploy is already running: $LOCK_FILE" >&2
|
||||||
|
exit 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_git_ref() {
|
||||||
|
if [[ ! "$GIT_REF" =~ ^[A-Za-z0-9._/-]{1,80}$ || "$GIT_REF" == -* || "$GIT_REF" == *..* ]]; then
|
||||||
|
echo "invalid GIT_REF: $GIT_REF" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_base_image() {
|
||||||
|
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if docker pull "$BASE_IMAGE"; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "base image not available from primary registry, pulling $FALLBACK_BASE_IMAGE"
|
||||||
|
docker pull "$FALLBACK_BASE_IMAGE"
|
||||||
|
docker tag "$FALLBACK_BASE_IMAGE" "$BASE_IMAGE"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_source_from_git() {
|
||||||
|
if [[ "$USE_GIT_SOURCE" != "1" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_cmd git
|
||||||
|
validate_git_ref
|
||||||
|
mkdir -p "$(dirname "$SRC_DIR")"
|
||||||
|
if [[ ! -d "$SRC_DIR/.git" ]]; then
|
||||||
|
rm -rf "$SRC_DIR"
|
||||||
|
log "git clone $GIT_REMOTE_URL ($GIT_REF) -> $SRC_DIR"
|
||||||
|
git clone --branch "$GIT_REF" "$GIT_REMOTE_URL" "$SRC_DIR"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "git reset source to origin/$GIT_REF from $GIT_REMOTE_URL"
|
||||||
|
git -C "$SRC_DIR" remote set-url origin "$GIT_REMOTE_URL"
|
||||||
|
git -C "$SRC_DIR" fetch origin "$GIT_REF"
|
||||||
|
git -C "$SRC_DIR" checkout -B "$GIT_REF" "origin/$GIT_REF"
|
||||||
|
git -C "$SRC_DIR" reset --hard "origin/$GIT_REF"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_module() {
|
||||||
|
case "$1" in
|
||||||
|
other) echo "rc-service/rc-service-other/other-start" ;;
|
||||||
|
external) echo "rc-service/rc-service-external/external-start" ;;
|
||||||
|
console) echo "rc-service/rc-service-console/console-start" ;;
|
||||||
|
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
service_dir() {
|
||||||
|
case "$1" in
|
||||||
|
other) echo "rc-service/rc-service-other" ;;
|
||||||
|
external) echo "rc-service/rc-service-external" ;;
|
||||||
|
console) echo "rc-service/rc-service-console" ;;
|
||||||
|
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
image_repo() {
|
||||||
|
case "$1" in
|
||||||
|
other) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/other" ;;
|
||||||
|
external) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/external" ;;
|
||||||
|
console) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/console" ;;
|
||||||
|
*) echo "unsupported service: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
build_service() {
|
||||||
|
local svc="$1"
|
||||||
|
local module
|
||||||
|
local dir
|
||||||
|
local repo
|
||||||
|
local tag
|
||||||
|
local image
|
||||||
|
module="$(service_module "$svc")"
|
||||||
|
dir="$(service_dir "$svc")"
|
||||||
|
repo="$(image_repo "$svc")"
|
||||||
|
tag="${svc}-${BUILD_TS}"
|
||||||
|
image="${repo}:${tag}"
|
||||||
|
|
||||||
|
cd "$SRC_DIR"
|
||||||
|
if [[ "$SKIP_BUILD" != "1" ]]; then
|
||||||
|
log "maven package $svc ($module)"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
# MAVEN_GOALS=package keeps the existing target cache for code-only fast builds; use the default clean package for safer full builds.
|
||||||
|
mvn $MAVEN_GOALS -pl "$module" -am -P "$MAVEN_PROFILE" -Dmaven.test.skip=true $MAVEN_EXTRA_ARGS
|
||||||
|
fi
|
||||||
|
|
||||||
|
ensure_base_image
|
||||||
|
log "docker build $image"
|
||||||
|
docker build \
|
||||||
|
--build-arg "SERVICE_VERSION=${IMAGE_PROJECT}:${tag}" \
|
||||||
|
-f "$dir/Dockerfile" \
|
||||||
|
-t "$image" \
|
||||||
|
"$dir"
|
||||||
|
docker tag "$image" "${repo}:latest"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
build-only)
|
||||||
|
log "build-only completed for $svc: $image"
|
||||||
|
;;
|
||||||
|
push)
|
||||||
|
push_image "$svc" "$image" "$repo"
|
||||||
|
rollout "$svc" "$image"
|
||||||
|
;;
|
||||||
|
preload)
|
||||||
|
ensure_preload_pull_policy "$svc"
|
||||||
|
preload_image_to_nodes "$svc" "$image"
|
||||||
|
rollout "$svc" "$image"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported MODE: $MODE" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
push_image() {
|
||||||
|
local svc="$1"
|
||||||
|
local image="$2"
|
||||||
|
local repo="$3"
|
||||||
|
if [[ -n "${ALIYUN_USER:-}" && -n "${ALIYUN_PASS:-}" ]]; then
|
||||||
|
log "docker login registry for $svc"
|
||||||
|
printf '%s' "$ALIYUN_PASS" | docker login --username "$ALIYUN_USER" --password-stdin tuokemi-con-registry.ap-southeast-1.cr.aliyuncs.com
|
||||||
|
fi
|
||||||
|
log "docker push $image"
|
||||||
|
docker push "$image"
|
||||||
|
docker push "${repo}:latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
preload_image_to_nodes() {
|
||||||
|
local svc="$1"
|
||||||
|
local image="$2"
|
||||||
|
local safe_tag="${image//[^A-Za-z0-9_.-]/-}"
|
||||||
|
local tar_path="$BASE_DIR/artifacts/${safe_tag}.tar"
|
||||||
|
mkdir -p "$BASE_DIR/artifacts"
|
||||||
|
log "docker save $image -> $tar_path"
|
||||||
|
docker save "$image" -o "$tar_path"
|
||||||
|
|
||||||
|
mapfile -t nodes < <(kubectl --kubeconfig "$KUBECONFIG" get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
|
||||||
|
if [[ "${#nodes[@]}" -eq 0 ]]; then
|
||||||
|
echo "no kubernetes nodes found" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
for node in "${nodes[@]}"; do
|
||||||
|
preload_image_to_node "$svc" "$image" "$tar_path" "$node"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_preload_pull_policy() {
|
||||||
|
local svc="$1"
|
||||||
|
local policy
|
||||||
|
policy="$(kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get "deployment/$svc" \
|
||||||
|
-o "jsonpath={.spec.template.spec.containers[?(@.name=='$svc')].imagePullPolicy}")"
|
||||||
|
# Preload mode imports images into node containerd; Always-pull pods would bypass that cache and fail.
|
||||||
|
if [[ "$policy" == "Always" ]]; then
|
||||||
|
echo "deployment/$svc uses imagePullPolicy=Always; preload mode requires IfNotPresent or unset" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
preload_image_to_node() {
|
||||||
|
local svc="$1"
|
||||||
|
local image="$2"
|
||||||
|
local tar_path="$3"
|
||||||
|
local node="$4"
|
||||||
|
local pod="image-preload-${svc}-${node//[^a-zA-Z0-9-]/-}-$(date +%s)"
|
||||||
|
local manifest
|
||||||
|
manifest="$(mktemp)"
|
||||||
|
cat >"$manifest" <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: ${pod}
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
spec:
|
||||||
|
nodeName: "${node}"
|
||||||
|
restartPolicy: Never
|
||||||
|
hostPID: true
|
||||||
|
tolerations:
|
||||||
|
- operator: Exists
|
||||||
|
containers:
|
||||||
|
- name: preload
|
||||||
|
image: docker:26.1.3-dind
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: ["sh", "-c", "sleep 3600"]
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: containerd
|
||||||
|
mountPath: /run/containerd/containerd.sock
|
||||||
|
volumes:
|
||||||
|
- name: containerd
|
||||||
|
hostPath:
|
||||||
|
path: /run/containerd/containerd.sock
|
||||||
|
type: Socket
|
||||||
|
EOF
|
||||||
|
log "create preload pod $pod on node $node"
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" apply -f "$manifest" >/dev/null
|
||||||
|
rm -f "$manifest"
|
||||||
|
if ! kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" wait --for=condition=Ready "pod/$pod" --timeout=180s >/dev/null; then
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" describe "pod/$pod" || true
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" delete pod "$pod" --ignore-not-found=true >/dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "copy image tar to $pod"
|
||||||
|
if ! kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" cp "$tar_path" "$pod:/tmp/image.tar" -c preload >/dev/null; then
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" delete pod "$pod" --ignore-not-found=true >/dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "import $image into node $node"
|
||||||
|
if ! kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" exec "$pod" -c preload -- \
|
||||||
|
ctr --address /run/containerd/containerd.sock -n k8s.io images import /tmp/image.tar >/dev/null; then
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" delete pod "$pod" --ignore-not-found=true >/dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" delete pod "$pod" --ignore-not-found=true >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
rollout() {
|
||||||
|
local svc="$1"
|
||||||
|
local image="$2"
|
||||||
|
log "set image deployment/$svc $svc=$image"
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" set image "deployment/$svc" "$svc=$image"
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" rollout status "deployment/$svc" --timeout=600s
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy "$svc" -o wide
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy other external console -o wide
|
||||||
|
kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
while [[ "$#" -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--mode)
|
||||||
|
[[ -n "${2:-}" ]] || { echo "--mode requires an argument" >&2; exit 2; }
|
||||||
|
MODE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-build)
|
||||||
|
SKIP_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "status" || "$MODE" == "status" ]]; then
|
||||||
|
need_cmd kubectl
|
||||||
|
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
|
||||||
|
status
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
need_cmd java
|
||||||
|
need_cmd mvn
|
||||||
|
need_cmd docker
|
||||||
|
need_cmd kubectl
|
||||||
|
[[ -f "$KUBECONFIG" ]] || { echo "missing kubeconfig: $KUBECONFIG" >&2; exit 2; }
|
||||||
|
|
||||||
|
acquire_deploy_lock
|
||||||
|
update_source_from_git
|
||||||
|
|
||||||
|
local services=("$@")
|
||||||
|
if [[ "${#services[@]}" -eq 0 ]]; then
|
||||||
|
services=(other external console)
|
||||||
|
fi
|
||||||
|
|
||||||
|
for svc in "${services[@]}"; do
|
||||||
|
build_service "$svc"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
21
.deploy/test-deploy/ops/aslan-test-ops.service
Normal file
21
.deploy/test-deploy/ops/aslan-test-ops.service
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Aslan test deployment operations panel
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ubuntu
|
||||||
|
Group=ubuntu
|
||||||
|
WorkingDirectory=/opt/aslan-test-deploy
|
||||||
|
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
Environment=HOME=/home/ubuntu
|
||||||
|
Environment=ASLAN_ALLOWED_BRANCHES=aslan_test
|
||||||
|
EnvironmentFile=/etc/aslan-test-ops.env
|
||||||
|
ExecStart=/usr/bin/python3 /opt/aslan-test-deploy/ops/aslan_ops.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
607
.deploy/test-deploy/ops/aslan_ops.py
Normal file
607
.deploy/test-deploy/ops/aslan_ops.py
Normal file
@ -0,0 +1,607 @@
|
|||||||
|
#!/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-deploy"))
|
||||||
|
RUN_DIR = Path(os.environ.get("ASLAN_OPS_RUN_DIR", str(BASE_DIR / "ops" / "runs")))
|
||||||
|
KUBECONFIG = Path(os.environ.get("KUBECONFIG", str(BASE_DIR / "kubeconfig")))
|
||||||
|
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" / "likei-services")))
|
||||||
|
NAMESPACE = os.environ.get("ASLAN_NAMESPACE", "test")
|
||||||
|
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", "0.0.0.0")
|
||||||
|
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))
|
||||||
|
ALLOWED_BRANCHES = {
|
||||||
|
branch.strip()
|
||||||
|
for branch in os.environ.get("ASLAN_ALLOWED_BRANCHES", GIT_REF).split(",")
|
||||||
|
if branch.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVICES = {
|
||||||
|
"other": {
|
||||||
|
"label": "Other",
|
||||||
|
"port": "5800",
|
||||||
|
"health": "/actuator/health",
|
||||||
|
},
|
||||||
|
"external": {
|
||||||
|
"label": "External",
|
||||||
|
"port": "5200",
|
||||||
|
"health": "/actuator/health",
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"label": "Console",
|
||||||
|
"port": "5300",
|
||||||
|
"health": "/console/actuator/health",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]{1,80}$")
|
||||||
|
|
||||||
|
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_json(cmd, timeout=20):
|
||||||
|
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 json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(cmd, timeout=10):
|
||||||
|
result = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return ""
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_status():
|
||||||
|
names = list(SERVICES)
|
||||||
|
payload = run_json([
|
||||||
|
"kubectl",
|
||||||
|
"--kubeconfig",
|
||||||
|
str(KUBECONFIG),
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"get",
|
||||||
|
"deploy",
|
||||||
|
*names,
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
])
|
||||||
|
items = []
|
||||||
|
for item in payload.get("items", []):
|
||||||
|
name = item["metadata"]["name"]
|
||||||
|
spec = item.get("spec", {})
|
||||||
|
status = item.get("status", {})
|
||||||
|
containers = item.get("spec", {}).get("template", {}).get("spec", {}).get("containers", [])
|
||||||
|
image = next((c.get("image", "") for c in containers if c.get("name") == name), containers[0].get("image", "") if containers else "")
|
||||||
|
conditions = {c.get("type"): c.get("status") for c in status.get("conditions", [])}
|
||||||
|
items.append({
|
||||||
|
"name": name,
|
||||||
|
"label": SERVICES.get(name, {}).get("label", name),
|
||||||
|
"ready": f"{status.get('readyReplicas', 0)}/{spec.get('replicas', 0)}",
|
||||||
|
"updated": status.get("updatedReplicas", 0),
|
||||||
|
"available": status.get("availableReplicas", 0),
|
||||||
|
"image": image,
|
||||||
|
"port": SERVICES.get(name, {}).get("port", ""),
|
||||||
|
"health": SERVICES.get(name, {}).get("health", ""),
|
||||||
|
"healthy": conditions.get("Available") == "True",
|
||||||
|
})
|
||||||
|
order = {name: idx for idx, name in enumerate(names)}
|
||||||
|
return sorted(items, key=lambda x: order.get(x["name"], 99))
|
||||||
|
|
||||||
|
|
||||||
|
def pod_status():
|
||||||
|
selector = "app in (other,external,console)"
|
||||||
|
payload = run_json([
|
||||||
|
"kubectl",
|
||||||
|
"--kubeconfig",
|
||||||
|
str(KUBECONFIG),
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"get",
|
||||||
|
"pods",
|
||||||
|
"-l",
|
||||||
|
selector,
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
])
|
||||||
|
pods = []
|
||||||
|
for item in payload.get("items", []):
|
||||||
|
status = item.get("status", {})
|
||||||
|
containers = status.get("containerStatuses", [])
|
||||||
|
ready_count = sum(1 for c in containers if c.get("ready"))
|
||||||
|
pods.append({
|
||||||
|
"name": item["metadata"]["name"],
|
||||||
|
"app": item["metadata"].get("labels", {}).get("app", ""),
|
||||||
|
"ready": f"{ready_count}/{len(containers)}",
|
||||||
|
"phase": status.get("phase", ""),
|
||||||
|
"restarts": sum(c.get("restartCount", 0) for c in containers),
|
||||||
|
"node": item.get("spec", {}).get("nodeName", ""),
|
||||||
|
"ip": status.get("podIP", ""),
|
||||||
|
"age": item["metadata"].get("creationTimestamp", ""),
|
||||||
|
})
|
||||||
|
return pods
|
||||||
|
|
||||||
|
|
||||||
|
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"])
|
||||||
|
return {"head": head, "branch": branch, "status": short}
|
||||||
|
|
||||||
|
|
||||||
|
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)[:20]:
|
||||||
|
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({
|
||||||
|
"USE_GIT_SOURCE": "1",
|
||||||
|
"GIT_REF": branch,
|
||||||
|
"GIT_REMOTE_URL": REMOTE_URL,
|
||||||
|
"MODE": "preload",
|
||||||
|
"KUBECONFIG": str(KUBECONFIG),
|
||||||
|
"NAMESPACE": NAMESPACE,
|
||||||
|
"ASLAN_NAMESPACE": NAMESPACE,
|
||||||
|
"HOME": str(Path.home()),
|
||||||
|
})
|
||||||
|
if fast:
|
||||||
|
# Fast mode avoids Maven clean so unchanged modules and already downloaded dependencies can reuse local target output.
|
||||||
|
env["MAVEN_GOALS"] = "package"
|
||||||
|
cmd = [str(DEPLOY_SCRIPT), "--mode", "preload", *services]
|
||||||
|
thread = threading.Thread(target=stream_process, args=(job, cmd, env), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
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 Ops</title>
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: light; --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 Ops</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 Ops</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(3,minmax(0,1fr)); gap:12px; }
|
||||||
|
.svc { border:1px solid var(--line); border-radius:8px; padding:14px; display:grid; gap:10px; min-height:172px; }
|
||||||
|
.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:90px 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:360px; overflow:auto; background:#101820; color:#e6edf3; padding:14px; border-radius:8px; font-size:12px; line-height:1.45; white-space:pre-wrap; }
|
||||||
|
.split { display:grid; grid-template-columns:2fr 1fr; gap:16px; }
|
||||||
|
@media (max-width: 900px) { header { padding:0 14px; } main { padding:14px; } .grid, .split { grid-template-columns:1fr; } .bar { align-items:flex-start; flex-direction:column; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Aslan Ops</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">固定 preload 到 TKE 节点,不走 registry push</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>
|
||||||
|
<div class="split">
|
||||||
|
<section>
|
||||||
|
<div class="bar"><h2>部署日志</h2><span class="muted" id="jobState">无运行任务</span></div>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="bar"><h2>Pod</h2></div>
|
||||||
|
<table><thead><tr><th>名称</th><th>状态</th><th>节点</th></tr></thead><tbody id="pods"></tbody></table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<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 = ["other", "external", "console"];
|
||||||
|
let currentJob = "";
|
||||||
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
function badge(ok, text) { return `<span class="badge ${ok ? "" : "warn"}">${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.deployments.map(s => `
|
||||||
|
<article class="svc">
|
||||||
|
<div class="svc-head"><h3>${esc(s.label)}</h3>${badge(s.healthy, s.ready + " Ready")}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>端口</span><span>${esc(s.port)}</span>
|
||||||
|
<span>健康检查</span><span>${esc(s.health)}</span>
|
||||||
|
<span>镜像</span><span class="image" title="${esc(s.image)}">${esc(s.image)}</span>
|
||||||
|
</div>
|
||||||
|
<button data-service="${esc(s.name)}">部署 ${esc(s.name)}</button>
|
||||||
|
</article>`).join("");
|
||||||
|
document.querySelectorAll("[data-service]").forEach(btn => btn.onclick = () => deploy([btn.dataset.service]));
|
||||||
|
document.getElementById("pods").innerHTML = data.pods.map(p => `<tr><td>${esc(p.name)}<div class="muted">${esc(p.ip)}</div></td><td>${esc(p.ready)} ${esc(p.phase)}<div class="muted">restart ${esc(p.restarts)}</div></td><td>${esc(p.node)}</td></tr>`).join("");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 = "AslanOps/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", ["aslan_ops=; Max-Age=0; HttpOnly; SameSite=Lax; Path=/"])
|
||||||
|
return
|
||||||
|
if not self.require_auth():
|
||||||
|
return
|
||||||
|
if parsed.path == "/":
|
||||||
|
return text_response(self, 200, INDEX_HTML, "text/html; charset=utf-8")
|
||||||
|
if parsed.path == "/api/status":
|
||||||
|
try:
|
||||||
|
return json_response(self, 200, {
|
||||||
|
"deployments": deployment_status(),
|
||||||
|
"pods": pod_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)
|
||||||
|
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")[-120000:] if log_path.exists() else ""
|
||||||
|
return json_response(self, 200, {"job": job, "log": log})
|
||||||
|
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=/"])
|
||||||
|
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 ops listening on {HOST}:{PORT}", flush=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
84
.deploy/test-deploy/sync-and-deploy.sh
Executable file
84
.deploy/test-deploy/sync-and-deploy.sh
Executable file
@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_HOST="${DEPLOY_HOST:-43.160.219.15}"
|
||||||
|
DEPLOY_USER="${DEPLOY_USER:-ubuntu}"
|
||||||
|
SSH_KEY="${SSH_KEY:-$HOME/.ssh/aslan-deploy-sg-ed25519}"
|
||||||
|
REMOTE_BASE="${REMOTE_BASE:-/opt/aslan-test-deploy}"
|
||||||
|
REMOTE_SRC="$REMOTE_BASE/source/likei-services"
|
||||||
|
LOCAL_M2_ROOT="${LOCAL_M2_ROOT:-$HOME/.m2/repository}"
|
||||||
|
REMOTE_M2_ROOT="${REMOTE_M2_ROOT:-/home/$DEPLOY_USER/.m2/repository}"
|
||||||
|
M2_CACHE_GROUPS="${M2_CACHE_GROUPS:-com/red/circle com/github/sud}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
.deploy/test-deploy/sync-and-deploy.sh [remote deploy args...]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
.deploy/test-deploy/sync-and-deploy.sh status
|
||||||
|
.deploy/test-deploy/sync-and-deploy.sh --mode preload other
|
||||||
|
.deploy/test-deploy/sync-and-deploy.sh --mode preload other external console
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
DEPLOY_HOST default 43.160.219.15
|
||||||
|
DEPLOY_USER default ubuntu
|
||||||
|
SSH_KEY default ~/.ssh/aslan-deploy-sg-ed25519
|
||||||
|
|
||||||
|
Git source on deploy host:
|
||||||
|
ssh -i ~/.ssh/aslan-deploy-sg-ed25519 ubuntu@43.160.219.15 \
|
||||||
|
'USE_GIT_SOURCE=1 GIT_REF=aslan_test /opt/aslan-test-deploy/deploy-likei-services.sh --mode preload other'
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh_base=(ssh -i "$SSH_KEY" -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST")
|
||||||
|
rsync_ssh="ssh -i $SSH_KEY -o StrictHostKeyChecking=accept-new"
|
||||||
|
|
||||||
|
"${ssh_base[@]}" "mkdir -p '$REMOTE_SRC' '$REMOTE_BASE'"
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude '.git/' \
|
||||||
|
--exclude 'target/' \
|
||||||
|
--exclude '**/target/' \
|
||||||
|
--exclude '.idea/' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude 'build.md' \
|
||||||
|
--exclude 'node_modules/' \
|
||||||
|
-e "$rsync_ssh" \
|
||||||
|
"$REPO_ROOT/" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_SRC/"
|
||||||
|
|
||||||
|
rsync -az \
|
||||||
|
-e "$rsync_ssh" \
|
||||||
|
"$SCRIPT_DIR/deploy-likei-services.sh" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/deploy-likei-services.sh"
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "$rsync_ssh" \
|
||||||
|
"$SCRIPT_DIR/ops/" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_BASE/ops/"
|
||||||
|
|
||||||
|
for cache_group in $M2_CACHE_GROUPS; do
|
||||||
|
local_cache_path="$LOCAL_M2_ROOT/$cache_group"
|
||||||
|
remote_cache_parent="$REMOTE_M2_ROOT/$(dirname "$cache_group")"
|
||||||
|
if [[ ! -d "$local_cache_path" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
"${ssh_base[@]}" "mkdir -p '$remote_cache_parent'"
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude '*.lastUpdated' \
|
||||||
|
-e "$rsync_ssh" \
|
||||||
|
"$local_cache_path" "$DEPLOY_USER@$DEPLOY_HOST:$remote_cache_parent/"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf -v remote_script_q '%q' "$REMOTE_BASE/deploy-likei-services.sh"
|
||||||
|
remote_cmd="$remote_script_q"
|
||||||
|
if [[ "$#" -gt 0 ]]; then
|
||||||
|
printf -v remote_args_q ' %q' "$@"
|
||||||
|
remote_cmd+="$remote_args_q"
|
||||||
|
fi
|
||||||
|
"${ssh_base[@]}" "chmod +x $remote_script_q && $remote_cmd"
|
||||||
Loading…
x
Reference in New Issue
Block a user