diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index d42dbe29..edf6ef1c 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -9,6 +9,25 @@ metadata: - 339 - 340 +defaults: + targetId: D601 + cleanup: + externalDbState: + postgresStatefulSetName: sub2api-postgres + postgresServiceName: sub2api-postgres + postgresPvcName: postgres-data-sub2api-postgres-0 + appDataPvcName: sub2api-data + redisPersistentState: + pvcName: sub2api-redis-data + publicExposure: + deploymentName: sub2api-frpc + configMapName: sub2api-frpc-config + secretName: sub2api-frpc-secrets + egressProxy: + deploymentName: sub2api-egress-proxy + serviceName: sub2api-egress-proxy + secretName: sub2api-egress-proxy-config + image: repository: weishaw/sub2api tag: 0.1.136 diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 7f47bfc5..2c35c498 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -18,13 +18,8 @@ import { import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; const g14K3sRoute = "G14:k3s"; -const defaultTargetId = "D601"; -const defaultTargetRoute = "D601:k3s"; -const namespace = "platform-infra"; const serviceName = "sub2api"; -const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`; const fieldManager = "unidesk-platform-infra"; -const appSecretName = "sub2api-secrets"; const sub2apiConfigPath = rootPath("config", "platform-infra", "sub2api.yaml"); const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); const sentinelImageDockerfilePath = rootPath("src", "components", "platform-infra", "sub2api", "sentinel.Dockerfile"); @@ -242,6 +237,7 @@ interface CodexLocalConsumerTomlOptions { export function codexPoolHelp(): unknown { const pool = readCodexPoolConfig(); + const runtimeTarget = codexPoolRuntimeTarget(); return { command: "platform-infra sub2api codex-pool plan|sync|validate|trace|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", output: "json, except trace and sentinel-report default to low-noise text tables", @@ -261,14 +257,15 @@ export function codexPoolHelp(): unknown { ], description: "Import YAML-selected ~/.codex API-key profiles into one Sub2API OpenAI pool, expose one unified API_KEY, and optionally configure this master server's ~/.codex consumer endpoint.", target: { - route: g14K3sRoute, - namespace, - serviceDns, + default: runtimeTarget.id, + route: runtimeTarget.route, + namespace: runtimeTarget.namespace, + serviceDns: runtimeTarget.serviceDns, configPath: codexPoolConfigPath, poolGroupName: pool.groupName, poolApiKeySecretName: pool.apiKeySecretName, poolApiKeySecretKey: pool.apiKeySecretKey, - publicBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.publicBaseUrl : null, + publicBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.publicBaseUrl : runtimeTarget.publicBaseUrl, masterBaseUrl: pool.publicExposure.enabled ? pool.publicExposure.masterBaseUrl : null, sentinelMonitorEnabled: pool.sentinel.monitor.enabled, sentinelActionsEnabled: pool.sentinel.actions.enabled, @@ -527,7 +524,7 @@ function parseDisclosureOptions(args: string[]): DisclosureOptions { } function parseTargetId(args: string[]): string { - let targetId = defaultTargetId; + let targetId: string | null = null; for (let index = 0; index < args.length; index += 1) { const arg = args[index]!; if (arg === "--target") { @@ -539,8 +536,9 @@ function parseTargetId(args: string[]): string { } if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length); } - if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id"); - return targetId; + const resolvedTargetId = targetId ?? defaultCodexPoolRuntimeTargetId(); + if (!/^[A-Za-z0-9._-]+$/u.test(resolvedTargetId)) throw new Error("--target must be a simple target id"); + return resolvedTargetId; } function splitAccountNames(value: string): string[] { @@ -564,15 +562,45 @@ function stripBooleanOptions(args: string[], stripped: Set): string[] { return args.filter((arg) => !stripped.has(arg)); } -function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRuntimeTarget { +interface Sub2ApiRuntimeConfig { + defaultTargetId: string; + appSecretName: string; + targets: Record[]; +} + +function readSub2ApiRuntimeConfig(): Sub2ApiRuntimeConfig { const parsed = Bun.YAML.parse(readFileSync(sub2apiConfigPath, "utf8")) as unknown; - if (!isRecord(parsed) || !Array.isArray(parsed.targets)) throw new Error(`${sub2apiConfigPath}.targets must be a list`); - const raw = parsed.targets.find((item) => isRecord(item) && String(item.id ?? "").toLowerCase() === targetId.toLowerCase()); + 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); + if (!Array.isArray(parsed.targets) || !parsed.targets.every(isRecord)) throw new Error(`${sub2apiConfigPath}.targets must be a list`); + return { + defaultTargetId, + appSecretName, + targets: parsed.targets, + }; +} + +function defaultCodexPoolRuntimeTargetId(): string { + return readSub2ApiRuntimeConfig().defaultTargetId; +} + +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) ?? targetId; - const route = stringValue(raw.route) ?? (id === defaultTargetId ? defaultTargetRoute : ""); - const targetNamespace = stringValue(raw.namespace) ?? namespace; + 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); let egressProxy: CodexPoolRuntimeTarget["egressProxy"] = null; @@ -605,18 +633,19 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu serviceName, serviceDns: `${serviceName}.${targetNamespace}.svc.cluster.local:8080`, publicBaseUrl, - appSecretName, + appSecretName: runtimeConfig.appSecretName, egressProxy, }; } function targetFlag(target: CodexPoolRuntimeTarget): string { - return target.id === defaultTargetId ? "" : ` --target ${target.id}`; + return target.id === defaultCodexPoolRuntimeTargetId() ? "" : ` --target ${target.id}`; } -function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, targetId: defaultTargetId }): Record { +function codexPoolPlan(options?: DisclosureOptions): Record { + const resolvedOptions = options ?? { full: false, raw: false, targetId: defaultCodexPoolRuntimeTargetId() }; const pool = readCodexPoolConfig(); - const runtimeTarget = codexPoolRuntimeTarget(options.targetId); + const runtimeTarget = codexPoolRuntimeTarget(resolvedOptions.targetId); const profiles = collectCodexProfiles(); const ok = profiles.length > 0 && profiles.every((profile) => profile.ok); const consumerBaseUrl = codexConsumerBaseUrl(pool, runtimeTarget); @@ -973,12 +1002,13 @@ async function codexPoolCleanupProbes(config: UniDeskConfig, options: ConfirmOpt async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions): Promise> { const pool = readCodexPoolConfig(); + const runtimeTarget = codexPoolRuntimeTarget(options.targetId); if (!pool.publicExposure.enabled) { return { ok: true, action: "platform-infra-sub2api-codex-pool-expose", mode: "disabled-by-yaml", - target: poolTarget(pool), + target: poolTarget(pool, runtimeTarget), }; } if (!options.confirm) { @@ -986,16 +1016,16 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions): ok: true, action: "platform-infra-sub2api-codex-pool-expose", mode: "dry-run", - target: poolTarget(pool), - publicExposure: publicExposureSummary(pool), + target: poolTarget(pool, runtimeTarget), + publicExposure: publicExposureSummary(pool, runtimeTarget), next: { - confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool expose --confirm", + confirm: `bun scripts/cli.ts platform-infra sub2api codex-pool expose${targetFlag(runtimeTarget)} --confirm`, }, }; } const masterResult = await applyMasterFrpsAllowPort(pool); const caddyResult = await applyMasterCaddySite(pool); - const remoteResult = await capture(config, g14K3sRoute, ["script"], exposeScript(pool)); + const remoteResult = await capture(config, runtimeTarget.route, ["script"], exposeScript(pool, runtimeTarget)); const parsed = parseJsonOutput(remoteResult.stdout); const publicProbe = await probePublicModels(pool, "without-api-key", undefined, "public"); const ok = masterResult.ok && caddyResult.ok && remoteResult.exitCode === 0 && boolField(parsed, "ok", false) && publicProbe.httpStatus === 401; @@ -1014,7 +1044,7 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions): ok, action: "platform-infra-sub2api-codex-pool-expose", mode: "confirmed", - publicExposure: publicExposureSummary(pool), + publicExposure: publicExposureSummary(pool, runtimeTarget), masterFrps: masterResult, masterCaddy: caddyResult, remote: parsed ?? compactCapture(remoteResult, { full: options.full || remoteResult.exitCode !== 0 }), @@ -1283,6 +1313,7 @@ function readCodexPoolConfig(): CodexPoolConfig { } function defaultCodexPoolConfig(): CodexPoolConfig { + const runtimeTarget = codexPoolRuntimeTarget(); return { version: 1, kind: "platform-infra-sub2api-codex-pool", @@ -1313,7 +1344,7 @@ function defaultCodexPoolConfig(): CodexPoolConfig { serverAddr: "74.48.78.17", serverPort: 7000, remotePort: 21880, - localIP: serviceDns.split(":")[0], + localIP: runtimeTarget.serviceDns.split(":")[0], localPort: 8080, publicBaseUrl: "http://74.48.78.17:21880", masterBaseUrl: "http://127.0.0.1:21880", @@ -2423,7 +2454,7 @@ function renderSentinelReport( `schedule=${cronJob.schedule ?? "-"}`, `last=${shortIso(cronJob.lastScheduleTime)}`, `active=${cronJob.active ?? "-"}`, - `state=${metadata.namespace ?? namespace}/${metadata.stateConfigMapName ?? "-"}`, + `state=${metadata.namespace ?? "-"}/${metadata.stateConfigMapName ?? "-"}`, `ledger=req:${globalLedger.requestCount ?? 0} tok:${formatNumber(globalLedger.totalTokens)} cost:$${formatCost(globalLedger.estimatedCostUsd)}`, ].join(" ")); lines.push(""); @@ -2767,7 +2798,7 @@ function codexPoolSyncSummary(parsed: Record | null): Record { +function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarget()): Record { return { id: target.id, route: target.route, @@ -2817,11 +2848,11 @@ function sentinelProfileSecrets(profiles: CodexProfile[]): CodexPoolSentinelProf })); } -function publicExposureSummary(pool: CodexPoolConfig): Record { +function publicExposureSummary(pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): Record { return { enabled: pool.publicExposure.enabled, proxyName: pool.publicExposure.proxyName, - namespace, + namespace: target.namespace, configMapName: pool.publicExposure.configMapName, deploymentName: pool.publicExposure.deploymentName, frpcImage: pool.publicExposure.frpcImage, @@ -2844,7 +2875,7 @@ function publicExposureSummary(pool: CodexPoolConfig): Record { upstream: { localIP: pool.publicExposure.localIP, localPort: pool.publicExposure.localPort, - serviceDns, + serviceDns: target.serviceDns, }, urls: { publicBaseUrl: pool.publicExposure.publicBaseUrl, @@ -2968,8 +2999,8 @@ function frpsAllowPortExists(toml: string, port: number): boolean { }); } -function exposeScript(pool: CodexPoolConfig): string { - const manifest = frpcManifest(pool); +function exposeScript(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { + const manifest = frpcManifest(pool, target); const encoded = Buffer.from(manifest, "utf8").toString("base64"); return ` set -u @@ -2983,7 +3014,7 @@ apply_out="$tmp/apply.out" apply_err="$tmp/apply.err" rollout_out="$tmp/rollout.out" rollout_err="$tmp/rollout.err" -kubectl get namespace ${namespace} >"$ns_out" 2>"$ns_err" +kubectl get namespace ${target.namespace} >"$ns_out" 2>"$ns_err" ns_rc=$? apply_rc=1 rollout_rc=1 @@ -2991,7 +3022,7 @@ if [ "$ns_rc" -eq 0 ]; then kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err" apply_rc=$? if [ "$apply_rc" -eq 0 ]; then - kubectl -n ${namespace} rollout status deployment/${pool.publicExposure.deploymentName} --timeout=30s >"$rollout_out" 2>"$rollout_err" + kubectl -n ${target.namespace} rollout status deployment/${pool.publicExposure.deploymentName} --timeout=30s >"$rollout_out" 2>"$rollout_err" rollout_rc=$? else printf '%s\\n' 'skipped because apply failed' >"$rollout_err" @@ -3004,11 +3035,11 @@ else fi pods_json="$tmp/pods.json" pods_err="$tmp/pods.err" -kubectl -n ${namespace} get pods -l app.kubernetes.io/name=${pool.publicExposure.deploymentName} -o json >"$pods_json" 2>"$pods_err" +kubectl -n ${target.namespace} get pods -l app.kubernetes.io/name=${pool.publicExposure.deploymentName} -o json >"$pods_json" 2>"$pods_err" pods_rc=$? logs_out="$tmp/logs.out" logs_err="$tmp/logs.err" -kubectl -n ${namespace} logs deployment/${pool.publicExposure.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err" +kubectl -n ${target.namespace} logs deployment/${pool.publicExposure.deploymentName} --tail=80 >"$logs_out" 2>"$logs_err" logs_rc=$? python3 - "$tmp" "$ns_rc" "$apply_rc" "$rollout_rc" "$pods_rc" "$logs_rc" <<'PY' import json @@ -3048,7 +3079,7 @@ if isinstance(pods_obj, dict): logs = text("logs.out", 4000) payload = { "ok": ns_rc == 0 and apply_rc == 0 and rollout_rc == 0 and pods_rc == 0 and len(pods) > 0 and all(p["ready"] for p in pods), - "namespace": "${namespace}", + "namespace": "${target.namespace}", "proxy": { "name": "${pool.publicExposure.proxyName}", "remotePort": ${JSON.stringify(pool.publicExposure.remotePort)}, @@ -3081,12 +3112,12 @@ PY `; } -function frpcManifest(pool: CodexPoolConfig): string { +function frpcManifest(pool: CodexPoolConfig, target: CodexPoolRuntimeTarget): string { return `apiVersion: v1 kind: ConfigMap metadata: name: ${pool.publicExposure.configMapName} - namespace: ${namespace} + namespace: ${target.namespace} labels: app.kubernetes.io/name: ${pool.publicExposure.deploymentName} app.kubernetes.io/part-of: platform-infra @@ -3108,7 +3139,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: ${pool.publicExposure.deploymentName} - namespace: ${namespace} + namespace: ${target.namespace} labels: app.kubernetes.io/name: ${pool.publicExposure.deploymentName} app.kubernetes.io/part-of: platform-infra @@ -3143,7 +3174,7 @@ spec: `; } -async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): Promise<{ apiKey: string | null; error: string | null }> { +async function fetchPoolApiKey(config: UniDeskConfig, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): Promise<{ apiKey: string | null; error: string | null }> { const result = await capture(config, target.route, ["script"], ` set -u kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json @@ -3163,7 +3194,7 @@ kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json } } -function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Record { +function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Record { const codexDir = join(homedir(), ".codex"); mkdirSync(codexDir, { recursive: true, mode: 0o700 }); const configPath = join(codexDir, "config.toml"); @@ -3221,7 +3252,7 @@ function copyIfMissing(source: string, target: string): "created" | "kept-existi return "created"; } -function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string { +function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): string { return renderCodexLocalConsumerToml(current, { providerName: pool.localCodex.providerName, baseUrl: codexConsumerBaseUrl(pool, target), @@ -3233,7 +3264,7 @@ function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = }); } -function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string { +function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): string { const baseUrl = target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl; return `${baseUrl.replace(/\/+$/u, "")}/`; } @@ -3331,7 +3362,7 @@ function upsertTomlSectionBoolean(text: string, sectionName: string, key: string return [...lines.slice(0, start + 1), assignment, ...lines.slice(start + 1)].join("\n"); } -async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Promise> { +async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Promise> { const probeBase = target.publicBaseUrl === null ? "public" : "target-public"; const probe = await probePublicModels(pool, "with-api-key", apiKey, probeBase, target); return { @@ -3343,7 +3374,7 @@ async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: strin }; } -async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" | "target-public" = "master", target = codexPoolRuntimeTarget(defaultTargetId)): Promise> { +async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" | "target-public" = "master", target = codexPoolRuntimeTarget()): Promise> { const baseUrl = base === "target-public" ? (target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl) : base === "public" @@ -6543,7 +6574,7 @@ async function capture(config: UniDeskConfig, target: string, args: string[], in type RemoteCodexPoolMode = "sync" | "validate" | "sentinel-probe" | "sentinel-image-status" | "sentinel-image-build"; -async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget(defaultTargetId)): Promise { +async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget()): Promise { const jobName = `codex-pool-${mode}-${Date.now().toString(36)}`.slice(0, 63); const startedAtMs = Date.now(); const start = await capture(config, target.route, ["script"], remoteJobStartScript(jobName, script)); diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index fc7eff63..64bf5df0 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -10,14 +10,11 @@ import { prepareFrpcSecret } from "./platform-infra-public-service"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; -const defaultTargetId = "D601"; -const namespace = "platform-infra"; const serviceName = "sub2api"; const fieldManager = "unidesk-platform-infra"; const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml"); const configPath = rootPath("config", "platform-infra", "sub2api.yaml"); const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); -const secretName = "sub2api-secrets"; const sub2apiCaddyManagedMarker = "sub2api"; const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const; @@ -32,6 +29,7 @@ interface Sub2ApiConfig { owner: string; relatedIssues: number[]; }; + defaults: Sub2ApiDefaults; image: { repository: string; tag: string; @@ -64,6 +62,31 @@ interface Sub2ApiConfig { }; } +interface Sub2ApiDefaults { + targetId: string; + cleanup: { + externalDbState: { + postgresStatefulSetName: string; + postgresServiceName: string; + postgresPvcName: string; + appDataPvcName: string; + }; + redisPersistentState: { + pvcName: string; + }; + publicExposure: { + deploymentName: string; + configMapName: string; + secretName: string; + }; + egressProxy: { + deploymentName: string; + serviceName: string; + secretName: string; + }; + }; +} + interface Sub2ApiTargetConfig { id: string; route: string; @@ -201,8 +224,17 @@ interface EgressProxySecretMaterial { } interface ManagedResourceCleanupPlan { - externalDbState: boolean; - redisPersistentState: boolean; + externalDbState: { + enabled: boolean; + postgresStatefulSetName: string; + postgresServiceName: string; + postgresPvcName: string; + appDataPvcName: string; + }; + redisPersistentState: { + enabled: boolean; + pvcName: string; + }; publicExposure: { enabled: boolean; deploymentName: string; @@ -228,6 +260,7 @@ interface ManagedResourceCleanupPlan { } export function platformInfraHelp(): unknown { + const target = sub2ApiHelpTargetSummary(); return { command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", output: "json", @@ -266,15 +299,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra wechat-archive collector-status --full", ], description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot, n8n and WeChat archive workflows. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.", - target: { - default: defaultTargetId, - namespace, - service: serviceName, - serviceDns: `${serviceName}.${namespace}.svc.cluster.local:8080`, - exposure: "k3s-cluster-internal-only", - resourceLimits: "unset-by-policy", - versionConfigPath: configPath, - }, + target, codexPool: { usage: [ "bun scripts/cli.ts platform-infra sub2api codex-pool plan", @@ -288,6 +313,20 @@ export function platformInfraHelp(): unknown { }; } +function sub2ApiHelpTargetSummary(): Record { + const sub2api = readSub2ApiConfig(); + const target = resolveTarget(sub2api, sub2api.defaults.targetId); + return { + default: sub2api.defaults.targetId, + namespace: target.namespace, + service: serviceName, + serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`, + exposure: target.publicExposure?.enabled === true ? "yaml-controlled-public-exposure" : "k3s-cluster-internal-only", + resourceLimits: "unset-by-policy", + versionConfigPath: configPath, + }; +} + export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [target, action] = args; if (target === "langbot") { @@ -366,7 +405,7 @@ function parseDisclosureOptions(args: string[]): DisclosureOptions { } function parseTargetOptions(args: string[]): TargetOptions { - let targetId = defaultTargetId; + let targetId: string | null = null; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--target") { @@ -378,8 +417,13 @@ function parseTargetOptions(args: string[]): TargetOptions { targetId = arg.slice("--target=".length); } } - if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id"); - return { targetId }; + const resolvedTargetId = targetId ?? defaultSub2ApiTargetId(); + if (!/^[A-Za-z0-9._-]+$/u.test(resolvedTargetId)) throw new Error("--target must be a simple target id"); + return { targetId: resolvedTargetId }; +} + +function defaultSub2ApiTargetId(): string { + return readSub2ApiConfig().defaults.targetId; } function validateOptions(args: string[], booleanOptions: Set): void { @@ -405,6 +449,7 @@ function readSub2ApiConfig(): Sub2ApiConfig { if (kind !== "platform-infra-sub2api") throw new Error(`${configPath}.kind must be platform-infra-sub2api`); const metadata = objectField(root, "metadata", ""); const relatedIssues = integerArrayField(metadata, "relatedIssues", "metadata"); + const defaults = parseDefaults(root); const image = (parsed as { image?: unknown }).image; if (typeof image !== "object" || image === null || Array.isArray(image)) throw new Error(`${configPath}.image must be an object`); const record = image as Record; @@ -421,7 +466,7 @@ function readSub2ApiConfig(): Sub2ApiConfig { const allowInsecureHttp = booleanField(urlAllowlist, "allowInsecureHttp", "security.urlAllowlist"); const allowPrivateHosts = booleanField(urlAllowlist, "allowPrivateHosts", "security.urlAllowlist"); const upstreamHosts = stringArrayField(urlAllowlist, "upstreamHosts", "security.urlAllowlist"); - const targets = parseTargets(root); + const targets = parseTargets(root, defaults.targetId); const runtime = parseRuntime(root); return { version, @@ -431,6 +476,7 @@ function readSub2ApiConfig(): Sub2ApiConfig { owner: stringField(metadata, "owner", "metadata"), relatedIssues, }, + defaults, image: { repository, tag, pullPolicy }, dependencyImages, security: { @@ -446,6 +492,55 @@ function readSub2ApiConfig(): Sub2ApiConfig { }; } +function parseDefaults(root: Record): Sub2ApiDefaults { + const defaults = objectField(root, "defaults", ""); + const targetId = stringField(defaults, "targetId", "defaults"); + if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error(`${configPath}.defaults.targetId must be a simple target id`); + const cleanup = objectField(defaults, "cleanup", "defaults"); + const externalDbState = objectField(cleanup, "externalDbState", "defaults.cleanup"); + const redisPersistentState = objectField(cleanup, "redisPersistentState", "defaults.cleanup"); + const publicExposure = objectField(cleanup, "publicExposure", "defaults.cleanup"); + const egressProxy = objectField(cleanup, "egressProxy", "defaults.cleanup"); + const parsed: Sub2ApiDefaults = { + targetId, + cleanup: { + externalDbState: { + postgresStatefulSetName: stringField(externalDbState, "postgresStatefulSetName", "defaults.cleanup.externalDbState"), + postgresServiceName: stringField(externalDbState, "postgresServiceName", "defaults.cleanup.externalDbState"), + postgresPvcName: stringField(externalDbState, "postgresPvcName", "defaults.cleanup.externalDbState"), + appDataPvcName: stringField(externalDbState, "appDataPvcName", "defaults.cleanup.externalDbState"), + }, + redisPersistentState: { + pvcName: stringField(redisPersistentState, "pvcName", "defaults.cleanup.redisPersistentState"), + }, + publicExposure: { + deploymentName: stringField(publicExposure, "deploymentName", "defaults.cleanup.publicExposure"), + configMapName: stringField(publicExposure, "configMapName", "defaults.cleanup.publicExposure"), + secretName: stringField(publicExposure, "secretName", "defaults.cleanup.publicExposure"), + }, + egressProxy: { + deploymentName: stringField(egressProxy, "deploymentName", "defaults.cleanup.egressProxy"), + serviceName: stringField(egressProxy, "serviceName", "defaults.cleanup.egressProxy"), + secretName: stringField(egressProxy, "secretName", "defaults.cleanup.egressProxy"), + }, + }, + }; + const resourceNames = { + ...parsed.cleanup.externalDbState, + redisPersistentPvcName: parsed.cleanup.redisPersistentState.pvcName, + publicExposureDeploymentName: parsed.cleanup.publicExposure.deploymentName, + publicExposureConfigMapName: parsed.cleanup.publicExposure.configMapName, + publicExposureSecretName: parsed.cleanup.publicExposure.secretName, + egressProxyDeploymentName: parsed.cleanup.egressProxy.deploymentName, + egressProxyServiceName: parsed.cleanup.egressProxy.serviceName, + egressProxySecretName: parsed.cleanup.egressProxy.secretName, + }; + for (const [key, value] of Object.entries(resourceNames)) { + validateKubernetesResourceName(value, `defaults.cleanup.${key}`); + } + return parsed; +} + function parseDependencyImages(root: Record): Sub2ApiConfig["dependencyImages"] { const value = root.dependencyImages; if (value === undefined) throw new Error(`${configPath}.dependencyImages must be an object`); @@ -458,7 +553,7 @@ function parseDependencyImages(root: Record): Sub2ApiConfig["de return { postgres, redis }; } -function parseTargets(root: Record): Sub2ApiTargetConfig[] { +function parseTargets(root: Record, defaultTargetId: string): Sub2ApiTargetConfig[] { const value = root.targets; if (value === undefined) throw new Error(`${configPath}.targets must be an array`); if (!Array.isArray(value)) throw new Error(`${configPath}.targets must be an array`); @@ -493,7 +588,7 @@ function parseTargets(root: Record): Sub2ApiTargetConfig[] { if (ids.has(target.id)) throw new Error(`${configPath}.targets contains duplicate id ${target.id}`); ids.add(target.id); } - if (!ids.has(defaultTargetId)) throw new Error(`${configPath}.targets must include ${defaultTargetId}`); + if (!ids.has(defaultTargetId)) throw new Error(`${configPath}.targets must include defaults.targetId ${defaultTargetId}`); return targets; } @@ -545,9 +640,7 @@ function parsePublicExposureConfig(value: unknown, path: string): Sub2ApiPublicE 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 = dnsRaw.resolvers === undefined - ? ["1.1.1.1", "8.8.8.8", "223.5.5.5", "114.114.114.114"] - : stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`); + const resolvers = stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`); const exposure: Sub2ApiPublicExposureConfig = { enabled, publicBaseUrl: normalizePublicBaseUrl(publicBaseUrl, `${path}.publicExposure.publicBaseUrl`), @@ -596,25 +689,8 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx const record = value as Record; const sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url"] as const); const preferredOutbound = enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const); - const imagePullPolicy = record.imagePullPolicy === undefined - ? "IfNotPresent" - : enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const); - const noProxy = record.noProxy === undefined - ? [ - "localhost", - "127.0.0.1", - "::1", - ".svc", - ".cluster.local", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "82.156.23.220", - "74.48.78.17", - "hyueapi.com", - ".hyueapi.com", - ] - : stringArrayField(record, "noProxy", `${path}.egressProxy`); + const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const); + const noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`); const proxy: Sub2ApiEgressProxyConfig = { enabled: booleanField(record, "enabled", `${path}.egressProxy`), deploymentName: stringField(record, "deploymentName", `${path}.egressProxy`), @@ -629,9 +705,9 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx sourceType, preferredOutbound, noProxy, - applyToSub2Api: record.applyToSub2Api === undefined ? true : booleanField(record, "applyToSub2Api", `${path}.egressProxy`), - applyToSentinel: record.applyToSentinel === undefined ? true : booleanField(record, "applyToSentinel", `${path}.egressProxy`), - healthProbeUrl: record.healthProbeUrl === undefined ? "https://www.gstatic.com/generate_204" : stringField(record, "healthProbeUrl", `${path}.egressProxy`), + applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`), + applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`), + healthProbeUrl: stringField(record, "healthProbeUrl", `${path}.egressProxy`), }; validateEgressProxyConfig(proxy, path); return proxy; @@ -922,19 +998,25 @@ function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan { const sentinel = codexPoolSentinelResourceNames(); return { - externalDbState: isExternalTarget(target), - redisPersistentState: target.redisMode === "local-ephemeral", + 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-frpc", - configMapName: "sub2api-frpc-config", - secretName: target.publicExposure?.frpc.secretName ?? "sub2api-frpc-secrets", + 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-egress-proxy", - serviceName: target.egressProxy?.serviceName ?? "sub2api-egress-proxy", - secretName: target.egressProxy?.secretName ?? "sub2api-egress-proxy-config", + 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, @@ -1256,17 +1338,17 @@ spec: - name: ADMIN_PASSWORD valueFrom: secretKeyRef: - name: ${secretName} + name: ${database.secretName} key: ADMIN_PASSWORD - name: JWT_SECRET valueFrom: secretKeyRef: - name: ${secretName} + name: ${database.secretName} key: JWT_SECRET - name: TOTP_ENCRYPTION_KEY valueFrom: secretKeyRef: - name: ${secretName} + name: ${database.secretName} key: TOTP_ENCRYPTION_KEY readinessProbe: httpGet: @@ -1508,7 +1590,7 @@ function plan(options: TargetOptions): Record { const sub2api = readSub2ApiConfig(); const target = resolveTarget(sub2api, options.targetId); const yaml = manifest(sub2api, target); - const policy = policyChecks(yaml, target); + const policy = policyChecks(sub2api, yaml, target); return { ok: policy.every((check) => check.ok), action: "platform-infra-sub2api-plan", @@ -1627,7 +1709,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise check.ok)) { return { ok: false, @@ -1740,7 +1822,7 @@ async function validate(config: UniDeskConfig, options: DisclosureOptions): Prom ? validateExternalPendingScript(sub2api, target) : target.databaseMode === "external-active" ? validateExternalActiveScript(sub2api, target) - : validateScript(target); + : validateScript(sub2api, target); const result = await capture(config, target.route, ["script"], script); const parsed = parseJsonOutput(result.stdout); if (options.raw) { @@ -1769,7 +1851,9 @@ async function validate(config: UniDeskConfig, options: DisclosureOptions): Prom }; } -function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] { +function policyChecks(sub2api: Sub2ApiConfig, yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] { + const cleanup = sub2api.defaults.cleanup; + const redisService = sub2api.runtime.redis.serviceName; const checks: PolicyCheck[] = [ { name: "no-ingress", @@ -1817,7 +1901,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] checks.push( { name: "external-db-no-local-postgres", - ok: !/^\s*kind:\s*StatefulSet\s*$/mu.test(yaml) && !/\bsub2api-postgres\b/u.test(yaml), + ok: !/^\s*kind:\s*StatefulSet\s*$/mu.test(yaml) && !new RegExp(`\\b${escapeRegExp(cleanup.externalDbState.postgresStatefulSetName)}\\b`, "u").test(yaml), detail: "External DB targets must not deploy a local PostgreSQL StatefulSet, Service, or PVC.", }); } @@ -1826,20 +1910,20 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] checks.push( { name: "pending-db-app-scaled-to-zero", - ok: target.appReplicas === 0 && target.redisReplicas === 0 && hasDeploymentReplicas(yaml, serviceName, 0) && hasDeploymentReplicas(yaml, "sub2api-redis", 0), + ok: target.appReplicas === 0 && target.redisReplicas === 0 && hasDeploymentReplicas(yaml, serviceName, 0) && hasDeploymentReplicas(yaml, redisService, 0), detail: "External-pending predeployment keeps the Sub2API app and local Redis cache at replicas=0 until the external DB secret, endpoint, and runtime images are ready.", }, ); } else if (target.databaseMode === "external-active") { checks.push({ name: "external-active-app-and-redis-running", - ok: target.appReplicas === 1 && target.redisReplicas === 1 && hasDeploymentReplicas(yaml, serviceName, 1) && hasDeploymentReplicas(yaml, "sub2api-redis", 1), + ok: target.appReplicas === 1 && target.redisReplicas === 1 && hasDeploymentReplicas(yaml, serviceName, 1) && hasDeploymentReplicas(yaml, redisService, 1), detail: "External-active targets run one Sub2API app replica and one local ephemeral Redis cache replica against the external PostgreSQL runtime.", }); } else { checks.push({ name: "bundled-db-present", - ok: /^\s*kind:\s*StatefulSet\s*$/mu.test(yaml) && /\bsub2api-postgres\b/u.test(yaml), + ok: /^\s*kind:\s*StatefulSet\s*$/mu.test(yaml) && new RegExp(`\\b${escapeRegExp(cleanup.externalDbState.postgresStatefulSetName)}\\b`, "u").test(yaml), detail: "Bundled active targets render the local PostgreSQL StatefulSet.", }); } @@ -1847,7 +1931,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] if (target.redisMode === "local-ephemeral") { checks.push({ name: "local-redis-ephemeral", - ok: !/\bsub2api-redis-data\b/u.test(yaml) && /^\s*emptyDir:\s*\{\}\s*$/mu.test(yaml), + ok: !new RegExp(`\\b${escapeRegExp(cleanup.redisPersistentState.pvcName)}\\b`, "u").test(yaml) && /^\s*emptyDir:\s*\{\}\s*$/mu.test(yaml), detail: "Local Redis is an ephemeral cache and must not allocate persistent Redis storage.", }); } @@ -2535,7 +2619,7 @@ payload = { "publicBaseUrl": "${exposure.publicBaseUrl}", "hostname": "${exposure.dns.hostname}", "expectedA": "${exposure.dns.expectedA}", - "dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> D601 frpc -> Sub2API", + "dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API", "pikanodeRole": "pikapython.com upstream only; api.pikapython.com does not pass through pikanode Express", "caddy": { "binaryPath": "${exposure.pk01.caddyBinaryPath}", @@ -2709,6 +2793,11 @@ function applyScript( ): string { const encoded = Buffer.from(yaml, "utf8").toString("base64"); const cleanupPlan = managedResourceCleanupPlan(sub2api, target); + const appSecretName = sub2api.runtime.database.secretName; + const publicExposureSecretName = publicExposureSecretMaterial?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName; + const publicExposureSecretKey = publicExposureSecretMaterial?.secretKey ?? "frpc.toml"; + const egressProxySecretName = egressProxySecretMaterial?.secretName ?? sub2api.defaults.cleanup.egressProxy.secretName; + const egressProxySecretKey = egressProxySecretMaterial?.secretKey ?? "config.json"; if (target.databaseMode === "external-active" && secretMaterial === null) throw new Error("external-active apply requires secret material"); const externalSecretFiles = secretMaterial === null ? "" @@ -2790,7 +2879,7 @@ if [ "$ns_rc" -eq 0 ]; then printf '%s\\n' 'external DB target expects its DB credential Secret from the platform DB handoff; predeploy does not create placeholder secrets' >"$secret_err" elif [ "${target.databaseMode}" = "external-active" ]; then ${externalSecretFiles} - kubectl -n ${target.namespace} create secret generic ${secretName} \\ + kubectl -n ${target.namespace} create secret generic ${appSecretName} \\ --from-file=POSTGRES_PASSWORD="$tmp/secret.POSTGRES_PASSWORD" \\ --from-file=ADMIN_PASSWORD="$tmp/secret.ADMIN_PASSWORD" \\ --from-file=JWT_SECRET="$tmp/secret.JWT_SECRET" \\ @@ -2798,7 +2887,7 @@ ${externalSecretFiles} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$secret_out" 2>"$secret_err" secret_rc=$? secret_action="external-active-synced" - elif kubectl -n ${target.namespace} get secret ${secretName} >/dev/null 2>&1; then + elif kubectl -n ${target.namespace} get secret ${appSecretName} >/dev/null 2>&1; then secret_action="kept-existing" : >"$secret_out" : >"$secret_err" @@ -2811,7 +2900,7 @@ ${externalSecretFiles} dd if=/dev/urandom bs="$bytes" count=1 2>/dev/null | od -An -tx1 | tr -d ' \\n' fi } - kubectl -n ${target.namespace} create secret generic ${secretName} \\ + kubectl -n ${target.namespace} create secret generic ${appSecretName} \\ --from-literal=POSTGRES_PASSWORD="$(rand_hex 32)" \\ --from-literal=ADMIN_PASSWORD="$(rand_hex 16)" \\ --from-literal=JWT_SECRET="$(rand_hex 32)" \\ @@ -2822,8 +2911,8 @@ ${externalSecretFiles} fi if [ "${publicExposureSecretMaterial === null ? "false" : "true"}" = "true" ]; then ${exposureSecretFile} - kubectl -n ${target.namespace} create secret generic ${publicExposureSecretMaterial?.secretName ?? "sub2api-frpc-secrets"} \\ - --from-file=${publicExposureSecretMaterial?.secretKey ?? "frpc.toml"}="$tmp/frpc.toml" \\ + kubectl -n ${target.namespace} create secret generic ${publicExposureSecretName} \\ + --from-file=${publicExposureSecretKey}="$tmp/frpc.toml" \\ --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$exposure_secret_out" 2>"$exposure_secret_err" exposure_secret_rc=$? exposure_secret_action="synced" @@ -2833,8 +2922,8 @@ ${exposureSecretFile} fi if [ "${egressProxySecretMaterial === null ? "false" : "true"}" = "true" ]; then ${egressProxySecretFile} - kubectl -n ${target.namespace} create secret generic ${egressProxySecretMaterial?.secretName ?? "sub2api-egress-proxy-config"} \\ - --from-file=${egressProxySecretMaterial?.secretKey ?? "config.json"}="$tmp/egress-proxy-config.json" \\ + kubectl -n ${target.namespace} create secret generic ${egressProxySecretName} \\ + --from-file=${egressProxySecretKey}="$tmp/egress-proxy-config.json" \\ --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$egress_secret_out" 2>"$egress_secret_err" egress_secret_rc=$? egress_secret_action="synced" @@ -2876,13 +2965,15 @@ def delete(kind, name): return run(["delete", kind, name, "--ignore-not-found=true", "--wait=false"]) items = [] -if plan["externalDbState"]: - items.append({"name": "legacy-postgres-statefulset", **delete("statefulset", "sub2api-postgres")}) - items.append({"name": "legacy-postgres-service", **delete("service", "sub2api-postgres")}) - items.append({"name": "legacy-postgres-pvc", **delete("pvc", "postgres-data-sub2api-postgres-0")}) - items.append({"name": "legacy-app-data-pvc", **delete("pvc", "sub2api-data")}) -if plan["redisPersistentState"]: - items.append({"name": "legacy-redis-pvc", **delete("pvc", "sub2api-redis-data")}) +external_db_state = plan["externalDbState"] +redis_persistent_state = plan["redisPersistentState"] +if external_db_state["enabled"]: + items.append({"name": "legacy-postgres-statefulset", **delete("statefulset", external_db_state["postgresStatefulSetName"])}) + items.append({"name": "legacy-postgres-service", **delete("service", external_db_state["postgresServiceName"])}) + items.append({"name": "legacy-postgres-pvc", **delete("pvc", external_db_state["postgresPvcName"])}) + items.append({"name": "legacy-app-data-pvc", **delete("pvc", external_db_state["appDataPvcName"])}) +if redis_persistent_state["enabled"]: + items.append({"name": "legacy-redis-pvc", **delete("pvc", redis_persistent_state["pvcName"])}) if not plan["publicExposure"]["enabled"]: items.append({"name": "disabled-public-frpc-deployment", **delete("deployment", plan["publicExposure"]["deploymentName"])}) items.append({"name": "disabled-public-frpc-configmap", **delete("configmap", plan["publicExposure"]["configMapName"])}) @@ -2901,10 +2992,15 @@ if not plan["sentinel"]["enabled"]: items.append({"name": "disabled-sentinel-rolebinding", **delete("rolebinding", plan["sentinel"]["roleBindingName"])}) watch = [] -if plan["externalDbState"]: - watch.extend([("statefulset", "sub2api-postgres"), ("service", "sub2api-postgres"), ("pvc", "postgres-data-sub2api-postgres-0"), ("pvc", "sub2api-data")]) -if plan["redisPersistentState"]: - watch.append(("pvc", "sub2api-redis-data")) +if external_db_state["enabled"]: + watch.extend([ + ("statefulset", external_db_state["postgresStatefulSetName"]), + ("service", external_db_state["postgresServiceName"]), + ("pvc", external_db_state["postgresPvcName"]), + ("pvc", external_db_state["appDataPvcName"]), + ]) +if redis_persistent_state["enabled"]: + watch.append(("pvc", redis_persistent_state["pvcName"])) if not plan["publicExposure"]["enabled"]: watch.append(("deployment", plan["publicExposure"]["deploymentName"])) if not plan["egressProxy"]["enabled"]: @@ -2973,7 +3069,7 @@ payload = { "appReplicas": ${target.appReplicas}, "redisReplicas": ${target.redisReplicas}, "secret": { - "name": "${secretName}", + "name": "${appSecretName}", "action": secret_action, "requiredKeys": ${JSON.stringify(requiredSecretKeys)}, "managedByThisApply": ${target.databaseMode === "external-pending" ? "False" : "True"}, @@ -3004,6 +3100,9 @@ function statusScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): stri const externalActive = target.databaseMode === "external-active"; const proxy = target.egressProxy?.enabled ? target.egressProxy : null; const expectedProxyEnv = sub2ApiProxyEnv(target); + const cleanupPlan = managedResourceCleanupPlan(sub2api, target); + const appSecretName = sub2api.runtime.database.secretName; + const redisService = sub2api.runtime.redis.serviceName; return ` set -u tmp="$(mktemp -d)" @@ -3021,7 +3120,7 @@ capture_json statefulsets kubectl -n ${target.namespace} get statefulsets -l app capture_json pods kubectl -n ${target.namespace} get pods -l app.kubernetes.io/part-of=platform-infra capture_json services kubectl -n ${target.namespace} get services -l app.kubernetes.io/part-of=platform-infra capture_json pvc kubectl -n ${target.namespace} get pvc -l app.kubernetes.io/part-of=platform-infra -capture_json secrets kubectl -n ${target.namespace} get secret ${secretName} +capture_json secrets kubectl -n ${target.namespace} get secret ${appSecretName} capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config capture_json networkpolicies kubectl -n ${target.namespace} get networkpolicy capture_json cronjobs kubectl -n ${target.namespace} get cronjob -l app.kubernetes.io/part-of=platform-infra @@ -3231,6 +3330,11 @@ external_pending = ${externalPending ? "True" : "False"} external_active = ${externalActive ? "True" : "False"} expected_app_replicas = ${target.appReplicas} expected_redis_replicas = ${target.redisReplicas} +cleanup_plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))}) +external_db_state = cleanup_plan["externalDbState"] +redis_persistent_state = cleanup_plan["redisPersistentState"] +public_exposure_state = cleanup_plan["publicExposure"] +egress_proxy_state = cleanup_plan["egressProxy"] service_violations = [] for svc in services: spec = svc.get("spec") or {} @@ -3243,7 +3347,7 @@ resource_violations = resource_findings("Deployment", deployments) + resource_fi expected_image = "${expectedImage}" expected_url_allowlist = json.loads(${JSON.stringify(JSON.stringify(expectedUrlAllowlist))}) sub2api_deployment = next((deployment_summary(item) for item in deployments if item["metadata"]["name"] == "${serviceName}"), None) -redis_deployment = next((deployment_summary(item) for item in deployments if item["metadata"]["name"] == "sub2api-redis"), None) +redis_deployment = next((deployment_summary(item) for item in deployments if item["metadata"]["name"] == "${redisService}"), None) sub2api_desired_aligned = sub2api_deployment is not None and sub2api_deployment.get("desired") == expected_app_replicas redis_desired_aligned = redis_deployment is not None and redis_deployment.get("desired") == expected_redis_replicas image_aligned = sub2api_deployment is not None and expected_image in sub2api_deployment.get("images", []) @@ -3315,10 +3419,14 @@ boundary = { deployment_summaries = [deployment_summary(item) for item in deployments] statefulset_summaries = [statefulset_summary(item) for item in statefulsets] workload_ready = all(d["ready"] for d in deployment_summaries) and all(s["ready"] for s in statefulset_summaries) -local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in statefulsets + services + pvcs) -redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in pvcs) -public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-frpc" for item in deployments) -egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-egress-proxy" for item in deployments) +local_postgres_present = ( + any(item.get("metadata", {}).get("name") == external_db_state["postgresStatefulSetName"] for item in statefulsets) + or any(item.get("metadata", {}).get("name") == external_db_state["postgresServiceName"] for item in services) + or any(item.get("metadata", {}).get("name") in [external_db_state["postgresPvcName"], external_db_state["appDataPvcName"]] for item in pvcs) +) +redis_pvc_present = any(item.get("metadata", {}).get("name") == redis_persistent_state["pvcName"] for item in pvcs) +public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == public_exposure_state["deploymentName"] for item in deployments) +egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == egress_proxy_state["deploymentName"] for item in deployments) sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs) standby_disabled_resources_ok = not external_pending or ( not public_exposure_deployment_present @@ -3367,7 +3475,7 @@ payload = { "pvcs": [pvc_summary(item) for item in pvcs], "networkPolicy": network_policy, "secret": { - "name": "${secretName}", + "name": "${appSecretName}", "exists": rc("secrets") == 0, "requiredKeys": ${JSON.stringify(requiredSecretKeys)}, "missingKeys": missing_secret_keys, @@ -3433,7 +3541,12 @@ PY `; } -function validateScript(target: Sub2ApiTargetConfig): string { +function validateScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string { + const cleanup = sub2api.defaults.cleanup; + const dependencyImages = targetDependencyImages(sub2api, target); + const postgresStatefulSetName = cleanup.externalDbState.postgresStatefulSetName; + const postgresServiceName = cleanup.externalDbState.postgresServiceName; + const redisService = sub2api.runtime.redis.serviceName; return ` set -u tmp="$(mktemp -d)" @@ -3451,8 +3564,8 @@ kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName} root_rc=$? kubectl -n ${target.namespace} get networkpolicy allow-all -o json >"$tmp/network-policy.json" 2>"$tmp/network-policy.err" network_policy_rc=$? -pg_pod="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=sub2api-postgres -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pg-pod.err")" -redis_pod="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=sub2api-redis -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/redis-pod.err")" +pg_pod="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=${postgresStatefulSetName} -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pg-pod.err")" +redis_pod="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=${redisService} -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/redis-pod.err")" if [ -n "$pg_pod" ]; then kubectl -n ${target.namespace} exec "$pg_pod" -- pg_isready -U sub2api -d sub2api -h 127.0.0.1 >"$tmp/pg.out" 2>"$tmp/pg.err" pg_rc=$? @@ -3475,9 +3588,9 @@ if ! command -v timeout >/dev/null 2>&1; then pg_cross_rc=127 redis_cross_rc=127 else - timeout 35s kubectl -n ${target.namespace} run "$pg_probe" --restart=Never --rm -i --image=postgres:18-alpine --image-pull-policy=IfNotPresent --command -- pg_isready -h sub2api-postgres -U sub2api -d sub2api -t 5 >"$tmp/pg-cross.out" 2>"$tmp/pg-cross.err" + timeout 35s kubectl -n ${target.namespace} run "$pg_probe" --restart=Never --rm -i --image=${dependencyImages.postgres} --image-pull-policy=IfNotPresent --command -- pg_isready -h ${postgresServiceName} -U sub2api -d sub2api -t 5 >"$tmp/pg-cross.out" 2>"$tmp/pg-cross.err" pg_cross_rc=$? - timeout 35s kubectl -n ${target.namespace} run "$redis_probe" --restart=Never --rm -i --image=redis:8-alpine --image-pull-policy=IfNotPresent --command -- redis-cli -h sub2api-redis -p 6379 ping >"$tmp/redis-cross.out" 2>"$tmp/redis-cross.err" + timeout 35s kubectl -n ${target.namespace} run "$redis_probe" --restart=Never --rm -i --image=${dependencyImages.redis} --image-pull-policy=IfNotPresent --command -- redis-cli -h ${redisService} -p 6379 ping >"$tmp/redis-cross.out" 2>"$tmp/redis-cross.err" redis_cross_rc=$? fi python3 - "$tmp" "$health_rc" "$root_rc" "$pg_rc" "$redis_rc" "$network_policy_rc" "$pg_cross_rc" "$redis_cross_rc" <<'PY' @@ -3555,7 +3668,7 @@ payload = { }, "postgresCrossPodPgIsReady": { "exitCode": pg_cross_rc, - "method": "temporary postgres:18-alpine pod pg_isready -h sub2api-postgres -U sub2api -d sub2api -t 5, bounded by outer timeout", + "method": "temporary ${dependencyImages.postgres} pod pg_isready -h ${postgresServiceName} -U sub2api -d sub2api -t 5, bounded by outer timeout", "stdout": text("pg-cross.out", 2000), "stderr": text("pg-cross.err", 2000), }, @@ -3566,7 +3679,7 @@ payload = { }, "redisCrossPodPing": { "exitCode": redis_cross_rc, - "method": "temporary redis:8-alpine pod redis-cli -h sub2api-redis -p 6379 ping, bounded by outer timeout", + "method": "temporary ${dependencyImages.redis} pod redis-cli -h ${redisService} -p 6379 ping, bounded by outer timeout", "stdout": text("redis-cross.out", 2000), "stderr": text("redis-cross.err", 2000), }, @@ -3581,6 +3694,7 @@ PY function validateExternalActiveScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string { const database = sub2api.runtime.database; const redisService = sub2api.runtime.redis.serviceName; + const cleanupPlan = managedResourceCleanupPlan(sub2api, target); const exposure = target.publicExposure?.enabled ? target.publicExposure : null; const proxy = target.egressProxy?.enabled ? target.egressProxy : null; const proxyUrl = proxy === null ? "" : `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`; @@ -3660,7 +3774,7 @@ fi "hostname": "${exposure.dns.hostname}", "expectedA": "${exposure.dns.expectedA}", "mode": "pk01-caddy-frp-direct", - "dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> D601 frpc -> Sub2API", + "dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API", "dns": { "exitCode": public_dns_rc, "resolvers": ${JSON.stringify(exposure.dns.resolvers)}, @@ -3687,7 +3801,7 @@ capture_json() { printf '%s' "$rc" >"$tmp/$name.rc" } capture_json networkpolicy kubectl -n ${target.namespace} get networkpolicy allow-all -capture_json secret kubectl -n ${target.namespace} get secret ${secretName} +capture_json secret kubectl -n ${target.namespace} get secret ${database.secretName} capture_json statefulsets kubectl -n ${target.namespace} get statefulsets capture_json pvc kubectl -n ${target.namespace} get pvc app_pod="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=${serviceName},app.kubernetes.io/component=app -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/app-pod.err" || true)" @@ -3783,8 +3897,14 @@ network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(net secret = load("secret") secret_keys = sorted(((secret or {}).get("data") or {}).keys()) missing_secret_keys = [key for key in ${JSON.stringify(requiredSecretKeys)} if key not in secret_keys] -local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in items("statefulsets") + items("pvc")) -redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in items("pvc")) +cleanup_plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))}) +external_db_state = cleanup_plan["externalDbState"] +redis_persistent_state = cleanup_plan["redisPersistentState"] +local_postgres_present = ( + any(item.get("metadata", {}).get("name") == external_db_state["postgresStatefulSetName"] for item in items("statefulsets")) + or any(item.get("metadata", {}).get("name") in [external_db_state["postgresPvcName"], external_db_state["appDataPvcName"]] for item in items("pvc")) +) +redis_pvc_present = any(item.get("metadata", {}).get("name") == redis_persistent_state["pvcName"] for item in items("pvc")) env_aligned = ( rc("app-env") == 0 and env.get("DATABASE_HOST") == "${database.host}" @@ -3849,6 +3969,7 @@ function validateExternalPendingScript(sub2api: Sub2ApiConfig, target: Sub2ApiTa const expectedImage = imageRef(sub2api, target); const database = sub2api.runtime.database; const redisService = sub2api.runtime.redis.serviceName; + const cleanupPlan = managedResourceCleanupPlan(sub2api, target); return ` set -u tmp="$(mktemp -d)" @@ -3941,6 +4062,11 @@ statefulsets = items("statefulsets") pvcs = items("pvc") configmap = load("configmap") configmap_data = (configmap or {}).get("data") or {} +cleanup_plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))}) +external_db_state = cleanup_plan["externalDbState"] +redis_persistent_state = cleanup_plan["redisPersistentState"] +public_exposure_state = cleanup_plan["publicExposure"] +egress_proxy_state = cleanup_plan["egressProxy"] app = load("app") redis = load("redis") app_summary = deployment_summary(app) @@ -3948,11 +4074,15 @@ redis_summary = deployment_summary(redis) network_policy_obj = load("networkpolicy") network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(network_policy_obj) service_names = sorted(item.get("metadata", {}).get("name") for item in services) -local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in services + statefulsets + pvcs) -redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in pvcs) -public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-frpc" for item in deployments) -egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-egress-proxy" for item in deployments) -egress_proxy_service_present = "sub2api-egress-proxy" in service_names +local_postgres_present = ( + any(item.get("metadata", {}).get("name") == external_db_state["postgresServiceName"] for item in services) + or any(item.get("metadata", {}).get("name") == external_db_state["postgresStatefulSetName"] for item in statefulsets) + or any(item.get("metadata", {}).get("name") in [external_db_state["postgresPvcName"], external_db_state["appDataPvcName"]] for item in pvcs) +) +redis_pvc_present = any(item.get("metadata", {}).get("name") == redis_persistent_state["pvcName"] for item in pvcs) +public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == public_exposure_state["deploymentName"] for item in deployments) +egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == egress_proxy_state["deploymentName"] for item in deployments) +egress_proxy_service_present = egress_proxy_state["serviceName"] in service_names sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs) standby_disabled_ok = ( not public_exposure_deployment_present