435 lines
20 KiB
TypeScript
435 lines
20 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote-scripts module for scripts/src/platform-infra-sub2api-codex.ts.
|
|
|
|
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:3800-4208 for #903.
|
|
|
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { UniDeskConfig } from "../config";
|
|
import { rootPath } from "../config";
|
|
import type { RenderedCliResult } from "../output";
|
|
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest, type PublicServiceExposure, type PublicServiceTarget } from "../platform-infra-public-service";
|
|
import { shortSha256Fingerprint } from "../platform-infra-ops-library";
|
|
import {
|
|
codexPoolSentinelSummary,
|
|
codexPoolSentinelRuntimeImage,
|
|
readCodexPoolSentinelConfig,
|
|
renderCodexPoolSentinelManifest,
|
|
type CodexPoolSentinelConfig,
|
|
type CodexPoolSentinelProfileSecret,
|
|
} from "../platform-infra-sub2api-codex-sentinel";
|
|
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
|
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
|
|
|
import type { CodexPoolConfig, CodexPoolRuntimeTarget, TraceOptions } from "./types";
|
|
import { shQuote } from "./remote";
|
|
import { remotePythonScript } from "./remote-python-sync";
|
|
import { sentinelImageDockerfilePath } from "./types";
|
|
|
|
export function syncScript(payload: unknown, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
|
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
return remotePythonScript("sync", encoded, pool, target);
|
|
}
|
|
|
|
export function validateScript(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
|
return remotePythonScript("validate", "", pool, target);
|
|
}
|
|
|
|
export function traceScript(pool: CodexPoolConfig, options: TraceOptions, target: CodexPoolRuntimeTarget): string {
|
|
const encoded = Buffer.from(JSON.stringify({
|
|
requestId: options.requestId,
|
|
since: options.since,
|
|
tail: options.tail,
|
|
contextSeconds: options.contextSeconds,
|
|
showLines: options.showLines,
|
|
}), "utf8").toString("base64");
|
|
return remotePythonScript("trace", encoded, pool, target);
|
|
}
|
|
|
|
export function sentinelProbeScript(payload: unknown, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
|
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
return remotePythonScript("sentinel-probe", encoded, pool, target);
|
|
}
|
|
|
|
export function sentinelReportScript(pool: CodexPoolConfig, events: number, target: CodexPoolRuntimeTarget): string {
|
|
const stateName = pool.sentinel.stateConfigMapName;
|
|
const cronJobName = pool.sentinel.cronJobName;
|
|
return `
|
|
set -eu
|
|
python3 - <<'PY'
|
|
import json
|
|
import subprocess
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
NAMESPACE = ${JSON.stringify(target.namespace)}
|
|
STATE_NAME = ${JSON.stringify(stateName)}
|
|
CRONJOB_NAME = ${JSON.stringify(cronJobName)}
|
|
EVENT_LIMIT = ${JSON.stringify(events)}
|
|
|
|
def run(cmd):
|
|
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
def text(data, limit=2000):
|
|
if isinstance(data, bytes):
|
|
data = data.decode("utf-8", errors="replace")
|
|
return data[-limit:]
|
|
|
|
def kube_json(args):
|
|
proc = run(["kubectl", *args, "-o", "json"])
|
|
if proc.returncode != 0:
|
|
return None, text(proc.stderr)
|
|
try:
|
|
return json.loads(proc.stdout.decode("utf-8")), None
|
|
except Exception as exc:
|
|
return None, str(exc)
|
|
|
|
def parse_iso(value):
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
|
|
def minutes_between(a, b):
|
|
left = parse_iso(a)
|
|
right = parse_iso(b)
|
|
if left is None or right is None:
|
|
return None
|
|
return round((right - left).total_seconds() / 60, 1)
|
|
|
|
def day_ledgers(state):
|
|
ledger = {}
|
|
raw = state.get("ledger")
|
|
if isinstance(raw, dict):
|
|
for item in raw.values():
|
|
if not isinstance(item, dict):
|
|
continue
|
|
add_ledger(ledger, item)
|
|
return ledger
|
|
|
|
def add_ledger(target, source):
|
|
target["inputTokens"] = target.get("inputTokens", 0) + int(source.get("inputTokens") or 0)
|
|
target["outputTokens"] = target.get("outputTokens", 0) + int(source.get("outputTokens") or 0)
|
|
target["totalTokens"] = target.get("totalTokens", 0) + int(source.get("totalTokens") or 0)
|
|
target["estimatedCostUsd"] = target.get("estimatedCostUsd", 0.0) + float(source.get("estimatedCostUsd") or 0)
|
|
target["requestCount"] = target.get("requestCount", 0) + int(source.get("requestCount") or 0)
|
|
|
|
def account_ledger(account_state):
|
|
total = {}
|
|
raw = account_state.get("daily")
|
|
if isinstance(raw, dict):
|
|
for item in raw.values():
|
|
if isinstance(item, dict):
|
|
add_ledger(total, item)
|
|
return total
|
|
|
|
def action_type(probe):
|
|
action = probe.get("action") if isinstance(probe, dict) else None
|
|
if isinstance(action, dict):
|
|
value = action.get("type")
|
|
if value:
|
|
return value
|
|
return "taken" if action.get("taken") is True else ""
|
|
return ""
|
|
|
|
def error_code(probe):
|
|
details = probe.get("errorDetails") if isinstance(probe, dict) else None
|
|
if not isinstance(details, dict):
|
|
return ""
|
|
openai_error = details.get("openaiError")
|
|
if isinstance(openai_error, dict):
|
|
return openai_error.get("code") or openai_error.get("type") or ""
|
|
return details.get("kind") or ""
|
|
|
|
def sentinel_protect_summary(account_state, probe):
|
|
probe_protect = probe.get("sentinelProtect") if isinstance(probe.get("sentinelProtect"), dict) else {}
|
|
config_protect = account_state.get("sentinelProtectConfig") if isinstance(account_state.get("sentinelProtectConfig"), dict) else {}
|
|
source = probe_protect if probe_protect else config_protect
|
|
if not isinstance(source, dict) or source.get("enabled") is not True:
|
|
return {
|
|
"enabled": False,
|
|
"threshold": None,
|
|
"decision": None,
|
|
"failureCount": None,
|
|
}
|
|
return {
|
|
"enabled": True,
|
|
"threshold": source.get("threshold") or source.get("consecutiveFailures"),
|
|
"decision": probe_protect.get("decision"),
|
|
"failureCount": probe_protect.get("failureCount"),
|
|
}
|
|
|
|
def report():
|
|
cronjob, cron_error = kube_json(["-n", NAMESPACE, "get", "cronjob", CRONJOB_NAME])
|
|
state_cm, state_error = kube_json(["-n", NAMESPACE, "get", "configmap", STATE_NAME])
|
|
state = {}
|
|
parse_error = None
|
|
if isinstance(state_cm, dict):
|
|
raw_state = (state_cm.get("data") or {}).get("state.json")
|
|
if isinstance(raw_state, str) and raw_state.strip():
|
|
try:
|
|
state = json.loads(raw_state)
|
|
except Exception as exc:
|
|
parse_error = str(exc)
|
|
accounts = state.get("accounts") if isinstance(state.get("accounts"), dict) else {}
|
|
history = state.get("history") if isinstance(state.get("history"), list) else []
|
|
account_rows = []
|
|
for name, account_state in sorted(accounts.items()):
|
|
if not isinstance(account_state, dict):
|
|
continue
|
|
probe = account_state.get("lastProbe") if isinstance(account_state.get("lastProbe"), dict) else {}
|
|
quarantine = account_state.get("quarantine") if isinstance(account_state.get("quarantine"), dict) else {}
|
|
gateway_failure = account_state.get("lastGatewayFailure") if isinstance(account_state.get("lastGatewayFailure"), dict) else {}
|
|
last_event_at = account_state.get("lastGatewayFailureAt") or account_state.get("lastProbeAt")
|
|
last_failure_kind = quarantine.get("failureKind") if quarantine.get("active") is True and quarantine.get("failureKind") else probe.get("failureKind")
|
|
last_action = action_type(probe)
|
|
if gateway_failure and (not account_state.get("lastProbeAt") or str(account_state.get("lastGatewayFailureAt") or "") >= str(account_state.get("lastProbeAt") or "")):
|
|
last_action = action_type(gateway_failure)
|
|
ledger = account_ledger(account_state)
|
|
protect = sentinel_protect_summary(account_state, probe)
|
|
account_rows.append({
|
|
"account": name,
|
|
"status": account_state.get("lastStatus"),
|
|
"quarantineActive": quarantine.get("active") is True,
|
|
"quarantineApplied": quarantine.get("applied") if isinstance(quarantine, dict) else None,
|
|
"freezeIntervalMin": quarantine.get("intervalMinutes") if isinstance(quarantine, dict) else None,
|
|
"freezeUntil": quarantine.get("until") if isinstance(quarantine, dict) else None,
|
|
"runtimeAccountId": account_state.get("runtimeAccountId"),
|
|
"runtimeStatus": account_state.get("runtimeStatus"),
|
|
"runtimeSchedulable": account_state.get("runtimeSchedulable"),
|
|
"runtimeMissing": account_state.get("runtimeMissing"),
|
|
"runtimeTempUnschedulableSet": account_state.get("runtimeTempUnschedulableSet"),
|
|
"runtimeTempUnschedulableUntil": account_state.get("runtimeTempUnschedulableUntil"),
|
|
"runtimeSyncedAt": account_state.get("runtimeSyncedAt"),
|
|
"runtimeSyncError": account_state.get("runtimeSyncError"),
|
|
"trustUpstream": account_state.get("trustUpstream") if account_state.get("trustUpstream") is not None else probe.get("trustUpstream"),
|
|
"successStreak": account_state.get("successStreak") or 0,
|
|
"successIntervalMin": account_state.get("successIntervalMinutes") or 0,
|
|
"successMaxIntervalMin": account_state.get("successMaxIntervalMinutes") or probe.get("successMaxIntervalMinutes"),
|
|
"probeCount": ledger.get("requestCount", 0),
|
|
"inputTokens": ledger.get("inputTokens", 0),
|
|
"outputTokens": ledger.get("outputTokens", 0),
|
|
"totalTokens": ledger.get("totalTokens", 0),
|
|
"estimatedCostUsd": round(float(ledger.get("estimatedCostUsd", 0)), 6),
|
|
"lastProbeAt": account_state.get("lastProbeAt"),
|
|
"lastEventAt": last_event_at,
|
|
"lastGatewayFailureAt": account_state.get("lastGatewayFailureAt"),
|
|
"lastPurpose": probe.get("purpose"),
|
|
"lastHttp": probe.get("httpStatus"),
|
|
"lastMarker": probe.get("markerMatched"),
|
|
"sentinelProtectEnabled": protect.get("enabled"),
|
|
"sentinelProtectDecision": protect.get("decision"),
|
|
"sentinelProtectFailureCount": protect.get("failureCount"),
|
|
"sentinelProtectThreshold": protect.get("threshold"),
|
|
"lastFailureKind": last_failure_kind,
|
|
"lastErrorCode": error_code(probe),
|
|
"lastAction": last_action,
|
|
"nextProbeAfter": account_state.get("nextProbeAfter"),
|
|
"observedLastToNextMin": minutes_between(last_event_at, account_state.get("nextProbeAfter")),
|
|
"requestShape": probe.get("requestShape"),
|
|
})
|
|
run_rows = []
|
|
for item in history:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
selection = item.get("selection") if isinstance(item.get("selection"), dict) else {}
|
|
gateway = item.get("gatewayFailureMonitor") if isinstance(item.get("gatewayFailureMonitor"), dict) else {}
|
|
run_rows.append({
|
|
"at": item.get("at"),
|
|
"selected": item.get("selected"),
|
|
"due": selection.get("due"),
|
|
"ok": item.get("okCount"),
|
|
"mismatch": item.get("mismatchCount") if item.get("mismatchCount") is not None else item.get("markerMismatchCount"),
|
|
"transportFailures": item.get("transportFailureCount"),
|
|
"actionsTaken": item.get("actionsTaken"),
|
|
"gatewayFailures": gateway.get("newFailures"),
|
|
"gatewayActions": gateway.get("actionsTaken"),
|
|
"reasserts": len(item.get("reconcile") or []),
|
|
})
|
|
quarantined = [item for item in account_rows if item.get("quarantineActive") is True]
|
|
runtime_schedulable = [item for item in account_rows if item.get("runtimeSchedulable") is True]
|
|
runtime_unschedulable = [item for item in account_rows if item.get("runtimeSchedulable") is False]
|
|
cron_spec = cronjob.get("spec") if isinstance(cronjob, dict) else {}
|
|
cron_status = cronjob.get("status") if isinstance(cronjob, dict) else {}
|
|
global_ledger = day_ledgers(state)
|
|
result = {
|
|
"ok": state_error is None and parse_error is None,
|
|
"metadata": {
|
|
"namespace": NAMESPACE,
|
|
"stateConfigMapName": STATE_NAME,
|
|
"cronJobName": CRONJOB_NAME,
|
|
"generatedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"valuesPrinted": False,
|
|
},
|
|
"cronJob": {
|
|
"exists": isinstance(cronjob, dict),
|
|
"schedule": cron_spec.get("schedule") if isinstance(cron_spec, dict) else None,
|
|
"suspend": cron_spec.get("suspend") if isinstance(cron_spec, dict) else None,
|
|
"lastScheduleTime": cron_status.get("lastScheduleTime") if isinstance(cron_status, dict) else None,
|
|
"active": len(cron_status.get("active") or []) if isinstance(cron_status, dict) else None,
|
|
"error": cron_error,
|
|
},
|
|
"summary": {
|
|
"accountCount": len(account_rows),
|
|
"quarantinedCount": len(quarantined),
|
|
"runtimeSchedulableCount": len(runtime_schedulable),
|
|
"runtimeUnschedulableCount": len(runtime_unschedulable),
|
|
"historyCount": len(history),
|
|
"historyFrom": run_rows[0].get("at") if run_rows else None,
|
|
"historyTo": run_rows[-1].get("at") if run_rows else None,
|
|
"lastRun": state.get("lastRun"),
|
|
"runtimeSchedulable": state.get("runtimeSchedulable"),
|
|
},
|
|
"globalLedger": global_ledger,
|
|
"accounts": account_rows,
|
|
"runs": run_rows[-EVENT_LIMIT:],
|
|
"errors": {
|
|
"state": state_error,
|
|
"parse": parse_error,
|
|
},
|
|
"valuesPrinted": False,
|
|
}
|
|
return result
|
|
|
|
payload = report()
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
raise SystemExit(0 if payload.get("ok") else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function cleanupProbesScript(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string {
|
|
return remotePythonScript("cleanup-probes", "", pool, target);
|
|
}
|
|
|
|
export function sentinelImageStatusScript(pool: CodexPoolConfig, targetRuntime: CodexPoolRuntimeTarget): string {
|
|
const target = codexPoolSentinelRuntimeImage(pool.sentinel);
|
|
return remoteSentinelImageScript("status", target, pool.sentinel, null, targetRuntime);
|
|
}
|
|
|
|
export function sentinelImageBuildScript(pool: CodexPoolConfig, targetRuntime: CodexPoolRuntimeTarget): string {
|
|
const target = codexPoolSentinelRuntimeImage(pool.sentinel);
|
|
const dockerfile = readFileSync(sentinelImageDockerfilePath, "utf8");
|
|
return remoteSentinelImageScript("build", target, pool.sentinel, dockerfile, targetRuntime);
|
|
}
|
|
|
|
export function remoteSentinelImageScript(mode: "status" | "build", target: ReturnType<typeof codexPoolSentinelRuntimeImage>, sentinel: CodexPoolSentinelConfig, dockerfile: string | null, targetRuntime: CodexPoolRuntimeTarget): string {
|
|
const dockerfileB64 = dockerfile === null ? "" : Buffer.from(dockerfile, "utf8").toString("base64");
|
|
return `
|
|
set -eu
|
|
mode=${shQuote(mode)}
|
|
image=${shQuote(target.runtimeImage)}
|
|
repo=${shQuote("platform-infra/sub2api-account-sentinel")}
|
|
tag=${shQuote(target.tag)}
|
|
base_image=${shQuote(target.baseImage)}
|
|
openai_version=${shQuote(sentinel.sdk.openaiPythonVersion)}
|
|
base_image_cache_policy=${shQuote(targetRuntime.sentinelImageBuild.baseImageCachePolicy)}
|
|
proxy_env_path=${shQuote(targetRuntime.sentinelImageBuild.proxyEnvPath ?? "")}
|
|
work=/tmp/unidesk-sub2api-sentinel-image
|
|
mkdir -p "$work"
|
|
dockerfile_path="$work/sentinel.Dockerfile"
|
|
registry_has_tag=false
|
|
if curl -fsS --max-time 10 "http://127.0.0.1:5000/v2/$repo/tags/list" 2>/dev/null | grep -F '"'"$tag"'"' >/dev/null 2>&1; then
|
|
registry_has_tag=true
|
|
fi
|
|
local_id="$(docker image inspect "$image" --format '{{.Id}}' 2>/dev/null || true)"
|
|
if [ "$mode" = "status" ]; then
|
|
if [ -n "$local_id" ]; then
|
|
python_version="$(docker run --rm "$image" python3 --version 2>/dev/null || true)"
|
|
openai_runtime_version="$(docker run --rm "$image" python3 -c 'import importlib.metadata; print(importlib.metadata.version("openai"))' 2>/dev/null || true)"
|
|
else
|
|
python_version=
|
|
openai_runtime_version=
|
|
fi
|
|
python3 - <<PY
|
|
import json
|
|
print(json.dumps({
|
|
"ok": bool("${"${registry_has_tag}"}" == "true" or "${"${local_id}"}"),
|
|
"mode": "status",
|
|
"image": "${target.runtimeImage}",
|
|
"baseImage": "${target.baseImage}",
|
|
"tag": "${target.tag}",
|
|
"localExists": bool("${"${local_id}"}"),
|
|
"localImageId": "${"${local_id}"}" or None,
|
|
"registryHasTag": "${"${registry_has_tag}"}" == "true",
|
|
"pythonVersion": "${"${python_version}"}" or None,
|
|
"openaiPythonVersion": "${"${openai_runtime_version}"}" or None,
|
|
}, ensure_ascii=False, indent=2))
|
|
PY
|
|
exit 0
|
|
fi
|
|
if [ "$registry_has_tag" = "true" ]; then
|
|
if [ -z "$local_id" ]; then
|
|
docker pull "$image" >/dev/null 2>&1 || true
|
|
local_id="$(docker image inspect "$image" --format '{{.Id}}' 2>/dev/null || true)"
|
|
fi
|
|
python3 - <<PY
|
|
import json
|
|
print(json.dumps({
|
|
"ok": True,
|
|
"mode": "reused-existing",
|
|
"image": "${target.runtimeImage}",
|
|
"baseImage": "${target.baseImage}",
|
|
"tag": "${target.tag}",
|
|
"registryHasTag": True,
|
|
"localImageId": "${"${local_id}"}" or None,
|
|
}, ensure_ascii=False, indent=2))
|
|
PY
|
|
exit 0
|
|
fi
|
|
base64 -d > "$dockerfile_path" <<'UNIDESK_SENTINEL_DOCKERFILE_B64'
|
|
${dockerfileB64}
|
|
UNIDESK_SENTINEL_DOCKERFILE_B64
|
|
if [ -n "$proxy_env_path" ] && [ -r "$proxy_env_path" ]; then
|
|
set -a
|
|
. "$proxy_env_path"
|
|
set +a
|
|
fi
|
|
HTTP_PROXY="$(printenv HTTP_PROXY 2>/dev/null || true)"
|
|
HTTPS_PROXY="$(printenv HTTPS_PROXY 2>/dev/null || true)"
|
|
ALL_PROXY="$(printenv ALL_PROXY 2>/dev/null || true)"
|
|
http_proxy_value="$(printenv http_proxy 2>/dev/null || true)"
|
|
https_proxy_value="$(printenv https_proxy 2>/dev/null || true)"
|
|
all_proxy_value="$(printenv all_proxy 2>/dev/null || true)"
|
|
if [ -z "$HTTP_PROXY" ]; then HTTP_PROXY="$http_proxy_value"; fi
|
|
if [ -z "$HTTPS_PROXY" ]; then HTTPS_PROXY="$https_proxy_value"; fi
|
|
if [ -z "$ALL_PROXY" ]; then ALL_PROXY="$all_proxy_value"; fi
|
|
if [ -z "$HTTPS_PROXY" ]; then HTTPS_PROXY="$HTTP_PROXY"; fi
|
|
if [ -z "$ALL_PROXY" ]; then ALL_PROXY="$HTTP_PROXY"; fi
|
|
export NO_PROXY=${shQuote(targetRuntime.sentinelImageBuild.noProxy)}
|
|
export no_proxy=$NO_PROXY
|
|
set -- --pull
|
|
base_image_source="registry"
|
|
if [ "$base_image_cache_policy" = "local-if-present" ] && docker image inspect "$base_image" >/dev/null 2>&1; then
|
|
set --
|
|
base_image_source="local-cache"
|
|
fi
|
|
docker build "$@" \\
|
|
--network host \\
|
|
--build-arg BASE_IMAGE="$base_image" \\
|
|
--build-arg OPENAI_PYTHON_VERSION="$openai_version" \\
|
|
--build-arg HTTP_PROXY="$HTTP_PROXY" --build-arg HTTPS_PROXY="$HTTPS_PROXY" --build-arg ALL_PROXY="$ALL_PROXY" \\
|
|
--build-arg http_proxy="$HTTP_PROXY" --build-arg https_proxy="$HTTPS_PROXY" --build-arg all_proxy="$ALL_PROXY" \\
|
|
--build-arg NO_PROXY --build-arg no_proxy \\
|
|
-f "$dockerfile_path" \\
|
|
-t "$image" \\
|
|
"$work"
|
|
docker run --rm "$image" python3 -c 'import importlib.metadata, sys; expected=sys.argv[1]; actual=importlib.metadata.version("openai"); assert actual == expected, (actual, expected); print("openai", actual)' "$openai_version"
|
|
docker push "$image"
|
|
digest="$(docker image inspect "$image" --format '{{index .RepoDigests 0}}' 2>/dev/null || true)"
|
|
python3 - <<PY
|
|
import json
|
|
print(json.dumps({
|
|
"ok": True,
|
|
"mode": "built-and-published",
|
|
"image": "${target.runtimeImage}",
|
|
"baseImage": "${target.baseImage}",
|
|
"tag": "${target.tag}",
|
|
"baseImageSource": "${"${base_image_source}"}",
|
|
"digest": "${"${digest}"}" or None,
|
|
}, ensure_ascii=False, indent=2))
|
|
PY
|
|
`;
|
|
}
|