262 lines
12 KiB
TypeScript
262 lines
12 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/platform-infra.ts.
|
|
|
|
// Moved mechanically from scripts/src/platform-infra.ts:338-453 for #903.
|
|
|
|
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 { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service";
|
|
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
|
|
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
|
|
|
import { apply, plan, status } from "./actions";
|
|
import { defaultSub2ApiTargetId, readSub2ApiConfig, validateOptions } from "./config";
|
|
import { configPath, platformInfraHelp, serviceName } from "./entry";
|
|
import { resolveTarget } from "./manifest";
|
|
import { validate } from "./policy";
|
|
|
|
export function sub2ApiHelpTargetSummary(): Record<string, unknown> {
|
|
const sub2api = readSub2ApiConfig();
|
|
const target = resolveTarget(sub2api, sub2api.defaults.targetId);
|
|
return {
|
|
default: sub2api.defaults.targetId,
|
|
namespace: target.namespace,
|
|
service: serviceName,
|
|
serviceDns: `${serviceName}.${target.namespace}.svc.cluster.local:8080`,
|
|
exposure: target.publicExposure?.enabled === true ? "yaml-controlled-public-exposure" : "k3s-cluster-internal-only",
|
|
resourceLimits: "unset-by-policy",
|
|
versionConfigPath: configPath,
|
|
};
|
|
}
|
|
|
|
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
const [target, action] = args;
|
|
if (target === "egress-proxy") {
|
|
const { runPlatformInfraEgressProxyCommand } = await import("../platform-infra-egress-proxy");
|
|
return await runPlatformInfraEgressProxyCommand(config, args.slice(1));
|
|
}
|
|
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 === "observability") {
|
|
const { runPlatformObservabilityCommand } = await import("../platform-infra-observability");
|
|
return await runPlatformObservabilityCommand(config, args.slice(1));
|
|
}
|
|
if (target !== "sub2api") return unsupported(args);
|
|
if (action === "plan" || action === undefined) {
|
|
const planArgs = args.slice(2);
|
|
const result = plan(parseTargetOptions(planArgs));
|
|
return planArgs.includes("--full") || planArgs.includes("--raw") ? result : renderSub2ApiPlan(result);
|
|
}
|
|
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));
|
|
if (action === "status") {
|
|
const options = parseDisclosureOptions(args.slice(2));
|
|
const result = await status(config, options);
|
|
return options.full || options.raw ? result : renderSub2ApiStatus(result);
|
|
}
|
|
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);
|
|
}
|
|
|
|
export interface ApplyOptions {
|
|
targetId: string;
|
|
dryRun: boolean;
|
|
confirm: boolean;
|
|
wait: boolean;
|
|
}
|
|
|
|
export interface DisclosureOptions {
|
|
targetId: string;
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
export interface TargetOptions {
|
|
targetId: string;
|
|
}
|
|
|
|
export interface PolicyCheck {
|
|
name: string;
|
|
ok: boolean;
|
|
detail: string;
|
|
}
|
|
|
|
function renderSub2ApiPlan(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const config = record(record(result.config).target);
|
|
const exposure = record(config.publicExposure);
|
|
const egress = record(config.egressProxy);
|
|
const next = record(result.next);
|
|
const policy = arrayRecords(result.policy);
|
|
const failedPolicy = policy.filter((item) => item.ok === false);
|
|
const rows = [
|
|
["TARGET", stringValue(target.id), "route", stringValue(target.route)],
|
|
["NAMESPACE", stringValue(target.namespace), "role", stringValue(target.role)],
|
|
["DATABASE", stringValue(config.databaseMode), "redis", stringValue(config.redisMode)],
|
|
["REPLICAS", `${stringValue(config.appReplicas)}/${stringValue(config.redisReplicas)}`, "service", stringValue(target.serviceDns)],
|
|
["PUBLIC", boolText(exposure.enabled), "url", stringValue(exposure.publicBaseUrl, "-")],
|
|
["EGRESS", boolText(egress.enabled), "source", `${stringValue(egress.sourceType, "-")} ${stringValue(egress.sourceFingerprint, "-")}`],
|
|
["POLICY", failedPolicy.length === 0 ? "ok" : `failed=${failedPolicy.length}`, "valuesPrinted", "false"],
|
|
];
|
|
const lines = [
|
|
"PLATFORM-INFRA SUB2API PLAN",
|
|
...table(["FIELD", "VALUE", "DETAIL", "VALUE"], rows),
|
|
"",
|
|
"NEXT",
|
|
` dry-run: ${stringValue(next.dryRun, `bun scripts/cli.ts platform-infra sub2api apply --target ${stringValue(target.id)} --dry-run`)}`,
|
|
` apply: ${stringValue(next.apply, `bun scripts/cli.ts platform-infra sub2api apply --target ${stringValue(target.id)} --confirm`)}`,
|
|
` status: ${stringValue(next.status, `bun scripts/cli.ts platform-infra sub2api status --target ${stringValue(target.id)}`)}`,
|
|
" full: bun scripts/cli.ts platform-infra sub2api plan --target " + stringValue(target.id) + " --full",
|
|
"",
|
|
"Disclosure: default output is bounded; Secret and proxy source values are not printed.",
|
|
];
|
|
return rendered(result, "platform-infra sub2api plan", lines);
|
|
}
|
|
|
|
function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const summary = record(result.summary);
|
|
const secret = record(summary.secret);
|
|
const egress = record(summary.egressProxy);
|
|
const networkPolicy = record(summary.networkPolicy);
|
|
const deployments = arrayRecords(summary.deployments)
|
|
.filter((item) => stringValue(item.name).startsWith("sub2api"))
|
|
.map((item) => [
|
|
stringValue(item.name),
|
|
boolText(item.ready),
|
|
`${stringValue(item.readyReplicas, "0")}/${stringValue(item.desired, "0")}`,
|
|
arrayValues(item.images).slice(0, 1).map((value) => stringValue(value)).join(",") || "-",
|
|
]);
|
|
const services = arrayRecords(summary.services)
|
|
.filter((item) => stringValue(item.name).startsWith("sub2api"))
|
|
.map((item) => [
|
|
stringValue(item.name),
|
|
stringValue(item.type),
|
|
arrayRecords(item.ports).map((port) => `${stringValue(port.name, "port")}:${stringValue(port.port)}`).join(","),
|
|
]);
|
|
const lines = [
|
|
"PLATFORM-INFRA SUB2API STATUS",
|
|
...table(["TARGET", "ROUTE", "NAMESPACE", "STATUS"], [[stringValue(summary.target, stringValue(target.id)), stringValue(summary.route, stringValue(target.route)), stringValue(summary.namespace, stringValue(target.namespace)), stringValue(summary.status, result.ok === false ? "failed" : "ok")]]),
|
|
"",
|
|
"WORKLOADS",
|
|
...(deployments.length === 0 ? ["-"] : table(["NAME", "READY", "REPLICAS", "IMAGE"], deployments)),
|
|
"",
|
|
"SERVICES",
|
|
...(services.length === 0 ? ["-"] : table(["NAME", "TYPE", "PORTS"], services)),
|
|
"",
|
|
"CHECKS",
|
|
...table(["CHECK", "VALUE", "DETAIL"], [
|
|
["namespace", boolText(summary.namespaceExists), stringValue(summary.namespace)],
|
|
["secret", boolText(secret.ready), `name=${stringValue(secret.name, "-")} missing=${arrayValues(secret.missingKeys).length}`],
|
|
["egressProxy", boolText(egress.aligned), `enabled=${boolText(egress.enabled)} valuesPrinted=false`],
|
|
["networkPolicy", boolText(networkPolicy.ok), `required=${stringValue(networkPolicy.requiredName, "-")}`],
|
|
]),
|
|
"",
|
|
"NEXT",
|
|
` validate: bun scripts/cli.ts platform-infra sub2api validate --target ${stringValue(summary.target, stringValue(target.id))}`,
|
|
` full: bun scripts/cli.ts platform-infra sub2api status --target ${stringValue(summary.target, stringValue(target.id))} --full`,
|
|
"Disclosure: default output is bounded; use --full or --raw for pod/service/event details.",
|
|
];
|
|
return rendered(result, "platform-infra sub2api status", lines);
|
|
}
|
|
|
|
function rendered(result: Record<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
|
return {
|
|
ok: result.ok !== false,
|
|
command,
|
|
renderedText: lines.join("\n"),
|
|
contentType: "text/plain",
|
|
};
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.map(record) : [];
|
|
}
|
|
|
|
function arrayValues(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function stringValue(value: unknown, fallback = "-"): string {
|
|
if (value === undefined || value === null || value === "") return fallback;
|
|
return String(value);
|
|
}
|
|
|
|
function boolText(value: unknown): string {
|
|
return value === true ? "yes" : value === false ? "no" : "-";
|
|
}
|
|
|
|
function table(headers: string[], rows: string[][]): string[] {
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
|
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
|
return [renderRow(headers), ...rows.map(renderRow)];
|
|
}
|
|
|
|
export function unsupported(args: string[]): Record<string, unknown> {
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-command",
|
|
args,
|
|
help: platformInfraHelp(),
|
|
};
|
|
}
|
|
|
|
export 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"),
|
|
};
|
|
}
|
|
|
|
export 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 };
|
|
}
|
|
|
|
export function parseTargetOptions(args: string[]): TargetOptions {
|
|
let targetId: string | null = null;
|
|
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);
|
|
}
|
|
}
|
|
const resolvedTargetId = targetId ?? defaultSub2ApiTargetId();
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(resolvedTargetId)) throw new Error("--target must be a simple target id");
|
|
return { targetId: resolvedTargetId };
|
|
}
|