// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. manifest module for scripts/src/platform-infra.ts. // Moved mechanically from scripts/src/platform-infra.ts:937-1587 for #903. import { createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "../config"; import { rootPath } from "../config"; import { startJob } from "../jobs"; import type { RenderedCliResult } from "../output"; import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy"; import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service"; import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import type { ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiTargetConfig } from "./entry"; import { isKubernetesName, stringField } from "./config"; import { codexPoolConfigPath, configPath, manifestPath } from "./entry"; export function normalizePublicBaseUrl(value: string, path: string): string { let parsed: URL; try { parsed = new URL(value); } catch { throw new Error(`${configPath}.${path} must be a valid URL`); } parsed.pathname = parsed.pathname.replace(/\/+$/u, ""); if (parsed.search || parsed.hash) throw new Error(`${configPath}.${path} must not include query or hash`); return parsed.toString().replace(/\/+$/u, ""); } export function validateHostname(value: string, path: string): void { if (!/^[A-Za-z0-9.-]+$/u.test(value) || !value.includes(".")) throw new Error(`${configPath}.${path} must be a hostname`); } export function validateKubernetesResourceName(value: string, path: string): void { if (!isKubernetesName(value)) throw new Error(`${configPath}.${path} must be a Kubernetes resource name`); } export function validateHostOrIp(value: string, path: string): void { if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`); } export function validatePort(value: number, path: string): void { if (value < 1 || value > 65535) throw new Error(`${configPath}.${path} must be a TCP port`); } export function validateProxyName(value: string, path: string): void { if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`); } export function validateProxyUrl(value: string, path: string): void { let url: URL; try { url = new URL(value); } catch { throw new Error(`${configPath}.${path} must be a valid proxy URL`); } if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path} must use http:// or https://`); if (url.username || url.password || url.search || url.hash) throw new Error(`${configPath}.${path} must not include credentials, query, or hash`); } export function resolveTarget(sub2api: Sub2ApiConfig, targetId: string): Sub2ApiTargetConfig { const target = sub2api.targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase()); if (target === undefined) { const known = sub2api.targets.map((item) => item.id).join(", "); throw new Error(`unknown Sub2API target ${targetId}; known targets: ${known}`); } if (!target.enabled) throw new Error(`Sub2API target ${target.id} is disabled in ${configPath}`); return target; } export function targetImage(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["image"] { return { repository: target.image.repository ?? sub2api.image.repository, tag: target.image.tag ?? sub2api.image.tag, pullPolicy: target.image.pullPolicy ?? sub2api.image.pullPolicy, }; } export function targetDependencyImages(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["dependencyImages"] { return { postgres: target.dependencyImages.postgres ?? sub2api.dependencyImages.postgres, redis: target.dependencyImages.redis ?? sub2api.dependencyImages.redis, }; } export function imageRef(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string { const image = targetImage(sub2api, target); return `${image.repository}:${image.tag}`; } export function isExternalTarget(target: Sub2ApiTargetConfig): boolean { return target.databaseMode === "external-pending" || target.databaseMode === "external-active"; } export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string { if (isExternalTarget(target)) return externalPendingManifest(sub2api, target); const template = readFileSync(manifestPath, "utf8"); const urlAllowlist = sub2api.security.urlAllowlist; const configHash = configHashFor(sub2api, target); const image = targetImage(sub2api, target); const dependencyImages = targetDependencyImages(sub2api, target); return template .replaceAll("__SUB2API_IMAGE__", imageRef(sub2api, target)) .replaceAll("__SUB2API_IMAGE_PULL_POLICY__", image.pullPolicy) .replaceAll("postgres:18-alpine", dependencyImages.postgres) .replaceAll("redis:8-alpine", dependencyImages.redis) .replaceAll("__SUB2API_CONFIG_HASH__", configHash) .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ENABLED__", String(urlAllowlist.enabled)) .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP__", String(urlAllowlist.allowInsecureHttp)) .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS__", String(urlAllowlist.allowPrivateHosts)) .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS__", urlAllowlist.upstreamHosts.join(",")); } export function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string { return createHash("sha256") .update(JSON.stringify({ image: targetImage(sub2api, target), dependencyImages: targetDependencyImages(sub2api, target), security: sub2api.security, target, runtime: sub2api.runtime, })) .digest("hex") .slice(0, 16); } export function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): boolean { return sub2api.runtime.sentinel.enabledOnTargets.some((id) => id.toLowerCase() === target.id.toLowerCase()); } export function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan { const sentinel = codexPoolSentinelResourceNames(); return { externalDbState: { enabled: isExternalTarget(target), ...sub2api.defaults.cleanup.externalDbState, }, redisPersistentState: { enabled: target.redisMode === "local-ephemeral", ...sub2api.defaults.cleanup.redisPersistentState, }, publicExposure: { enabled: target.publicExposure?.enabled === true, deploymentName: target.publicExposure?.frpc.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName, configMapName: sub2api.defaults.cleanup.publicExposure.configMapName, secretName: target.publicExposure?.frpc.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName, }, egressProxy: { enabled: target.egressProxy?.enabled === true, deploymentName: target.egressProxy?.deploymentName ?? sub2api.defaults.cleanup.egressProxy.deploymentName, serviceName: target.egressProxy?.serviceName ?? sub2api.defaults.cleanup.egressProxy.serviceName, secretName: target.egressProxy?.secretName ?? sub2api.defaults.cleanup.egressProxy.secretName, }, sentinel: { ...sentinel, enabled: targetHasSentinel(sub2api, target), }, }; } export function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["sentinel"] { if (!existsSync(codexPoolConfigPath)) throw new Error(`${codexPoolConfigPath} is required for Sub2API codex-pool sentinel cleanup resource names`); const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${codexPoolConfigPath} must be an object`); const sentinel = (parsed as Record).sentinel; if (typeof sentinel !== "object" || sentinel === null || Array.isArray(sentinel)) throw new Error(`${codexPoolConfigPath}.sentinel must be an object`); const record = sentinel as Record; const resourceName = (key: string): string => { const value = stringField(record, key, "sentinel"); validateKubernetesResourceName(value, `sentinel.${key}`); return value; }; return { enabled: false, cronJobName: resourceName("cronJobName"), configMapName: resourceName("configMapName"), credentialsSecretName: resourceName("credentialsSecretName"), stateConfigMapName: resourceName("stateConfigMapName"), serviceAccountName: resourceName("serviceAccountName"), roleName: resourceName("roleName"), roleBindingName: resourceName("roleBindingName"), }; } export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string { const urlAllowlist = sub2api.security.urlAllowlist; const database = sub2api.runtime.database; const databaseStatus = target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db"; const redisService = sub2api.runtime.redis.serviceName; const appReplicas = target.appReplicas; const redisReplicas = target.redisReplicas; const image = targetImage(sub2api, target); const dependencyImages = targetDependencyImages(sub2api, target); const publicExposure = renderPublicExposureManifest(target); const egressProxy = renderEgressProxyManifest(target); const proxyEnv = sub2ApiProxyEnv(target); return `apiVersion: v1 kind: Namespace metadata: name: ${target.namespace} labels: app.kubernetes.io/name: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-all namespace: ${target.namespace} labels: app.kubernetes.io/name: platform-infra app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk spec: podSelector: {} policyTypes: - Ingress - Egress ingress: - {} egress: - {} --- apiVersion: v1 kind: ConfigMap metadata: name: sub2api-config namespace: ${target.namespace} labels: app.kubernetes.io/name: sub2api app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} data: AUTO_SETUP: "true" SERVER_HOST: "0.0.0.0" SERVER_PORT: "8080" SERVER_MODE: "release" RUN_MODE: "standard" DATABASE_HOST: "${database.host}" DATABASE_PORT: "${database.port}" DATABASE_USER: "${database.user}" DATABASE_DBNAME: "${database.dbName}" DATABASE_SSLMODE: "${database.sslMode}" DATABASE_MAX_OPEN_CONNS: "10" DATABASE_MAX_IDLE_CONNS: "2" DATABASE_CONN_MAX_LIFETIME_MINUTES: "30" DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5" REDIS_HOST: "${redisService}" REDIS_PORT: "6379" REDIS_PASSWORD: "" REDIS_DB: "0" REDIS_POOL_SIZE: "32" REDIS_MIN_IDLE_CONNS: "2" REDIS_ENABLE_TLS: "false" ADMIN_EMAIL: "admin@sub2api.platform-infra.local" JWT_EXPIRE_HOUR: "24" TZ: "Asia/Shanghai" SECURITY_URL_ALLOWLIST_ENABLED: "${urlAllowlist.enabled}" SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: "${urlAllowlist.allowInsecureHttp}" SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: "${urlAllowlist.allowPrivateHosts}" SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: "${urlAllowlist.upstreamHosts.join(",")}" UPDATE_PROXY_URL: "${proxyEnv.httpProxy}" HTTP_PROXY: "${proxyEnv.httpProxy}" HTTPS_PROXY: "${proxyEnv.httpProxy}" ALL_PROXY: "${proxyEnv.httpProxy}" http_proxy: "${proxyEnv.httpProxy}" https_proxy: "${proxyEnv.httpProxy}" all_proxy: "${proxyEnv.httpProxy}" NO_PROXY: "${proxyEnv.noProxy}" no_proxy: "${proxyEnv.noProxy}" GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0" GATEWAY_OPENAI_HTTP2_ENABLED: "true" GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true" GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2" GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60" GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600" GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900" GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10" GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false" GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0" GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject" GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30" GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100" --- apiVersion: v1 kind: Service metadata: name: ${redisService} namespace: ${target.namespace} labels: app.kubernetes.io/name: sub2api-redis app.kubernetes.io/component: redis app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} unidesk.ai/state-mode: ephemeral-cache spec: selector: app.kubernetes.io/name: sub2api-redis app.kubernetes.io/component: redis ports: - name: redis port: 6379 targetPort: redis --- apiVersion: v1 kind: Service metadata: name: sub2api namespace: ${target.namespace} labels: app.kubernetes.io/name: sub2api app.kubernetes.io/component: app app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} spec: selector: app.kubernetes.io/name: sub2api app.kubernetes.io/component: app ports: - name: http port: 8080 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: sub2api-redis namespace: ${target.namespace} labels: app.kubernetes.io/name: sub2api-redis app.kubernetes.io/component: redis app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} unidesk.ai/state-mode: ephemeral-cache spec: replicas: ${redisReplicas} strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: sub2api-redis app.kubernetes.io/component: redis template: metadata: labels: app.kubernetes.io/name: sub2api-redis app.kubernetes.io/component: redis app.kubernetes.io/part-of: platform-infra spec: securityContext: fsGroup: 999 containers: - name: redis image: ${dependencyImages.redis} imagePullPolicy: IfNotPresent command: - sh - -c args: - redis-server --save "" --appendonly no ports: - name: redis containerPort: 6379 env: - name: TZ value: Asia/Shanghai readinessProbe: exec: command: - redis-cli - ping initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 6 livenessProbe: exec: command: - redis-cli - ping initialDelaySeconds: 30 periodSeconds: 20 timeoutSeconds: 5 failureThreshold: 6 volumeMounts: - name: redis-data mountPath: /data volumes: - name: redis-data emptyDir: {} --- apiVersion: apps/v1 kind: Deployment metadata: name: sub2api namespace: ${target.namespace} labels: app.kubernetes.io/name: sub2api app.kubernetes.io/component: app app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} unidesk.ai/database-status: ${databaseStatus} spec: replicas: ${appReplicas} strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: sub2api app.kubernetes.io/component: app template: metadata: annotations: unidesk.ai/sub2api-config-hash: "${configHashFor(sub2api, target)}" unidesk.ai/database-source-ref: "${database.sourceRef}" unidesk.ai/database-status: "${databaseStatus}" labels: app.kubernetes.io/name: sub2api app.kubernetes.io/component: app app.kubernetes.io/part-of: platform-infra spec: securityContext: fsGroup: 1000 initContainers: - name: wait-postgres image: ${dependencyImages.postgres} imagePullPolicy: IfNotPresent command: - sh - -c - until pg_isready -h ${database.host} -p ${database.port} -U ${database.user} -d ${database.dbName}; do sleep 2; done - name: wait-redis image: ${dependencyImages.redis} imagePullPolicy: IfNotPresent command: - sh - -c - until redis-cli -h ${redisService} ping | grep -q PONG; do sleep 2; done containers: - name: sub2api image: ${imageRef(sub2api, target)} imagePullPolicy: ${image.pullPolicy} ports: - name: http containerPort: 8080 envFrom: - configMapRef: name: sub2api-config env: - name: DATABASE_PASSWORD valueFrom: secretKeyRef: name: ${database.secretName} key: ${database.passwordKey} - name: ADMIN_PASSWORD valueFrom: secretKeyRef: name: ${database.secretName} key: ADMIN_PASSWORD - name: JWT_SECRET valueFrom: secretKeyRef: name: ${database.secretName} key: JWT_SECRET - name: TOTP_ENCRYPTION_KEY valueFrom: secretKeyRef: name: ${database.secretName} key: TOTP_ENCRYPTION_KEY readinessProbe: httpGet: path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 6 livenessProbe: httpGet: path: /health port: http initialDelaySeconds: 30 periodSeconds: 20 timeoutSeconds: 5 failureThreshold: 6 startupProbe: httpGet: path: /health port: http periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 30 volumeMounts: - name: sub2api-data mountPath: /app/data volumes: - name: sub2api-data emptyDir: {} ${publicExposure} ${egressProxy} `; } export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: string; noProxy: string } { const proxy = target.egressProxy; if (proxy === null || !proxy.enabled || !proxy.applyToSub2Api) return { httpProxy: "", noProxy: "" }; return { httpProxy: `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`, noProxy: proxy.noProxy.join(","), }; } export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string { const exposure = target.publicExposure; if (exposure === null || !exposure.enabled) return ""; return `--- apiVersion: apps/v1 kind: Deployment metadata: name: ${exposure.frpc.deploymentName} namespace: ${target.namespace} labels: app.kubernetes.io/name: ${exposure.frpc.deploymentName} app.kubernetes.io/component: tunnel app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} unidesk.ai/public-hostname: ${exposure.dns.hostname} spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: ${exposure.frpc.deploymentName} app.kubernetes.io/component: tunnel template: metadata: labels: app.kubernetes.io/name: ${exposure.frpc.deploymentName} app.kubernetes.io/component: tunnel app.kubernetes.io/part-of: platform-infra annotations: unidesk.ai/public-base-url: "${exposure.publicBaseUrl}" unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}" unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}" spec: containers: - name: frpc image: ${exposure.frpc.image} imagePullPolicy: IfNotPresent args: - -c - /etc/frp/frpc.toml volumeMounts: - name: frpc-config mountPath: /etc/frp/frpc.toml subPath: ${exposure.frpc.secretKey} readOnly: true volumes: - name: frpc-config secret: secretName: ${exposure.frpc.secretName} `; } export function renderEgressProxyManifest(target: Sub2ApiTargetConfig): string { const proxy = target.egressProxy; if (proxy === null || !proxy.enabled) return ""; return `--- apiVersion: v1 kind: Service metadata: name: ${proxy.serviceName} namespace: ${target.namespace} labels: app.kubernetes.io/name: ${proxy.deploymentName} app.kubernetes.io/component: egress-proxy app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} unidesk.ai/proxy-source: ${proxy.sourceType} spec: selector: app.kubernetes.io/name: ${proxy.deploymentName} app.kubernetes.io/component: egress-proxy ports: - name: http port: ${proxy.listenPort} targetPort: proxy --- apiVersion: apps/v1 kind: Deployment metadata: name: ${proxy.deploymentName} namespace: ${target.namespace} labels: app.kubernetes.io/name: ${proxy.deploymentName} app.kubernetes.io/component: egress-proxy app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} unidesk.ai/proxy-source: ${proxy.sourceType} spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: ${proxy.deploymentName} app.kubernetes.io/component: egress-proxy template: metadata: labels: app.kubernetes.io/name: ${proxy.deploymentName} app.kubernetes.io/component: egress-proxy app.kubernetes.io/part-of: platform-infra annotations: unidesk.ai/proxy-source-ref: "${proxy.sourceRef}" unidesk.ai/proxy-source-type: "${proxy.sourceType}" unidesk.ai/proxy-selected-outbound: "${proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks"}" spec: containers: - name: proxy image: ${proxy.image} imagePullPolicy: ${proxy.imagePullPolicy} args: - run - -c - /etc/sing-box/${proxy.secretKey} ports: - name: proxy containerPort: ${proxy.listenPort} readinessProbe: tcpSocket: port: proxy initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 6 livenessProbe: tcpSocket: port: proxy initialDelaySeconds: 30 periodSeconds: 20 timeoutSeconds: 5 failureThreshold: 6 volumeMounts: - name: proxy-config mountPath: /etc/sing-box/${proxy.secretKey} subPath: ${proxy.secretKey} readOnly: true volumes: - name: proxy-config secret: secretName: ${proxy.secretName} `; }