fix: move sub2api runtime to PK01 host docker

This commit is contained in:
Codex
2026-06-28 14:24:08 +00:00
parent 1d07df3df1
commit d225fd1a61
18 changed files with 1088 additions and 186 deletions
+69 -16
View File
@@ -15,7 +15,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote }
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiHostDockerConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { secretRoot } from "./apply-script";
import { configPath } from "./entry";
import { normalizePublicBaseUrl, validateHostOrIp, validateHostname, validateKubernetesResourceName, validatePort, validateProxyName, validateProxyUrl } from "./manifest";
@@ -162,6 +162,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const id = stringField(record, "id", path);
const route = stringField(record, "route", path);
const targetNamespace = stringField(record, "namespace", path);
const runtimeMode = record.runtimeMode === undefined ? "k3s" : enumField(record, "runtimeMode", path, ["k3s", "host-docker"] as const);
const role = stringField(record, "role", path);
const enabled = booleanField(record, "enabled", path);
const databaseMode = enumField(record, "databaseMode", path, ["bundled", "external-pending", "external-active"] as const);
@@ -170,6 +171,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
const redisReplicas = integerField(record, "redisReplicas", path);
const image = targetImageOverride(record, path);
const dependencyImages = targetDependencyImageOverride(record, path);
const hostDocker = parseHostDockerConfig(record.hostDocker, path, runtimeMode);
const publicExposure = parsePublicExposureConfig(record.publicExposure, path);
const egressProxy = parseEgressProxyConfig(record.egressProxy, path);
if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new Error(`${configPath}.${path}.id must be a simple target id`);
@@ -177,7 +179,10 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
if (!isKubernetesName(targetNamespace)) throw new Error(`${configPath}.${path}.namespace must be a Kubernetes namespace name`);
if (appReplicas < 0) throw new Error(`${configPath}.${path}.appReplicas must be >= 0`);
if (redisReplicas < 0) throw new Error(`${configPath}.${path}.redisReplicas must be >= 0`);
return { id, route, namespace: targetNamespace, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, publicExposure, egressProxy };
if (runtimeMode === "host-docker" && databaseMode !== "external-active") throw new Error(`${configPath}.${path}.databaseMode must be external-active for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && egressProxy?.enabled === true) throw new Error(`${configPath}.${path}.egressProxy must be disabled or omitted for runtimeMode=host-docker`);
if (runtimeMode === "host-docker" && publicExposure?.enabled === true && publicExposure.mode !== "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode must be pk01-local for runtimeMode=host-docker`);
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, hostDocker, publicExposure, egressProxy };
});
const ids = new Set<string>();
for (const target of targets) {
@@ -188,6 +193,37 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
return targets;
}
export function parseHostDockerConfig(value: unknown, path: string, runtimeMode: Sub2ApiTargetConfig["runtimeMode"]): Sub2ApiHostDockerConfig | null {
if (value === undefined || value === null) {
if (runtimeMode === "host-docker") throw new Error(`${configPath}.${path}.hostDocker is required when runtimeMode=host-docker`);
return null;
}
if (runtimeMode !== "host-docker") throw new Error(`${configPath}.${path}.hostDocker is only supported when runtimeMode=host-docker`);
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.hostDocker must be an object`);
const record = value as Record<string, unknown>;
const hostDocker: Sub2ApiHostDockerConfig = {
projectName: stringField(record, "projectName", `${path}.hostDocker`),
workDir: stringField(record, "workDir", `${path}.hostDocker`),
composePath: stringField(record, "composePath", `${path}.hostDocker`),
envPath: stringField(record, "envPath", `${path}.hostDocker`),
appDataDir: stringField(record, "appDataDir", `${path}.hostDocker`),
appPort: integerField(record, "appPort", `${path}.hostDocker`),
redisPort: integerField(record, "redisPort", `${path}.hostDocker`),
databaseHost: stringField(record, "databaseHost", `${path}.hostDocker`),
databaseSslMode: stringField(record, "databaseSslMode", `${path}.hostDocker`),
noProxy: stringArrayField(record, "noProxy", `${path}.hostDocker`),
};
validateProxyName(hostDocker.projectName, `${path}.hostDocker.projectName`);
for (const key of ["workDir", "composePath", "envPath", "appDataDir"] as const) {
if (!hostDocker[key].startsWith("/")) throw new Error(`${configPath}.${path}.hostDocker.${key} must be absolute`);
}
validatePort(hostDocker.appPort, `${path}.hostDocker.appPort`);
validatePort(hostDocker.redisPort, `${path}.hostDocker.redisPort`);
validateHostOrIp(hostDocker.databaseHost, `${path}.hostDocker.databaseHost`);
if (!/^[A-Za-z0-9_-]+$/u.test(hostDocker.databaseSslMode)) throw new Error(`${configPath}.${path}.hostDocker.databaseSslMode has an unsupported format`);
return hostDocker;
}
export function targetImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["image"]> {
if (record.image === undefined) return {};
const image = objectField(record, "image", path);
@@ -230,22 +266,31 @@ export function parsePublicExposureConfig(value: unknown, path: string): Sub2Api
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.publicExposure must be an object`);
const record = value as Record<string, unknown>;
const enabled = booleanField(record, "enabled", `${path}.publicExposure`);
const mode = record.mode === undefined ? "frp" : enumField(record, "mode", `${path}.publicExposure`, ["frp", "pk01-local"] as const);
const publicBaseUrl = stringField(record, "publicBaseUrl", `${path}.publicExposure`);
const dnsRaw = objectField(record, "dns", `${path}.publicExposure`);
const frpcRaw = objectField(record, "frpc", `${path}.publicExposure`);
const localRaw = record.local === undefined || record.local === null ? null : objectField(record, "local", `${path}.publicExposure`);
const frpcRaw = record.frpc === undefined || record.frpc === null ? null : objectField(record, "frpc", `${path}.publicExposure`);
const pk01Raw = objectField(record, "pk01", `${path}.publicExposure`);
const hostname = stringField(dnsRaw, "hostname", `${path}.publicExposure.dns`);
const expectedA = stringField(dnsRaw, "expectedA", `${path}.publicExposure.dns`);
const resolvers = stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`);
if (mode === "frp" && frpcRaw === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
if (mode === "pk01-local" && localRaw === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
const exposure: Sub2ApiPublicExposureConfig = {
enabled,
mode,
publicBaseUrl: normalizePublicBaseUrl(publicBaseUrl, `${path}.publicExposure.publicBaseUrl`),
dns: {
hostname,
expectedA,
resolvers,
},
frpc: {
local: localRaw === null ? null : {
upstreamHost: stringField(localRaw, "upstreamHost", `${path}.publicExposure.local`),
upstreamPort: integerField(localRaw, "upstreamPort", `${path}.publicExposure.local`),
},
frpc: frpcRaw === null ? null : {
deploymentName: stringField(frpcRaw, "deploymentName", `${path}.publicExposure.frpc`),
secretName: stringField(frpcRaw, "secretName", `${path}.publicExposure.frpc`),
secretKey: stringField(frpcRaw, "secretKey", `${path}.publicExposure.frpc`),
@@ -475,18 +520,26 @@ export function validatePublicExposureConfig(config: Sub2ApiPublicExposureConfig
for (const resolver of config.dns.resolvers) {
if (!/^[0-9.]+$/u.test(resolver)) throw new Error(`${configPath}.${path}.publicExposure.dns.resolvers entries must be IPv4 resolvers`);
}
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
if (config.mode === "frp") {
if (config.frpc === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
}
if (config.mode === "pk01-local") {
if (config.local === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
validateHostOrIp(config.local.upstreamHost, `${path}.publicExposure.local.upstreamHost`);
validatePort(config.local.upstreamPort, `${path}.publicExposure.local.upstreamPort`);
}
if (!/^[A-Za-z0-9:_./-]+$/u.test(config.pk01.route)) throw new Error(`${configPath}.${path}.publicExposure.pk01.route has an unsupported format`);
if (!config.pk01.caddyBinaryPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyBinaryPath must be absolute`);
if (!/^https:\/\//u.test(config.pk01.caddyDownloadUrl)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyDownloadUrl must use https://`);