267 lines
14 KiB
TypeScript
267 lines
14 KiB
TypeScript
import type { UniDeskConfig } from "../config";
|
|
import type { RenderedCliResult } from "../output";
|
|
|
|
import { readCodexPoolConfig } from "./config";
|
|
import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote";
|
|
import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
|
import { codexPoolConfigPath, type CodexErrorPassthroughRule } from "./types";
|
|
|
|
type Action = "list" | "apply" | "delete";
|
|
|
|
interface Options {
|
|
action: Action;
|
|
template: string | null;
|
|
confirm: boolean;
|
|
json: boolean;
|
|
targetId: string;
|
|
}
|
|
|
|
export async function codexPoolErrorPassthrough(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
if (args.includes("--help")) return help();
|
|
const options = parseOptions(args);
|
|
const pool = readCodexPoolConfig();
|
|
const target = codexPoolRuntimeTarget(options.targetId);
|
|
const selected = options.template === null ? null : pool.runtime.errorPassthroughRulesById[options.template];
|
|
if (options.template !== null && selected === undefined) {
|
|
throw new Error(`unknown error-passthrough template ${options.template}; available: ${pool.runtime.errorPassthroughRules.map((item) => item.id).join(", ")}`);
|
|
}
|
|
const payload = {
|
|
action: options.action,
|
|
confirm: options.confirm,
|
|
selected: selected === null || selected === undefined ? null : apiRule(selected),
|
|
managed: pool.runtime.errorPassthroughRules.map((rule) => ({ id: rule.id, ...apiRule(rule) })),
|
|
};
|
|
const result = await runRemoteCodexPoolScript(config, "error-passthrough", remoteScript(payload, target), target);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const ok = result.exitCode === 0 && boolField(parsed, "ok", false);
|
|
const response = {
|
|
ok,
|
|
action: `platform-infra-sub2api-codex-pool-error-passthrough-${options.action}`,
|
|
target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode, endpoint: target.serviceDns },
|
|
source: { configPath: `${codexPoolConfigPath}#runtime.errorPassthroughRules`, api: "/api/v1/admin/error-passthrough-rules", official: true },
|
|
request: { template: options.template, confirm: options.confirm },
|
|
result: parsed,
|
|
remote: compactCapture(result, { full: parsed === null || result.exitCode !== 0 }),
|
|
valuesPrinted: false,
|
|
};
|
|
return options.json ? response : render(response);
|
|
}
|
|
|
|
function parseOptions(args: string[]): Options {
|
|
const [actionRaw = "list", ...rest] = args;
|
|
if (actionRaw !== "list" && actionRaw !== "apply" && actionRaw !== "delete") throw new Error("error-passthrough usage: list|apply|delete [--template <id>] [--confirm] [--target <id>] [--json]");
|
|
let template: string | null = null;
|
|
let confirm = false;
|
|
let json = false;
|
|
let targetId = defaultCodexPoolRuntimeTargetId();
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|
const arg = rest[index]!;
|
|
const readValue = (name: string): string => {
|
|
const value = rest[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
index += 1;
|
|
return value;
|
|
};
|
|
if (arg === "--template") template = readValue("--template");
|
|
else if (arg.startsWith("--template=")) template = arg.slice("--template=".length);
|
|
else if (arg === "--target") targetId = readValue("--target");
|
|
else if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length);
|
|
else if (arg === "--confirm") confirm = true;
|
|
else if (arg === "--json") json = true;
|
|
else throw new Error(`unsupported error-passthrough option: ${arg}`);
|
|
}
|
|
if ((actionRaw === "apply" || actionRaw === "delete") && template === null) throw new Error(`error-passthrough ${actionRaw} requires --template <id>`);
|
|
if (actionRaw === "list" && (template !== null || confirm)) throw new Error("error-passthrough list does not accept --template or --confirm");
|
|
return { action: actionRaw, template, confirm, json, targetId };
|
|
}
|
|
|
|
function apiRule(rule: CodexErrorPassthroughRule): Record<string, unknown> {
|
|
return {
|
|
name: rule.name,
|
|
enabled: rule.enabled,
|
|
priority: rule.priority,
|
|
error_codes: rule.errorCodes,
|
|
keywords: rule.keywords,
|
|
match_mode: rule.matchMode,
|
|
platforms: rule.platforms,
|
|
passthrough_code: rule.passthroughCode,
|
|
response_code: rule.responseCode,
|
|
passthrough_body: rule.passthroughBody,
|
|
custom_message: rule.customMessage,
|
|
skip_monitoring: rule.skipMonitoring,
|
|
description: rule.description,
|
|
};
|
|
}
|
|
|
|
function help(): RenderedCliResult {
|
|
return {
|
|
ok: true,
|
|
command: "platform-infra sub2api codex-pool error-passthrough --help",
|
|
renderedText: [
|
|
"SUB2API ERROR PASSTHROUGH",
|
|
"Usage:",
|
|
" bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough list [--target PK01] [--json]",
|
|
" bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough apply --template <id> [--target PK01] [--confirm] [--json]",
|
|
" bun scripts/cli.ts platform-infra sub2api codex-pool error-passthrough delete --template <id> [--target PK01] [--confirm] [--json]",
|
|
"Writes use the official Sub2API admin error-passthrough-rules API. Without --confirm, apply/delete are dry-run.",
|
|
].join("\n"),
|
|
contentType: "text/plain",
|
|
};
|
|
}
|
|
|
|
function render(response: Record<string, unknown>): RenderedCliResult {
|
|
const result = record(response.result);
|
|
const rows = Array.isArray(result.rules) ? result.rules.map(record) : [];
|
|
const lines = [
|
|
`SUB2API ERROR PASSTHROUGH ok=${response.ok === true ? "true" : "false"} mode=${text(result.mode)} mutation=${text(result.mutation)}`,
|
|
"TEMPLATE RULE_ID ENABLED MATCH ERROR_CODES RESPONSE PLATFORMS STATE",
|
|
];
|
|
for (const row of rows) {
|
|
lines.push([
|
|
text(row.templateId).padEnd(36),
|
|
text(row.ruleId).padEnd(7),
|
|
text(row.enabled).padEnd(7),
|
|
text(row.matchMode).padEnd(5),
|
|
text(row.errorCodes).padEnd(11),
|
|
text(row.responseCode).padEnd(8),
|
|
text(row.platforms).padEnd(9),
|
|
text(row.state),
|
|
].join(" ").trimEnd());
|
|
}
|
|
if (result.unmanagedCount !== undefined) lines.push(`UNMANAGED ${text(result.unmanagedCount)}`);
|
|
if (result.message) lines.push(`MESSAGE ${text(result.message)}`);
|
|
return { ok: response.ok === true, command: String(response.action), renderedText: lines.join("\n"), contentType: "text/plain", projection: response };
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function text(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") return "-";
|
|
if (Array.isArray(value)) return value.join(",");
|
|
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
return String(value).replace(/[\r\n\t]+/gu, " ");
|
|
}
|
|
|
|
function remoteScript(payload: unknown, target: ReturnType<typeof codexPoolRuntimeTarget>): string {
|
|
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
return `set -u
|
|
python3 - <<'PY'
|
|
import base64, json, subprocess
|
|
from urllib.parse import urlencode
|
|
|
|
PAYLOAD = json.loads(base64.b64decode(${JSON.stringify(encoded)}).decode("utf-8"))
|
|
RUNTIME_MODE = ${JSON.stringify(target.runtimeMode)}
|
|
NAMESPACE = ${JSON.stringify(target.namespace)}
|
|
APP_SECRET_NAME = ${JSON.stringify(target.appSecretName)}
|
|
HOST_PORT = ${JSON.stringify(target.hostDockerAppPort)}
|
|
APP_POD = None
|
|
|
|
def run(cmd, body=None):
|
|
return subprocess.run(cmd, input=body, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
def docker(args):
|
|
proc = run(["docker", *args])
|
|
return proc if proc.returncode == 0 else run(["sudo", "-n", "docker", *args])
|
|
|
|
def kubectl(args, body=None):
|
|
return run(["kubectl", *args], body)
|
|
|
|
def kube_json(args):
|
|
proc = kubectl([*args, "-o", "json"])
|
|
if proc.returncode != 0: raise RuntimeError("kubectl read failed")
|
|
return json.loads(proc.stdout.decode())
|
|
|
|
if RUNTIME_MODE != "host-docker":
|
|
pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app=sub2api"]).get("items") or []
|
|
running = [x for x in pods if x.get("status", {}).get("phase") == "Running"]
|
|
if not running: raise RuntimeError("sub2api app pod not found")
|
|
APP_POD = running[0]["metadata"]["name"]
|
|
|
|
def env_value(key, default=None):
|
|
if RUNTIME_MODE == "host-docker":
|
|
proc = docker(["inspect", "sub2api-app", "--format", "{{json .Config.Env}}"])
|
|
if proc.returncode != 0: return default
|
|
for item in json.loads(proc.stdout.decode()):
|
|
if item.startswith(key + "="): return item.split("=", 1)[1]
|
|
return default
|
|
cm = kube_json(["-n", NAMESPACE, "get", "configmap", "sub2api-config"]).get("data") or {}
|
|
return cm.get(key, default)
|
|
|
|
def secret_value(key):
|
|
if RUNTIME_MODE == "host-docker": return env_value(key)
|
|
data = kube_json(["-n", NAMESPACE, "get", "secret", APP_SECRET_NAME]).get("data") or {}
|
|
return base64.b64decode(data[key]).decode() if key in data else None
|
|
|
|
def api(method, path, token=None, payload=None):
|
|
body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode()
|
|
script = '''set -eu
|
|
method="$1"; url="$2"; token="$3"; tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT; cat >"$tmp"
|
|
args=""; [ -n "$token" ] && args="Authorization: Bearer $token"
|
|
if [ -n "$args" ] && [ -s "$tmp" ]; then curl -sS -w '\n__CODE__:%{http_code}' -X "$method" -H "$args" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"
|
|
elif [ -n "$args" ]; then curl -sS -w '\n__CODE__:%{http_code}' -X "$method" -H "$args" "$url"
|
|
elif [ -s "$tmp" ]; then curl -sS -w '\n__CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"
|
|
else curl -sS -w '\n__CODE__:%{http_code}' -X "$method" "$url"; fi'''
|
|
base = f"http://127.0.0.1:{HOST_PORT}" if RUNTIME_MODE == "host-docker" else "http://127.0.0.1:8080"
|
|
cmd = ["sh", "-c", script, "sh", method, base + path, token or ""]
|
|
proc = run(cmd, body) if RUNTIME_MODE == "host-docker" else kubectl(["-n", NAMESPACE, "exec", "-i", APP_POD, "--", *cmd], body)
|
|
out = proc.stdout.decode(errors="replace"); marker = "\\n__CODE__:"; pos = out.rfind(marker)
|
|
code = int(out[pos + len(marker):].strip()) if pos >= 0 else 0; raw = out[:pos] if pos >= 0 else out
|
|
parsed = json.loads(raw) if raw.strip() else None
|
|
if proc.returncode != 0 or not 200 <= code < 300 or (isinstance(parsed, dict) and parsed.get("code") not in (None, 0)):
|
|
raise RuntimeError(f"official admin API failed method={method} path={path} http={code}")
|
|
return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed
|
|
|
|
def login():
|
|
password = secret_value("ADMIN_PASSWORD")
|
|
if not password: raise RuntimeError("ADMIN_PASSWORD missing")
|
|
data = api("POST", "/api/v1/auth/login", payload={"email": env_value("ADMIN_EMAIL", "admin@example.com"), "password": password})
|
|
token = data.get("access_token") or data.get("token") if isinstance(data, dict) else None
|
|
if not token: raise RuntimeError("admin login response has no token")
|
|
return token
|
|
|
|
FIELDS = ("name","enabled","priority","error_codes","keywords","match_mode","platforms","passthrough_code","response_code","passthrough_body","custom_message","skip_monitoring","description")
|
|
def normalized(rule): return {k: rule.get(k) for k in FIELDS}
|
|
def state(current, desired):
|
|
if current is None: return "missing"
|
|
return "synced" if normalized(current) == normalized(desired) else "drift"
|
|
|
|
def execute():
|
|
token = login(); current = api("GET", "/api/v1/admin/error-passthrough-rules", token=token) or []
|
|
if not isinstance(current, list): raise RuntimeError("official rule list is not an array")
|
|
managed = PAYLOAD.get("managed") or []; selected = PAYLOAD.get("selected")
|
|
action = PAYLOAD["action"]; confirm = bool(PAYLOAD.get("confirm")); mutation = False
|
|
if action in ("apply", "delete"):
|
|
matches = [r for r in current if r.get("name") == selected.get("name")]
|
|
if len(matches) > 1: raise RuntimeError("duplicate official rules with managed name")
|
|
existing = matches[0] if matches else None
|
|
if action == "apply" and state(existing, selected) != "synced" and confirm:
|
|
if existing is None: api("POST", "/api/v1/admin/error-passthrough-rules", token, selected)
|
|
else: api("PUT", f"/api/v1/admin/error-passthrough-rules/{existing['id']}", token, selected)
|
|
mutation = True
|
|
if action == "delete" and existing is not None and confirm:
|
|
api("DELETE", f"/api/v1/admin/error-passthrough-rules/{existing['id']}", token)
|
|
mutation = True
|
|
current = api("GET", "/api/v1/admin/error-passthrough-rules", token=token) or []
|
|
rows = []
|
|
names = {m["name"] for m in managed}
|
|
for desired in managed:
|
|
found = [r for r in current if r.get("name") == desired["name"]]
|
|
actual = found[0] if len(found) == 1 else None
|
|
row_state = "duplicate" if len(found) > 1 else state(actual, desired)
|
|
if action == "delete" and selected and desired["name"] == selected["name"] and actual is None: row_state = "absent"
|
|
rows.append({"templateId": desired["id"], "ruleId": actual.get("id") if actual else None, "enabled": actual.get("enabled") if actual else desired.get("enabled"), "matchMode": desired.get("match_mode"), "errorCodes": desired.get("error_codes"), "responseCode": desired.get("response_code"), "platforms": desired.get("platforms"), "state": row_state})
|
|
target_row = next((x for x in rows if selected and x["templateId"] == next((m["id"] for m in managed if m["name"] == selected["name"]), None)), None)
|
|
ok = all(x["state"] not in ("duplicate",) for x in rows)
|
|
if action == "apply" and confirm: ok = ok and target_row is not None and target_row["state"] == "synced"
|
|
if action == "delete" and confirm: ok = ok and target_row is not None and target_row["state"] == "absent"
|
|
return {"ok": ok, "mode": "confirm" if confirm else "dry-run" if action != "list" else "list", "mutation": mutation, "rules": rows, "unmanagedCount": sum(1 for r in current if r.get("name") not in names), "message": "official Sub2API error-passthrough-rules API"}
|
|
|
|
try: output = execute()
|
|
except Exception as exc: output = {"ok": False, "error": str(exc), "mutation": False, "rules": []}
|
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
raise SystemExit(0 if output.get("ok") else 1)
|
|
PY`;
|
|
}
|