Files
pikasTech-unidesk/scripts/src/platform-infra-sub2api-codex/public-exposure.ts
T
2026-06-25 16:16:25 +00:00

355 lines
15 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. public-exposure module for scripts/src/platform-infra-sub2api-codex.ts.
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:3097-3421 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, CodexPoolManualAccountGroupBinding, CodexPoolManualAccountProxyBinding, CodexPoolManualBindingSource, CodexPoolRuntimeTarget, CodexProfile } from "./types";
import { desiredAccountCapacityTotal } from "./accounts";
import { readCodexPoolConfig } from "./config";
import { codexConsumerBaseUrl } from "./local-codex";
import { codexPoolRuntimeTarget } from "./runtime-target";
import { codexPoolConfigPath, fieldManager, serviceName, sub2apiConfigPath } from "./types";
export function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarget()): Record<string, unknown> {
return {
id: target.id,
route: target.route,
namespace: target.namespace,
service: serviceName,
serviceDns: target.serviceDns,
publicBaseUrl: target.publicBaseUrl,
consumerBaseUrl: target.publicBaseUrl === null ? null : codexConsumerBaseUrl(pool, target),
configPath: codexPoolConfigPath,
groupName: pool.groupName,
apiKeyName: pool.apiKeyName,
apiKeySecret: `${target.namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
publicExposure: targetPublicExposureSummary(target),
sentinelImageBuild: {
source: `${sub2apiConfigPath}.targets[${target.id}].codexPool.sentinelImageBuild`,
baseImageCachePolicy: target.sentinelImageBuild.baseImageCachePolicy,
noProxy: target.sentinelImageBuild.noProxy,
},
minOwnerConcurrency: pool.minOwnerConcurrency,
minOwnerConcurrencySource: pool.minOwnerConcurrencySource,
accountCapacityTotal: desiredAccountCapacityTotal(pool),
defaultAccountPriority: pool.defaultAccountPriority,
defaultAccountCapacity: pool.defaultAccountCapacity,
defaultAccountLoadFactor: pool.defaultAccountLoadFactor,
defaultSentinelProtect: pool.defaultSentinelProtect,
sentinel: {
monitorEnabled: pool.sentinel.monitor.enabled,
actionsEnabled: pool.sentinel.actions.enabled,
cronJobName: pool.sentinel.cronJobName,
stateConfigMapName: pool.sentinel.stateConfigMapName,
},
egressProxy: target.egressProxy === null ? null : {
enabled: target.egressProxy.enabled,
applyToSentinel: target.egressProxy.applyToSentinel,
serviceName: target.egressProxy.serviceName,
listenPort: target.egressProxy.listenPort,
httpProxy: target.egressProxy.httpProxy,
noProxy: target.egressProxy.noProxy,
},
valuesPrinted: false,
};
}
export function sentinelProfileSecrets(profiles: CodexProfile[]): CodexPoolSentinelProfileSecret[] {
return profiles
.filter((profile) => profile.ok && profile.apiKey !== null && profile.apiKey.length > 0)
.map((profile) => ({
accountName: profile.accountName,
profile: profile.profile,
baseUrl: profile.baseUrl,
apiKey: profile.apiKey ?? "",
upstreamUserAgent: profile.upstreamUserAgent,
trustUpstream: profile.trustUpstream,
sentinelProtect: profile.sentinelProtect,
}));
}
export function resolvedManualAccountProtections(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): Record<string, unknown>[] {
return pool.manualAccounts.protected.map((account) => ({
accountName: account.accountName,
reason: account.reason,
proxyBinding: resolveManualProxyBinding(account.proxyBinding, pool, target),
groupBinding: resolveManualGroupBinding(account.groupBinding, pool),
}));
}
export function resolveManualProxyBinding(binding: CodexPoolManualAccountProxyBinding | null, pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): Record<string, unknown> | null {
if (binding === null) return null;
const source = manualBindingSource(pool, binding.source);
if (source.kind !== "proxy") throw new Error(`${codexPoolConfigPath}.manualAccounts binding source ${source.id} must have kind=proxy`);
if (binding.enabled && source.provider === "target-egress-proxy" && (target.egressProxy === null || target.egressProxy.enabled !== true)) {
throw new Error(`${codexPoolConfigPath}.manualAccounts.bindingSources.${source.id} requires ${sub2apiConfigPath}.targets[${target.id}].egressProxy.enabled=true`);
}
return {
enabled: binding.enabled,
source: source.id,
proxyName: binding.proxyName,
sourcePlan: {
...manualBindingSourcePlan(source),
targetEgressProxy: source.provider === "target-egress-proxy" && target.egressProxy !== null
? {
targetId: target.id,
namespace: target.namespace,
serviceName: target.egressProxy.serviceName,
listenPort: target.egressProxy.listenPort,
httpProxy: target.egressProxy.httpProxy,
noProxy: target.egressProxy.noProxy,
}
: null,
},
};
}
export function resolveManualGroupBinding(binding: CodexPoolManualAccountGroupBinding | null, pool: CodexPoolConfig): Record<string, unknown> | null {
if (binding === null) return null;
const source = manualBindingSource(pool, binding.source);
if (source.kind !== "group") throw new Error(`${codexPoolConfigPath}.manualAccounts binding source ${source.id} must have kind=group`);
return {
enabled: binding.enabled,
source: source.id,
sourcePlan: {
...manualBindingSourcePlan(source),
poolGroupName: source.provider === "pool-group" ? pool.groupName : null,
},
};
}
export function manualBindingSource(pool: CodexPoolConfig, sourceId: string): CodexPoolManualBindingSource {
const source = pool.manualAccounts.bindingSources.byId[sourceId];
if (source === undefined) throw new Error(`${codexPoolConfigPath}.manualAccounts binding source ${sourceId} is not declared`);
return source;
}
export function manualBindingSourcePlan(source: CodexPoolManualBindingSource): Record<string, unknown> {
return {
id: source.id,
enabled: source.enabled,
kind: source.kind,
provider: source.provider,
description: source.description,
};
}
export function targetPublicExposureSummary(target: CodexPoolRuntimeTarget): Record<string, unknown> | null {
const exposure = target.publicExposure;
if (exposure === null) return null;
return {
source: `${sub2apiConfigPath}.targets[${target.id}].publicExposure`,
enabled: exposure.enabled,
target: {
id: target.id,
route: target.route,
namespace: target.namespace,
serviceDns: target.serviceDns,
},
urls: {
publicBaseUrl: exposure.publicBaseUrl,
consumerBaseUrl: target.publicBaseUrl === null ? null : `${target.publicBaseUrl.replace(/\/+$/u, "")}/`,
},
dns: exposure.dns,
frpc: {
deploymentName: exposure.frpc.deploymentName,
secretName: exposure.frpc.secretName,
secretKey: exposure.frpc.secretKey,
image: exposure.frpc.image,
serverAddr: exposure.frpc.serverAddr,
serverPort: exposure.frpc.serverPort,
proxyName: exposure.frpc.proxyName,
remotePort: exposure.frpc.remotePort,
localIP: exposure.frpc.localIP,
localPort: exposure.frpc.localPort,
tokenSourceRef: exposure.frpc.tokenSourceRef,
tokenSourceKey: exposure.frpc.tokenSourceKey,
},
pk01: exposure.pk01,
valuesPrinted: false,
};
}
export function prepareTargetPublicExposureSecret(target: CodexPoolRuntimeTarget): ReturnType<typeof prepareFrpcSecret> {
if (target.publicExposure === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure is required`);
return prepareFrpcSecret({
secretRoot: target.secretsRoot,
exposure: target.publicExposure,
sourcePathRedactor: redactRepoPath,
parseEnvFile,
requiredEnvValue,
readTextFile: (sourcePath) => {
if (!existsSync(sourcePath)) throw new Error(`publicExposure requires ${redactRepoPath(sourcePath)} with ${target.publicExposure?.frpc.tokenSourceKey}; materialize the PK01 frps token source first`);
return readTextFile(sourcePath);
},
});
}
export function secretMaterialSummary(secret: ReturnType<typeof prepareFrpcSecret>): Record<string, unknown> {
return {
sourceRef: secret.sourceRef,
sourcePath: secret.sourcePath,
secretName: secret.secretName,
secretKey: secret.secretKey,
fingerprint: secret.fingerprint,
valuesPrinted: false,
};
}
export function targetPublicExposureApplyScript(target: CodexPoolRuntimeTarget, secret: ReturnType<typeof prepareFrpcSecret>): string {
if (target.publicExposure === null) throw new Error(`${sub2apiConfigPath}.targets[${target.id}].publicExposure is required`);
const manifest = renderFrpcManifest({
id: target.id,
route: target.route,
namespace: target.namespace,
replicas: 1,
publicExposure: target.publicExposure,
} satisfies PublicServiceTarget);
const manifestB64 = Buffer.from(manifest, "utf8").toString("base64");
const frpcTomlB64 = Buffer.from(secret.frpcToml, "utf8").toString("base64");
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$tmp/sub2api-public-exposure.yaml"
frpc_toml="$tmp/frpc.toml"
printf '%s' '${manifestB64}' | base64 -d > "$manifest"
printf '%s' '${frpcTomlB64}' | base64 -d > "$frpc_toml"
ns_out="$tmp/ns.out"
ns_err="$tmp/ns.err"
secret_out="$tmp/secret.out"
secret_err="$tmp/secret.err"
apply_out="$tmp/apply.out"
apply_err="$tmp/apply.err"
rollout_out="$tmp/rollout.out"
rollout_err="$tmp/rollout.err"
pods_json="$tmp/pods.json"
pods_err="$tmp/pods.err"
logs_out="$tmp/logs.out"
logs_err="$tmp/logs.err"
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
ns_rc=$?
secret_rc=1
apply_rc=1
rollout_rc=1
if [ "$ns_rc" -eq 0 ]; then
kubectl -n ${target.namespace} create secret generic ${secret.secretName} \\
--from-file=${secret.secretKey}="$frpc_toml" \\
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$secret_out" 2>"$secret_err"
secret_rc=$?
else
: >"$secret_out"
printf '%s\\n' 'skipped because namespace apply failed' >"$secret_err"
fi
if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ]; then
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err"
apply_rc=$?
if [ "$apply_rc" -eq 0 ]; then
kubectl -n ${target.namespace} rollout status deployment/${target.publicExposure.frpc.deploymentName} --timeout=60s >"$rollout_out" 2>"$rollout_err"
rollout_rc=$?
else
printf '%s\\n' 'skipped because apply failed' >"$rollout_err"
fi
else
: >"$apply_out"
printf '%s\\n' 'skipped because namespace or secret step failed' >"$apply_err"
: >"$rollout_out"
printf '%s\\n' 'skipped because namespace or secret step failed' >"$rollout_err"
fi
kubectl -n ${target.namespace} get pods -l app.kubernetes.io/name=${target.publicExposure.frpc.deploymentName} -o json >"$pods_json" 2>"$pods_err"
pods_rc=$?
kubectl -n ${target.namespace} logs deployment/${target.publicExposure.frpc.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err"
logs_rc=$?
python3 - "$tmp" "$ns_rc" "$secret_rc" "$apply_rc" "$rollout_rc" "$pods_rc" "$logs_rc" <<'PY'
import json
import os
import sys
tmp = sys.argv[1]
ns_rc, secret_rc, apply_rc, rollout_rc, pods_rc, logs_rc = [int(value) for value in sys.argv[2:]]
def text(name, limit=4000):
path = os.path.join(tmp, name)
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
def load_json(name):
path = os.path.join(tmp, name)
try:
return json.load(open(path, encoding="utf-8"))
except Exception:
return None
pods_obj = load_json("pods.json")
pods = []
if isinstance(pods_obj, dict):
for item in pods_obj.get("items") or []:
status = item.get("status") or {}
statuses = status.get("containerStatuses") or []
pods.append({
"name": item.get("metadata", {}).get("name"),
"phase": status.get("phase"),
"ready": all(cs.get("ready") is True for cs in statuses) if statuses else False,
"restarts": sum(int(cs.get("restartCount") or 0) for cs in statuses),
})
logs = text("logs.out", 4000)
payload = {
"ok": ns_rc == 0 and secret_rc == 0 and apply_rc == 0 and rollout_rc == 0 and pods_rc == 0 and len(pods) > 0 and all(p["ready"] for p in pods),
"target": "${target.id}",
"namespace": "${target.namespace}",
"source": "${sub2apiConfigPath}.targets[${target.id}].publicExposure",
"proxy": {
"name": "${target.publicExposure.frpc.proxyName}",
"remotePort": ${JSON.stringify(target.publicExposure.frpc.remotePort)},
"publicBaseUrl": "${target.publicExposure.publicBaseUrl}",
"localIP": "${target.publicExposure.frpc.localIP}",
"localPort": ${JSON.stringify(target.publicExposure.frpc.localPort)},
},
"resources": {
"secret": "${secret.secretName}",
"secretKey": "${secret.secretKey}",
"deployment": "${target.publicExposure.frpc.deploymentName}",
"image": "${target.publicExposure.frpc.image}",
},
"steps": {
"namespace": {"exitCode": ns_rc, "stdout": text("ns.out"), "stderr": text("ns.err")},
"secret": {"exitCode": secret_rc, "stdout": text("secret.out"), "stderr": text("secret.err"), "valuesPrinted": False},
"apply": {"exitCode": apply_rc, "stdout": text("apply.out"), "stderr": text("apply.err")},
"rollout": {"exitCode": rollout_rc, "stdout": text("rollout.out"), "stderr": text("rollout.err")},
"pods": {"exitCode": pods_rc, "items": pods, "stderr": text("pods.err")},
"logs": {
"exitCode": logs_rc,
"startProxySuccess": "start proxy success" in logs,
"stdoutTail": logs,
"stderrTail": text("logs.err"),
},
},
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}