refactor: split control-plane cli modules
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// 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 { 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`);
|
||||
if (!Array.isArray(parsed.targets) || !parsed.targets.every(isRecord)) throw new Error(`${sub2apiConfigPath}.targets must be a list`);
|
||||
return {
|
||||
defaultTargetId,
|
||||
appSecretName,
|
||||
secretsRoot,
|
||||
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 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 sentinelImageBuild = readTargetSentinelImageBuild(raw, id);
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
id,
|
||||
route,
|
||||
namespace: targetNamespace,
|
||||
serviceName,
|
||||
serviceDns: `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
|
||||
publicBaseUrl,
|
||||
publicExposure,
|
||||
appSecretName: runtimeConfig.appSecretName,
|
||||
secretsRoot: runtimeConfig.secretsRoot,
|
||||
sentinelImageBuild,
|
||||
egressProxy,
|
||||
};
|
||||
}
|
||||
|
||||
export function readTargetSentinelImageBuild(raw: Record<string, unknown>, targetId: string): CodexPoolRuntimeTarget["sentinelImageBuild"] {
|
||||
const codexPool = isRecord(raw.codexPool) ? raw.codexPool : null;
|
||||
const imageBuild = codexPool !== null && isRecord(codexPool.sentinelImageBuild) ? codexPool.sentinelImageBuild : 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`);
|
||||
}
|
||||
return {
|
||||
baseImageCachePolicy: policy,
|
||||
noProxy: readTargetNoProxy(imageBuild.noProxy, `${sub2apiConfigPath}.targets[${targetId}].codexPool.sentinelImageBuild.noProxy`),
|
||||
};
|
||||
}
|
||||
|
||||
export function readTargetPublicExposure(raw: Record<string, unknown>, targetId: string): PublicServiceExposure | 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 dns = readRequiredTargetRecord(exposure.dns, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns`);
|
||||
const frpc = readRequiredTargetRecord(exposure.frpc, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.frpc`);
|
||||
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: PublicServiceExposure = {
|
||||
enabled,
|
||||
publicBaseUrl,
|
||||
dns: {
|
||||
hostname,
|
||||
expectedA: readRequiredTargetString(dns.expectedA, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.expectedA`),
|
||||
resolvers: readTargetStringArray(dns.resolvers, `${sub2apiConfigPath}.targets[${targetId}].publicExposure.dns.resolvers`),
|
||||
},
|
||||
frpc: {
|
||||
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`),
|
||||
},
|
||||
};
|
||||
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`);
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user