169 lines
6.0 KiB
Python
Executable File
169 lines
6.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Dict, List, Optional, Tuple
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
|
|
def request_json(api_url: str, method: str, path: str, payload: Optional[Dict] = None) -> Tuple[int, Dict]:
|
|
data = None
|
|
headers = {"Accept": "application/json"}
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
req = urllib.request.Request(
|
|
f"{api_url.rstrip('/')}{path}",
|
|
data=data,
|
|
method=method.upper(),
|
|
headers=headers,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
status = int(resp.getcode() or 200)
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read().decode("utf-8", errors="replace")
|
|
status = int(e.code or 500)
|
|
except Exception as e:
|
|
return 0, {"ok": False, "message": str(e), "code": "HTTP_CLIENT_ERROR"}
|
|
|
|
try:
|
|
parsed = json.loads(raw) if raw else {}
|
|
except Exception:
|
|
parsed = {"raw": raw}
|
|
if not isinstance(parsed, dict):
|
|
parsed = {"raw": parsed}
|
|
return status, parsed
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Transport runbook helper for /api/v1/transport/*")
|
|
parser.add_argument("--api-url", default=os.environ.get("API_URL", "http://127.0.0.1:8080"))
|
|
parser.add_argument("--client-id", default="")
|
|
parser.add_argument("--kind", default="singbox", choices=["singbox", "dnstt", "phoenix"])
|
|
parser.add_argument("--name", default="")
|
|
parser.add_argument("--enabled", action="store_true")
|
|
parser.add_argument("--config-json", default='{"runner":"mock","runtime_mode":"exec"}')
|
|
parser.add_argument("--actions", default="capabilities")
|
|
parser.add_argument("--force-delete", action="store_true")
|
|
parser.add_argument("--allow-fail", default="")
|
|
return parser.parse_args()
|
|
|
|
|
|
def require_client_id(action: str, client_id: str) -> None:
|
|
if action == "capabilities":
|
|
return
|
|
if not client_id.strip():
|
|
raise ValueError(f"--client-id is required for action '{action}'")
|
|
|
|
|
|
def format_summary(action: str, status: int, payload: Dict) -> str:
|
|
ok = payload.get("ok")
|
|
code = payload.get("code")
|
|
msg = payload.get("message")
|
|
extras: List[str] = []
|
|
if "status" in payload:
|
|
extras.append(f"status={payload.get('status')}")
|
|
if "status_before" in payload:
|
|
extras.append(f"before={payload.get('status_before')}")
|
|
if "status_after" in payload:
|
|
extras.append(f"after={payload.get('status_after')}")
|
|
extra_text = f" {' '.join(extras)}" if extras else ""
|
|
return f"[transport_runbook] {action}: http={status} ok={ok} code={code} message={msg}{extra_text}"
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
api_url = args.api_url.strip()
|
|
if not api_url:
|
|
print("[transport_runbook] ERROR: empty --api-url")
|
|
return 1
|
|
|
|
actions = [a.strip().lower() for a in args.actions.split(",") if a.strip()]
|
|
if not actions:
|
|
print("[transport_runbook] ERROR: --actions must not be empty")
|
|
return 1
|
|
allow_fail = {a.strip().lower() for a in args.allow_fail.split(",") if a.strip()}
|
|
|
|
try:
|
|
cfg = json.loads(args.config_json)
|
|
except Exception as e:
|
|
print(f"[transport_runbook] ERROR: invalid --config-json: {e}")
|
|
return 1
|
|
if not isinstance(cfg, dict):
|
|
print("[transport_runbook] ERROR: --config-json must be a JSON object")
|
|
return 1
|
|
|
|
for action in actions:
|
|
try:
|
|
require_client_id(action, args.client_id)
|
|
except ValueError as e:
|
|
print(f"[transport_runbook] ERROR: {e}")
|
|
return 1
|
|
|
|
path = ""
|
|
method = "GET"
|
|
payload: Optional[Dict] = None
|
|
|
|
if action == "capabilities":
|
|
path = "/api/v1/transport/capabilities"
|
|
method = "GET"
|
|
elif action == "create":
|
|
path = "/api/v1/transport/clients"
|
|
method = "POST"
|
|
payload = {
|
|
"id": args.client_id,
|
|
"name": args.name.strip() or f"Runbook {args.client_id}",
|
|
"kind": args.kind,
|
|
"enabled": bool(args.enabled),
|
|
"config": cfg,
|
|
}
|
|
elif action == "provision":
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/provision"
|
|
method = "POST"
|
|
elif action == "start":
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/start"
|
|
method = "POST"
|
|
elif action == "health":
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/health"
|
|
method = "GET"
|
|
elif action == "metrics":
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/metrics"
|
|
method = "GET"
|
|
elif action == "restart":
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/restart"
|
|
method = "POST"
|
|
elif action == "stop":
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}/stop"
|
|
method = "POST"
|
|
elif action == "delete":
|
|
q = "?force=true" if args.force_delete else ""
|
|
path = f"/api/v1/transport/clients/{urllib.parse.quote(args.client_id)}{q}"
|
|
method = "DELETE"
|
|
else:
|
|
print(f"[transport_runbook] ERROR: unsupported action '{action}'")
|
|
return 1
|
|
|
|
status, res = request_json(api_url, method, path, payload)
|
|
print(format_summary(action, status, res))
|
|
if status != 200:
|
|
if action in allow_fail:
|
|
continue
|
|
return 1
|
|
if not bool(res.get("ok", False)) and action not in allow_fail:
|
|
return 1
|
|
|
|
print("[transport_runbook] done")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|