chore: make sub2api d601 primary
This commit is contained in:
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user