Files
pikasTech-unidesk/scripts/src/platform-infra-sub2api-codex/runtime-target.ts
T
2026-07-02 02:43:13 +00:00

278 lines
18 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. runtime-target module for scripts/src/platform-infra-sub2api-codex.ts.
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:621-819 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 { Sub2ApiRuntimeConfig } from "./options";
import type { CodexPoolRuntimePublicExposure, CodexPoolRuntimeTarget } from "./types";
import { validatePort, validateProxyName } from "./config";
import { booleanValue, isRecord, normalizeBaseUrl, numberValue, stringValue, validateKubernetesName } from "./config-utils";
import { serviceName, sub2apiConfigPath } from "./types";
export function readSub2ApiRuntimeConfig(): Sub2ApiRuntimeConfig {
const parsed = Bun.YAML.parse(readFileSync(sub2apiConfigPath, "utf8")) as unknown;
if (!isRecord(parsed)) throw new Error(`${sub2apiConfigPath} must contain a YAML object`);
const defaults = isRecord(parsed.defaults) ? parsed.defaults : null;
const defaultTargetId = defaults === null ? null : stringValue(defaults.targetId);
if (defaultTargetId === null || !/^[A-Za-z0-9._-]+$/u.test(defaultTargetId)) throw new Error(`${sub2apiConfigPath}.defaults.targetId must be a simple target id`);
const runtime = isRecord(parsed.runtime) ? parsed.runtime : null;
const database = runtime !== null && isRecord(runtime.database) ? runtime.database : null;
const appSecretName = database === null ? null : stringValue(database.secretName);
if (appSecretName === null) throw new Error(`${sub2apiConfigPath}.runtime.database.secretName is required`);
validateKubernetesName(appSecretName, `${sub2apiConfigPath}.runtime.database.secretName`, true);
const secrets = runtime !== null && isRecord(runtime.secrets) ? runtime.secrets : null;
const secretsRoot = secrets === null ? null : stringValue(secrets.root);
if (secretsRoot === null || !secretsRoot.startsWith("/")) throw new Error(`${sub2apiConfigPath}.runtime.secrets.root must be an absolute path`);
const appSourceRef = secrets === null ? null : stringValue(secrets.appSourceRef);
if (appSourceRef === null || !/^[A-Za-z0-9_./-]+$/u.test(appSourceRef)) throw new Error(`${sub2apiConfigPath}.runtime.secrets.appSourceRef has an unsupported format`);
const sentinel = runtime !== null && isRecord(runtime.sentinel) ? runtime.sentinel : null;
const enabledOnTargets = Array.isArray(sentinel?.enabledOnTargets)
? sentinel.enabledOnTargets.map((entry) => stringValue(entry)).filter((entry): entry is string => entry !== null && entry.length > 0)
: [];
if (!Array.isArray(parsed.targets) || !parsed.targets.every(isRecord)) throw new Error(`${sub2apiConfigPath}.targets must be a list`);
return {
defaultTargetId,
appSecretName,
secretsRoot,
appSourceRef,
sentinelEnabledOnTargets: enabledOnTargets,
targets: parsed.targets,
};
}
export function defaultCodexPoolRuntimeTargetId(): string {
return readSub2ApiRuntimeConfig().defaultTargetId;
}
export function codexPoolRuntimeTarget(targetId?: string): CodexPoolRuntimeTarget {
const runtimeConfig = readSub2ApiRuntimeConfig();
const resolvedTargetId = targetId ?? runtimeConfig.defaultTargetId;
const raw = runtimeConfig.targets.find((item) => String(item.id ?? "").toLowerCase() === resolvedTargetId.toLowerCase());
if (!isRecord(raw)) throw new Error(`${sub2apiConfigPath}.targets does not contain target ${targetId}`);
const id = stringValue(raw.id) ?? resolvedTargetId;
const route = stringValue(raw.route) ?? "";
const runtimeMode = stringValue(raw.runtimeMode) ?? "k3s";
if (runtimeMode !== "k3s" && runtimeMode !== "host-docker") throw new Error(`${sub2apiConfigPath}.targets[${id}].runtimeMode must be k3s or host-docker`);
const targetNamespace = stringValue(raw.namespace);
if (route.length === 0) throw new Error(`${sub2apiConfigPath}.targets[${id}].route is required`);
if (targetNamespace === null) throw new Error(`${sub2apiConfigPath}.targets[${id}].namespace is required`);
validateKubernetesName(targetNamespace, `${sub2apiConfigPath}.targets[${id}].namespace`, true);
const sentinelEnabled = runtimeConfig.sentinelEnabledOnTargets.some((entry) => entry.toLowerCase() === id.toLowerCase());
const sentinelImageBuild = readTargetSentinelImageBuild(raw, id, sentinelEnabled);
let egressProxy: CodexPoolRuntimeTarget["egressProxy"] = null;
if (isRecord(raw.egressProxy) && raw.egressProxy.enabled === true) {
const proxyServiceName = stringValue(raw.egressProxy.serviceName);
const listenPort = numberValue(raw.egressProxy.listenPort);
if (proxyServiceName === null || listenPort === null) throw new Error(`${sub2apiConfigPath}.targets[${id}].egressProxy.serviceName/listenPort are required`);
validateKubernetesName(proxyServiceName, `${sub2apiConfigPath}.targets[${id}].egressProxy.serviceName`, true);
if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) throw new Error(`${sub2apiConfigPath}.targets[${id}].egressProxy.listenPort must be a TCP port`);
const noProxyRaw = Array.isArray(raw.egressProxy.noProxy) ? raw.egressProxy.noProxy : [];
const noProxy = noProxyRaw.map((entry) => stringValue(entry)).filter((entry): entry is string => entry !== null && entry.length > 0).join(",");
egressProxy = {
enabled: true,
applyToSentinel: raw.egressProxy.applyToSentinel === undefined ? true : raw.egressProxy.applyToSentinel === true,
serviceName: proxyServiceName,
listenPort,
httpProxy: `http://${proxyServiceName}.${targetNamespace}.svc.cluster.local:${listenPort}`,
noProxy,
};
}
let publicBaseUrl: string | null = null;
const publicExposure = readTargetPublicExposure(raw, id);
if (publicExposure !== null && publicExposure.enabled) publicBaseUrl = publicExposure.publicBaseUrl;
const hostDocker = runtimeMode === "host-docker" && isRecord(raw.hostDocker) ? raw.hostDocker : null;
const hostDockerAppPort = hostDocker === null ? null : numberValue(hostDocker.appPort);
const hostDockerEnvPath = hostDocker === null ? null : stringValue(hostDocker.envPath);
if (runtimeMode === "host-docker" && (hostDockerAppPort === null || !Number.isInteger(hostDockerAppPort) || hostDockerAppPort < 1 || hostDockerAppPort > 65535)) {
throw new Error(`${sub2apiConfigPath}.targets[${id}].hostDocker.appPort must be an integer TCP port when runtimeMode=host-docker`);
}
if (runtimeMode === "host-docker" && (hostDockerEnvPath === null || !hostDockerEnvPath.startsWith("/"))) {
throw new Error(`${sub2apiConfigPath}.targets[${id}].hostDocker.envPath must be an absolute path when runtimeMode=host-docker`);
}
return {
id,
route,
namespace: targetNamespace,
runtimeMode,
serviceName,
serviceDns: runtimeMode === "host-docker" ? `host-docker:127.0.0.1:${hostDockerAppPort}` : `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
publicBaseUrl,
publicExposure,
appSecretName: runtimeConfig.appSecretName,
secretsRoot: runtimeConfig.secretsRoot,
appSourceRef: runtimeConfig.appSourceRef,
hostDockerAppPort,
hostDockerEnvPath,
sentinelEnabled,
sentinelImageBuild,
egressProxy,
};
}
export function readTargetSentinelImageBuild(raw: Record<string, unknown>, targetId: string, required = true): CodexPoolRuntimeTarget["sentinelImageBuild"] {
const codexPool = isRecord(raw.codexPool) ? raw.codexPool : null;
const imageBuild = codexPool !== null && isRecord(codexPool.sentinelImageBuild) ? codexPool.sentinelImageBuild : null;
if (imageBuild === null && !required) return { baseImageCachePolicy: "pull", noProxy: [], proxyEnvPath: null };
if (imageBuild === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild must be a YAML object`);
const policy = stringValue(imageBuild.baseImageCachePolicy);
if (policy !== "pull" && policy !== "local-if-present") {
throw new Error(`${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.baseImageCachePolicy must be pull or local-if-present`);
}
const egressProxy = isRecord(raw.egressProxy) ? raw.egressProxy : null;
const proxyEnvPath = egressProxy === null ? null : readProxyEnvPath(egressProxy.proxyEnvPath, `${sub2apiConfigPath}.targets[${targetId}].egressProxy.proxyEnvPath`);
return {
baseImageCachePolicy: policy,
noProxy: readTargetNoProxy(imageBuild.noProxy, `${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.noProxy`),
proxyEnvPath,
};
}
export function readProxyEnvPath(value: unknown, key: string): string | null {
if (value === undefined || value === null) return null;
const path = stringValue(value);
if (path === null) throw new Error(`${key} must be a non-empty string`);
if (!/^\/[A-Za-z0-9._/-]+$/u.test(path)) throw new Error(`${key} has an unsupported absolute path format`);
return path;
}
export function readTargetPublicExposure(raw: Record<string, unknown>, targetId: string): CodexPoolRuntimePublicExposure | null {
if (raw.publicExposure === undefined || raw.publicExposure === null) return null;
if (!isRecord(raw.publicExposure)) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure must be a YAML object`);
const exposure = raw.publicExposure;
const enabled = readRequiredTargetBoolean(exposure.enabled, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.enabled`);
const mode = stringValue(exposure.mode) ?? "frp";
if (mode !== "frp" && mode !== "pk01-local") throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure.mode must be frp or pk01-local`);
const dns = readRequiredTargetRecord(exposure.dns, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns`);
const local = exposure.local === undefined || exposure.local === null ? null : readRequiredTargetRecord(exposure.local, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local`);
const frpc = exposure.frpc === undefined || exposure.frpc === null ? null : readRequiredTargetRecord(exposure.frpc, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc`);
if (mode === "frp" && frpc === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc is required when mode=frp`);
if (mode === "pk01-local" && local === null) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure.local is required when mode=pk01-local`);
const pk01 = readRequiredTargetRecord(exposure.pk01, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01`);
const publicBaseUrl = readTargetBaseUrl(exposure.publicBaseUrl, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.publicBaseUrl`, "https:");
const hostname = readRequiredTargetString(dns.hostname, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.hostname`);
if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${sub2apiConfigPath}.targets[${targetId}].publicExposure publicBaseUrl hostname must match dns.hostname`);
const config: CodexPoolRuntimePublicExposure = {
enabled,
mode,
publicBaseUrl,
dns: {
hostname,
expectedA: readRequiredTargetString(dns.expectedA, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.expectedA`),
resolvers: readTargetStringArray(dns.resolvers, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.resolvers`),
},
local: local === null ? null : {
upstreamHost: readRequiredTargetString(local.upstreamHost, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local.upstreamHost`),
upstreamPort: readTargetPort(local.upstreamPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local.upstreamPort`),
},
frpc: frpc === null ? null : {
deploymentName: readRequiredTargetString(frpc.deploymentName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.deploymentName`),
secretName: readRequiredTargetString(frpc.secretName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretName`),
secretKey: readRequiredTargetString(frpc.secretKey, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretKey`),
image: readRequiredTargetString(frpc.image, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.image`),
serverAddr: readRequiredTargetString(frpc.serverAddr, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.serverAddr`),
serverPort: readTargetPort(frpc.serverPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.serverPort`),
proxyName: readRequiredTargetString(frpc.proxyName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.proxyName`),
remotePort: readTargetPort(frpc.remotePort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.remotePort`),
localIP: readRequiredTargetString(frpc.localIP, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.localIP`),
localPort: readTargetPort(frpc.localPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.localPort`),
tokenSourceRef: readRequiredTargetString(frpc.tokenSourceRef, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.tokenSourceRef`),
tokenSourceKey: readRequiredTargetString(frpc.tokenSourceKey, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.tokenSourceKey`),
},
pk01: {
route: readRequiredTargetString(pk01.route, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01.route`),
caddyConfigPath: readAbsoluteTargetPath(pk01.caddyConfigPath, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01.caddyConfigPath`),
caddyServiceName: readRequiredTargetString(pk01.caddyServiceName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01.caddyServiceName`),
responseHeaderTimeoutSeconds: readPositiveTargetInteger(pk01.responseHeaderTimeoutSeconds, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.pk01.responseHeaderTimeoutSeconds`),
},
};
if (config.frpc !== null) {
validateKubernetesName(config.frpc.deploymentName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.deploymentName`, true);
validateKubernetesName(config.frpc.secretName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.secretName`, true);
validateProxyName(config.frpc.proxyName, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.proxyName`);
validatePort(config.frpc.serverPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.serverPort`);
validatePort(config.frpc.remotePort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.remotePort`);
validatePort(config.frpc.localPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc.localPort`);
}
if (config.local !== null) validatePort(config.local.upstreamPort, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.local.upstreamPort`);
return config;
}
export function readRequiredTargetRecord(value: unknown, path: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${path} must be a YAML object`);
return value;
}
export function readRequiredTargetString(value: unknown, path: string): string {
const text = stringValue(value);
if (text === null) throw new Error(`${path} must be a non-empty string`);
if (/[\r\n]/u.test(text)) throw new Error(`${path} must not contain newlines`);
return text;
}
export function readRequiredTargetBoolean(value: unknown, path: string): boolean {
const parsed = booleanValue(value);
if (parsed === null) throw new Error(`${path} must be a boolean`);
return parsed;
}
export function readTargetBaseUrl(value: unknown, path: string, protocol: "http:" | "https:" | null = null): string {
const baseUrl = normalizeBaseUrl(stringValue(value));
if (baseUrl === null) throw new Error(`${path} must be a valid http(s) URL`);
if (protocol !== null && new URL(baseUrl).protocol !== protocol) throw new Error(`${path} must use ${protocol}//`);
return baseUrl;
}
export function readTargetPort(value: unknown, path: string): number {
const port = numberValue(value);
if (port === null || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${path} must be an integer TCP port`);
return port;
}
export function readPositiveTargetInteger(value: unknown, path: string): number {
const number = numberValue(value);
if (number === null || !Number.isInteger(number) || number < 1) throw new Error(`${path} must be a positive integer`);
return number;
}
export function readAbsoluteTargetPath(value: unknown, path: string): string {
const text = readRequiredTargetString(value, path);
if (!text.startsWith("/")) throw new Error(`${path} must be an absolute path`);
return text;
}
export function readTargetStringArray(value: unknown, path: string): string[] {
if (!Array.isArray(value)) throw new Error(`${path} must be a YAML array`);
const result = value.map((item, index) => readRequiredTargetString(item, `${path}[${index}]`));
if (result.length === 0) throw new Error(`${path} must not be empty`);
return result;
}
export function readTargetNoProxy(value: unknown, path: string): string {
return readTargetStringArray(value, path).join(",");
}
export function targetFlag(target: CodexPoolRuntimeTarget): string {
return target.id === defaultCodexPoolRuntimeTargetId() ? "" : ` --target ${target.id}`;
}