355 lines
15 KiB
TypeScript
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
|
|
`;
|
|
}
|