refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
// 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
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user