Files
wehub-resource-sync a1fa97429b
Deploy Documentation to Pages / build (push) Has been cancelled
Deploy Documentation to Pages / deploy (push) Has been cancelled
Release / Tag + GitHub Release (push) Has been cancelled
Sync Codex Skills Symlinks / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:41:47 +08:00

274 lines
9.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Deployment Manager
Generates blue/green or rolling Kubernetes deployment manifests plus an ordered
runbook of kubectl commands, and audits existing manifests. It never talks to a
cluster itself — review the manifests and run the printed commands yourself.
Subcommands:
deploy --env --image [--strategy] [--health-check-url] — write manifests + runbook
rollback --env --to-version — write a rollback runbook
analyze --env — audit manifests on disk
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List
DEPLOYMENT_TEMPLATE = """apiVersion: apps/v1
kind: Deployment
metadata:
name: {name}
namespace: {env}
labels:
app: {app}{slot_label}
spec:
replicas: {replicas}
selector:
matchLabels:
app: {app}{slot_label_indented}
template:
metadata:
labels:
app: {app}{slot_label_indented2}
spec:
containers:
- name: app
image: {image}
readinessProbe:
httpGet:
path: {health_path}
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
"""
SERVICE_TEMPLATE = """apiVersion: v1
kind: Service
metadata:
name: {app}-svc
namespace: {env}
spec:
selector:
app: {app}{slot_selector}
ports:
- port: 80
targetPort: 8080
"""
def app_name_from_image(image: str) -> str:
"""ghcr.io/org/my-app:1.2.3 -> my-app"""
repo = image.rsplit(":", 1)[0]
return repo.rsplit("/", 1)[-1] or "app"
def render_deployment(app: str, env: str, image: str, replicas: int,
health_path: str, slot: str = "") -> str:
return DEPLOYMENT_TEMPLATE.format(
name=f"{app}-{slot}" if slot else app,
env=env,
app=app,
image=image,
replicas=replicas,
health_path=health_path,
slot_label=f"\n slot: {slot}" if slot else "",
slot_label_indented=f"\n slot: {slot}" if slot else "",
slot_label_indented2=f"\n slot: {slot}" if slot else "",
)
def cmd_deploy(args) -> Dict:
app = args.app or app_name_from_image(args.image)
health_path = "/healthz"
if args.health_check_url:
match = re.search(r"https?://[^/]+(/.*)", args.health_check_url)
if match:
health_path = match.group(1)
out_dir = Path(args.output_dir) / args.env
out_dir.mkdir(parents=True, exist_ok=True)
written: List[str] = []
runbook: List[str] = []
if args.strategy == "blue-green":
slot = args.slot
manifest = out_dir / f"deployment-{slot}.yaml"
manifest.write_text(
render_deployment(app, args.env, args.image, args.replicas, health_path, slot),
encoding="utf-8",
)
written.append(str(manifest))
service = out_dir / "service.yaml"
if not service.exists():
# service starts pointing at the OTHER slot; traffic switches in the runbook
other = "green" if slot == "blue" else "blue"
service.write_text(SERVICE_TEMPLATE.format(
app=app, env=args.env, slot_selector=f"\n slot: {other}"), encoding="utf-8")
written.append(str(service))
runbook = [
f"kubectl apply -f {manifest}",
f"kubectl rollout status deployment/{app}-{slot} -n {args.env}",
]
if args.health_check_url:
runbook.append(f"curl -sf {args.health_check_url} || echo 'HEALTH CHECK FAILED — do not switch traffic'")
runbook += [
f"# switch traffic to the {slot} slot only after the checks above pass:",
f"kubectl patch service {app}-svc -n {args.env} "
f"-p '{{\"spec\":{{\"selector\":{{\"app\":\"{app}\",\"slot\":\"{slot}\"}}}}}}'",
]
else: # rolling
manifest = out_dir / "deployment.yaml"
manifest.write_text(
render_deployment(app, args.env, args.image, args.replicas, health_path),
encoding="utf-8",
)
written.append(str(manifest))
service = out_dir / "service.yaml"
if not service.exists():
service.write_text(SERVICE_TEMPLATE.format(
app=app, env=args.env, slot_selector=""), encoding="utf-8")
written.append(str(service))
runbook = [
f"kubectl apply -f {manifest}",
f"kubectl rollout status deployment/{app} -n {args.env}",
]
if args.health_check_url:
runbook.append(f"curl -sf {args.health_check_url} || kubectl rollout undo deployment/{app} -n {args.env}")
return {
"status": "success",
"action": "deploy",
"env": args.env,
"app": app,
"image": args.image,
"strategy": args.strategy,
"manifests_written": written,
"runbook": runbook,
}
def cmd_rollback(args) -> Dict:
app = args.app
runbook = [
f"# Option 1 — pin the previous image version explicitly:",
f"kubectl set image deployment/{app} app={app}:{args.to_version} -n {args.env}",
f"kubectl rollout status deployment/{app} -n {args.env}",
f"# Option 2 — revert to the previous ReplicaSet:",
f"kubectl rollout undo deployment/{app} -n {args.env}",
f"# Verify:",
f"kubectl get pods -n {args.env} -l app={app}",
]
return {
"status": "success",
"action": "rollback",
"env": args.env,
"app": app,
"to_version": args.to_version,
"runbook": runbook,
}
def cmd_analyze(args) -> Dict:
env_dir = Path(args.output_dir) / args.env
deployments = []
if env_dir.is_dir():
for manifest in sorted(env_dir.glob("deployment*.yaml")):
text = manifest.read_text(encoding="utf-8")
image = re.search(r"image:\s*(\S+)", text)
replicas = re.search(r"replicas:\s*(\d+)", text)
slot = re.search(r"slot:\s*(\S+)", text)
deployments.append({
"manifest": str(manifest),
"image": image.group(1) if image else "unknown",
"replicas": int(replicas.group(1)) if replicas else 0,
"slot": slot.group(1) if slot else None,
})
return {
"status": "success" if deployments else "empty",
"action": "analyze",
"env": args.env,
"manifest_dir": str(env_dir),
"deployments": deployments,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate deployment manifests and runbooks (blue/green or rolling)."
)
sub = parser.add_subparsers(dest="command", required=True)
deploy = sub.add_parser("deploy", help="Generate deployment manifests + runbook")
deploy.add_argument("--env", required=True, help="Target environment / namespace")
deploy.add_argument("--image", required=True, help="Container image (repo:tag)")
deploy.add_argument("--strategy", default="rolling", choices=["blue-green", "rolling"])
deploy.add_argument("--health-check-url", help="Health check URL gating traffic switch")
deploy.add_argument("--app", help="App name (default: derived from image)")
deploy.add_argument("--slot", default="blue", choices=["blue", "green"],
help="Slot to deploy into (blue-green only)")
deploy.add_argument("--replicas", type=int, default=3)
deploy.add_argument("--output-dir", default="./deploy", help="Manifest output directory")
rollback = sub.add_parser("rollback", help="Generate a rollback runbook")
rollback.add_argument("--env", required=True)
rollback.add_argument("--to-version", required=True, help="Version to roll back to")
rollback.add_argument("--app", default="app", help="App / deployment name")
analyze = sub.add_parser("analyze", help="Audit deployment manifests on disk")
analyze.add_argument("--env", required=True)
analyze.add_argument("--output-dir", default="./deploy", help="Manifest directory")
for p in (deploy, rollback, analyze):
p.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
p.add_argument("--json", action="store_true", help="Output results as JSON")
p.add_argument("--output", "-o", help="Write JSON results to this file")
return parser
def main():
# support the documented `--analyze --env=...` flag form as an alias
argv = ["analyze" if a == "--analyze" else a for a in sys.argv[1:]]
args = build_parser().parse_args(argv)
handlers = {"deploy": cmd_deploy, "rollback": cmd_rollback, "analyze": cmd_analyze}
results = handlers[args.command](args)
print(f"🚀 {results['action']} ({results['env']}) — status: {results['status']}")
for manifest in results.get("manifests_written", []):
print(f"✓ Wrote {manifest}")
for dep in results.get("deployments", []):
slot = f" slot={dep['slot']}" if dep["slot"] else ""
print(f" - {dep['manifest']}: image={dep['image']} replicas={dep['replicas']}{slot}")
if results.get("runbook"):
print("\nRunbook — review, then execute in order:")
for step in results["runbook"]:
print(f" {step}")
if args.json or args.output:
output = json.dumps(results, indent=2)
if args.output:
Path(args.output).write_text(output, encoding="utf-8")
print(f"Results written to {args.output}")
else:
print(output)
if __name__ == "__main__":
main()