chore: make sub2api d601 primary

This commit is contained in:
Codex
2026-06-13 04:01:31 +00:00
parent c29ca99a50
commit 5a7afb5236
6 changed files with 331 additions and 71 deletions
+44 -22
View File
@@ -17,7 +17,8 @@ import {
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
const g14K3sRoute = "G14:k3s";
const defaultTargetId = "G14";
const defaultTargetId = "D601";
const defaultTargetRoute = "D601:k3s";
const namespace = "platform-infra";
const serviceName = "sub2api";
const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`;
@@ -79,6 +80,7 @@ interface CodexPoolRuntimeTarget {
namespace: string;
serviceName: string;
serviceDns: string;
publicBaseUrl: string | null;
appSecretName: string;
egressProxy: {
enabled: boolean;
@@ -549,7 +551,7 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu
const raw = parsed.targets.find((item) => isRecord(item) && String(item.id ?? "").toLowerCase() === targetId.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 ? g14K3sRoute : "");
const route = stringValue(raw.route) ?? (id === defaultTargetId ? defaultTargetRoute : "");
const targetNamespace = stringValue(raw.namespace) ?? namespace;
if (route.length === 0) throw new Error(`${sub2apiConfigPath}.targets[${id}].route is required`);
validateKubernetesName(targetNamespace, `${sub2apiConfigPath}.targets[${id}].namespace`, true);
@@ -571,12 +573,19 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu
};
}
let publicBaseUrl: string | null = null;
if (isRecord(raw.publicExposure) && raw.publicExposure.enabled === true) {
publicBaseUrl = normalizeBaseUrl(stringValue(raw.publicExposure.publicBaseUrl));
if (publicBaseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${id}].publicExposure.publicBaseUrl must be a valid http(s) URL when target public exposure is enabled`);
}
return {
id,
route,
namespace: targetNamespace,
serviceName,
serviceDns: `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
publicBaseUrl,
appSecretName,
egressProxy,
};
@@ -591,6 +600,7 @@ function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, t
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
const profiles = collectCodexProfiles();
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
const consumerBaseUrl = codexConsumerBaseUrl(pool, runtimeTarget);
return {
ok,
action: "platform-infra-sub2api-codex-pool-plan",
@@ -614,8 +624,10 @@ function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, t
? `Account sentinel is enabled as k8s CronJob ${runtimeTarget.namespace}/${pool.sentinel.cronJobName}; actions.enabled=${pool.sentinel.actions.enabled}.`
: "Account sentinel monitoring is disabled by YAML.",
publicExposure: pool.publicExposure.enabled
? `Default Codex consumers use ${codexConsumerBaseUrl(pool)}; bounded master-local probes may use ${pool.publicExposure.masterBaseUrl}. FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
: "Public FRP exposure is disabled by YAML.",
? `Default Codex consumers use ${consumerBaseUrl}; bounded master-local probes may use ${pool.publicExposure.masterBaseUrl}. Legacy Codex-pool FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
: runtimeTarget.publicBaseUrl === null
? "Public FRP exposure is disabled by YAML."
: `Legacy Codex-pool FRP exposure is disabled by YAML; Codex consumers for target ${runtimeTarget.id} use target-level public exposure ${consumerBaseUrl}.`,
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files; managed accounts missing from YAML are preserved unless --prune-removed is explicitly provided.",
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
},
@@ -994,6 +1006,8 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions):
async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
const pool = readCodexPoolConfig();
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
const consumerExposureAvailable = pool.publicExposure.enabled || runtimeTarget.publicBaseUrl !== null;
const codexDir = join(homedir(), ".codex");
const configPath = join(codexDir, "config.toml");
const authPath = join(codexDir, "auth.json");
@@ -1010,7 +1024,7 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
authPath,
backupConfigPath,
backupAuthPath,
baseUrl: codexConsumerBaseUrl(pool),
baseUrl: consumerExposureAvailable ? codexConsumerBaseUrl(pool, runtimeTarget) : null,
providerName: pool.localCodex.providerName,
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
@@ -1021,12 +1035,12 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
valuesPrinted: false,
},
next: {
confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm",
confirm: `bun scripts/cli.ts platform-infra sub2api codex-pool configure-local${targetFlag(runtimeTarget)} --confirm`,
},
};
}
if (!pool.publicExposure.enabled) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled must be true before configure-local`);
const keyResult = await fetchPoolApiKey(config, pool);
if (!consumerExposureAvailable) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled is false and ${sub2apiConfigPath}.targets[${runtimeTarget.id}].publicExposure.enabled is not true; configure-local needs one consumer URL`);
const keyResult = await fetchPoolApiKey(config, pool, runtimeTarget);
if (keyResult.apiKey === null) {
return {
ok: false,
@@ -1035,8 +1049,8 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
valuesPrinted: false,
};
}
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey);
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey);
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey, runtimeTarget);
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey, runtimeTarget);
return {
ok: writeResult.ok && validateResult.ok,
action: "platform-infra-sub2api-codex-pool-configure-local",
@@ -1044,7 +1058,7 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
local: writeResult,
validation: validateResult,
apiKey: {
secret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
secret: `${runtimeTarget.namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
keyPreview: apiKeyPreview(keyResult.apiKey),
apiKeyFingerprint: fingerprint(keyResult.apiKey),
valuesPrinted: false,
@@ -2633,6 +2647,8 @@ function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarge
namespace: target.namespace,
service: serviceName,
serviceDns: target.serviceDns,
publicBaseUrl: target.publicBaseUrl,
consumerBaseUrl: pool.publicExposure.enabled || target.publicBaseUrl !== null ? codexConsumerBaseUrl(pool, target) : null,
configPath: codexPoolConfigPath,
groupName: pool.groupName,
apiKeyName: pool.apiKeyName,
@@ -3070,7 +3086,7 @@ kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json
}
}
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<string, unknown> {
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Record<string, unknown> {
const codexDir = join(homedir(), ".codex");
mkdirSync(codexDir, { recursive: true, mode: 0o700 });
const configPath = join(codexDir, "config.toml");
@@ -3082,7 +3098,7 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
const configBackupAction = copyIfMissing(configPath, backupConfigPath);
const authBackupAction = copyIfMissing(authPath, backupAuthPath);
const currentToml = readFileSync(configPath, "utf8");
const nextToml = updateCodexConfigToml(currentToml, pool);
const nextToml = updateCodexConfigToml(currentToml, pool, target);
writeFileSync(configPath, nextToml, { encoding: "utf8", mode: 0o600 });
chmodSync(configPath, 0o600);
writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
@@ -3110,7 +3126,7 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
},
provider: {
name: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool),
baseUrl: codexConsumerBaseUrl(pool, target),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
@@ -3128,10 +3144,10 @@ function copyIfMissing(source: string, target: string): "created" | "kept-existi
return "created";
}
function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string {
return renderCodexLocalConsumerToml(current, {
providerName: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool),
baseUrl: codexConsumerBaseUrl(pool, target),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
@@ -3140,8 +3156,9 @@ function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
});
}
function codexConsumerBaseUrl(pool: CodexPoolConfig): string {
return `${pool.publicExposure.publicBaseUrl.replace(/\/+$/u, "")}/`;
function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string {
const baseUrl = target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl;
return `${baseUrl.replace(/\/+$/u, "")}/`;
}
export function renderCodexLocalConsumerToml(current: string, options: CodexLocalConsumerTomlOptions): string {
@@ -3237,8 +3254,9 @@ 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): Promise<Record<string, unknown>> {
const probe = await probePublicModels(pool, "with-api-key", apiKey, "public");
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Promise<Record<string, unknown>> {
const probeBase = target.publicBaseUrl === null ? "public" : "target-public";
const probe = await probePublicModels(pool, "with-api-key", apiKey, probeBase, target);
return {
...probe,
ok: probe.ok === true,
@@ -3248,8 +3266,12 @@ async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: strin
};
}
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" = "master"): Promise<Record<string, unknown>> {
const baseUrl = base === "public" ? pool.publicExposure.publicBaseUrl : pool.publicExposure.masterBaseUrl;
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" | "target-public" = "master", target = codexPoolRuntimeTarget(defaultTargetId)): Promise<Record<string, unknown>> {
const baseUrl = base === "target-public"
? (target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl)
: base === "public"
? pool.publicExposure.publicBaseUrl
: pool.publicExposure.masterBaseUrl;
const url = `${baseUrl.replace(/\/+$/u, "")}/v1/models`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
+257 -19
View File
@@ -7,12 +7,13 @@ import { startJob } from "./jobs";
import type { RenderedCliResult } from "./output";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
const defaultTargetId = "G14";
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 repoRoot = rootPath();
const secretName = "sub2api-secrets";
const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
@@ -189,6 +190,33 @@ interface EgressProxySecretMaterial {
valuesPrinted: false;
}
interface ManagedResourceCleanupPlan {
externalDbState: boolean;
redisPersistentState: boolean;
publicExposure: {
enabled: boolean;
deploymentName: string;
configMapName: string;
secretName: string;
};
egressProxy: {
enabled: boolean;
deploymentName: string;
serviceName: string;
secretName: string;
};
sentinel: {
enabled: boolean;
cronJobName: string;
configMapName: string;
credentialsSecretName: string;
stateConfigMapName: string;
serviceAccountName: string;
roleName: string;
roleBindingName: string;
};
}
export function platformInfraHelp(): unknown {
return {
command: "platform-infra sub2api|langbot|n8n|wechat-archive ...",
@@ -898,6 +926,68 @@ function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): str
.slice(0, 16);
}
function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): boolean {
return sub2api.runtime.sentinel.enabledOnTargets.some((id) => id.toLowerCase() === target.id.toLowerCase());
}
function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan {
const sentinel = codexPoolSentinelResourceNames();
return {
externalDbState: isExternalTarget(target),
redisPersistentState: target.redisMode === "local-ephemeral",
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",
},
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",
},
sentinel: {
...sentinel,
enabled: targetHasSentinel(sub2api, target),
},
};
}
function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["sentinel"] {
const defaults: ManagedResourceCleanupPlan["sentinel"] = {
enabled: false,
cronJobName: "sub2api-account-sentinel",
configMapName: "sub2api-account-sentinel-config",
credentialsSecretName: "sub2api-account-sentinel-profiles",
stateConfigMapName: "sub2api-account-sentinel-state",
serviceAccountName: "sub2api-account-sentinel",
roleName: "sub2api-account-sentinel",
roleBindingName: "sub2api-account-sentinel",
};
if (!existsSync(codexPoolConfigPath)) return defaults;
const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return defaults;
const sentinel = (parsed as Record<string, unknown>).sentinel;
if (typeof sentinel !== "object" || sentinel === null || Array.isArray(sentinel)) return defaults;
const record = sentinel as Record<string, unknown>;
const text = (key: string, fallback: string): string => {
const value = record[key];
return typeof value === "string" && value.length > 0 ? value : fallback;
};
const serviceAccountName = text("serviceAccountName", defaults.serviceAccountName);
return {
enabled: false,
cronJobName: text("cronJobName", defaults.cronJobName),
configMapName: text("configMapName", defaults.configMapName),
credentialsSecretName: text("credentialsSecretName", defaults.credentialsSecretName),
stateConfigMapName: text("stateConfigMapName", defaults.stateConfigMapName),
serviceAccountName,
roleName: serviceAccountName,
roleBindingName: serviceAccountName,
};
}
function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const urlAllowlist = sub2api.security.urlAllowlist;
const database = sub2api.runtime.database;
@@ -1473,13 +1563,13 @@ function plan(options: TargetOptions): Record<string, unknown> {
decision: {
owner: "UniDesk",
namespace: target.namespace,
reason: target.id === "G14"
? "Sub2API remains the active G14 platform-infra deployment."
reason: target.databaseMode === "external-pending"
? `${target.id} is a standby Sub2API platform-infra target prepared through YAML; it stays scaled to zero until this target is promoted in YAML.`
: target.databaseMode === "external-active"
? "D601 is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node."
: "D601 is a standby Sub2API platform-infra target prepared through YAML; it does not run until the external Pika01/PK01 database secret is ready.",
? `${target.id} is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node.`
: `${target.id} is a bundled active Sub2API platform-infra target.`,
exposure: target.publicExposure?.enabled
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and D601 frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
: "ClusterIP only; no public ingress or node-level exposure.",
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
@@ -1488,7 +1578,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
publicExposure: target.publicExposure?.enabled
? {
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`,
pikanodeRole: "pikapython.com upstream only; api.pikapython.com does not pass through pikanode Express",
publicBaseUrl: target.publicExposure.publicBaseUrl,
hostname: target.publicExposure.dns.hostname,
@@ -1497,7 +1587,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
: null,
egressProxy: target.egressProxy?.enabled
? {
mode: "D601 in-cluster HTTP proxy client to the master VPN subscription",
mode: `${target.id} in-cluster HTTP proxy client to the master VPN subscription`,
service: `${target.egressProxy.serviceName}.${target.namespace}.svc.cluster.local:${target.egressProxy.listenPort}`,
sourceRef: target.egressProxy.sourceRef,
applyToSub2Api: target.egressProxy.applyToSub2Api,
@@ -1508,7 +1598,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
dataStores: isExternalTarget(target)
? [
target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
target.redisReplicas === 0 ? "D601 local Redis 8 ephemeral cache, scaled to zero until activation" : "D601 local Redis 8 ephemeral cache",
target.redisReplicas === 0 ? `${target.id} local Redis 8 ephemeral cache, scaled to zero until activation` : `${target.id} local Redis 8 ephemeral cache`,
]
: ["PostgreSQL 18", "Redis 8"],
appPoolCaps: {
@@ -1517,6 +1607,16 @@ function plan(options: TargetOptions): Record<string, unknown> {
redisPoolSize: 32,
redisMinIdleConns: 2,
},
standbyActivation: target.databaseMode === "external-pending"
? {
target: target.id,
currentMode: "predeployed-standby",
enableByYaml: `change target ${target.id} databaseMode to external-active and set appReplicas/redisReplicas to 1, then apply --target ${target.id} --confirm`,
sharedExternalDb: `${sub2api.runtime.database.host}:${sub2api.runtime.database.port}/${sub2api.runtime.database.dbName}`,
sentinelEnabled: targetHasSentinel(sub2api, target),
}
: null,
cleanup: managedResourceCleanupPlan(sub2api, target),
},
policy,
next: {
@@ -1587,7 +1687,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null;
const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target);
const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target);
const result = await capture(config, target.route, ["script"], applyScript(yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const result = await capture(config, target.route, ["script"], applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const parsed = parseJsonOutput(result.stdout);
const pk01Exposure = publicExposureSecretMaterial === null ? null : await applyPk01PublicExposure(config, target);
return {
@@ -1755,7 +1855,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[]
checks.push({
name: "local-redis-ephemeral",
ok: !/\bsub2api-redis-data\b/u.test(yaml) && /^\s*emptyDir:\s*\{\}\s*$/mu.test(yaml),
detail: "D601 standby Redis is a local ephemeral cache and must not allocate persistent Redis storage.",
detail: "Local Redis is an ephemeral cache and must not allocate persistent Redis storage.",
});
}
@@ -1763,7 +1863,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[]
checks.push({
name: "egress-proxy-yaml-controlled",
ok: hasDeploymentReplicas(yaml, target.egressProxy.deploymentName, 1) && new RegExp(`^\\s*name:\\s*${escapeRegExp(target.egressProxy.serviceName)}\\s*$`, "mu").test(yaml),
detail: "D601 egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
detail: "Egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
});
}
@@ -2656,6 +2756,7 @@ PY
}
function applyScript(
sub2api: Sub2ApiConfig,
yaml: string,
target: Sub2ApiTargetConfig,
secretMaterial: ExternalActiveSecretMaterial | null,
@@ -2663,6 +2764,7 @@ function applyScript(
egressProxySecretMaterial: EgressProxySecretMaterial | null,
): string {
const encoded = Buffer.from(yaml, "utf8").toString("base64");
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
if (target.databaseMode === "external-active" && secretMaterial === null) throw new Error("external-active apply requires secret material");
const externalSecretFiles = secretMaterial === null
? ""
@@ -2727,6 +2829,8 @@ egress_secret_out="$tmp/egress-secret.out"
egress_secret_err="$tmp/egress-secret.err"
apply_out="$tmp/apply.out"
apply_err="$tmp/apply.err"
cleanup_out="$tmp/cleanup.out"
cleanup_err="$tmp/cleanup.err"
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
ns_rc=$?
secret_action="unknown"
@@ -2803,7 +2907,98 @@ else
: >"$apply_out"
printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, or egress proxy secret step failed' >"$apply_err"
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" <<'PY'
cleanup_rc=0
if [ "$apply_rc" -eq 0 ]; then
python3 - "$cleanup_out" "$cleanup_err" <<'PY'
import json
import subprocess
import sys
import time
out_path, err_path = sys.argv[1:3]
namespace = ${JSON.stringify(target.namespace)}
plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))})
def run(args):
proc = subprocess.run(["kubectl", "-n", namespace, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return {
"args": ["kubectl", "-n", namespace, *args],
"exitCode": proc.returncode,
"stdout": proc.stdout.decode("utf-8", errors="replace")[-2000:],
"stderr": proc.stderr.decode("utf-8", errors="replace")[-2000:],
}
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")})
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"])})
items.append({"name": "disabled-public-frpc-secret", **delete("secret", plan["publicExposure"]["secretName"])})
if not plan["egressProxy"]["enabled"]:
items.append({"name": "disabled-egress-proxy-deployment", **delete("deployment", plan["egressProxy"]["deploymentName"])})
items.append({"name": "disabled-egress-proxy-service", **delete("service", plan["egressProxy"]["serviceName"])})
items.append({"name": "disabled-egress-proxy-secret", **delete("secret", plan["egressProxy"]["secretName"])})
if not plan["sentinel"]["enabled"]:
items.append({"name": "disabled-sentinel-cronjob", **delete("cronjob", plan["sentinel"]["cronJobName"])})
items.append({"name": "disabled-sentinel-jobs", **run(["delete", "job", "-l", f"app.kubernetes.io/name={plan['sentinel']['cronJobName']}", "--ignore-not-found=true", "--wait=false"])})
items.append({"name": "disabled-sentinel-configmap", **delete("configmap", plan["sentinel"]["configMapName"])})
items.append({"name": "disabled-sentinel-credentials-secret", **delete("secret", plan["sentinel"]["credentialsSecretName"])})
items.append({"name": "disabled-sentinel-serviceaccount", **delete("serviceaccount", plan["sentinel"]["serviceAccountName"])})
items.append({"name": "disabled-sentinel-role", **delete("role", plan["sentinel"]["roleName"])})
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 not plan["publicExposure"]["enabled"]:
watch.append(("deployment", plan["publicExposure"]["deploymentName"]))
if not plan["egressProxy"]["enabled"]:
watch.append(("deployment", plan["egressProxy"]["deploymentName"]))
watch.append(("service", plan["egressProxy"]["serviceName"]))
if not plan["sentinel"]["enabled"]:
watch.append(("cronjob", plan["sentinel"]["cronJobName"]))
deadline = time.time() + 90
remaining = []
while True:
remaining = []
for kind, name in watch:
result = run(["get", kind, name])
if result["exitCode"] == 0:
remaining.append({"kind": kind, "name": name})
if not remaining or time.time() >= deadline:
break
time.sleep(3)
payload = {
"ok": all(item["exitCode"] == 0 for item in items) and not remaining,
"plan": plan,
"items": items,
"remainingAfterWait": remaining,
"valuesPrinted": False,
}
open(out_path, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2))
open(err_path, "w", encoding="utf-8").write("")
sys.exit(0 if payload["ok"] else 1)
PY
cleanup_rc=$?
else
: >"$cleanup_out"
printf '%s\\n' 'skipped because apply failed' >"$cleanup_err"
cleanup_rc=1
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY'
import json
import sys
ns_rc = int(sys.argv[1])
@@ -2811,17 +3006,23 @@ secret_rc = int(sys.argv[2])
exposure_secret_rc = int(sys.argv[3])
egress_secret_rc = int(sys.argv[4])
apply_rc = int(sys.argv[5])
secret_action = sys.argv[6]
exposure_secret_action = sys.argv[7]
egress_secret_action = sys.argv[8]
paths = sys.argv[9:]
cleanup_rc = int(sys.argv[6])
secret_action = sys.argv[7]
exposure_secret_action = sys.argv[8]
egress_secret_action = sys.argv[9]
paths = sys.argv[10:]
def text(path):
try:
return open(path, encoding="utf-8").read()
except FileNotFoundError:
return ""
def parsed(path):
try:
return json.load(open(path, encoding="utf-8"))
except Exception:
return None
payload = {
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0,
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0,
"target": "${target.id}",
"namespace": "${target.namespace}",
"databaseMode": "${target.databaseMode}",
@@ -2843,6 +3044,7 @@ payload = {
"publicExposureSecret": {"exitCode": exposure_secret_rc, "action": exposure_secret_action, "stdout": text(paths[4])[-4000:], "stderr": text(paths[5])[-4000:]},
"egressProxySecret": {"exitCode": egress_secret_rc, "action": egress_secret_action, "stdout": text(paths[6])[-4000:], "stderr": text(paths[7])[-4000:]},
"apply": {"exitCode": apply_rc, "stdout": text(paths[8])[-8000:], "stderr": text(paths[9])[-4000:]},
"cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[10]), "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -2878,6 +3080,7 @@ capture_json pvc kubectl -n ${target.namespace} get pvc -l app.kubernetes.io/par
capture_json secrets kubectl -n ${target.namespace} get secret ${secretName}
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
capture_json ingresses kubectl -n ${target.namespace} get ingress
capture_json quotas kubectl -n ${target.namespace} get resourcequota
capture_json limitranges kubectl -n ${target.namespace} get limitrange
@@ -3074,6 +3277,7 @@ services = items("services")
pods = items("pods")
pvcs = items("pvc")
networkpolicies = items("networkpolicies")
cronjobs = items("cronjobs")
secret = load("secrets")
configmap = load("configmap")
configmap_data = (configmap or {}).get("data") or {}
@@ -3169,6 +3373,14 @@ 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)
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
and not egress_proxy_deployment_present
and not sentinel_cronjob_present
)
secret_ready = len(missing_secret_keys) == 0
secret_ok = secret_ready or external_pending
if external_pending:
@@ -3179,6 +3391,7 @@ if external_pending:
and redis_desired_aligned
and expected_app_replicas == 0
and expected_redis_replicas == 0
and standby_disabled_resources_ok
)
elif external_active:
state_model_ok = (
@@ -3223,6 +3436,10 @@ payload = {
"redisPvcPresent": redis_pvc_present,
"sub2apiDesiredReplicasAligned": sub2api_desired_aligned,
"redisDesiredReplicasAligned": redis_desired_aligned,
"standbyDisabledResourcesOk": standby_disabled_resources_ok,
"publicExposureDeploymentPresent": public_exposure_deployment_present,
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
"sentinelCronJobPresent": sentinel_cronjob_present,
"ok": state_model_ok,
},
"imageControl": {
@@ -3702,7 +3919,9 @@ capture_json() {
capture_json ns kubectl get namespace ${target.namespace}
capture_json app kubectl -n ${target.namespace} get deployment ${serviceName}
capture_json redis kubectl -n ${target.namespace} get deployment ${redisService}
capture_json deployments kubectl -n ${target.namespace} get deployments
capture_json services kubectl -n ${target.namespace} get service
capture_json cronjobs kubectl -n ${target.namespace} get cronjob
capture_json statefulsets kubectl -n ${target.namespace} get statefulsets
capture_json pvc kubectl -n ${target.namespace} get pvc
capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config
@@ -3772,6 +3991,8 @@ def deployment_summary(item):
}
services = items("services")
deployments = items("deployments")
cronjobs = items("cronjobs")
statefulsets = items("statefulsets")
pvcs = items("pvc")
configmap = load("configmap")
@@ -3785,6 +4006,16 @@ network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(net
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
sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs)
standby_disabled_ok = (
not public_exposure_deployment_present
and not egress_proxy_deployment_present
and not egress_proxy_service_present
and not sentinel_cronjob_present
)
redis_ephemeral = any(volume.get("emptyDir") == {} for volume in redis_summary["volumes"])
configmap_aligned = (
configmap_data.get("DATABASE_HOST") == "${database.host}"
@@ -3798,7 +4029,7 @@ app_scaled_to_zero = app_summary["exists"] and app_summary["desired"] == ${targe
image_aligned = "${expectedImage}" in app_summary["images"]
redis_ready = redis_summary["exists"] and redis_summary["desired"] == ${target.redisReplicas} and redis_ephemeral and (redis_summary["ready"] or ${target.redisReplicas} == 0)
payload = {
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and "${serviceName}" in service_names and "${redisService}" in service_names,
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and standby_disabled_ok and "${serviceName}" in service_names and "${redisService}" in service_names,
"target": "${target.id}",
"namespace": "${target.namespace}",
"status": "pending-external-db",
@@ -3825,6 +4056,13 @@ payload = {
"redisReadyEphemeral": {"ok": redis_ready, "summary": redis_summary, "redisPvcPresent": redis_pvc_present, "readinessRequired": ${target.redisReplicas} > 0},
"serviceBoundary": {"serviceNames": service_names, "servicePresent": "${serviceName}" in service_names, "redisServicePresent": "${redisService}" in service_names},
"externalDbOnly": {"ok": not local_postgres_present, "localPostgresPresent": local_postgres_present},
"standbyDisabled": {
"ok": standby_disabled_ok,
"publicExposureDeploymentPresent": public_exposure_deployment_present,
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
"egressProxyServicePresent": egress_proxy_service_present,
"sentinelCronJobPresent": sentinel_cronjob_present,
},
"configMapAligned": {"ok": configmap_aligned, "databaseHost": configmap_data.get("DATABASE_HOST"), "redisHost": configmap_data.get("REDIS_HOST")},
},
"next": {