refactor: normalize sub2api target defaults (#352)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>): string[] {
|
||||
return args.filter((arg) => !stripped.has(arg));
|
||||
}
|
||||
|
||||
function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRuntimeTarget {
|
||||
interface Sub2ApiRuntimeConfig {
|
||||
defaultTargetId: string;
|
||||
appSecretName: string;
|
||||
targets: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
function codexPoolPlan(options?: DisclosureOptions): Record<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown> | null): Record<st
|
||||
};
|
||||
}
|
||||
|
||||
function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarget(defaultTargetId)): Record<string, unknown> {
|
||||
function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarget()): Record<string, unknown> {
|
||||
return {
|
||||
id: target.id,
|
||||
route: target.route,
|
||||
@@ -2817,11 +2848,11 @@ function sentinelProfileSecrets(profiles: CodexProfile[]): CodexPoolSentinelProf
|
||||
}));
|
||||
}
|
||||
|
||||
function publicExposureSummary(pool: CodexPoolConfig): Record<string, unknown> {
|
||||
function publicExposureSummary(pool: CodexPoolConfig, target = codexPoolRuntimeTarget()): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Record<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget()): Promise<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" | "target-public" = "master", target = codexPoolRuntimeTarget()): Promise<Record<string, unknown>> {
|
||||
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<SshCaptureResult> {
|
||||
async function runRemoteCodexPoolScript(config: UniDeskConfig, mode: RemoteCodexPoolMode, script: string, target = codexPoolRuntimeTarget()): Promise<SshCaptureResult> {
|
||||
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));
|
||||
|
||||
+238
-108
@@ -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<string, unknown> {
|
||||
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<Record<string, unknown> | 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<string>): 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<string, unknown>;
|
||||
@@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): Sub2ApiConfig["de
|
||||
return { postgres, redis };
|
||||
}
|
||||
|
||||
function parseTargets(root: Record<string, unknown>): Sub2ApiTargetConfig[] {
|
||||
function parseTargets(root: Record<string, unknown>, 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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown> {
|
||||
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<Reco
|
||||
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);
|
||||
if (!policy.every((check) => 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
|
||||
|
||||
Reference in New Issue
Block a user