4133 lines
176 KiB
TypeScript
4133 lines
176 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, isAbsolute, join } from "node:path";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { startJob } from "./jobs";
|
|
import type { RenderedCliResult } from "./output";
|
|
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "./pk01-caddy";
|
|
import { 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 repoRoot = rootPath();
|
|
const secretName = "sub2api-secrets";
|
|
const sub2apiCaddyManagedMarker = "sub2api";
|
|
const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
|
|
|
|
type DatabaseMode = "bundled" | "external-pending" | "external-active";
|
|
type RedisMode = "bundled-persistent" | "local-ephemeral";
|
|
|
|
interface Sub2ApiConfig {
|
|
image: {
|
|
repository: string;
|
|
tag: string;
|
|
pullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
};
|
|
dependencyImages: {
|
|
postgres: string;
|
|
redis: string;
|
|
};
|
|
security: {
|
|
urlAllowlist: {
|
|
enabled: boolean;
|
|
allowInsecureHttp: boolean;
|
|
allowPrivateHosts: boolean;
|
|
upstreamHosts: string[];
|
|
};
|
|
};
|
|
targets: Sub2ApiTargetConfig[];
|
|
runtime: {
|
|
database: ExternalDatabaseConfig;
|
|
secrets: RuntimeSecretsConfig;
|
|
redis: RuntimeRedisConfig;
|
|
appData: {
|
|
mode: "persistent-pvc" | "empty-dir";
|
|
};
|
|
sentinel: {
|
|
mode: "singleton";
|
|
enabledOnTargets: string[];
|
|
};
|
|
};
|
|
}
|
|
|
|
interface Sub2ApiTargetConfig {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
role: string;
|
|
enabled: boolean;
|
|
databaseMode: DatabaseMode;
|
|
redisMode: RedisMode;
|
|
appReplicas: number;
|
|
redisReplicas: number;
|
|
image: Partial<Sub2ApiConfig["image"]>;
|
|
dependencyImages: Partial<Sub2ApiConfig["dependencyImages"]>;
|
|
publicExposure: Sub2ApiPublicExposureConfig | null;
|
|
egressProxy: Sub2ApiEgressProxyConfig | null;
|
|
}
|
|
|
|
interface Sub2ApiPublicExposureConfig {
|
|
enabled: boolean;
|
|
publicBaseUrl: string;
|
|
dns: {
|
|
hostname: string;
|
|
expectedA: string;
|
|
resolvers: string[];
|
|
};
|
|
frpc: {
|
|
deploymentName: string;
|
|
secretName: string;
|
|
secretKey: string;
|
|
image: string;
|
|
serverAddr: string;
|
|
serverPort: number;
|
|
proxyName: string;
|
|
remotePort: number;
|
|
localIP: string;
|
|
localPort: number;
|
|
tokenSourceRef: string;
|
|
tokenSourceKey: string;
|
|
};
|
|
pk01: {
|
|
route: string;
|
|
caddyBinaryPath: string;
|
|
caddyDownloadUrl: string;
|
|
caddyDownloadProxyUrl: string | null;
|
|
caddyConfigPath: string;
|
|
caddyServiceName: string;
|
|
caddyStorageDir: string;
|
|
caddyEmail: string;
|
|
pikanodeRoot: string;
|
|
pikanodeContainerName: string;
|
|
pikanodeImage: string;
|
|
pikanodeHttpHostPort: number;
|
|
responseHeaderTimeoutSeconds: number;
|
|
};
|
|
}
|
|
|
|
interface Sub2ApiEgressProxyConfig {
|
|
enabled: boolean;
|
|
deploymentName: string;
|
|
serviceName: string;
|
|
secretName: string;
|
|
secretKey: string;
|
|
image: string;
|
|
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
listenPort: number;
|
|
sourceRef: string;
|
|
sourceKey: string;
|
|
sourceType: "subscription-url";
|
|
preferredOutbound: "vless-reality" | "hysteria2";
|
|
noProxy: string[];
|
|
applyToSub2Api: boolean;
|
|
applyToSentinel: boolean;
|
|
healthProbeUrl: string;
|
|
}
|
|
|
|
interface ExternalDatabaseConfig {
|
|
mode: "external";
|
|
sourceRef: string;
|
|
sourceKeys: {
|
|
user: string;
|
|
password: string;
|
|
dbName: string;
|
|
};
|
|
secretName: string;
|
|
passwordKey: string;
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
dbName: string;
|
|
sslMode: string;
|
|
pendingAllowed: boolean;
|
|
}
|
|
|
|
interface RuntimeSecretsConfig {
|
|
root: string;
|
|
appSourceRef: string;
|
|
}
|
|
|
|
interface RuntimeRedisConfig {
|
|
serviceName: string;
|
|
persistence: boolean;
|
|
}
|
|
|
|
interface ExternalActiveSecretMaterial {
|
|
sourceRef: string;
|
|
sourcePath: string;
|
|
appSourceRef: string;
|
|
appSourcePath: string;
|
|
action: "create" | "update" | "none";
|
|
fingerprint: string;
|
|
values: Record<typeof requiredSecretKeys[number], string>;
|
|
}
|
|
|
|
interface PublicExposureSecretMaterial {
|
|
sourceRef: string;
|
|
sourcePath: string;
|
|
secretName: string;
|
|
secretKey: string;
|
|
fingerprint: string;
|
|
frpcToml: string;
|
|
valuesPrinted: false;
|
|
}
|
|
|
|
interface EgressProxySecretMaterial {
|
|
sourceRef: string;
|
|
sourcePath: string;
|
|
secretName: string;
|
|
secretKey: string;
|
|
fingerprint: string;
|
|
subscriptionBytes: number;
|
|
selectedOutbound: string;
|
|
configJson: string;
|
|
proxyUrl: string;
|
|
noProxy: string;
|
|
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 ...",
|
|
output: "json",
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
|
|
"bun scripts/cli.ts platform-infra sub2api apply [--target G14|D601] --dry-run",
|
|
"bun scripts/cli.ts platform-infra sub2api apply [--target G14|D601] --confirm",
|
|
"bun scripts/cli.ts platform-infra sub2api status [--target G14|D601] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra sub2api validate [--target G14|D601] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm",
|
|
"bun scripts/cli.ts platform-infra langbot plan [--target G14]",
|
|
"bun scripts/cli.ts platform-infra langbot apply [--target G14] --confirm",
|
|
"bun scripts/cli.ts platform-infra langbot status [--target G14] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra langbot logs [--target G14] [--component app|plugin-runtime|frpc|all]",
|
|
"bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm",
|
|
"bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots",
|
|
"bun scripts/cli.ts platform-infra n8n plan [--target G14]",
|
|
"bun scripts/cli.ts platform-infra n8n apply [--target G14] --confirm",
|
|
"bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all]",
|
|
"bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra wechat-archive plan [--target G14]",
|
|
"bun scripts/cli.ts platform-infra wechat-archive apply [--target G14] --confirm",
|
|
"bun scripts/cli.ts platform-infra wechat-archive status [--target G14] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra wechat-archive validate [--target G14] [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra wechat-archive pull --remote-path /UniDesk/WeChatArchive/...",
|
|
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-prepare --confirm",
|
|
"bun scripts/cli.ts platform-infra wechat-archive wcf-host-start --confirm",
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-image-build --confirm",
|
|
"bun scripts/cli.ts platform-infra wechat-archive collector-apply --confirm",
|
|
"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,
|
|
},
|
|
codexPool: {
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool plan",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool validate",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
|
|
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
|
|
],
|
|
module: "scripts/src/platform-infra-sub2api-codex.ts",
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
const [target, action] = args;
|
|
if (target === "langbot") {
|
|
const { runLangBotCommand } = await import("./platform-infra-langbot");
|
|
return await runLangBotCommand(config, args.slice(1));
|
|
}
|
|
if (target === "n8n") {
|
|
const { runN8nCommand } = await import("./platform-infra-n8n");
|
|
return await runN8nCommand(config, args.slice(1));
|
|
}
|
|
if (target === "wechat-archive") {
|
|
const { runWechatArchiveCommand } = await import("./platform-infra-wechat-archive");
|
|
return await runWechatArchiveCommand(config, args.slice(1));
|
|
}
|
|
if (target !== "sub2api") return unsupported(args);
|
|
if (action === "plan" || action === undefined) return plan(parseTargetOptions(args.slice(2)));
|
|
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));
|
|
if (action === "status") return await status(config, parseDisclosureOptions(args.slice(2)));
|
|
if (action === "validate") return await validate(config, parseDisclosureOptions(args.slice(2)));
|
|
if (action === "codex-pool") {
|
|
const { runCodexPoolCommand } = await import("./platform-infra-sub2api-codex");
|
|
return await runCodexPoolCommand(config, args.slice(2));
|
|
}
|
|
return unsupported(args);
|
|
}
|
|
|
|
interface ApplyOptions {
|
|
targetId: string;
|
|
dryRun: boolean;
|
|
confirm: boolean;
|
|
wait: boolean;
|
|
}
|
|
|
|
interface DisclosureOptions {
|
|
targetId: string;
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
interface TargetOptions {
|
|
targetId: string;
|
|
}
|
|
|
|
interface PolicyCheck {
|
|
name: string;
|
|
ok: boolean;
|
|
detail: string;
|
|
}
|
|
|
|
function unsupported(args: string[]): Record<string, unknown> {
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-command",
|
|
args,
|
|
help: platformInfraHelp(),
|
|
};
|
|
}
|
|
|
|
function parseApplyOptions(args: string[]): ApplyOptions {
|
|
const target = parseTargetOptions(args);
|
|
validateOptions(args, new Set(["--dry-run", "--confirm", "--wait", "--target"]));
|
|
if (args.includes("--dry-run") && args.includes("--confirm")) throw new Error("apply accepts only one of --dry-run or --confirm");
|
|
return {
|
|
targetId: target.targetId,
|
|
dryRun: args.includes("--dry-run") || !args.includes("--confirm"),
|
|
confirm: args.includes("--confirm"),
|
|
wait: args.includes("--wait"),
|
|
};
|
|
}
|
|
|
|
function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
|
const target = parseTargetOptions(args);
|
|
validateOptions(args, new Set(["--full", "--raw", "--target"]));
|
|
const raw = args.includes("--raw");
|
|
return { targetId: target.targetId, full: raw || args.includes("--full"), raw };
|
|
}
|
|
|
|
function parseTargetOptions(args: string[]): TargetOptions {
|
|
let targetId = defaultTargetId;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--target") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
|
targetId = value;
|
|
index += 1;
|
|
} else 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 };
|
|
}
|
|
|
|
function validateOptions(args: string[], booleanOptions: Set<string>): void {
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--target") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--target=") && booleanOptions.has("--target")) continue;
|
|
if (booleanOptions.has(arg)) continue;
|
|
throw new Error(`unsupported option: ${arg}`);
|
|
}
|
|
}
|
|
|
|
function readSub2ApiConfig(): Sub2ApiConfig {
|
|
const parsed = Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${configPath} must contain a YAML object`);
|
|
const root = parsed as Record<string, unknown>;
|
|
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>;
|
|
const repository = stringField(record, "repository", "image");
|
|
const tag = stringField(record, "tag", "image");
|
|
const pullPolicy = stringField(record, "pullPolicy", "image");
|
|
if (pullPolicy !== "Always" && pullPolicy !== "IfNotPresent" && pullPolicy !== "Never") throw new Error(`${configPath}.image.pullPolicy must be Always, IfNotPresent, or Never`);
|
|
if (!isImageRepository(repository)) throw new Error(`${configPath}.image.repository has an unsupported format`);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${configPath}.image.tag has an unsupported format`);
|
|
const dependencyImages = parseDependencyImages(root);
|
|
const security = objectField(parsed as Record<string, unknown>, "security", "");
|
|
const urlAllowlist = objectField(security, "urlAllowlist", "security");
|
|
const enabled = booleanField(urlAllowlist, "enabled", "security.urlAllowlist");
|
|
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 runtime = parseRuntime(root);
|
|
return {
|
|
image: { repository, tag, pullPolicy },
|
|
dependencyImages,
|
|
security: {
|
|
urlAllowlist: {
|
|
enabled,
|
|
allowInsecureHttp,
|
|
allowPrivateHosts,
|
|
upstreamHosts,
|
|
},
|
|
},
|
|
targets,
|
|
runtime,
|
|
};
|
|
}
|
|
|
|
function parseDependencyImages(root: Record<string, unknown>): Sub2ApiConfig["dependencyImages"] {
|
|
const value = root.dependencyImages;
|
|
if (value === undefined) return { postgres: "postgres:18-alpine", redis: "redis:8-alpine" };
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.dependencyImages must be an object`);
|
|
const record = value as Record<string, unknown>;
|
|
const postgres = stringField(record, "postgres", "dependencyImages");
|
|
const redis = stringField(record, "redis", "dependencyImages");
|
|
if (!isImageReference(postgres)) throw new Error(`${configPath}.dependencyImages.postgres has an unsupported format`);
|
|
if (!isImageReference(redis)) throw new Error(`${configPath}.dependencyImages.redis has an unsupported format`);
|
|
return { postgres, redis };
|
|
}
|
|
|
|
function parseTargets(root: Record<string, unknown>): Sub2ApiTargetConfig[] {
|
|
const value = root.targets;
|
|
if (value === undefined) return defaultTargets();
|
|
if (!Array.isArray(value)) throw new Error(`${configPath}.targets must be an array`);
|
|
const targets = value.map((item, index) => {
|
|
if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error(`${configPath}.targets[${index}] must be an object`);
|
|
const record = item as Record<string, unknown>;
|
|
const path = `targets[${index}]`;
|
|
const id = stringField(record, "id", path);
|
|
const route = stringField(record, "route", path);
|
|
const targetNamespace = stringField(record, "namespace", path);
|
|
const role = stringField(record, "role", path);
|
|
const enabled = booleanField(record, "enabled", path);
|
|
const databaseMode = enumField(record, "databaseMode", path, ["bundled", "external-pending", "external-active"] as const);
|
|
const redisMode = enumField(record, "redisMode", path, ["bundled-persistent", "local-ephemeral"] as const);
|
|
const appReplicas = integerField(record, "appReplicas", path);
|
|
const redisReplicas = record.redisReplicas === undefined
|
|
? (databaseMode === "external-pending" && appReplicas === 0 ? 0 : 1)
|
|
: integerField(record, "redisReplicas", path);
|
|
const image = targetImageOverride(record, path);
|
|
const dependencyImages = targetDependencyImageOverride(record, path);
|
|
const publicExposure = parsePublicExposureConfig(record.publicExposure, path);
|
|
const egressProxy = parseEgressProxyConfig(record.egressProxy, path);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new Error(`${configPath}.${path}.id must be a simple target id`);
|
|
if (!/^[A-Za-z0-9:_./-]+$/u.test(route)) throw new Error(`${configPath}.${path}.route has an unsupported format`);
|
|
if (!isKubernetesName(targetNamespace)) throw new Error(`${configPath}.${path}.namespace must be a Kubernetes namespace name`);
|
|
if (appReplicas < 0 || appReplicas > 1) throw new Error(`${configPath}.${path}.appReplicas must be 0 or 1`);
|
|
if (redisReplicas < 0 || redisReplicas > 1) throw new Error(`${configPath}.${path}.redisReplicas must be 0 or 1`);
|
|
return { id, route, namespace: targetNamespace, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, publicExposure, egressProxy };
|
|
});
|
|
const ids = new Set<string>();
|
|
for (const target of targets) {
|
|
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}`);
|
|
return targets;
|
|
}
|
|
|
|
function defaultTargets(): Sub2ApiTargetConfig[] {
|
|
return [
|
|
{
|
|
id: "G14",
|
|
route: "G14:k3s",
|
|
namespace,
|
|
role: "active",
|
|
enabled: true,
|
|
databaseMode: "bundled",
|
|
redisMode: "bundled-persistent",
|
|
appReplicas: 1,
|
|
redisReplicas: 1,
|
|
image: {},
|
|
dependencyImages: {},
|
|
publicExposure: null,
|
|
egressProxy: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
function targetImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["image"]> {
|
|
if (record.image === undefined) return {};
|
|
const image = objectField(record, "image", path);
|
|
const override: Partial<Sub2ApiConfig["image"]> = {};
|
|
if (image.repository !== undefined) {
|
|
const repository = stringField(image, "repository", `${path}.image`);
|
|
if (!isImageRepository(repository)) throw new Error(`${configPath}.${path}.image.repository has an unsupported format`);
|
|
override.repository = repository;
|
|
}
|
|
if (image.tag !== undefined) {
|
|
const tag = stringField(image, "tag", `${path}.image`);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${configPath}.${path}.image.tag has an unsupported format`);
|
|
override.tag = tag;
|
|
}
|
|
if (image.pullPolicy !== undefined) {
|
|
override.pullPolicy = enumField(image, "pullPolicy", `${path}.image`, ["Always", "IfNotPresent", "Never"] as const);
|
|
}
|
|
return override;
|
|
}
|
|
|
|
function targetDependencyImageOverride(record: Record<string, unknown>, path: string): Partial<Sub2ApiConfig["dependencyImages"]> {
|
|
if (record.dependencyImages === undefined) return {};
|
|
const dependencyImages = objectField(record, "dependencyImages", path);
|
|
const override: Partial<Sub2ApiConfig["dependencyImages"]> = {};
|
|
if (dependencyImages.postgres !== undefined) {
|
|
const postgres = stringField(dependencyImages, "postgres", `${path}.dependencyImages`);
|
|
if (!isImageReference(postgres)) throw new Error(`${configPath}.${path}.dependencyImages.postgres has an unsupported format`);
|
|
override.postgres = postgres;
|
|
}
|
|
if (dependencyImages.redis !== undefined) {
|
|
const redis = stringField(dependencyImages, "redis", `${path}.dependencyImages`);
|
|
if (!isImageReference(redis)) throw new Error(`${configPath}.${path}.dependencyImages.redis has an unsupported format`);
|
|
override.redis = redis;
|
|
}
|
|
return override;
|
|
}
|
|
|
|
function parsePublicExposureConfig(value: unknown, path: string): Sub2ApiPublicExposureConfig | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.publicExposure must be an object`);
|
|
const record = value as Record<string, unknown>;
|
|
const enabled = booleanField(record, "enabled", `${path}.publicExposure`);
|
|
const publicBaseUrl = stringField(record, "publicBaseUrl", `${path}.publicExposure`);
|
|
const dnsRaw = objectField(record, "dns", `${path}.publicExposure`);
|
|
const frpcRaw = objectField(record, "frpc", `${path}.publicExposure`);
|
|
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 exposure: Sub2ApiPublicExposureConfig = {
|
|
enabled,
|
|
publicBaseUrl: normalizePublicBaseUrl(publicBaseUrl, `${path}.publicExposure.publicBaseUrl`),
|
|
dns: {
|
|
hostname,
|
|
expectedA,
|
|
resolvers,
|
|
},
|
|
frpc: {
|
|
deploymentName: stringField(frpcRaw, "deploymentName", `${path}.publicExposure.frpc`),
|
|
secretName: stringField(frpcRaw, "secretName", `${path}.publicExposure.frpc`),
|
|
secretKey: stringField(frpcRaw, "secretKey", `${path}.publicExposure.frpc`),
|
|
image: stringField(frpcRaw, "image", `${path}.publicExposure.frpc`),
|
|
serverAddr: stringField(frpcRaw, "serverAddr", `${path}.publicExposure.frpc`),
|
|
serverPort: integerField(frpcRaw, "serverPort", `${path}.publicExposure.frpc`),
|
|
proxyName: stringField(frpcRaw, "proxyName", `${path}.publicExposure.frpc`),
|
|
remotePort: integerField(frpcRaw, "remotePort", `${path}.publicExposure.frpc`),
|
|
localIP: stringField(frpcRaw, "localIP", `${path}.publicExposure.frpc`),
|
|
localPort: integerField(frpcRaw, "localPort", `${path}.publicExposure.frpc`),
|
|
tokenSourceRef: stringField(frpcRaw, "tokenSourceRef", `${path}.publicExposure.frpc`),
|
|
tokenSourceKey: stringField(frpcRaw, "tokenSourceKey", `${path}.publicExposure.frpc`),
|
|
},
|
|
pk01: {
|
|
route: stringField(pk01Raw, "route", `${path}.publicExposure.pk01`),
|
|
caddyBinaryPath: stringField(pk01Raw, "caddyBinaryPath", `${path}.publicExposure.pk01`),
|
|
caddyDownloadUrl: stringField(pk01Raw, "caddyDownloadUrl", `${path}.publicExposure.pk01`),
|
|
caddyDownloadProxyUrl: pk01Raw.caddyDownloadProxyUrl === undefined ? null : stringField(pk01Raw, "caddyDownloadProxyUrl", `${path}.publicExposure.pk01`),
|
|
caddyConfigPath: stringField(pk01Raw, "caddyConfigPath", `${path}.publicExposure.pk01`),
|
|
caddyServiceName: stringField(pk01Raw, "caddyServiceName", `${path}.publicExposure.pk01`),
|
|
caddyStorageDir: stringField(pk01Raw, "caddyStorageDir", `${path}.publicExposure.pk01`),
|
|
caddyEmail: stringField(pk01Raw, "caddyEmail", `${path}.publicExposure.pk01`),
|
|
pikanodeRoot: stringField(pk01Raw, "pikanodeRoot", `${path}.publicExposure.pk01`),
|
|
pikanodeContainerName: stringField(pk01Raw, "pikanodeContainerName", `${path}.publicExposure.pk01`),
|
|
pikanodeImage: stringField(pk01Raw, "pikanodeImage", `${path}.publicExposure.pk01`),
|
|
pikanodeHttpHostPort: integerField(pk01Raw, "pikanodeHttpHostPort", `${path}.publicExposure.pk01`),
|
|
responseHeaderTimeoutSeconds: integerField(pk01Raw, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`),
|
|
},
|
|
};
|
|
validatePublicExposureConfig(exposure, path);
|
|
return exposure;
|
|
}
|
|
|
|
function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProxyConfig | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.egressProxy must be an object`);
|
|
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 proxy: Sub2ApiEgressProxyConfig = {
|
|
enabled: booleanField(record, "enabled", `${path}.egressProxy`),
|
|
deploymentName: stringField(record, "deploymentName", `${path}.egressProxy`),
|
|
serviceName: stringField(record, "serviceName", `${path}.egressProxy`),
|
|
secretName: stringField(record, "secretName", `${path}.egressProxy`),
|
|
secretKey: stringField(record, "secretKey", `${path}.egressProxy`),
|
|
image: stringField(record, "image", `${path}.egressProxy`),
|
|
imagePullPolicy,
|
|
listenPort: integerField(record, "listenPort", `${path}.egressProxy`),
|
|
sourceRef: stringField(record, "sourceRef", `${path}.egressProxy`),
|
|
sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`),
|
|
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`),
|
|
};
|
|
validateEgressProxyConfig(proxy, path);
|
|
return proxy;
|
|
}
|
|
|
|
function parseRuntime(root: Record<string, unknown>): Sub2ApiConfig["runtime"] {
|
|
const value = root.runtime;
|
|
if (value === undefined) {
|
|
return {
|
|
database: {
|
|
mode: "external",
|
|
sourceRef: "platform-db/postgres-active.env",
|
|
sourceKeys: {
|
|
user: "SUB2API_DB_USER",
|
|
password: "SUB2API_DB_PASSWORD",
|
|
dbName: "SUB2API_DB_NAME",
|
|
},
|
|
secretName,
|
|
passwordKey: "POSTGRES_PASSWORD",
|
|
host: "pika01-postgres.pending.local",
|
|
port: 5432,
|
|
user: "sub2api",
|
|
dbName: "sub2api",
|
|
sslMode: "prefer",
|
|
pendingAllowed: true,
|
|
},
|
|
secrets: {
|
|
root: ".state/secrets",
|
|
appSourceRef: "platform-infra/sub2api.env",
|
|
},
|
|
redis: { serviceName: "sub2api-redis", persistence: false },
|
|
appData: { mode: "persistent-pvc" },
|
|
sentinel: { mode: "singleton", enabledOnTargets: ["G14"] },
|
|
};
|
|
}
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.runtime must be an object`);
|
|
const runtime = value as Record<string, unknown>;
|
|
const database = objectField(runtime, "database", "runtime");
|
|
const databaseMode = enumField(database, "mode", "runtime.database", ["external"] as const);
|
|
const sourceRef = stringField(database, "sourceRef", "runtime.database");
|
|
const sourceKeysRaw = database.sourceKeys === undefined ? {} : objectField(database, "sourceKeys", "runtime.database");
|
|
const sourceKeys = {
|
|
user: sourceKeysRaw.user === undefined ? "SUB2API_DB_USER" : stringField(sourceKeysRaw, "user", "runtime.database.sourceKeys"),
|
|
password: sourceKeysRaw.password === undefined ? "SUB2API_DB_PASSWORD" : stringField(sourceKeysRaw, "password", "runtime.database.sourceKeys"),
|
|
dbName: sourceKeysRaw.dbName === undefined ? "SUB2API_DB_NAME" : stringField(sourceKeysRaw, "dbName", "runtime.database.sourceKeys"),
|
|
};
|
|
const secret = stringField(database, "secretName", "runtime.database");
|
|
const passwordKey = stringField(database, "passwordKey", "runtime.database");
|
|
const host = stringField(database, "host", "runtime.database");
|
|
const port = integerField(database, "port", "runtime.database");
|
|
const user = stringField(database, "user", "runtime.database");
|
|
const dbName = stringField(database, "dbName", "runtime.database");
|
|
const sslMode = stringField(database, "sslMode", "runtime.database");
|
|
const pendingAllowed = booleanField(database, "pendingAllowed", "runtime.database");
|
|
if (!isKubernetesName(secret)) throw new Error(`${configPath}.runtime.database.secretName must be a Kubernetes Secret name`);
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(sourceRef)) throw new Error(`${configPath}.runtime.database.sourceRef has an unsupported format`);
|
|
for (const [key, value] of Object.entries(sourceKeys)) {
|
|
if (!/^[A-Z0-9_]+$/u.test(value)) throw new Error(`${configPath}.runtime.database.sourceKeys.${key} must be an env key`);
|
|
}
|
|
if (!/^[A-Z0-9_]+$/u.test(passwordKey)) throw new Error(`${configPath}.runtime.database.passwordKey must be an env key`);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(host)) throw new Error(`${configPath}.runtime.database.host has an unsupported format`);
|
|
if (!/^[A-Za-z0-9_][-A-Za-z0-9_]*$/u.test(user)) throw new Error(`${configPath}.runtime.database.user has an unsupported format`);
|
|
if (!/^[A-Za-z0-9_][-A-Za-z0-9_]*$/u.test(dbName)) throw new Error(`${configPath}.runtime.database.dbName has an unsupported format`);
|
|
if (!/^[A-Za-z0-9_-]+$/u.test(sslMode)) throw new Error(`${configPath}.runtime.database.sslMode has an unsupported format`);
|
|
if (port < 1 || port > 65535) throw new Error(`${configPath}.runtime.database.port must be a TCP port`);
|
|
if (databaseMode !== "external") throw new Error(`${configPath}.runtime.database.mode must be external`);
|
|
const redis = objectField(runtime, "redis", "runtime");
|
|
const redisServiceName = stringField(redis, "serviceName", "runtime.redis");
|
|
const redisPersistence = booleanField(redis, "persistence", "runtime.redis");
|
|
if (!isKubernetesName(redisServiceName)) throw new Error(`${configPath}.runtime.redis.serviceName must be a Kubernetes Service name`);
|
|
const secretsRaw = runtime.secrets === undefined ? {} : objectField(runtime, "secrets", "runtime");
|
|
const secretRoot = secretsRaw.root === undefined ? ".state/secrets" : stringField(secretsRaw, "root", "runtime.secrets");
|
|
const appSourceRef = secretsRaw.appSourceRef === undefined ? "platform-infra/sub2api.env" : stringField(secretsRaw, "appSourceRef", "runtime.secrets");
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(secretRoot)) throw new Error(`${configPath}.runtime.secrets.root has an unsupported format`);
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(appSourceRef)) throw new Error(`${configPath}.runtime.secrets.appSourceRef has an unsupported format`);
|
|
const appData = objectField(runtime, "appData", "runtime");
|
|
const appDataMode = enumField(appData, "mode", "runtime.appData", ["persistent-pvc", "empty-dir"] as const);
|
|
const sentinel = objectField(runtime, "sentinel", "runtime");
|
|
const sentinelMode = enumField(sentinel, "mode", "runtime.sentinel", ["singleton"] as const);
|
|
const enabledOnTargets = stringArrayField(sentinel, "enabledOnTargets", "runtime.sentinel");
|
|
if (sentinelMode !== "singleton") throw new Error(`${configPath}.runtime.sentinel.mode must be singleton`);
|
|
return {
|
|
database: { mode: "external", sourceRef, sourceKeys, secretName: secret, passwordKey, host, port, user, dbName, sslMode, pendingAllowed },
|
|
secrets: { root: secretRoot, appSourceRef },
|
|
redis: { serviceName: redisServiceName, persistence: redisPersistence },
|
|
appData: { mode: appDataMode },
|
|
sentinel: { mode: "singleton", enabledOnTargets },
|
|
};
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${configPath}.${path}.${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
|
const value = obj[key];
|
|
const prefix = path.length > 0 ? `${path}.` : "";
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.${prefix}${key} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = obj[key];
|
|
if (typeof value !== "boolean") throw new Error(`${configPath}.${path}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
|
const value = obj[key];
|
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim().length > 0)) {
|
|
throw new Error(`${configPath}.${path}.${key} must be an array of non-empty strings`);
|
|
}
|
|
return value.map((item) => item.trim());
|
|
}
|
|
|
|
function enumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, path: string, values: T): T[number] {
|
|
const value = stringField(obj, key, path);
|
|
if (!(values as readonly string[]).includes(value)) throw new Error(`${configPath}.${path}.${key} must be one of ${values.join(", ")}`);
|
|
return value as T[number];
|
|
}
|
|
|
|
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${configPath}.${path}.${key} must be an integer`);
|
|
return value;
|
|
}
|
|
|
|
function isKubernetesName(value: string): boolean {
|
|
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value);
|
|
}
|
|
|
|
function isImageRepository(value: string): boolean {
|
|
return /^[A-Za-z0-9._:/-]+$/u.test(value) && !value.includes("..") && !value.includes("@") && !value.endsWith(":");
|
|
}
|
|
|
|
function isImageReference(value: string): boolean {
|
|
return /^[A-Za-z0-9._:/-]+$/u.test(value) && value.includes(":") && !value.includes("..");
|
|
}
|
|
|
|
function validatePublicExposureConfig(config: Sub2ApiPublicExposureConfig, path: string): void {
|
|
const url = new URL(config.publicBaseUrl);
|
|
if (url.protocol !== "https:") throw new Error(`${configPath}.${path}.publicExposure.publicBaseUrl must use https://`);
|
|
if (url.hostname !== config.dns.hostname) throw new Error(`${configPath}.${path}.publicExposure.publicBaseUrl hostname must match publicExposure.dns.hostname`);
|
|
validateHostname(config.dns.hostname, `${path}.publicExposure.dns.hostname`);
|
|
if (!/^[0-9.]+$/u.test(config.dns.expectedA)) throw new Error(`${configPath}.${path}.publicExposure.dns.expectedA must be an IPv4 address`);
|
|
for (const resolver of config.dns.resolvers) {
|
|
if (!/^[0-9.]+$/u.test(resolver)) throw new Error(`${configPath}.${path}.publicExposure.dns.resolvers entries must be IPv4 resolvers`);
|
|
}
|
|
validateKubernetesResourceName(config.frpc.deploymentName, `${path}.publicExposure.frpc.deploymentName`);
|
|
validateKubernetesResourceName(config.frpc.secretName, `${path}.publicExposure.frpc.secretName`);
|
|
if (config.frpc.secretKey !== "frpc.toml") throw new Error(`${configPath}.${path}.publicExposure.frpc.secretKey must be frpc.toml`);
|
|
if (!isImageReference(config.frpc.image)) throw new Error(`${configPath}.${path}.publicExposure.frpc.image has an unsupported format`);
|
|
validateHostOrIp(config.frpc.serverAddr, `${path}.publicExposure.frpc.serverAddr`);
|
|
validatePort(config.frpc.serverPort, `${path}.publicExposure.frpc.serverPort`);
|
|
validateProxyName(config.frpc.proxyName, `${path}.publicExposure.frpc.proxyName`);
|
|
validatePort(config.frpc.remotePort, `${path}.publicExposure.frpc.remotePort`);
|
|
validateHostOrIp(config.frpc.localIP, `${path}.publicExposure.frpc.localIP`);
|
|
validatePort(config.frpc.localPort, `${path}.publicExposure.frpc.localPort`);
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(config.frpc.tokenSourceRef)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceRef has an unsupported format`);
|
|
if (!/^[A-Z0-9_]+$/u.test(config.frpc.tokenSourceKey)) throw new Error(`${configPath}.${path}.publicExposure.frpc.tokenSourceKey must be an env key`);
|
|
if (!/^[A-Za-z0-9:_./-]+$/u.test(config.pk01.route)) throw new Error(`${configPath}.${path}.publicExposure.pk01.route has an unsupported format`);
|
|
if (!config.pk01.caddyBinaryPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyBinaryPath must be absolute`);
|
|
if (!/^https:\/\//u.test(config.pk01.caddyDownloadUrl)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyDownloadUrl must use https://`);
|
|
if (config.pk01.caddyDownloadProxyUrl !== null) validateProxyUrl(config.pk01.caddyDownloadProxyUrl, `${path}.publicExposure.pk01.caddyDownloadProxyUrl`);
|
|
if (!config.pk01.caddyConfigPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyConfigPath must be absolute`);
|
|
validateProxyName(config.pk01.caddyServiceName, `${path}.publicExposure.pk01.caddyServiceName`);
|
|
if (!config.pk01.caddyStorageDir.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyStorageDir must be absolute`);
|
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(config.pk01.caddyEmail)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyEmail must be an email address`);
|
|
if (!config.pk01.pikanodeRoot.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.pikanodeRoot must be absolute`);
|
|
validateProxyName(config.pk01.pikanodeContainerName, `${path}.publicExposure.pk01.pikanodeContainerName`);
|
|
if (!isImageRepository(config.pk01.pikanodeImage) && !isImageReference(config.pk01.pikanodeImage)) throw new Error(`${configPath}.${path}.publicExposure.pk01.pikanodeImage has an unsupported format`);
|
|
validatePort(config.pk01.pikanodeHttpHostPort, `${path}.publicExposure.pk01.pikanodeHttpHostPort`);
|
|
if (config.pk01.responseHeaderTimeoutSeconds < 1 || config.pk01.responseHeaderTimeoutSeconds > 3600) throw new Error(`${configPath}.${path}.publicExposure.pk01.responseHeaderTimeoutSeconds must be 1..3600`);
|
|
}
|
|
|
|
function validateEgressProxyConfig(config: Sub2ApiEgressProxyConfig, path: string): void {
|
|
validateKubernetesResourceName(config.deploymentName, `${path}.egressProxy.deploymentName`);
|
|
validateKubernetesResourceName(config.serviceName, `${path}.egressProxy.serviceName`);
|
|
validateKubernetesResourceName(config.secretName, `${path}.egressProxy.secretName`);
|
|
if (config.secretKey !== "config.json") throw new Error(`${configPath}.${path}.egressProxy.secretKey must be config.json`);
|
|
if (!isImageReference(config.image)) throw new Error(`${configPath}.${path}.egressProxy.image has an unsupported format`);
|
|
validatePort(config.listenPort, `${path}.egressProxy.listenPort`);
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.egressProxy.sourceRef has an unsupported format`);
|
|
if (!/^[A-Z0-9_]+$/u.test(config.sourceKey)) throw new Error(`${configPath}.${path}.egressProxy.sourceKey must be an env key`);
|
|
for (const entry of config.noProxy) {
|
|
if (!/^[A-Za-z0-9.:_/*-]+$/u.test(entry)) throw new Error(`${configPath}.${path}.egressProxy.noProxy contains an unsupported entry`);
|
|
}
|
|
const url = new URL(config.healthProbeUrl);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path}.egressProxy.healthProbeUrl must use http:// or https://`);
|
|
}
|
|
|
|
function normalizePublicBaseUrl(value: string, path: string): string {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
throw new Error(`${configPath}.${path} must be a valid URL`);
|
|
}
|
|
parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
if (parsed.search || parsed.hash) throw new Error(`${configPath}.${path} must not include query or hash`);
|
|
return parsed.toString().replace(/\/+$/u, "");
|
|
}
|
|
|
|
function validateHostname(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9.-]+$/u.test(value) || !value.includes(".")) throw new Error(`${configPath}.${path} must be a hostname`);
|
|
}
|
|
|
|
function validateKubernetesResourceName(value: string, path: string): void {
|
|
if (!isKubernetesName(value)) throw new Error(`${configPath}.${path} must be a Kubernetes resource name`);
|
|
}
|
|
|
|
function validateHostOrIp(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`);
|
|
}
|
|
|
|
function validatePort(value: number, path: string): void {
|
|
if (value < 1 || value > 65535) throw new Error(`${configPath}.${path} must be a TCP port`);
|
|
}
|
|
|
|
function validateProxyName(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`);
|
|
}
|
|
|
|
function validateProxyUrl(value: string, path: string): void {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new Error(`${configPath}.${path} must be a valid proxy URL`);
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path} must use http:// or https://`);
|
|
if (url.username || url.password || url.search || url.hash) throw new Error(`${configPath}.${path} must not include credentials, query, or hash`);
|
|
}
|
|
|
|
function resolveTarget(sub2api: Sub2ApiConfig, targetId: string): Sub2ApiTargetConfig {
|
|
const target = sub2api.targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase());
|
|
if (target === undefined) {
|
|
const known = sub2api.targets.map((item) => item.id).join(", ");
|
|
throw new Error(`unknown Sub2API target ${targetId}; known targets: ${known}`);
|
|
}
|
|
if (!target.enabled) throw new Error(`Sub2API target ${target.id} is disabled in ${configPath}`);
|
|
return target;
|
|
}
|
|
|
|
function targetImage(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["image"] {
|
|
return {
|
|
repository: target.image.repository ?? sub2api.image.repository,
|
|
tag: target.image.tag ?? sub2api.image.tag,
|
|
pullPolicy: target.image.pullPolicy ?? sub2api.image.pullPolicy,
|
|
};
|
|
}
|
|
|
|
function targetDependencyImages(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["dependencyImages"] {
|
|
return {
|
|
postgres: target.dependencyImages.postgres ?? sub2api.dependencyImages.postgres,
|
|
redis: target.dependencyImages.redis ?? sub2api.dependencyImages.redis,
|
|
};
|
|
}
|
|
|
|
function imageRef(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const image = targetImage(sub2api, target);
|
|
return `${image.repository}:${image.tag}`;
|
|
}
|
|
|
|
function isExternalTarget(target: Sub2ApiTargetConfig): boolean {
|
|
return target.databaseMode === "external-pending" || target.databaseMode === "external-active";
|
|
}
|
|
|
|
function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
if (isExternalTarget(target)) return externalPendingManifest(sub2api, target);
|
|
const template = readFileSync(manifestPath, "utf8");
|
|
const urlAllowlist = sub2api.security.urlAllowlist;
|
|
const configHash = configHashFor(sub2api, target);
|
|
const image = targetImage(sub2api, target);
|
|
const dependencyImages = targetDependencyImages(sub2api, target);
|
|
return template
|
|
.replaceAll("__SUB2API_IMAGE__", imageRef(sub2api, target))
|
|
.replaceAll("__SUB2API_IMAGE_PULL_POLICY__", image.pullPolicy)
|
|
.replaceAll("postgres:18-alpine", dependencyImages.postgres)
|
|
.replaceAll("redis:8-alpine", dependencyImages.redis)
|
|
.replaceAll("__SUB2API_CONFIG_HASH__", configHash)
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ENABLED__", String(urlAllowlist.enabled))
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP__", String(urlAllowlist.allowInsecureHttp))
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS__", String(urlAllowlist.allowPrivateHosts))
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS__", urlAllowlist.upstreamHosts.join(","));
|
|
}
|
|
|
|
function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
return createHash("sha256")
|
|
.update(JSON.stringify({
|
|
image: targetImage(sub2api, target),
|
|
dependencyImages: targetDependencyImages(sub2api, target),
|
|
security: sub2api.security,
|
|
target,
|
|
runtime: sub2api.runtime,
|
|
}))
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
}
|
|
|
|
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;
|
|
const databaseStatus = target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db";
|
|
const redisService = sub2api.runtime.redis.serviceName;
|
|
const appReplicas = target.appReplicas;
|
|
const redisReplicas = target.redisReplicas;
|
|
const image = targetImage(sub2api, target);
|
|
const dependencyImages = targetDependencyImages(sub2api, target);
|
|
const publicExposure = renderPublicExposureManifest(target);
|
|
const egressProxy = renderEgressProxyManifest(target);
|
|
const proxyEnv = sub2ApiProxyEnv(target);
|
|
return `apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-all
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: platform-infra
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
ingress:
|
|
- {}
|
|
egress:
|
|
- {}
|
|
---
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: sub2api-config
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
data:
|
|
AUTO_SETUP: "true"
|
|
SERVER_HOST: "0.0.0.0"
|
|
SERVER_PORT: "8080"
|
|
SERVER_MODE: "release"
|
|
RUN_MODE: "standard"
|
|
DATABASE_HOST: "${database.host}"
|
|
DATABASE_PORT: "${database.port}"
|
|
DATABASE_USER: "${database.user}"
|
|
DATABASE_DBNAME: "${database.dbName}"
|
|
DATABASE_SSLMODE: "${database.sslMode}"
|
|
DATABASE_MAX_OPEN_CONNS: "10"
|
|
DATABASE_MAX_IDLE_CONNS: "2"
|
|
DATABASE_CONN_MAX_LIFETIME_MINUTES: "30"
|
|
DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5"
|
|
REDIS_HOST: "${redisService}"
|
|
REDIS_PORT: "6379"
|
|
REDIS_PASSWORD: ""
|
|
REDIS_DB: "0"
|
|
REDIS_POOL_SIZE: "32"
|
|
REDIS_MIN_IDLE_CONNS: "2"
|
|
REDIS_ENABLE_TLS: "false"
|
|
ADMIN_EMAIL: "admin@sub2api.platform-infra.local"
|
|
JWT_EXPIRE_HOUR: "24"
|
|
TZ: "Asia/Shanghai"
|
|
SECURITY_URL_ALLOWLIST_ENABLED: "${urlAllowlist.enabled}"
|
|
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: "${urlAllowlist.allowInsecureHttp}"
|
|
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: "${urlAllowlist.allowPrivateHosts}"
|
|
SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: "${urlAllowlist.upstreamHosts.join(",")}"
|
|
UPDATE_PROXY_URL: "${proxyEnv.httpProxy}"
|
|
HTTP_PROXY: "${proxyEnv.httpProxy}"
|
|
HTTPS_PROXY: "${proxyEnv.httpProxy}"
|
|
ALL_PROXY: "${proxyEnv.httpProxy}"
|
|
http_proxy: "${proxyEnv.httpProxy}"
|
|
https_proxy: "${proxyEnv.httpProxy}"
|
|
all_proxy: "${proxyEnv.httpProxy}"
|
|
NO_PROXY: "${proxyEnv.noProxy}"
|
|
no_proxy: "${proxyEnv.noProxy}"
|
|
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0"
|
|
GATEWAY_OPENAI_HTTP2_ENABLED: "true"
|
|
GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true"
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2"
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60"
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600"
|
|
GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900"
|
|
GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10"
|
|
GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false"
|
|
GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0"
|
|
GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject"
|
|
GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30"
|
|
GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100"
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${redisService}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/state-mode: ephemeral-cache
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
ports:
|
|
- name: redis
|
|
port: 6379
|
|
targetPort: redis
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: sub2api
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
ports:
|
|
- name: http
|
|
port: 8080
|
|
targetPort: http
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: sub2api-redis
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/state-mode: ephemeral-cache
|
|
spec:
|
|
replicas: ${redisReplicas}
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
app.kubernetes.io/part-of: platform-infra
|
|
spec:
|
|
securityContext:
|
|
fsGroup: 999
|
|
containers:
|
|
- name: redis
|
|
image: ${dependencyImages.redis}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
args:
|
|
- redis-server --save "" --appendonly no
|
|
ports:
|
|
- name: redis
|
|
containerPort: 6379
|
|
env:
|
|
- name: TZ
|
|
value: Asia/Shanghai
|
|
readinessProbe:
|
|
exec:
|
|
command:
|
|
- redis-cli
|
|
- ping
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
exec:
|
|
command:
|
|
- redis-cli
|
|
- ping
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
volumeMounts:
|
|
- name: redis-data
|
|
mountPath: /data
|
|
volumes:
|
|
- name: redis-data
|
|
emptyDir: {}
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: sub2api
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/database-status: ${databaseStatus}
|
|
spec:
|
|
replicas: ${appReplicas}
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
template:
|
|
metadata:
|
|
annotations:
|
|
unidesk.ai/sub2api-config-hash: "${configHashFor(sub2api, target)}"
|
|
unidesk.ai/database-source-ref: "${database.sourceRef}"
|
|
unidesk.ai/database-status: "${databaseStatus}"
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
app.kubernetes.io/part-of: platform-infra
|
|
spec:
|
|
securityContext:
|
|
fsGroup: 1000
|
|
initContainers:
|
|
- name: wait-postgres
|
|
image: ${dependencyImages.postgres}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
- until pg_isready -h ${database.host} -p ${database.port} -U ${database.user} -d ${database.dbName}; do sleep 2; done
|
|
- name: wait-redis
|
|
image: ${dependencyImages.redis}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
- until redis-cli -h ${redisService} ping | grep -q PONG; do sleep 2; done
|
|
containers:
|
|
- name: sub2api
|
|
image: ${imageRef(sub2api, target)}
|
|
imagePullPolicy: ${image.pullPolicy}
|
|
ports:
|
|
- name: http
|
|
containerPort: 8080
|
|
envFrom:
|
|
- configMapRef:
|
|
name: sub2api-config
|
|
env:
|
|
- name: DATABASE_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${database.secretName}
|
|
key: ${database.passwordKey}
|
|
- name: ADMIN_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${secretName}
|
|
key: ADMIN_PASSWORD
|
|
- name: JWT_SECRET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${secretName}
|
|
key: JWT_SECRET
|
|
- name: TOTP_ENCRYPTION_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${secretName}
|
|
key: TOTP_ENCRYPTION_KEY
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: http
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: http
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
startupProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: http
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 30
|
|
volumeMounts:
|
|
- name: sub2api-data
|
|
mountPath: /app/data
|
|
volumes:
|
|
- name: sub2api-data
|
|
emptyDir: {}
|
|
${publicExposure}
|
|
${egressProxy}
|
|
`;
|
|
}
|
|
|
|
function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: string; noProxy: string } {
|
|
const proxy = target.egressProxy;
|
|
if (proxy === null || !proxy.enabled || !proxy.applyToSub2Api) return { httpProxy: "", noProxy: "" };
|
|
return {
|
|
httpProxy: `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`,
|
|
noProxy: proxy.noProxy.join(","),
|
|
};
|
|
}
|
|
|
|
function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string {
|
|
const exposure = target.publicExposure;
|
|
if (exposure === null || !exposure.enabled) return "";
|
|
return `---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${exposure.frpc.deploymentName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
|
app.kubernetes.io/component: tunnel
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/public-hostname: ${exposure.dns.hostname}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
|
app.kubernetes.io/component: tunnel
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
|
app.kubernetes.io/component: tunnel
|
|
app.kubernetes.io/part-of: platform-infra
|
|
annotations:
|
|
unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
|
|
unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
|
|
unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
|
|
spec:
|
|
containers:
|
|
- name: frpc
|
|
image: ${exposure.frpc.image}
|
|
imagePullPolicy: IfNotPresent
|
|
args:
|
|
- -c
|
|
- /etc/frp/frpc.toml
|
|
volumeMounts:
|
|
- name: frpc-config
|
|
mountPath: /etc/frp/frpc.toml
|
|
subPath: ${exposure.frpc.secretKey}
|
|
readOnly: true
|
|
volumes:
|
|
- name: frpc-config
|
|
secret:
|
|
secretName: ${exposure.frpc.secretName}
|
|
`;
|
|
}
|
|
|
|
function renderEgressProxyManifest(target: Sub2ApiTargetConfig): string {
|
|
const proxy = target.egressProxy;
|
|
if (proxy === null || !proxy.enabled) return "";
|
|
return `---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${proxy.serviceName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/proxy-source: master-vpn-subscription
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
ports:
|
|
- name: http
|
|
port: ${proxy.listenPort}
|
|
targetPort: proxy
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${proxy.deploymentName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/proxy-source: master-vpn-subscription
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
app.kubernetes.io/part-of: platform-infra
|
|
annotations:
|
|
unidesk.ai/proxy-source-ref: "${proxy.sourceRef}"
|
|
unidesk.ai/proxy-preferred-outbound: "${proxy.preferredOutbound}"
|
|
spec:
|
|
containers:
|
|
- name: proxy
|
|
image: ${proxy.image}
|
|
imagePullPolicy: ${proxy.imagePullPolicy}
|
|
args:
|
|
- run
|
|
- -c
|
|
- /etc/sing-box/${proxy.secretKey}
|
|
ports:
|
|
- name: proxy
|
|
containerPort: ${proxy.listenPort}
|
|
readinessProbe:
|
|
tcpSocket:
|
|
port: proxy
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
tcpSocket:
|
|
port: proxy
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
volumeMounts:
|
|
- name: proxy-config
|
|
mountPath: /etc/sing-box/${proxy.secretKey}
|
|
subPath: ${proxy.secretKey}
|
|
readOnly: true
|
|
volumes:
|
|
- name: proxy-config
|
|
secret:
|
|
secretName: ${proxy.secretName}
|
|
`;
|
|
}
|
|
|
|
function publicExposureSummary(exposure: Sub2ApiPublicExposureConfig): Record<string, unknown> {
|
|
return {
|
|
enabled: exposure.enabled,
|
|
publicBaseUrl: exposure.publicBaseUrl,
|
|
dns: exposure.dns,
|
|
frpc: {
|
|
deploymentName: exposure.frpc.deploymentName,
|
|
secretName: exposure.frpc.secretName,
|
|
image: exposure.frpc.image,
|
|
serverAddr: exposure.frpc.serverAddr,
|
|
serverPort: exposure.frpc.serverPort,
|
|
proxyName: exposure.frpc.proxyName,
|
|
remotePort: exposure.frpc.remotePort,
|
|
localIP: exposure.frpc.localIP,
|
|
localPort: exposure.frpc.localPort,
|
|
tokenSourceRef: exposure.frpc.tokenSourceRef,
|
|
valuesPrinted: false,
|
|
},
|
|
pk01: {
|
|
route: exposure.pk01.route,
|
|
mode: "caddy-edge",
|
|
caddyBinaryPath: exposure.pk01.caddyBinaryPath,
|
|
caddyDownloadProxyUrl: exposure.pk01.caddyDownloadProxyUrl,
|
|
caddyConfigPath: exposure.pk01.caddyConfigPath,
|
|
caddyServiceName: exposure.pk01.caddyServiceName,
|
|
pikanodeHttpHostPort: exposure.pk01.pikanodeHttpHostPort,
|
|
},
|
|
};
|
|
}
|
|
|
|
function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unknown> {
|
|
return {
|
|
enabled: proxy.enabled,
|
|
mode: "master-vpn-subscription-http-proxy",
|
|
deploymentName: proxy.deploymentName,
|
|
serviceName: proxy.serviceName,
|
|
secretName: proxy.secretName,
|
|
image: proxy.image,
|
|
imagePullPolicy: proxy.imagePullPolicy,
|
|
listenPort: proxy.listenPort,
|
|
sourceRef: proxy.sourceRef,
|
|
sourceType: proxy.sourceType,
|
|
preferredOutbound: proxy.preferredOutbound,
|
|
applyToSub2Api: proxy.applyToSub2Api,
|
|
applyToSentinel: proxy.applyToSentinel,
|
|
healthProbeUrl: proxy.healthProbeUrl,
|
|
noProxy: proxy.noProxy,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
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);
|
|
return {
|
|
ok: policy.every((check) => check.ok),
|
|
action: "platform-infra-sub2api-plan",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
role: target.role,
|
|
manifestPath,
|
|
configPath,
|
|
fieldManager,
|
|
serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
|
|
},
|
|
config: {
|
|
image: imageRef(sub2api, target),
|
|
pullPolicy: targetImage(sub2api, target).pullPolicy,
|
|
dependencyImages: targetDependencyImages(sub2api, target),
|
|
security: sub2api.security,
|
|
target: {
|
|
databaseMode: target.databaseMode,
|
|
redisMode: target.redisMode,
|
|
appReplicas: target.appReplicas,
|
|
redisReplicas: target.redisReplicas,
|
|
publicExposure: target.publicExposure === null ? null : publicExposureSummary(target.publicExposure),
|
|
egressProxy: target.egressProxy === null ? null : egressProxySummary(target.egressProxy),
|
|
},
|
|
externalDatabase: isExternalTarget(target) ? {
|
|
status: target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db",
|
|
sourceRef: sub2api.runtime.database.sourceRef,
|
|
sourceKeys: sub2api.runtime.database.sourceKeys,
|
|
secretName: sub2api.runtime.database.secretName,
|
|
passwordKey: sub2api.runtime.database.passwordKey,
|
|
host: sub2api.runtime.database.host,
|
|
port: sub2api.runtime.database.port,
|
|
user: sub2api.runtime.database.user,
|
|
dbName: sub2api.runtime.database.dbName,
|
|
sslMode: sub2api.runtime.database.sslMode,
|
|
pendingAllowed: sub2api.runtime.database.pendingAllowed,
|
|
} : null,
|
|
},
|
|
decision: {
|
|
owner: "UniDesk",
|
|
namespace: target.namespace,
|
|
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"
|
|
? `${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 ${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.",
|
|
urlAllowlistControl: "Sub2API upstream URL validation options are controlled by config/platform-infra/sub2api.yaml and rendered to SECURITY_URL_ALLOWLIST_* env vars.",
|
|
networkPolicy: "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
|
|
publicExposure: target.publicExposure?.enabled
|
|
? {
|
|
mode: "pk01-caddy-frp-direct",
|
|
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,
|
|
expectedA: target.publicExposure.dns.expectedA,
|
|
}
|
|
: null,
|
|
egressProxy: target.egressProxy?.enabled
|
|
? {
|
|
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,
|
|
applyToSentinel: target.egressProxy.applyToSentinel,
|
|
valuesPrinted: false,
|
|
}
|
|
: null,
|
|
dataStores: isExternalTarget(target)
|
|
? [
|
|
target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
|
|
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: {
|
|
databaseMaxOpenConns: 10,
|
|
databaseMaxIdleConns: 2,
|
|
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: {
|
|
dryRun: `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --dry-run`,
|
|
apply: target.databaseMode === "external-pending" && target.appReplicas === 0
|
|
? `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm # predeploy only; app replicas=0 until DB is ready`
|
|
: `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm`,
|
|
status: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`,
|
|
validate: `bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
const sub2api = readSub2ApiConfig();
|
|
const target = resolveTarget(sub2api, options.targetId);
|
|
const yaml = manifest(sub2api, target);
|
|
const policy = policyChecks(yaml, target);
|
|
if (!policy.every((check) => check.ok)) {
|
|
return {
|
|
ok: false,
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "policy-blocked",
|
|
target: target.id,
|
|
policy,
|
|
};
|
|
}
|
|
if (options.confirm && !options.wait) {
|
|
const job = startJob(
|
|
`platform_infra_sub2api_apply_${target.id.toLowerCase()}`,
|
|
["bun", "scripts/cli.ts", "platform-infra", "sub2api", "apply", "--target", target.id, "--confirm", "--wait"],
|
|
`Apply ${target.id} k3s platform-infra Sub2API manifests through the controlled UniDesk CLI`,
|
|
);
|
|
return {
|
|
ok: true,
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "async-job",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
job,
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
next: {
|
|
status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
rollout: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`,
|
|
validate: `bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
if (options.dryRun) {
|
|
const result = await capture(config, target.route, ["script"], dryRunScript(yaml, target));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "dry-run",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
policy,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
};
|
|
}
|
|
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(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const pk01Exposure = publicExposureSecretMaterial === null ? null : await applyPk01PublicExposure(config, target);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && (pk01Exposure === null || pk01Exposure.ok === true),
|
|
action: "platform-infra-sub2api-apply",
|
|
mode: "confirmed",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
policy,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
pk01Exposure,
|
|
next: {
|
|
status: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`,
|
|
validate: `bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function status(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
|
const sub2api = readSub2ApiConfig();
|
|
const target = resolveTarget(sub2api, options.targetId);
|
|
const result = await capture(config, target.route, ["script"], statusScript(sub2api, target));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
if (options.raw) {
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-status",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
remote: compactCapture(result, { full: true }),
|
|
parsed,
|
|
};
|
|
}
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-status",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
summary: parsed,
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
};
|
|
}
|
|
|
|
async function validate(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
|
const sub2api = readSub2ApiConfig();
|
|
const target = resolveTarget(sub2api, options.targetId);
|
|
const script = target.databaseMode === "external-pending"
|
|
? validateExternalPendingScript(sub2api, target)
|
|
: target.databaseMode === "external-active"
|
|
? validateExternalActiveScript(sub2api, target)
|
|
: validateScript(target);
|
|
const result = await capture(config, target.route, ["script"], script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
if (options.raw) {
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-validate",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
remote: compactCapture(result, { full: true }),
|
|
parsed,
|
|
};
|
|
}
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
action: "platform-infra-sub2api-validate",
|
|
target: {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
},
|
|
summary: parsed,
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
};
|
|
}
|
|
|
|
function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[] {
|
|
const checks: PolicyCheck[] = [
|
|
{
|
|
name: "no-ingress",
|
|
ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml),
|
|
detail: "Sub2API must not be exposed through Kubernetes Ingress.",
|
|
},
|
|
{
|
|
name: "no-nodeport-or-loadbalancer",
|
|
ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml),
|
|
detail: "Services must remain ClusterIP/internal-only.",
|
|
},
|
|
{
|
|
name: "no-host-network",
|
|
ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml),
|
|
detail: "Pods must not join the host network.",
|
|
},
|
|
{
|
|
name: "no-host-port",
|
|
ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml),
|
|
detail: "Pods must not expose host ports.",
|
|
},
|
|
{
|
|
name: "no-cpu-memory-resources",
|
|
ok: !/^\s*(cpu|memory):\s*/mu.test(yaml),
|
|
detail: "Issue #220 requires no Kubernetes CPU/memory requests or limits.",
|
|
},
|
|
{
|
|
name: "no-resource-quota-or-limit-range",
|
|
ok: !/^\s*kind:\s*(ResourceQuota|LimitRange)\s*$/mu.test(yaml),
|
|
detail: "The platform-infra namespace must not receive quota/default limit objects for this deployment.",
|
|
},
|
|
{
|
|
name: "expected-namespace",
|
|
ok: new RegExp(`^\\s*name:\\s*${escapeRegExp(target.namespace)}\\s*$`, "mu").test(yaml),
|
|
detail: `Manifest declares namespace ${target.namespace}.`,
|
|
},
|
|
{
|
|
name: "allow-all-network-policy",
|
|
ok: hasAllowAllNetworkPolicy(yaml, target.namespace),
|
|
detail: `Manifest must include NetworkPolicy/allow-all in ${target.namespace} to keep kube-router from blocking Sub2API cross-pod traffic.`,
|
|
},
|
|
];
|
|
|
|
if (isExternalTarget(target)) {
|
|
checks.push(
|
|
{
|
|
name: "external-db-no-local-postgres",
|
|
ok: !/^\s*kind:\s*StatefulSet\s*$/mu.test(yaml) && !/\bsub2api-postgres\b/u.test(yaml),
|
|
detail: "External DB targets must not deploy a local PostgreSQL StatefulSet, Service, or PVC.",
|
|
});
|
|
}
|
|
|
|
if (target.databaseMode === "external-pending") {
|
|
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),
|
|
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),
|
|
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),
|
|
detail: "Bundled active targets render the local PostgreSQL StatefulSet.",
|
|
});
|
|
}
|
|
|
|
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),
|
|
detail: "Local Redis is an ephemeral cache and must not allocate persistent Redis storage.",
|
|
});
|
|
}
|
|
|
|
if (target.egressProxy?.enabled) {
|
|
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: "Egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
|
|
});
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function hasAllowAllNetworkPolicy(yaml: string, namespaceName: string): boolean {
|
|
return yaml.split(/^---\s*$/mu).some((document) => {
|
|
return /^\s*kind:\s*NetworkPolicy\s*$/mu.test(document)
|
|
&& /^\s*name:\s*allow-all\s*$/mu.test(document)
|
|
&& new RegExp(`^\\s*namespace:\\s*${escapeRegExp(namespaceName)}\\s*$`, "mu").test(document)
|
|
&& /^\s*podSelector:\s*\{\}\s*$/mu.test(document)
|
|
&& /^\s*-\s*Ingress\s*$/mu.test(document)
|
|
&& /^\s*-\s*Egress\s*$/mu.test(document)
|
|
&& /^\s*ingress:\s*\n\s*-\s*\{\}\s*$/mu.test(document)
|
|
&& /^\s*egress:\s*\n\s*-\s*\{\}\s*$/mu.test(document);
|
|
});
|
|
}
|
|
|
|
function hasDeploymentReplicas(yaml: string, name: string, replicas: number): boolean {
|
|
return yaml.split(/^---\s*$/mu).some((document) => {
|
|
return /^\s*kind:\s*Deployment\s*$/mu.test(document)
|
|
&& new RegExp(`^\\s*name:\\s*${escapeRegExp(name)}\\s*$`, "mu").test(document)
|
|
&& new RegExp(`^\\s*replicas:\\s*${replicas}\\s*$`, "mu").test(document);
|
|
});
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
}
|
|
|
|
function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalActiveSecretMaterial {
|
|
const database = sub2api.runtime.database;
|
|
const root = secretRoot(sub2api);
|
|
const dbSourcePath = join(root, database.sourceRef);
|
|
if (!existsSync(dbSourcePath)) {
|
|
throw new Error(`external-active requires ${redactRepoPath(dbSourcePath)}; run platform-db postgres apply/status first to materialize the PK01 DB credential source`);
|
|
}
|
|
const dbValues = parseEnvFile(readFileSync(dbSourcePath, "utf8"));
|
|
const dbUser = requiredEnvValue(dbValues, database.sourceKeys.user, database.sourceRef);
|
|
const dbPassword = requiredEnvValue(dbValues, database.sourceKeys.password, database.sourceRef);
|
|
const dbName = requiredEnvValue(dbValues, database.sourceKeys.dbName, database.sourceRef);
|
|
if (dbUser !== database.user) throw new Error(`${database.sourceRef}.${database.sourceKeys.user} does not match ${configPath}.runtime.database.user`);
|
|
if (dbName !== database.dbName) throw new Error(`${database.sourceRef}.${database.sourceKeys.dbName} does not match ${configPath}.runtime.database.dbName`);
|
|
|
|
const appSourcePath = join(root, sub2api.runtime.secrets.appSourceRef);
|
|
const existedBefore = existsSync(appSourcePath);
|
|
const existing = existedBefore ? parseEnvFile(readFileSync(appSourcePath, "utf8")) : {};
|
|
const next = { ...existing };
|
|
next.POSTGRES_PASSWORD = dbPassword;
|
|
if (next.ADMIN_PASSWORD === undefined || next.ADMIN_PASSWORD.length === 0) next.ADMIN_PASSWORD = randomBytes(16).toString("hex");
|
|
if (next.JWT_SECRET === undefined || next.JWT_SECRET.length === 0) next.JWT_SECRET = randomBytes(32).toString("hex");
|
|
if (next.TOTP_ENCRYPTION_KEY === undefined || next.TOTP_ENCRYPTION_KEY.length === 0) next.TOTP_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
|
const values: Record<typeof requiredSecretKeys[number], string> = {
|
|
POSTGRES_PASSWORD: next.POSTGRES_PASSWORD,
|
|
ADMIN_PASSWORD: next.ADMIN_PASSWORD,
|
|
JWT_SECRET: next.JWT_SECRET,
|
|
TOTP_ENCRYPTION_KEY: next.TOTP_ENCRYPTION_KEY,
|
|
};
|
|
const missing = requiredSecretKeys.filter((key) => values[key].length === 0);
|
|
if (missing.length > 0) throw new Error(`external-active app secret source is missing ${missing.join(", ")}`);
|
|
const action = !existedBefore
|
|
? "create"
|
|
: requiredSecretKeys.some((key) => existing[key] !== values[key])
|
|
? "update"
|
|
: "none";
|
|
if (action !== "none") writeEnvFile(appSourcePath, { ...existing, ...values });
|
|
return {
|
|
sourceRef: database.sourceRef,
|
|
sourcePath: redactRepoPath(dbSourcePath),
|
|
appSourceRef: sub2api.runtime.secrets.appSourceRef,
|
|
appSourcePath: redactRepoPath(appSourcePath),
|
|
action,
|
|
fingerprint: fingerprintValues(values, [...requiredSecretKeys]),
|
|
values,
|
|
};
|
|
}
|
|
|
|
function preparePublicExposureSecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): PublicExposureSecretMaterial | null {
|
|
const exposure = target.publicExposure;
|
|
if (exposure === null || !exposure.enabled) return null;
|
|
const sourcePath = join(secretRoot(sub2api), exposure.frpc.tokenSourceRef);
|
|
if (!existsSync(sourcePath)) throw new Error(`publicExposure requires ${redactRepoPath(sourcePath)} with ${exposure.frpc.tokenSourceKey}; copy the PK01 frps token into the local secret source first`);
|
|
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
|
const token = requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef);
|
|
const frpcToml = [
|
|
`serverAddr = "${exposure.frpc.serverAddr}"`,
|
|
`serverPort = ${exposure.frpc.serverPort}`,
|
|
`loginFailExit = true`,
|
|
`auth.token = "${escapeTomlString(token)}"`,
|
|
"",
|
|
"[[proxies]]",
|
|
`name = "${exposure.frpc.proxyName}"`,
|
|
`type = "tcp"`,
|
|
`localIP = "${exposure.frpc.localIP}"`,
|
|
`localPort = ${exposure.frpc.localPort}`,
|
|
`remotePort = ${exposure.frpc.remotePort}`,
|
|
"",
|
|
].join("\n");
|
|
return {
|
|
sourceRef: exposure.frpc.tokenSourceRef,
|
|
sourcePath: redactRepoPath(sourcePath),
|
|
secretName: exposure.frpc.secretName,
|
|
secretKey: exposure.frpc.secretKey,
|
|
fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]),
|
|
frpcToml,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): EgressProxySecretMaterial | null {
|
|
const proxy = target.egressProxy;
|
|
if (proxy === null || !proxy.enabled) return null;
|
|
const sourcePath = join(secretRoot(sub2api), proxy.sourceRef);
|
|
if (!existsSync(sourcePath)) throw new Error(`egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the master VPN subscription source first`);
|
|
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
|
const subscriptionUrl = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef);
|
|
const subscription = fetchSubscription(subscriptionUrl);
|
|
const decoded = decodeSubscription(subscription.body);
|
|
const nodes = decoded.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean);
|
|
const selected = selectSubscriptionNode(nodes, proxy.preferredOutbound);
|
|
const configJson = renderSingBoxConfig(selected, proxy);
|
|
const proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`;
|
|
const noProxy = proxy.noProxy.join(",");
|
|
return {
|
|
sourceRef: proxy.sourceRef,
|
|
sourcePath: redactRepoPath(sourcePath),
|
|
secretName: proxy.secretName,
|
|
secretKey: proxy.secretKey,
|
|
fingerprint: fingerprintValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]),
|
|
subscriptionBytes: Buffer.byteLength(subscription.body, "utf8"),
|
|
selectedOutbound: selected.kind,
|
|
configJson,
|
|
proxyUrl,
|
|
noProxy,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function fetchSubscription(subscriptionUrl: string): { body: string } {
|
|
const shellUrl = shQuote(subscriptionUrl);
|
|
const command = `curl -fsSL --connect-timeout 10 --max-time 30 ${shellUrl}`;
|
|
const proc = Bun.spawnSync(["bash", "-lc", command], { stdout: "pipe", stderr: "pipe" });
|
|
if (proc.exitCode !== 0) {
|
|
const stderr = new TextDecoder().decode(proc.stderr).slice(-500);
|
|
throw new Error(`failed to fetch master VPN subscription: exit=${proc.exitCode} stderr=${stderr}`);
|
|
}
|
|
const body = new TextDecoder().decode(proc.stdout).trim();
|
|
if (body.length === 0) throw new Error("master VPN subscription is empty");
|
|
return { body };
|
|
}
|
|
|
|
function decodeSubscription(raw: string): string {
|
|
if (raw.includes("://")) return raw;
|
|
const normalized = raw.replace(/\s+/gu, "");
|
|
const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
try {
|
|
return Buffer.from(`${normalized}${padding}`, "base64").toString("utf8");
|
|
} catch (error) {
|
|
throw new Error(`master VPN subscription is neither URI list nor base64 URI list: ${String(error)}`);
|
|
}
|
|
}
|
|
|
|
type SubscriptionNode =
|
|
| { kind: "vless-reality"; uri: string; uuid: string; host: string; port: number; sni: string; flow: string | null; fingerprint: string | null; publicKey: string; shortId: string | null }
|
|
| { kind: "hysteria2"; uri: string; password: string; host: string; port: number; sni: string | null; obfsPassword: string | null; insecure: boolean };
|
|
|
|
function selectSubscriptionNode(nodes: string[], preferred: Sub2ApiEgressProxyConfig["preferredOutbound"]): SubscriptionNode {
|
|
const parsed = nodes.map(parseSubscriptionNode).filter((node): node is SubscriptionNode => node !== null);
|
|
const preferredNode = parsed.find((node) => node.kind === preferred);
|
|
if (preferredNode !== undefined) return preferredNode;
|
|
const fallback = parsed.find((node) => node.kind === "vless-reality") ?? parsed.find((node) => node.kind === "hysteria2");
|
|
if (fallback !== undefined) return fallback;
|
|
throw new Error("master VPN subscription does not contain a supported vless-reality or hysteria2 node");
|
|
}
|
|
|
|
function parseSubscriptionNode(uri: string): SubscriptionNode | null {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(uri);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.protocol === "vless:") {
|
|
const params = url.searchParams;
|
|
if (params.get("security") !== "reality") return null;
|
|
const publicKey = params.get("pbk");
|
|
if (publicKey === null || publicKey.length === 0) return null;
|
|
return {
|
|
kind: "vless-reality",
|
|
uri,
|
|
uuid: decodeURIComponent(url.username),
|
|
host: requiredUrlHost(url, uri),
|
|
port: requiredUrlPort(url, uri),
|
|
sni: params.get("sni") ?? "",
|
|
flow: params.get("flow"),
|
|
fingerprint: params.get("fp"),
|
|
publicKey,
|
|
shortId: params.get("sid"),
|
|
};
|
|
}
|
|
if (url.protocol === "hysteria2:" || url.protocol === "hy2:") {
|
|
const params = url.searchParams;
|
|
return {
|
|
kind: "hysteria2",
|
|
uri,
|
|
password: decodeURIComponent(url.username),
|
|
host: requiredUrlHost(url, uri),
|
|
port: requiredUrlPort(url, uri),
|
|
sni: params.get("sni"),
|
|
obfsPassword: params.get("obfs-password"),
|
|
insecure: params.get("insecure") === "1" || params.get("insecure") === "true",
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function requiredUrlHost(url: URL, uri: string): string {
|
|
if (url.hostname.length === 0) throw new Error(`VPN subscription node is missing host: ${redactSubscriptionUri(uri)}`);
|
|
return url.hostname;
|
|
}
|
|
|
|
function requiredUrlPort(url: URL, uri: string): number {
|
|
const port = Number(url.port);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`VPN subscription node is missing a valid port: ${redactSubscriptionUri(uri)}`);
|
|
return port;
|
|
}
|
|
|
|
function renderSingBoxConfig(node: SubscriptionNode, proxy: Sub2ApiEgressProxyConfig): string {
|
|
const outbound = node.kind === "vless-reality"
|
|
? {
|
|
type: "vless",
|
|
tag: "master-vpn",
|
|
server: node.host,
|
|
server_port: node.port,
|
|
uuid: node.uuid,
|
|
flow: node.flow ?? undefined,
|
|
tls: {
|
|
enabled: true,
|
|
server_name: node.sni,
|
|
utls: { enabled: true, fingerprint: node.fingerprint ?? "chrome" },
|
|
reality: { enabled: true, public_key: node.publicKey, short_id: node.shortId ?? "" },
|
|
},
|
|
}
|
|
: {
|
|
type: "hysteria2",
|
|
tag: "master-vpn",
|
|
server: node.host,
|
|
server_port: node.port,
|
|
password: node.password,
|
|
obfs: node.obfsPassword === null ? undefined : { type: "salamander", password: node.obfsPassword },
|
|
tls: { enabled: true, server_name: node.sni ?? node.host, insecure: node.insecure },
|
|
};
|
|
const config = stripUndefined({
|
|
log: { level: "info", timestamp: true },
|
|
inbounds: [
|
|
{
|
|
type: "mixed",
|
|
tag: "mixed-in",
|
|
listen: "0.0.0.0",
|
|
listen_port: proxy.listenPort,
|
|
},
|
|
],
|
|
outbounds: [
|
|
outbound,
|
|
{ type: "direct", tag: "direct" },
|
|
{ type: "block", tag: "block" },
|
|
],
|
|
route: {
|
|
rules: [
|
|
{ ip_is_private: true, outbound: "direct" },
|
|
{ domain_suffix: ["cluster.local", "svc"], outbound: "direct" },
|
|
],
|
|
final: "master-vpn",
|
|
},
|
|
});
|
|
return `${JSON.stringify(config, null, 2)}\n`;
|
|
}
|
|
|
|
function stripUndefined(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(stripUndefined);
|
|
if (value !== null && typeof value === "object") {
|
|
const output: Record<string, unknown> = {};
|
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
if (child !== undefined) output[key] = stripUndefined(child);
|
|
}
|
|
return output;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function redactSubscriptionUri(uri: string): string {
|
|
return uri.replace(/\/\/[^@]+@/u, "//<redacted>@").replace(/password=[^&#]+/giu, "password=<redacted>").replace(/pbk=[^&#]+/giu, "pbk=<redacted>");
|
|
}
|
|
|
|
function escapeTomlString(value: string): string {
|
|
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
|
|
}
|
|
|
|
async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig): Promise<Record<string, unknown>> {
|
|
const exposure = target.publicExposure;
|
|
if (exposure === null || !exposure.enabled) return { ok: true, action: "not-enabled" };
|
|
const start = await startPk01PublicExposureJob(config, target, exposure);
|
|
if (!start.ok || typeof start.remoteJobId !== "string") {
|
|
return {
|
|
ok: false,
|
|
action: "platform-infra-sub2api-pk01-public-exposure",
|
|
route: exposure.pk01.route,
|
|
mode: "remote-job-start-failed",
|
|
remoteJob: start,
|
|
};
|
|
}
|
|
const waited = await waitPk01PublicExposureJob(config, exposure, start.remoteJobId);
|
|
const terminal = asRecordOrNull(waited.terminal);
|
|
const summary = asRecordOrNull(terminal?.summary);
|
|
return {
|
|
ok: waited.ok === true && boolField(summary, "ok", false),
|
|
action: "platform-infra-sub2api-pk01-public-exposure",
|
|
route: exposure.pk01.route,
|
|
mode: "remote-job",
|
|
summary,
|
|
remoteJob: {
|
|
start,
|
|
waited,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function startPk01PublicExposureJob(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise<Record<string, unknown>> {
|
|
const jobId = `sub2api-pk01-exposure-${new Date().toISOString().replace(/[^0-9A-Za-z]/gu, "")}-${randomBytes(3).toString("hex")}`;
|
|
const payload = pk01PublicExposureScript(target, exposure);
|
|
const payloadB64 = Buffer.from(payload, "utf8").toString("base64");
|
|
const script = `
|
|
set -eu
|
|
job_id=${shQuote(jobId)}
|
|
base="$HOME/.unidesk/platform-infra/sub2api-pk01-exposure/jobs"
|
|
job_dir="$base/$job_id"
|
|
mkdir -p "$job_dir"
|
|
payload="$job_dir/payload.sh"
|
|
runner="$job_dir/runner.sh"
|
|
stdout="$job_dir/stdout.log"
|
|
stderr="$job_dir/stderr.log"
|
|
state="$job_dir/state.json"
|
|
printf '%s' '${payloadB64}' | base64 -d >"$payload"
|
|
chmod 0700 "$payload"
|
|
cat >"$runner" <<'RUNNER'
|
|
#!/bin/sh
|
|
set +e
|
|
job_dir="$1"
|
|
payload="$job_dir/payload.sh"
|
|
stdout="$job_dir/stdout.log"
|
|
stderr="$job_dir/stderr.log"
|
|
state="$job_dir/state.json"
|
|
write_state() {
|
|
status="$1"
|
|
stage="$2"
|
|
exit_code="$3"
|
|
python3 - "$state" "$status" "$stage" "$exit_code" <<'PY'
|
|
import datetime
|
|
import json
|
|
import sys
|
|
|
|
path, status, stage, exit_code = sys.argv[1:5]
|
|
payload = {
|
|
"ok": status == "succeeded",
|
|
"status": status,
|
|
"stage": stage,
|
|
"updatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"exitCode": None if exit_code == "" else int(exit_code),
|
|
}
|
|
if status in ("succeeded", "failed"):
|
|
payload["finishedAt"] = payload["updatedAt"]
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, ensure_ascii=False)
|
|
PY
|
|
}
|
|
write_state running start ""
|
|
sh "$payload" >"$stdout" 2>"$stderr"
|
|
rc=$?
|
|
if [ "$rc" -eq 0 ]; then
|
|
write_state succeeded complete "$rc"
|
|
else
|
|
write_state failed complete "$rc"
|
|
fi
|
|
exit "$rc"
|
|
RUNNER
|
|
chmod 0700 "$runner"
|
|
python3 - "$state" <<'PY'
|
|
import datetime
|
|
import json
|
|
import sys
|
|
|
|
payload = {
|
|
"ok": False,
|
|
"status": "queued",
|
|
"stage": "queued",
|
|
"updatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"exitCode": None,
|
|
}
|
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, ensure_ascii=False)
|
|
PY
|
|
nohup "$runner" "$job_dir" >/dev/null 2>&1 &
|
|
printf '%s' "$!" >"$job_dir/pid"
|
|
python3 - "$job_id" "$job_dir" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
job_id, job_dir = sys.argv[1:3]
|
|
payload = {
|
|
"ok": True,
|
|
"remoteJobId": job_id,
|
|
"statePath": os.path.join(job_dir, "state.json"),
|
|
"stdoutPath": os.path.join(job_dir, "stdout.log"),
|
|
"stderrPath": os.path.join(job_dir, "stderr.log"),
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
PY
|
|
`;
|
|
const result = await capture(config, exposure.pk01.route, ["script"], script);
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
remoteJobId: typeof parsed?.remoteJobId === "string" ? parsed.remoteJobId : null,
|
|
parsed,
|
|
capture: compactCapture(result, { full: result.exitCode !== 0 }),
|
|
};
|
|
}
|
|
|
|
async function waitPk01PublicExposureJob(config: UniDeskConfig, exposure: Sub2ApiPublicExposureConfig, remoteJobId: string): Promise<Record<string, unknown>> {
|
|
const observations: Array<Record<string, unknown>> = [];
|
|
const maxPolls = 120;
|
|
let lastProgressKey: string | null = null;
|
|
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
const observation = await readPk01PublicExposureJob(config, exposure, remoteJobId);
|
|
observations.push(observation.summary);
|
|
const statusValue = observation.status;
|
|
const stage = typeof observation.summary.stage === "string" ? observation.summary.stage : null;
|
|
const progressKey = `${statusValue ?? "unknown"}:${stage ?? "unknown"}`;
|
|
if (attempt === 0 || progressKey !== lastProgressKey || attempt % 6 === 0 || statusValue === "succeeded" || statusValue === "failed") {
|
|
lastProgressKey = progressKey;
|
|
console.error(JSON.stringify({
|
|
event: "platform-infra.sub2api.pk01-exposure.progress",
|
|
at: new Date().toISOString(),
|
|
remoteJobId,
|
|
attempt: attempt + 1,
|
|
status: statusValue,
|
|
stage,
|
|
exitCode: observation.summary.exitCode ?? null,
|
|
}));
|
|
}
|
|
if (statusValue === "succeeded" || statusValue === "failed") {
|
|
return {
|
|
ok: statusValue === "succeeded" && boolField(asRecordOrNull(observation.summary.summary), "ok", false),
|
|
attempts: attempt + 1,
|
|
terminal: observation.summary,
|
|
recent: observations.slice(-10),
|
|
};
|
|
}
|
|
await Bun.sleep(5000);
|
|
}
|
|
return {
|
|
ok: false,
|
|
attempts: maxPolls,
|
|
terminal: null,
|
|
recent: observations.slice(-10),
|
|
error: "PK01 public exposure job did not finish before local polling budget",
|
|
};
|
|
}
|
|
|
|
async function readPk01PublicExposureJob(config: UniDeskConfig, exposure: Sub2ApiPublicExposureConfig, remoteJobId: string): Promise<{ status: string | null; summary: Record<string, unknown> }> {
|
|
const safeId = remoteJobId.replace(/[^A-Za-z0-9_.-]/gu, "");
|
|
const script = `
|
|
set +e
|
|
job_dir="$HOME/.unidesk/platform-infra/sub2api-pk01-exposure/jobs/${safeId}"
|
|
state="$job_dir/state.json"
|
|
stdout="$job_dir/stdout.log"
|
|
stderr="$job_dir/stderr.log"
|
|
if [ -f "$state" ]; then cat "$state"; else printf '{"status":"missing","stage":"missing","exitCode":null}'; fi
|
|
printf '\\n---STDOUT---\\n'
|
|
if [ -f "$stdout" ]; then tail -200 "$stdout"; fi
|
|
printf '\\n---STDERR---\\n'
|
|
if [ -f "$stderr" ]; then tail -120 "$stderr"; fi
|
|
printf '\\n---DOWNLOAD---\\n'
|
|
download_part="$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64.part"
|
|
download_cache="$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64"
|
|
if [ -f "$download_part" ]; then stat -c 'part_bytes=%s part_mtime=%y' "$download_part"; fi
|
|
if [ -f "$download_cache" ]; then stat -c 'cache_bytes=%s cache_mtime=%y' "$download_cache"; fi
|
|
`;
|
|
const result = await capture(config, exposure.pk01.route, ["script"], script);
|
|
const [stateText, rest = ""] = result.stdout.split("\n---STDOUT---\n");
|
|
const [stdoutAndMaybeStderr = "", downloadProgress = ""] = rest.split("\n---DOWNLOAD---\n");
|
|
const [stdoutTail = "", stderrTail = ""] = stdoutAndMaybeStderr.split("\n---STDERR---\n");
|
|
const parsed = parseJsonOutput(stateText) ?? {};
|
|
const statusValue = typeof parsed.status === "string" ? parsed.status : null;
|
|
const summary = parseJsonOutput(stdoutTail);
|
|
return {
|
|
status: statusValue,
|
|
summary: {
|
|
ok: result.exitCode === 0,
|
|
remoteJobId,
|
|
status: statusValue,
|
|
stage: parsed.stage ?? null,
|
|
updatedAt: parsed.updatedAt ?? null,
|
|
finishedAt: parsed.finishedAt ?? null,
|
|
exitCode: parsed.exitCode ?? null,
|
|
summary,
|
|
stdoutTail: stdoutTail.slice(-8000),
|
|
stderrTail: stderrTail.slice(-4000),
|
|
downloadProgress: downloadProgress.slice(-1000),
|
|
capture: compactCapture(result, { full: result.exitCode !== 0 }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function pk01PublicExposureScript(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
|
|
const caddyfile = renderPk01Caddyfile(exposure);
|
|
const serviceUnit = renderPk01CaddyService(exposure);
|
|
const caddyfileB64 = Buffer.from(caddyfile, "utf8").toString("base64");
|
|
const serviceUnitB64 = Buffer.from(serviceUnit, "utf8").toString("base64");
|
|
const caddyDownloadProxyArgs = exposure.pk01.caddyDownloadProxyUrl === null ? "" : `--proxy ${shQuote(exposure.pk01.caddyDownloadProxyUrl)}`;
|
|
const caddyDownloadProxyUrl = exposure.pk01.caddyDownloadProxyUrl ?? "";
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
caddyfile="$tmp/Caddyfile"
|
|
service_unit="$tmp/${exposure.pk01.caddyServiceName}.service"
|
|
printf '%s' '${caddyfileB64}' | base64 -d >"$caddyfile"
|
|
printf '%s' '${serviceUnitB64}' | base64 -d >"$service_unit"
|
|
download_out="$tmp/download.out"
|
|
download_err="$tmp/download.err"
|
|
install_out="$tmp/install.out"
|
|
install_err="$tmp/install.err"
|
|
validate_out="$tmp/validate.out"
|
|
validate_err="$tmp/validate.err"
|
|
pikanode_out="$tmp/pikanode.out"
|
|
pikanode_err="$tmp/pikanode.err"
|
|
caddy_out="$tmp/caddy.out"
|
|
caddy_err="$tmp/caddy.err"
|
|
probe_out="$tmp/probe.out"
|
|
probe_err="$tmp/probe.err"
|
|
download_action="kept-existing"
|
|
download_proxy_url=${shQuote(caddyDownloadProxyUrl)}
|
|
download_proxy_mode="direct"
|
|
if [ -n "$download_proxy_url" ]; then download_proxy_mode="curl-proxy"; fi
|
|
download_rc=0
|
|
if ! [ -x ${shQuote(exposure.pk01.caddyBinaryPath)} ]; then
|
|
cache_dir="$HOME/.cache/unidesk/platform-infra"
|
|
cache_path="$cache_dir/caddy-linux-amd64"
|
|
cache_part="$cache_path.part"
|
|
mkdir -p "$cache_dir"
|
|
if [ -x "$cache_path" ]; then
|
|
download_action="used-cache"
|
|
"$cache_path" version >"$download_out" 2>"$download_err" || true
|
|
else
|
|
download_action="downloaded"
|
|
resume_args=
|
|
if [ -s "$cache_part" ]; then resume_args="-C -"; fi
|
|
curl -fL --connect-timeout 20 --retry 5 --retry-delay 3 --max-time 600 ${caddyDownloadProxyArgs} $resume_args ${shQuote(exposure.pk01.caddyDownloadUrl)} -o "$cache_part" >"$download_out" 2>"$download_err"
|
|
download_rc=$?
|
|
if [ "$download_rc" -eq 33 ]; then
|
|
rm -f "$cache_part"
|
|
curl -fL --connect-timeout 20 --retry 5 --retry-delay 3 --max-time 600 ${caddyDownloadProxyArgs} ${shQuote(exposure.pk01.caddyDownloadUrl)} -o "$cache_part" >>"$download_out" 2>>"$download_err"
|
|
download_rc=$?
|
|
fi
|
|
if [ "$download_rc" -eq 0 ]; then
|
|
mv "$cache_part" "$cache_path"
|
|
chmod 0755 "$cache_path"
|
|
fi
|
|
fi
|
|
if [ "$download_rc" -eq 0 ]; then
|
|
sudo install -m 0755 "$cache_path" ${shQuote(exposure.pk01.caddyBinaryPath)} >>"$download_out" 2>>"$download_err"
|
|
download_rc=$?
|
|
fi
|
|
else
|
|
rm -f "$HOME/.cache/unidesk/platform-infra/caddy-linux-amd64.part"
|
|
${shQuote(exposure.pk01.caddyBinaryPath)} version >"$download_out" 2>"$download_err" || true
|
|
fi
|
|
install_rc=1
|
|
if [ "$download_rc" -eq 0 ]; then
|
|
sudo mkdir -p "$(dirname ${shQuote(exposure.pk01.caddyConfigPath)})" ${shQuote(exposure.pk01.caddyStorageDir)} /etc/systemd/system
|
|
merged_caddyfile="$tmp/Caddyfile.merged"
|
|
sudo python3 - "$caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} "$merged_caddyfile" ${shQuote(exposure.dns.hostname)} >"$install_out" 2>"$install_err" <<'PY'
|
|
${pk01CaddyMergeManagedBlocksPython()}
|
|
PY
|
|
install_rc=$?
|
|
if [ "$install_rc" -eq 0 ]; then
|
|
sudo install -m 0644 "$merged_caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} >>"$install_out" 2>>"$install_err"
|
|
install_rc=$?
|
|
fi
|
|
if [ "$install_rc" -eq 0 ]; then
|
|
sudo install -m 0644 "$service_unit" /etc/systemd/system/${exposure.pk01.caddyServiceName}.service >>"$install_out" 2>>"$install_err"
|
|
install_rc=$?
|
|
fi
|
|
fi
|
|
validate_rc=1
|
|
if [ "$install_rc" -eq 0 ]; then
|
|
sudo ${shQuote(exposure.pk01.caddyBinaryPath)} validate --config ${shQuote(exposure.pk01.caddyConfigPath)} >"$validate_out" 2>"$validate_err"
|
|
validate_rc=$?
|
|
else
|
|
: >"$validate_out"
|
|
: >"$validate_err"
|
|
fi
|
|
pikanode_rc=1
|
|
if [ "$validate_rc" -eq 0 ]; then
|
|
if docker inspect ${shQuote(exposure.pk01.pikanodeContainerName)} >/dev/null 2>&1; then
|
|
docker rm -f ${shQuote(exposure.pk01.pikanodeContainerName)} >"$pikanode_out" 2>"$pikanode_err"
|
|
pikanode_rc=$?
|
|
else
|
|
: >"$pikanode_out"
|
|
: >"$pikanode_err"
|
|
pikanode_rc=0
|
|
fi
|
|
if [ "$pikanode_rc" -eq 0 ]; then
|
|
docker run -d --name ${shQuote(exposure.pk01.pikanodeContainerName)} \\
|
|
--restart=always \\
|
|
-p 127.0.0.1:${exposure.pk01.pikanodeHttpHostPort}:8888 \\
|
|
-p 22000-22099:22000-22099 \\
|
|
-p 7500:7500 \\
|
|
-v ${shQuote(exposure.pk01.pikanodeRoot)}:/usr/src/app \\
|
|
-v /etc/letsencrypt:/etc/letsencrypt:ro \\
|
|
-w /usr/src/app \\
|
|
${shQuote(exposure.pk01.pikanodeImage)} \\
|
|
sh init.sh >>"$pikanode_out" 2>>"$pikanode_err"
|
|
pikanode_rc=$?
|
|
if [ "$pikanode_rc" -ne 0 ]; then
|
|
printf '%s\\n' '[rollback] restarting pikanode with previous public 80/443 mapping' >>"$pikanode_err"
|
|
docker rm -f ${shQuote(exposure.pk01.pikanodeContainerName)} >>"$pikanode_out" 2>>"$pikanode_err" || true
|
|
docker run -d --name ${shQuote(exposure.pk01.pikanodeContainerName)} \\
|
|
--restart=always \\
|
|
-p 80:8888 \\
|
|
-p 22000-22099:22000-22099 \\
|
|
-p 443:443 \\
|
|
-p 7500:7500 \\
|
|
-v ${shQuote(exposure.pk01.pikanodeRoot)}:/usr/src/app \\
|
|
-v /etc/letsencrypt:/etc/letsencrypt:ro \\
|
|
-w /usr/src/app \\
|
|
${shQuote(exposure.pk01.pikanodeImage)} \\
|
|
sh init.sh >>"$pikanode_out" 2>>"$pikanode_err" || true
|
|
fi
|
|
fi
|
|
else
|
|
: >"$pikanode_out"
|
|
: >"$pikanode_err"
|
|
fi
|
|
caddy_rc=1
|
|
if [ "$pikanode_rc" -eq 0 ]; then
|
|
sudo systemctl daemon-reload >"$caddy_out" 2>"$caddy_err"
|
|
sudo systemctl enable --now ${shQuote(exposure.pk01.caddyServiceName)} >>"$caddy_out" 2>>"$caddy_err"
|
|
caddy_rc=$?
|
|
if [ "$caddy_rc" -eq 0 ]; then
|
|
sudo systemctl reload ${shQuote(exposure.pk01.caddyServiceName)} >>"$caddy_out" 2>>"$caddy_err" || sudo systemctl restart ${shQuote(exposure.pk01.caddyServiceName)} >>"$caddy_out" 2>>"$caddy_err"
|
|
caddy_rc=$?
|
|
fi
|
|
fi
|
|
probe_rc=1
|
|
if [ "$caddy_rc" -eq 0 ]; then
|
|
{
|
|
printf '[pikanode]\\n'
|
|
curl -kfsS --max-time 10 --resolve pikapython.com:443:127.0.0.1 https://pikapython.com/ -o /dev/null -w 'status=%{http_code}\\n'
|
|
printf '[api-health]\\n'
|
|
curl -kfsS --max-time 10 --resolve ${exposure.dns.hostname}:443:127.0.0.1 ${exposure.publicBaseUrl}/health -o /dev/null -w 'status=%{http_code}\\n' || true
|
|
} >"$probe_out" 2>"$probe_err"
|
|
probe_rc=0
|
|
else
|
|
: >"$probe_out"
|
|
: >"$probe_err"
|
|
fi
|
|
python3 - "$download_rc" "$install_rc" "$validate_rc" "$pikanode_rc" "$caddy_rc" "$probe_rc" "$download_action" "$download_proxy_mode" "$download_proxy_url" "$download_out" "$download_err" "$install_out" "$install_err" "$validate_out" "$validate_err" "$pikanode_out" "$pikanode_err" "$caddy_out" "$caddy_err" "$probe_out" "$probe_err" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
download_rc, install_rc, validate_rc, pikanode_rc, caddy_rc, probe_rc = [int(value) for value in sys.argv[1:7]]
|
|
download_action = sys.argv[7]
|
|
download_proxy_mode = sys.argv[8]
|
|
download_proxy_url = sys.argv[9] or None
|
|
paths = sys.argv[10:]
|
|
|
|
def text(path, limit=4000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
payload = {
|
|
"ok": download_rc == 0 and install_rc == 0 and validate_rc == 0 and pikanode_rc == 0 and caddy_rc == 0,
|
|
"target": "${target.id}",
|
|
"mode": "pk01-caddy-frp-direct",
|
|
"publicBaseUrl": "${exposure.publicBaseUrl}",
|
|
"hostname": "${exposure.dns.hostname}",
|
|
"expectedA": "${exposure.dns.expectedA}",
|
|
"dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> D601 frpc -> Sub2API",
|
|
"pikanodeRole": "pikapython.com upstream only; api.pikapython.com does not pass through pikanode Express",
|
|
"caddy": {
|
|
"binaryPath": "${exposure.pk01.caddyBinaryPath}",
|
|
"configPath": "${exposure.pk01.caddyConfigPath}",
|
|
"serviceName": "${exposure.pk01.caddyServiceName}",
|
|
"downloadProxy": {"mode": download_proxy_mode, "url": download_proxy_url or None},
|
|
},
|
|
"frp": {
|
|
"server": "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}",
|
|
"remotePort": ${exposure.frpc.remotePort},
|
|
"proxyName": "${exposure.frpc.proxyName}",
|
|
},
|
|
"steps": {
|
|
"downloadCaddy": {"exitCode": download_rc, "action": download_action, "stdout": text(paths[0]), "stderr": text(paths[1])},
|
|
"installConfig": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
|
|
"validateCaddyConfig": {"exitCode": validate_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
|
|
"restartPikanodeLoopback": {"exitCode": pikanode_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
|
|
"startCaddy": {"exitCode": caddy_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
|
|
"localProbe": {"exitCode": probe_rc, "stdout": text(paths[10]), "stderr": text(paths[11])},
|
|
},
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function renderPk01Caddyfile(exposure: Sub2ApiPublicExposureConfig): string {
|
|
const apexHost = baseDomain(exposure.dns.hostname);
|
|
const apiBlock = renderCaddyManagedBlock(
|
|
sub2apiCaddyManagedMarker,
|
|
renderSimpleReverseProxyCaddySiteBlock({
|
|
hostname: exposure.dns.hostname,
|
|
upstream: `127.0.0.1:${exposure.frpc.remotePort}`,
|
|
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
|
|
}),
|
|
);
|
|
return `{
|
|
email ${exposure.pk01.caddyEmail}
|
|
storage file_system {
|
|
root ${exposure.pk01.caddyStorageDir}
|
|
}
|
|
}
|
|
|
|
${apexHost}, www.${apexHost} {
|
|
reverse_proxy 127.0.0.1:${exposure.pk01.pikanodeHttpHostPort}
|
|
}
|
|
|
|
${apiBlock.trim()}
|
|
`;
|
|
}
|
|
|
|
function renderPk01CaddyService(exposure: Sub2ApiPublicExposureConfig): string {
|
|
return `[Unit]
|
|
Description=UniDesk PK01 Caddy edge for PikaPython and Sub2API
|
|
After=network-online.target docker.service
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=${exposure.pk01.caddyBinaryPath} run --environ --config ${exposure.pk01.caddyConfigPath}
|
|
ExecReload=${exposure.pk01.caddyBinaryPath} reload --config ${exposure.pk01.caddyConfigPath} --force
|
|
Restart=always
|
|
RestartSec=3
|
|
LimitNOFILE=1048576
|
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
NoNewPrivileges=true
|
|
Environment=XDG_DATA_HOME=${exposure.pk01.caddyStorageDir}
|
|
Environment=XDG_CONFIG_HOME=/etc/caddy
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`;
|
|
}
|
|
|
|
function baseDomain(hostname: string): string {
|
|
const parts = hostname.split(".");
|
|
return parts.length <= 2 ? hostname : parts.slice(-2).join(".");
|
|
}
|
|
|
|
function shQuote(value: string): string {
|
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
}
|
|
|
|
function secretRoot(sub2api: Sub2ApiConfig): string {
|
|
const root = sub2api.runtime.secrets.root;
|
|
return isAbsolute(root) ? root : rootPath(root);
|
|
}
|
|
|
|
function requiredEnvValue(values: Record<string, string>, key: string, sourceRef: string): string {
|
|
const value = values[key];
|
|
if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`);
|
|
return value;
|
|
}
|
|
|
|
function parseEnvFile(text: string): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const rawLine of text.split(/\r?\n/u)) {
|
|
const line = rawLine.trim();
|
|
if (line.length === 0 || line.startsWith("#")) continue;
|
|
const eq = line.indexOf("=");
|
|
if (eq <= 0) continue;
|
|
const key = line.slice(0, eq).trim();
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
|
result[key] = unquoteEnvValue(line.slice(eq + 1).trim());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function unquoteEnvValue(value: string): string {
|
|
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) {
|
|
return value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function writeEnvFile(path: string, values: Record<string, string>): void {
|
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
const lines = Object.keys(values)
|
|
.sort()
|
|
.map((key) => `${key}=${quoteEnv(values[key])}`);
|
|
writeFileSync(path, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(path, 0o600);
|
|
}
|
|
|
|
function quoteEnv(value: string): string {
|
|
if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value;
|
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
}
|
|
|
|
function fingerprintValues(values: Record<string, string>, keys: string[]): string {
|
|
const hash = createHash("sha256");
|
|
for (const key of keys) {
|
|
hash.update(key);
|
|
hash.update("\0");
|
|
hash.update(values[key] ?? "");
|
|
hash.update("\0");
|
|
}
|
|
return `sha256:${hash.digest("hex")}`;
|
|
}
|
|
|
|
function redactRepoPath(path: string): string {
|
|
return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path;
|
|
}
|
|
|
|
function dryRunScript(yaml: string, target: Sub2ApiTargetConfig): string {
|
|
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/sub2api.k8s.yaml"
|
|
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
|
client_out="$tmp/client.out"
|
|
client_err="$tmp/client.err"
|
|
server_out="$tmp/server.out"
|
|
server_err="$tmp/server.err"
|
|
kubectl apply --dry-run=client -f "$manifest" >"$client_out" 2>"$client_err"
|
|
client_rc=$?
|
|
if kubectl get namespace ${target.namespace} >/dev/null 2>&1; then
|
|
namespace_exists=true
|
|
kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$manifest" >"$server_out" 2>"$server_err"
|
|
server_rc=$?
|
|
else
|
|
namespace_exists=false
|
|
server_rc=0
|
|
printf '%s\\n' 'server dry-run skipped because namespace does not exist yet; first apply creates it before namespaced resources' >"$server_out"
|
|
: >"$server_err"
|
|
fi
|
|
python3 - "$client_rc" "$server_rc" "$namespace_exists" "$client_out" "$client_err" "$server_out" "$server_err" <<'PY'
|
|
import json
|
|
import sys
|
|
client_rc = int(sys.argv[1])
|
|
server_rc = int(sys.argv[2])
|
|
namespace_exists = sys.argv[3] == "true"
|
|
paths = sys.argv[4:]
|
|
def text(path):
|
|
try:
|
|
return open(path, encoding="utf-8").read()
|
|
except FileNotFoundError:
|
|
return ""
|
|
payload = {
|
|
"ok": client_rc == 0 and server_rc == 0,
|
|
"target": "${target.id}",
|
|
"namespace": "${target.namespace}",
|
|
"namespaceExistsBeforeDryRun": namespace_exists,
|
|
"clientDryRun": {
|
|
"exitCode": client_rc,
|
|
"stdout": text(paths[0])[-4000:],
|
|
"stderr": text(paths[1])[-4000:],
|
|
},
|
|
"serverDryRun": {
|
|
"exitCode": server_rc,
|
|
"disposition": "executed" if namespace_exists else "skipped-namespace-missing",
|
|
"stdout": text(paths[2])[-4000:],
|
|
"stderr": text(paths[3])[-4000:],
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function applyScript(
|
|
sub2api: Sub2ApiConfig,
|
|
yaml: string,
|
|
target: Sub2ApiTargetConfig,
|
|
secretMaterial: ExternalActiveSecretMaterial | null,
|
|
publicExposureSecretMaterial: PublicExposureSecretMaterial | null,
|
|
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
|
|
? ""
|
|
: requiredSecretKeys.map((key) => {
|
|
const value = Buffer.from(secretMaterial.values[key], "utf8").toString("base64");
|
|
return ` printf '%s' '${value}' | base64 -d >"$tmp/secret.${key}"`;
|
|
}).join("\n");
|
|
const secretSourceSummary = secretMaterial === null
|
|
? "None"
|
|
: `json.loads(${JSON.stringify(JSON.stringify({
|
|
sourceRef: secretMaterial.sourceRef,
|
|
sourcePath: secretMaterial.sourcePath,
|
|
appSourceRef: secretMaterial.appSourceRef,
|
|
appSourcePath: secretMaterial.appSourcePath,
|
|
sourceAction: secretMaterial.action,
|
|
fingerprint: secretMaterial.fingerprint,
|
|
valuesPrinted: false,
|
|
}))})`;
|
|
const exposureSecretFile = publicExposureSecretMaterial === null
|
|
? ""
|
|
: ` printf '%s' '${Buffer.from(publicExposureSecretMaterial.frpcToml, "utf8").toString("base64")}' | base64 -d >"$tmp/frpc.toml"`;
|
|
const exposureSecretSummary = publicExposureSecretMaterial === null
|
|
? "None"
|
|
: `json.loads(${JSON.stringify(JSON.stringify({
|
|
sourceRef: publicExposureSecretMaterial.sourceRef,
|
|
sourcePath: publicExposureSecretMaterial.sourcePath,
|
|
secretName: publicExposureSecretMaterial.secretName,
|
|
secretKey: publicExposureSecretMaterial.secretKey,
|
|
fingerprint: publicExposureSecretMaterial.fingerprint,
|
|
valuesPrinted: false,
|
|
}))})`;
|
|
const egressProxySecretFile = egressProxySecretMaterial === null
|
|
? ""
|
|
: ` printf '%s' '${Buffer.from(egressProxySecretMaterial.configJson, "utf8").toString("base64")}' | base64 -d >"$tmp/egress-proxy-config.json"`;
|
|
const egressProxySecretSummary = egressProxySecretMaterial === null
|
|
? "None"
|
|
: `json.loads(${JSON.stringify(JSON.stringify({
|
|
sourceRef: egressProxySecretMaterial.sourceRef,
|
|
sourcePath: egressProxySecretMaterial.sourcePath,
|
|
secretName: egressProxySecretMaterial.secretName,
|
|
secretKey: egressProxySecretMaterial.secretKey,
|
|
fingerprint: egressProxySecretMaterial.fingerprint,
|
|
subscriptionBytes: egressProxySecretMaterial.subscriptionBytes,
|
|
selectedOutbound: egressProxySecretMaterial.selectedOutbound,
|
|
proxyUrl: egressProxySecretMaterial.proxyUrl,
|
|
noProxy: egressProxySecretMaterial.noProxy,
|
|
valuesPrinted: false,
|
|
}))})`;
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/sub2api.k8s.yaml"
|
|
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
|
ns_out="$tmp/ns.out"
|
|
ns_err="$tmp/ns.err"
|
|
secret_out="$tmp/secret.out"
|
|
secret_err="$tmp/secret.err"
|
|
exposure_secret_out="$tmp/exposure-secret.out"
|
|
exposure_secret_err="$tmp/exposure-secret.err"
|
|
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"
|
|
secret_rc=0
|
|
exposure_secret_action="not-enabled"
|
|
exposure_secret_rc=0
|
|
egress_secret_action="not-enabled"
|
|
egress_secret_rc=0
|
|
if [ "$ns_rc" -eq 0 ]; then
|
|
if [ "${target.databaseMode}" = "external-pending" ]; then
|
|
secret_action="external-pending-not-managed"
|
|
: >"$secret_out"
|
|
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} \\
|
|
--from-file=POSTGRES_PASSWORD="$tmp/secret.POSTGRES_PASSWORD" \\
|
|
--from-file=ADMIN_PASSWORD="$tmp/secret.ADMIN_PASSWORD" \\
|
|
--from-file=JWT_SECRET="$tmp/secret.JWT_SECRET" \\
|
|
--from-file=TOTP_ENCRYPTION_KEY="$tmp/secret.TOTP_ENCRYPTION_KEY" \\
|
|
--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
|
|
secret_action="kept-existing"
|
|
: >"$secret_out"
|
|
: >"$secret_err"
|
|
else
|
|
rand_hex() {
|
|
bytes="$1"
|
|
if command -v openssl >/dev/null 2>&1; then
|
|
openssl rand -hex "$bytes"
|
|
else
|
|
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} \\
|
|
--from-literal=POSTGRES_PASSWORD="$(rand_hex 32)" \\
|
|
--from-literal=ADMIN_PASSWORD="$(rand_hex 16)" \\
|
|
--from-literal=JWT_SECRET="$(rand_hex 32)" \\
|
|
--from-literal=TOTP_ENCRYPTION_KEY="$(rand_hex 32)" \\
|
|
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$secret_out" 2>"$secret_err"
|
|
secret_rc=$?
|
|
secret_action="created"
|
|
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" \\
|
|
--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"
|
|
else
|
|
: >"$exposure_secret_out"
|
|
: >"$exposure_secret_err"
|
|
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" \\
|
|
--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"
|
|
else
|
|
: >"$egress_secret_out"
|
|
: >"$egress_secret_err"
|
|
fi
|
|
fi
|
|
apply_rc=1
|
|
if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ] && [ "$exposure_secret_rc" -eq 0 ] && [ "$egress_secret_rc" -eq 0 ]; then
|
|
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err"
|
|
apply_rc=$?
|
|
else
|
|
: >"$apply_out"
|
|
printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, or egress proxy secret step failed' >"$apply_err"
|
|
fi
|
|
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])
|
|
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])
|
|
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 and cleanup_rc == 0,
|
|
"target": "${target.id}",
|
|
"namespace": "${target.namespace}",
|
|
"databaseMode": "${target.databaseMode}",
|
|
"appReplicas": ${target.appReplicas},
|
|
"redisReplicas": ${target.redisReplicas},
|
|
"secret": {
|
|
"name": "${secretName}",
|
|
"action": secret_action,
|
|
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
|
|
"managedByThisApply": ${target.databaseMode === "external-pending" ? "False" : "True"},
|
|
"externalActiveSource": ${secretSourceSummary},
|
|
"valuesPrinted": False,
|
|
},
|
|
"publicExposure": ${exposureSecretSummary},
|
|
"egressProxy": ${egressProxySecretSummary},
|
|
"steps": {
|
|
"namespace": {"exitCode": ns_rc, "stdout": text(paths[0])[-4000:], "stderr": text(paths[1])[-4000:]},
|
|
"secret": {"exitCode": secret_rc, "stdout": text(paths[2])[-4000:], "stderr": text(paths[3])[-4000:]},
|
|
"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))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function statusScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const expectedImage = imageRef(sub2api, target);
|
|
const expectedUrlAllowlist = sub2api.security.urlAllowlist;
|
|
const externalPending = target.databaseMode === "external-pending";
|
|
const externalActive = target.databaseMode === "external-active";
|
|
const proxy = target.egressProxy?.enabled ? target.egressProxy : null;
|
|
const expectedProxyEnv = sub2ApiProxyEnv(target);
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
capture_json() {
|
|
name="$1"
|
|
shift
|
|
"$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err"
|
|
rc=$?
|
|
printf '%s' "$rc" >"$tmp/$name.rc"
|
|
}
|
|
capture_json ns kubectl get namespace ${target.namespace}
|
|
capture_json deployments kubectl -n ${target.namespace} get deployments -l app.kubernetes.io/part-of=platform-infra
|
|
capture_json statefulsets kubectl -n ${target.namespace} get statefulsets -l app.kubernetes.io/part-of=platform-infra
|
|
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 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
|
|
pod_name="$(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/pod-name.err" || true)"
|
|
printf '%s' "$pod_name" >"$tmp/pod-name.txt"
|
|
if [ -n "$pod_name" ]; then
|
|
kubectl -n ${target.namespace} exec "$pod_name" -- sh -c 'printf "SECURITY_URL_ALLOWLIST_ENABLED=%s\\n" "$SECURITY_URL_ALLOWLIST_ENABLED"; printf "SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=%s\\n" "$SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP"; printf "SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=%s\\n" "$SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS"; printf "SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=%s\\n" "$SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS"; printf "HTTP_PROXY=%s\\n" "$HTTP_PROXY"; printf "HTTPS_PROXY=%s\\n" "$HTTPS_PROXY"; printf "ALL_PROXY=%s\\n" "$ALL_PROXY"; printf "NO_PROXY=%s\\n" "$NO_PROXY"' >"$tmp/pod-env.out" 2>"$tmp/pod-env.err"
|
|
printf '%s' "$?" >"$tmp/pod-env.rc"
|
|
else
|
|
: >"$tmp/pod-env.out"
|
|
printf '%s\\n' 'sub2api app pod not found' >"$tmp/pod-env.err"
|
|
printf '%s' "1" >"$tmp/pod-env.rc"
|
|
fi
|
|
python3 - "$tmp" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
tmp = sys.argv[1]
|
|
|
|
def rc(name):
|
|
try:
|
|
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
|
except FileNotFoundError:
|
|
return 1
|
|
|
|
def load(name):
|
|
path = os.path.join(tmp, f"{name}.json")
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
return json.load(open(path, encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def items(name):
|
|
data = load(name)
|
|
if not isinstance(data, dict):
|
|
return []
|
|
return data.get("items") or []
|
|
|
|
def text(name):
|
|
path = os.path.join(tmp, name)
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
def deployment_summary(item):
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
desired = spec.get("replicas", 1)
|
|
available = status.get("availableReplicas", 0)
|
|
init_containers = ((spec.get("template") or {}).get("spec") or {}).get("initContainers", [])
|
|
containers = ((spec.get("template") or {}).get("spec") or {}).get("containers", [])
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"desired": desired,
|
|
"readyReplicas": status.get("readyReplicas", 0),
|
|
"availableReplicas": available,
|
|
"updatedReplicas": status.get("updatedReplicas", 0),
|
|
"ready": available >= desired,
|
|
"images": [c.get("image") for c in containers],
|
|
"initImages": [c.get("image") for c in init_containers],
|
|
}
|
|
|
|
def statefulset_summary(item):
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
desired = spec.get("replicas", 1)
|
|
ready = status.get("readyReplicas", 0)
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"desired": desired,
|
|
"readyReplicas": ready,
|
|
"currentReplicas": status.get("currentReplicas", 0),
|
|
"updatedReplicas": status.get("updatedReplicas", 0),
|
|
"ready": ready >= desired,
|
|
"images": [c.get("image") for c in ((spec.get("template") or {}).get("spec") or {}).get("containers", [])],
|
|
}
|
|
|
|
def pod_summary(item):
|
|
status = item.get("status") or {}
|
|
container_statuses = status.get("containerStatuses") or []
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"phase": status.get("phase"),
|
|
"ready": all((cs.get("ready") is True) for cs in container_statuses) if container_statuses else False,
|
|
"restarts": sum(int(cs.get("restartCount") or 0) for cs in container_statuses),
|
|
"nodeName": (item.get("spec") or {}).get("nodeName"),
|
|
"containers": [
|
|
{
|
|
"name": cs.get("name"),
|
|
"ready": cs.get("ready"),
|
|
"restartCount": cs.get("restartCount"),
|
|
"image": cs.get("image"),
|
|
"state": list((cs.get("state") or {}).keys()),
|
|
"stateDetail": cs.get("state") or {},
|
|
}
|
|
for cs in container_statuses
|
|
],
|
|
"initContainers": [
|
|
{
|
|
"name": cs.get("name"),
|
|
"ready": cs.get("ready"),
|
|
"restartCount": cs.get("restartCount"),
|
|
"image": cs.get("image"),
|
|
"state": list((cs.get("state") or {}).keys()),
|
|
"stateDetail": cs.get("state") or {},
|
|
}
|
|
for cs in (status.get("initContainerStatuses") or [])
|
|
],
|
|
"conditions": [
|
|
{
|
|
"type": condition.get("type"),
|
|
"status": condition.get("status"),
|
|
"reason": condition.get("reason"),
|
|
"message": condition.get("message"),
|
|
}
|
|
for condition in status.get("conditions", [])
|
|
],
|
|
}
|
|
|
|
def service_summary(item):
|
|
spec = item.get("spec") or {}
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"type": spec.get("type", "ClusterIP"),
|
|
"clusterIP": spec.get("clusterIP"),
|
|
"ports": [
|
|
{
|
|
"name": p.get("name"),
|
|
"port": p.get("port"),
|
|
"targetPort": p.get("targetPort"),
|
|
"nodePort": p.get("nodePort"),
|
|
}
|
|
for p in spec.get("ports", [])
|
|
],
|
|
}
|
|
|
|
def pvc_summary(item):
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
req = (spec.get("resources") or {}).get("requests") or {}
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"phase": status.get("phase"),
|
|
"storageClassName": spec.get("storageClassName"),
|
|
"requestedStorage": req.get("storage"),
|
|
}
|
|
|
|
def network_policy_summary(item):
|
|
spec = item.get("spec") or {}
|
|
return {
|
|
"name": item["metadata"]["name"],
|
|
"podSelector": spec.get("podSelector"),
|
|
"policyTypes": spec.get("policyTypes") or [],
|
|
"ingress": spec.get("ingress") or [],
|
|
"egress": spec.get("egress") or [],
|
|
}
|
|
|
|
def is_allow_all_network_policy(item):
|
|
spec = item.get("spec") or {}
|
|
return (
|
|
item.get("metadata", {}).get("name") == "allow-all"
|
|
and spec.get("podSelector") == {}
|
|
and set(spec.get("policyTypes") or []) == {"Ingress", "Egress"}
|
|
and spec.get("ingress") == [{}]
|
|
and spec.get("egress") == [{}]
|
|
)
|
|
|
|
def resource_findings(kind, collection):
|
|
findings = []
|
|
for item in collection:
|
|
spec = item.get("spec") or {}
|
|
template_spec = ((spec.get("template") or {}).get("spec") or {})
|
|
if template_spec.get("hostNetwork") is True:
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "field": "hostNetwork"})
|
|
all_containers = [(container, "containers") for container in template_spec.get("containers", [])] + [(container, "initContainers") for container in template_spec.get("initContainers", [])]
|
|
for container, container_group in all_containers:
|
|
resources = container.get("resources") or {}
|
|
if resources.get("requests"):
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "resources.requests"})
|
|
if resources.get("limits"):
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "resources.limits"})
|
|
for port in container.get("ports", []):
|
|
if "hostPort" in port:
|
|
findings.append({"kind": kind, "name": item["metadata"]["name"], "container": container.get("name"), "containerGroup": container_group, "field": "hostPort", "value": port.get("hostPort")})
|
|
return findings
|
|
|
|
deployments = items("deployments")
|
|
statefulsets = items("statefulsets")
|
|
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 {}
|
|
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]
|
|
external_pending = ${externalPending ? "True" : "False"}
|
|
external_active = ${externalActive ? "True" : "False"}
|
|
expected_app_replicas = ${target.appReplicas}
|
|
expected_redis_replicas = ${target.redisReplicas}
|
|
service_violations = []
|
|
for svc in services:
|
|
spec = svc.get("spec") or {}
|
|
if spec.get("type", "ClusterIP") != "ClusterIP":
|
|
service_violations.append({"name": svc["metadata"]["name"], "type": spec.get("type")})
|
|
for port in spec.get("ports", []):
|
|
if "nodePort" in port:
|
|
service_violations.append({"name": svc["metadata"]["name"], "nodePort": port.get("nodePort")})
|
|
resource_violations = resource_findings("Deployment", deployments) + resource_findings("StatefulSet", statefulsets)
|
|
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)
|
|
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", [])
|
|
url_allowlist_runtime = {
|
|
"enabled": configmap_data.get("SECURITY_URL_ALLOWLIST_ENABLED"),
|
|
"allowInsecureHttp": configmap_data.get("SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP"),
|
|
"allowPrivateHosts": configmap_data.get("SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS"),
|
|
"upstreamHosts": configmap_data.get("SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS"),
|
|
}
|
|
pod_env = {}
|
|
for line in text("pod-env.out").splitlines():
|
|
if "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
pod_env[key] = value
|
|
url_allowlist_pod_env = {
|
|
"enabled": pod_env.get("SECURITY_URL_ALLOWLIST_ENABLED"),
|
|
"allowInsecureHttp": pod_env.get("SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP"),
|
|
"allowPrivateHosts": pod_env.get("SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS"),
|
|
"upstreamHosts": pod_env.get("SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS"),
|
|
}
|
|
expected_url_allowlist_strings = {
|
|
"enabled": str(expected_url_allowlist.get("enabled")).lower(),
|
|
"allowInsecureHttp": str(expected_url_allowlist.get("allowInsecureHttp")).lower(),
|
|
"allowPrivateHosts": str(expected_url_allowlist.get("allowPrivateHosts")).lower(),
|
|
"upstreamHosts": ",".join(expected_url_allowlist.get("upstreamHosts") or []),
|
|
}
|
|
expected_proxy_env = json.loads(${JSON.stringify(JSON.stringify({
|
|
enabled: proxy !== null && proxy.applyToSub2Api,
|
|
httpProxy: expectedProxyEnv.httpProxy,
|
|
noProxy: expectedProxyEnv.noProxy,
|
|
deploymentName: proxy?.deploymentName ?? null,
|
|
serviceName: proxy?.serviceName ?? null,
|
|
}))})
|
|
url_allowlist_configmap_aligned = url_allowlist_runtime == expected_url_allowlist_strings
|
|
url_allowlist_pod_env_aligned = rc("pod-env") == 0 and url_allowlist_pod_env == expected_url_allowlist_strings
|
|
url_allowlist_aligned = url_allowlist_configmap_aligned and (url_allowlist_pod_env_aligned or (external_pending and expected_app_replicas == 0))
|
|
proxy_env_runtime = {
|
|
"HTTP_PROXY": pod_env.get("HTTP_PROXY"),
|
|
"HTTPS_PROXY": pod_env.get("HTTPS_PROXY"),
|
|
"ALL_PROXY": pod_env.get("ALL_PROXY"),
|
|
"NO_PROXY": pod_env.get("NO_PROXY"),
|
|
}
|
|
if expected_proxy_env.get("enabled"):
|
|
proxy_env_aligned = (
|
|
rc("pod-env") == 0
|
|
and proxy_env_runtime.get("HTTP_PROXY") == expected_proxy_env.get("httpProxy")
|
|
and proxy_env_runtime.get("HTTPS_PROXY") == expected_proxy_env.get("httpProxy")
|
|
and proxy_env_runtime.get("ALL_PROXY") == expected_proxy_env.get("httpProxy")
|
|
and proxy_env_runtime.get("NO_PROXY") == expected_proxy_env.get("noProxy")
|
|
)
|
|
else:
|
|
proxy_env_aligned = True
|
|
allow_all_network_policy = next((item for item in networkpolicies if item.get("metadata", {}).get("name") == "allow-all"), None)
|
|
network_policy = {
|
|
"requiredName": "allow-all",
|
|
"exists": allow_all_network_policy is not None,
|
|
"ok": allow_all_network_policy is not None and is_allow_all_network_policy(allow_all_network_policy),
|
|
"policies": [network_policy_summary(item) for item in networkpolicies],
|
|
}
|
|
boundary = {
|
|
"internalOnly": len(service_violations) == 0 and len(items("ingresses")) == 0,
|
|
"serviceViolations": service_violations,
|
|
"ingressCount": len(items("ingresses")),
|
|
"resourceQuotaCount": len(items("quotas")),
|
|
"limitRangeCount": len(items("limitranges")),
|
|
"resourceViolations": resource_violations,
|
|
}
|
|
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)
|
|
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:
|
|
state_model_ok = (
|
|
not local_postgres_present
|
|
and not redis_pvc_present
|
|
and sub2api_desired_aligned
|
|
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 = (
|
|
not local_postgres_present
|
|
and not redis_pvc_present
|
|
and sub2api_desired_aligned
|
|
and redis_desired_aligned
|
|
and expected_app_replicas == 1
|
|
and expected_redis_replicas == 1
|
|
)
|
|
else:
|
|
state_model_ok = local_postgres_present and sub2api_desired_aligned and redis_desired_aligned
|
|
status_label = "pending-external-db" if external_pending else "external-db-active" if external_active else "active"
|
|
payload = {
|
|
"ok": rc("ns") == 0 and workload_ready and image_aligned and url_allowlist_aligned and proxy_env_aligned and network_policy["ok"] and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and secret_ok and state_model_ok,
|
|
"target": "${target.id}",
|
|
"route": "${target.route}",
|
|
"namespace": "${target.namespace}",
|
|
"status": status_label,
|
|
"databaseMode": "${target.databaseMode}",
|
|
"redisMode": "${target.redisMode}",
|
|
"expectedAppReplicas": expected_app_replicas,
|
|
"expectedRedisReplicas": expected_redis_replicas,
|
|
"namespaceExists": rc("ns") == 0,
|
|
"deployments": deployment_summaries,
|
|
"statefulsets": statefulset_summaries,
|
|
"pods": [pod_summary(item) for item in pods],
|
|
"services": [service_summary(item) for item in services],
|
|
"pvcs": [pvc_summary(item) for item in pvcs],
|
|
"networkPolicy": network_policy,
|
|
"secret": {
|
|
"name": "${secretName}",
|
|
"exists": rc("secrets") == 0,
|
|
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
|
|
"missingKeys": missing_secret_keys,
|
|
"ready": secret_ready,
|
|
"requiredForPredeployOk": not external_pending,
|
|
"valuesPrinted": False,
|
|
},
|
|
"stateModel": {
|
|
"localPostgresPresent": local_postgres_present,
|
|
"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": {
|
|
"desiredImage": expected_image,
|
|
"configPath": "config/platform-infra/sub2api.yaml",
|
|
"aligned": image_aligned,
|
|
"runningImages": sub2api_deployment.get("images", []) if sub2api_deployment else [],
|
|
},
|
|
"security": {
|
|
"urlAllowlist": {
|
|
"configPath": "config/platform-infra/sub2api.yaml",
|
|
"expected": expected_url_allowlist,
|
|
"runtime": url_allowlist_runtime,
|
|
"podEnv": url_allowlist_pod_env,
|
|
"aligned": url_allowlist_aligned,
|
|
"configMapAligned": url_allowlist_configmap_aligned,
|
|
"podEnvAligned": url_allowlist_pod_env_aligned,
|
|
"podEnvRequired": not (external_pending and expected_app_replicas == 0),
|
|
"configMapExists": rc("configmap") == 0,
|
|
"podEnvProbe": {
|
|
"podName": text("pod-name.txt"),
|
|
"exitCode": rc("pod-env"),
|
|
"stderr": text("pod-env.err")[-1000:],
|
|
},
|
|
}
|
|
},
|
|
"egressProxy": {
|
|
"enabled": expected_proxy_env.get("enabled"),
|
|
"deploymentName": expected_proxy_env.get("deploymentName"),
|
|
"serviceName": expected_proxy_env.get("serviceName"),
|
|
"expectedHttpProxy": expected_proxy_env.get("httpProxy"),
|
|
"expectedNoProxy": expected_proxy_env.get("noProxy"),
|
|
"podEnv": proxy_env_runtime,
|
|
"aligned": proxy_env_aligned,
|
|
"valuesPrinted": False,
|
|
},
|
|
"boundary": boundary,
|
|
"serviceDns": "${serviceName}.${target.namespace}.svc.cluster.local:8080",
|
|
"next": {
|
|
"apply": "bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm",
|
|
"validate": "bun scripts/cli.ts platform-infra sub2api validate --target ${target.id}",
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function validateScript(target: Sub2ApiTargetConfig): string {
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
probe_suffix="$(date +%s)-$$"
|
|
pg_probe="unidesk-sub2api-netcheck-pg-$probe_suffix"
|
|
redis_probe="unidesk-sub2api-netcheck-redis-$probe_suffix"
|
|
cleanup() {
|
|
kubectl -n ${target.namespace} delete pod "$pg_probe" "$redis_probe" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true
|
|
rm -rf "$tmp"
|
|
}
|
|
trap cleanup EXIT
|
|
kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/health >"$tmp/health.body" 2>"$tmp/health.err"
|
|
health_rc=$?
|
|
kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/ >"$tmp/root.body" 2>"$tmp/root.err"
|
|
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")"
|
|
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=$?
|
|
else
|
|
pg_rc=1
|
|
printf '%s\\n' 'sub2api postgres pod not found' >"$tmp/pg.err"
|
|
fi
|
|
if [ -n "$redis_pod" ]; then
|
|
kubectl -n ${target.namespace} exec "$redis_pod" -- redis-cli ping >"$tmp/redis.out" 2>"$tmp/redis.err"
|
|
redis_rc=$?
|
|
else
|
|
redis_rc=1
|
|
printf '%s\\n' 'sub2api redis pod not found' >"$tmp/redis.err"
|
|
fi
|
|
if ! command -v timeout >/dev/null 2>&1; then
|
|
printf '%s\\n' 'timeout command is required for bounded cross-pod probes' >"$tmp/pg-cross.err"
|
|
printf '%s\\n' 'timeout command is required for bounded cross-pod probes' >"$tmp/redis-cross.err"
|
|
: >"$tmp/pg-cross.out"
|
|
: >"$tmp/redis-cross.out"
|
|
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"
|
|
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"
|
|
redis_cross_rc=$?
|
|
fi
|
|
python3 - "$tmp" "$health_rc" "$root_rc" "$pg_rc" "$redis_rc" "$network_policy_rc" "$pg_cross_rc" "$redis_cross_rc" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
tmp = sys.argv[1]
|
|
health_rc, root_rc, pg_rc, redis_rc, network_policy_rc, pg_cross_rc, redis_cross_rc = [int(value) for value in sys.argv[2:]]
|
|
|
|
def text(name, limit=4000):
|
|
path = os.path.join(tmp, name)
|
|
try:
|
|
data = open(path, encoding="utf-8", errors="replace").read()
|
|
except FileNotFoundError:
|
|
return ""
|
|
return data[-limit:]
|
|
|
|
def json_file(name):
|
|
path = os.path.join(tmp, name)
|
|
try:
|
|
return json.load(open(path, encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
return None
|
|
|
|
def is_allow_all_network_policy(item):
|
|
if not isinstance(item, dict):
|
|
return False
|
|
spec = item.get("spec") or {}
|
|
return (
|
|
item.get("metadata", {}).get("name") == "allow-all"
|
|
and spec.get("podSelector") == {}
|
|
and set(spec.get("policyTypes") or []) == {"Ingress", "Egress"}
|
|
and spec.get("ingress") == [{}]
|
|
and spec.get("egress") == [{}]
|
|
)
|
|
|
|
health_body = text("health.body", 2000)
|
|
root_body = text("root.body", 2000)
|
|
network_policy_obj = json_file("network-policy.json")
|
|
network_policy_ok = network_policy_rc == 0 and is_allow_all_network_policy(network_policy_obj)
|
|
payload = {
|
|
"ok": health_rc == 0 and root_rc == 0 and pg_rc == 0 and redis_rc == 0 and network_policy_ok and pg_cross_rc == 0 and redis_cross_rc == 0,
|
|
"target": "${target.id}",
|
|
"namespace": "${target.namespace}",
|
|
"serviceDns": "${serviceName}.${target.namespace}.svc.cluster.local:8080",
|
|
"checks": {
|
|
"allowAllNetworkPolicy": {
|
|
"exitCode": network_policy_rc,
|
|
"ok": network_policy_ok,
|
|
"method": "kubectl -n ${target.namespace} get networkpolicy allow-all -o json",
|
|
"policyTypes": ((network_policy_obj or {}).get("spec") or {}).get("policyTypes") if isinstance(network_policy_obj, dict) else None,
|
|
"podSelector": ((network_policy_obj or {}).get("spec") or {}).get("podSelector") if isinstance(network_policy_obj, dict) else None,
|
|
"ingress": ((network_policy_obj or {}).get("spec") or {}).get("ingress") if isinstance(network_policy_obj, dict) else None,
|
|
"egress": ((network_policy_obj or {}).get("spec") or {}).get("egress") if isinstance(network_policy_obj, dict) else None,
|
|
"stderr": text("network-policy.err", 2000),
|
|
},
|
|
"sub2apiHealthViaKubernetesServiceProxy": {
|
|
"exitCode": health_rc,
|
|
"method": "kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/health",
|
|
"bodyPreview": health_body,
|
|
"stderr": text("health.err", 2000),
|
|
},
|
|
"sub2apiRootViaKubernetesServiceProxy": {
|
|
"exitCode": root_rc,
|
|
"method": "kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/",
|
|
"bodyBytes": len(root_body.encode("utf-8")),
|
|
"bodyPreview": root_body[:400],
|
|
"stderr": text("root.err", 2000),
|
|
},
|
|
"postgresPgIsReady": {
|
|
"exitCode": pg_rc,
|
|
"stdout": text("pg.out", 2000),
|
|
"stderr": text("pg.err", 2000),
|
|
},
|
|
"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",
|
|
"stdout": text("pg-cross.out", 2000),
|
|
"stderr": text("pg-cross.err", 2000),
|
|
},
|
|
"redisPing": {
|
|
"exitCode": redis_rc,
|
|
"stdout": text("redis.out", 2000),
|
|
"stderr": text("redis.err", 2000),
|
|
},
|
|
"redisCrossPodPing": {
|
|
"exitCode": redis_cross_rc,
|
|
"method": "temporary redis:8-alpine pod redis-cli -h sub2api-redis -p 6379 ping, bounded by outer timeout",
|
|
"stdout": text("redis-cross.out", 2000),
|
|
"stderr": text("redis-cross.err", 2000),
|
|
},
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function validateExternalActiveScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const database = sub2api.runtime.database;
|
|
const redisService = sub2api.runtime.redis.serviceName;
|
|
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}`;
|
|
const publicProbeBlock = exposure === null ? `
|
|
public_dns_rc=0
|
|
public_probe_rc=0
|
|
: >"$tmp/public-dns.out"
|
|
: >"$tmp/public-dns.err"
|
|
: >"$tmp/public-probe.out"
|
|
: >"$tmp/public-probe.err"
|
|
` : `
|
|
public_dns_rc=1
|
|
{
|
|
for resolver in ${exposure.dns.resolvers.map((resolver) => shQuote(resolver)).join(" ")}; do
|
|
printf 'resolver=%s\\n' "$resolver"
|
|
dig +short "@$resolver" ${shQuote(exposure.dns.hostname)} A || true
|
|
done
|
|
} >"$tmp/public-dns.out" 2>"$tmp/public-dns.err"
|
|
if grep -Fx ${shQuote(exposure.dns.expectedA)} "$tmp/public-dns.out" >/dev/null 2>&1; then public_dns_rc=0; fi
|
|
curl -fsS --max-time 20 ${shQuote(`${exposure.publicBaseUrl}/health`)} >"$tmp/public-probe.out" 2>"$tmp/public-probe.err"
|
|
public_probe_rc=$?
|
|
`;
|
|
const egressProbeBlock = proxy === null ? `
|
|
egress_deploy_rc=0
|
|
egress_svc_rc=0
|
|
egress_probe_rc=0
|
|
: >"$tmp/egress-deploy.json"
|
|
: >"$tmp/egress-deploy.err"
|
|
: >"$tmp/egress-svc.json"
|
|
: >"$tmp/egress-svc.err"
|
|
: >"$tmp/egress-probe.out"
|
|
: >"$tmp/egress-probe.err"
|
|
` : `
|
|
capture_json egress-deploy kubectl -n ${target.namespace} get deployment ${proxy.deploymentName}
|
|
egress_deploy_rc=$(cat "$tmp/egress-deploy.rc")
|
|
capture_json egress-svc kubectl -n ${target.namespace} get service ${proxy.serviceName}
|
|
egress_svc_rc=$(cat "$tmp/egress-svc.rc")
|
|
if [ -n "$app_pod" ]; then
|
|
kubectl -n ${target.namespace} exec "$app_pod" -- sh -c 'HTTP_PROXY="$1" HTTPS_PROXY="$1" ALL_PROXY="$1" NO_PROXY="$2" curl -fsS --max-time 20 -o /dev/null -w "status=%{http_code}\\n" "$3"' sh ${shQuote(proxyUrl)} ${shQuote(proxy.noProxy.join(","))} ${shQuote(proxy.healthProbeUrl)} >"$tmp/egress-probe.out" 2>"$tmp/egress-probe.err"
|
|
egress_probe_rc=$?
|
|
else
|
|
egress_probe_rc=1
|
|
printf '%s\\n' 'sub2api app pod not found' >"$tmp/egress-probe.err"
|
|
fi
|
|
`;
|
|
const egressProbeJson = proxy === null ? "None" : `{
|
|
"enabled": True,
|
|
"mode": "master-vpn-subscription-http-proxy",
|
|
"deploymentName": "${proxy.deploymentName}",
|
|
"serviceName": "${proxy.serviceName}",
|
|
"proxyUrl": "${proxyUrl}",
|
|
"healthProbeUrl": "${proxy.healthProbeUrl}",
|
|
"applyToSub2Api": ${proxy.applyToSub2Api ? "True" : "False"},
|
|
"applyToSentinel": ${proxy.applyToSentinel ? "True" : "False"},
|
|
"deployment": {
|
|
"exitCode": egress_deploy_rc,
|
|
"ready": deployment_ready(load("egress-deploy")),
|
|
"stderr": text("egress-deploy.err", 2000),
|
|
},
|
|
"service": {
|
|
"exitCode": egress_svc_rc,
|
|
"type": ((load("egress-svc") or {}).get("spec") or {}).get("type"),
|
|
"clusterIP": ((load("egress-svc") or {}).get("spec") or {}).get("clusterIP"),
|
|
"stderr": text("egress-svc.err", 2000),
|
|
},
|
|
"probe": {
|
|
"exitCode": egress_probe_rc,
|
|
"stdout": text("egress-probe.out", 2000),
|
|
"stderr": text("egress-probe.err", 2000),
|
|
},
|
|
"valuesPrinted": False,
|
|
}`;
|
|
const egressProbeOkExpr = proxy === null ? "True" : "(egress_deploy_rc == 0 and egress_svc_rc == 0 and deployment_ready(load('egress-deploy')) and egress_probe_rc == 0)";
|
|
const publicProbeJson = exposure === null ? "None" : `{
|
|
"enabled": True,
|
|
"publicBaseUrl": "${exposure.publicBaseUrl}",
|
|
"hostname": "${exposure.dns.hostname}",
|
|
"expectedA": "${exposure.dns.expectedA}",
|
|
"mode": "pk01-caddy-frp-direct",
|
|
"dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> D601 frpc -> Sub2API",
|
|
"dns": {
|
|
"exitCode": public_dns_rc,
|
|
"resolvers": ${JSON.stringify(exposure.dns.resolvers)},
|
|
"stdout": text("public-dns.out", 2000),
|
|
"stderr": text("public-dns.err", 2000),
|
|
},
|
|
"health": {
|
|
"exitCode": public_probe_rc,
|
|
"url": "${exposure.publicBaseUrl}/health",
|
|
"stdout": text("public-probe.out", 2000),
|
|
"stderr": text("public-probe.err", 2000),
|
|
},
|
|
}`;
|
|
const publicProbeOkExpr = exposure === null ? "True" : "(public_dns_rc == 0 and public_probe_rc == 0)";
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
capture_json() {
|
|
name="$1"
|
|
shift
|
|
"$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err"
|
|
rc=$?
|
|
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 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)"
|
|
redis_pod="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=${redisService},app.kubernetes.io/component=redis -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/redis-pod.err" || true)"
|
|
printf '%s' "$app_pod" >"$tmp/app-pod.txt"
|
|
printf '%s' "$redis_pod" >"$tmp/redis-pod.txt"
|
|
kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/health >"$tmp/health.body" 2>"$tmp/health.err"
|
|
health_rc=$?
|
|
kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/ >"$tmp/root.body" 2>"$tmp/root.err"
|
|
root_rc=$?
|
|
if [ -n "$app_pod" ]; then
|
|
kubectl -n ${target.namespace} exec "$app_pod" -- sh -c 'printf "DATABASE_HOST=%s\\n" "$DATABASE_HOST"; printf "DATABASE_PORT=%s\\n" "$DATABASE_PORT"; printf "DATABASE_USER=%s\\n" "$DATABASE_USER"; printf "DATABASE_DBNAME=%s\\n" "$DATABASE_DBNAME"; printf "DATABASE_SSLMODE=%s\\n" "$DATABASE_SSLMODE"; printf "REDIS_HOST=%s\\n" "$REDIS_HOST"; if [ -n "$DATABASE_PASSWORD" ]; then printf "DATABASE_PASSWORD_PRESENT=true\\n"; else printf "DATABASE_PASSWORD_PRESENT=false\\n"; fi' >"$tmp/app-env.out" 2>"$tmp/app-env.err"
|
|
printf '%s' "$?" >"$tmp/app-env.rc"
|
|
else
|
|
: >"$tmp/app-env.out"
|
|
printf '%s\\n' 'sub2api app pod not found' >"$tmp/app-env.err"
|
|
printf '%s' "1" >"$tmp/app-env.rc"
|
|
fi
|
|
if [ -n "$redis_pod" ]; then
|
|
kubectl -n ${target.namespace} exec "$redis_pod" -- redis-cli ping >"$tmp/redis.out" 2>"$tmp/redis.err"
|
|
redis_rc=$?
|
|
else
|
|
redis_rc=1
|
|
printf '%s\\n' 'sub2api redis pod not found' >"$tmp/redis.err"
|
|
fi
|
|
${publicProbeBlock}
|
|
${egressProbeBlock}
|
|
python3 - "$tmp" "$health_rc" "$root_rc" "$redis_rc" "$public_dns_rc" "$public_probe_rc" "$egress_deploy_rc" "$egress_svc_rc" "$egress_probe_rc" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
tmp = sys.argv[1]
|
|
health_rc, root_rc, redis_rc, public_dns_rc, public_probe_rc, egress_deploy_rc, egress_svc_rc, egress_probe_rc = [int(value) for value in sys.argv[2:]]
|
|
|
|
def rc(name):
|
|
try:
|
|
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
|
except FileNotFoundError:
|
|
return 1
|
|
|
|
def load(name):
|
|
path = os.path.join(tmp, f"{name}.json")
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
return json.load(open(path, encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def items(name):
|
|
data = load(name)
|
|
if not isinstance(data, dict):
|
|
return []
|
|
return data.get("items") or []
|
|
|
|
def text(name, limit=4000):
|
|
path = os.path.join(tmp, name)
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
def is_allow_all_network_policy(item):
|
|
if not isinstance(item, dict):
|
|
return False
|
|
spec = item.get("spec") or {}
|
|
return (
|
|
item.get("metadata", {}).get("name") == "allow-all"
|
|
and spec.get("podSelector") == {}
|
|
and set(spec.get("policyTypes") or []) == {"Ingress", "Egress"}
|
|
and spec.get("ingress") == [{}]
|
|
and spec.get("egress") == [{}]
|
|
)
|
|
|
|
def deployment_ready(item):
|
|
if not isinstance(item, dict):
|
|
return False
|
|
spec = item.get("spec") or {}
|
|
status = item.get("status") or {}
|
|
desired = spec.get("replicas", 1)
|
|
return status.get("availableReplicas", 0) >= desired
|
|
|
|
env = {}
|
|
for line in text("app-env.out").splitlines():
|
|
if "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
env[key] = value
|
|
|
|
network_policy_obj = load("networkpolicy")
|
|
network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(network_policy_obj)
|
|
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"))
|
|
env_aligned = (
|
|
rc("app-env") == 0
|
|
and env.get("DATABASE_HOST") == "${database.host}"
|
|
and env.get("DATABASE_PORT") == "${database.port}"
|
|
and env.get("DATABASE_USER") == "${database.user}"
|
|
and env.get("DATABASE_DBNAME") == "${database.dbName}"
|
|
and env.get("DATABASE_SSLMODE") == "${database.sslMode}"
|
|
and env.get("REDIS_HOST") == "${redisService}"
|
|
and env.get("DATABASE_PASSWORD_PRESENT") == "true"
|
|
)
|
|
payload = {
|
|
"ok": health_rc == 0 and root_rc == 0 and redis_rc == 0 and network_policy_ok and len(missing_secret_keys) == 0 and env_aligned and not local_postgres_present and not redis_pvc_present and ${publicProbeOkExpr} and ${egressProbeOkExpr},
|
|
"target": "${target.id}",
|
|
"namespace": "${target.namespace}",
|
|
"status": "external-db-active",
|
|
"databaseMode": "${target.databaseMode}",
|
|
"externalDatabase": {
|
|
"host": "${database.host}",
|
|
"port": ${database.port},
|
|
"user": "${database.user}",
|
|
"dbName": "${database.dbName}",
|
|
"sslMode": "${database.sslMode}",
|
|
"secretName": "${database.secretName}",
|
|
"passwordKey": "${database.passwordKey}",
|
|
"passwordPrinted": False,
|
|
},
|
|
"publicExposure": ${publicProbeJson},
|
|
"egressProxy": ${egressProbeJson},
|
|
"checks": {
|
|
"allowAllNetworkPolicy": {"exitCode": rc("networkpolicy"), "ok": network_policy_ok, "stderr": text("networkpolicy.err", 2000)},
|
|
"secretReady": {"ok": len(missing_secret_keys) == 0, "missingKeys": missing_secret_keys, "valuesPrinted": False},
|
|
"sub2apiHealthViaKubernetesServiceProxy": {
|
|
"exitCode": health_rc,
|
|
"method": "kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/health",
|
|
"bodyPreview": text("health.body", 2000),
|
|
"stderr": text("health.err", 2000),
|
|
},
|
|
"sub2apiRootViaKubernetesServiceProxy": {
|
|
"exitCode": root_rc,
|
|
"method": "kubectl get --raw /api/v1/namespaces/${target.namespace}/services/${serviceName}:8080/proxy/",
|
|
"bodyBytes": len(text("root.body").encode("utf-8")),
|
|
"bodyPreview": text("root.body", 400),
|
|
"stderr": text("root.err", 2000),
|
|
},
|
|
"appEnvAligned": {
|
|
"ok": env_aligned,
|
|
"podName": text("app-pod.txt"),
|
|
"env": {key: env.get(key) for key in ["DATABASE_HOST", "DATABASE_PORT", "DATABASE_USER", "DATABASE_DBNAME", "DATABASE_SSLMODE", "REDIS_HOST", "DATABASE_PASSWORD_PRESENT"]},
|
|
"stderr": text("app-env.err", 2000),
|
|
},
|
|
"redisPing": {"exitCode": redis_rc, "podName": text("redis-pod.txt"), "stdout": text("redis.out", 2000), "stderr": text("redis.err", 2000)},
|
|
"externalStateOnly": {"ok": not local_postgres_present and not redis_pvc_present, "localPostgresPresent": local_postgres_present, "redisPvcPresent": redis_pvc_present},
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function validateExternalPendingScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const expectedImage = imageRef(sub2api, target);
|
|
const database = sub2api.runtime.database;
|
|
const redisService = sub2api.runtime.redis.serviceName;
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
capture_json() {
|
|
name="$1"
|
|
shift
|
|
"$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err"
|
|
rc=$?
|
|
printf '%s' "$rc" >"$tmp/$name.rc"
|
|
}
|
|
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
|
|
capture_json networkpolicy kubectl -n ${target.namespace} get networkpolicy allow-all
|
|
python3 - "$tmp" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
tmp = sys.argv[1]
|
|
|
|
def rc(name):
|
|
try:
|
|
return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1")
|
|
except FileNotFoundError:
|
|
return 1
|
|
|
|
def load(name):
|
|
path = os.path.join(tmp, f"{name}.json")
|
|
if not os.path.exists(path):
|
|
return None
|
|
try:
|
|
return json.load(open(path, encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def items(name):
|
|
data = load(name)
|
|
if not isinstance(data, dict):
|
|
return []
|
|
return data.get("items") or []
|
|
|
|
def text(name, limit=2000):
|
|
path = os.path.join(tmp, name)
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
def is_allow_all_network_policy(item):
|
|
if not isinstance(item, dict):
|
|
return False
|
|
spec = item.get("spec") or {}
|
|
return (
|
|
item.get("metadata", {}).get("name") == "allow-all"
|
|
and spec.get("podSelector") == {}
|
|
and set(spec.get("policyTypes") or []) == {"Ingress", "Egress"}
|
|
and spec.get("ingress") == [{}]
|
|
and spec.get("egress") == [{}]
|
|
)
|
|
|
|
def deployment_summary(item):
|
|
spec = (item or {}).get("spec") or {}
|
|
status = (item or {}).get("status") or {}
|
|
containers = ((spec.get("template") or {}).get("spec") or {}).get("containers") or []
|
|
volumes = ((spec.get("template") or {}).get("spec") or {}).get("volumes") or []
|
|
desired = spec.get("replicas", 1)
|
|
available = status.get("availableReplicas", 0)
|
|
return {
|
|
"exists": isinstance(item, dict),
|
|
"desired": desired,
|
|
"availableReplicas": available,
|
|
"readyReplicas": status.get("readyReplicas", 0),
|
|
"ready": available >= desired,
|
|
"images": [container.get("image") for container in containers],
|
|
"volumes": volumes,
|
|
}
|
|
|
|
services = items("services")
|
|
deployments = items("deployments")
|
|
cronjobs = items("cronjobs")
|
|
statefulsets = items("statefulsets")
|
|
pvcs = items("pvc")
|
|
configmap = load("configmap")
|
|
configmap_data = (configmap or {}).get("data") or {}
|
|
app = load("app")
|
|
redis = load("redis")
|
|
app_summary = deployment_summary(app)
|
|
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
|
|
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}"
|
|
and configmap_data.get("DATABASE_PORT") == "${database.port}"
|
|
and configmap_data.get("DATABASE_USER") == "${database.user}"
|
|
and configmap_data.get("DATABASE_DBNAME") == "${database.dbName}"
|
|
and configmap_data.get("DATABASE_SSLMODE") == "${database.sslMode}"
|
|
and configmap_data.get("REDIS_HOST") == "${redisService}"
|
|
)
|
|
app_scaled_to_zero = app_summary["exists"] and app_summary["desired"] == ${target.appReplicas} and ${target.appReplicas} == 0
|
|
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 standby_disabled_ok and "${serviceName}" in service_names and "${redisService}" in service_names,
|
|
"target": "${target.id}",
|
|
"namespace": "${target.namespace}",
|
|
"status": "pending-external-db",
|
|
"databaseMode": "${target.databaseMode}",
|
|
"redisMode": "${target.redisMode}",
|
|
"expectedRedisReplicas": ${target.redisReplicas},
|
|
"externalDatabase": {
|
|
"sourceRef": "${database.sourceRef}",
|
|
"host": "${database.host}",
|
|
"port": ${database.port},
|
|
"user": "${database.user}",
|
|
"dbName": "${database.dbName}",
|
|
"sslMode": "${database.sslMode}",
|
|
"secretName": "${database.secretName}",
|
|
"passwordKey": "${database.passwordKey}",
|
|
"pendingAllowed": ${database.pendingAllowed ? "True" : "False"},
|
|
"passwordPrinted": False,
|
|
},
|
|
"checks": {
|
|
"namespaceExists": rc("ns") == 0,
|
|
"allowAllNetworkPolicy": {"ok": network_policy_ok, "stderr": text("networkpolicy.err")},
|
|
"appScaledToZero": {"ok": app_scaled_to_zero, "summary": app_summary},
|
|
"imageAligned": {"ok": image_aligned, "desiredImage": "${expectedImage}", "runningImages": app_summary["images"]},
|
|
"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": {
|
|
"status": "bun scripts/cli.ts platform-infra sub2api status --target ${target.id}",
|
|
"activateAfterDbReady": "update config/platform-infra/sub2api.yaml target ${target.id} appReplicas and redisReplicas to 1 after ${database.sourceRef}, ${database.secretName}/${database.passwordKey}, and runtime images are ready, then apply --target ${target.id} --confirm",
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
async function capture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
|
return await runSshCommandCapture(config, target, args, input);
|
|
}
|
|
|
|
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
|
|
const trimmed = stdout.trim();
|
|
if (trimmed.length === 0) return null;
|
|
const start = trimmed.indexOf("{");
|
|
const end = trimmed.lastIndexOf("}");
|
|
if (start === -1 || end === -1 || end <= start) return null;
|
|
try {
|
|
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
|
|
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function boolField(value: Record<string, unknown> | null, key: string, defaultValue: boolean): boolean {
|
|
if (value === null) return defaultValue;
|
|
const field = value[key];
|
|
return typeof field === "boolean" ? field : defaultValue;
|
|
}
|
|
|
|
function asRecordOrNull(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
|
|
const full = options.full ?? false;
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
|
|
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
|
|
stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "",
|
|
stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
|
|
};
|
|
}
|