278 lines
18 KiB
TypeScript
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}`;
|
|
}
|