336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/platform-infra-sub2api-codex.ts.
|
|
|
|
// Moved mechanically from scripts/src/platform-infra-sub2api-codex.ts:320-620 for #903.
|
|
|
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { UniDeskConfig } from "../config";
|
|
import { rootPath } from "../config";
|
|
import type { RenderedCliResult } from "../output";
|
|
import { applyPk01CaddyBlock, prepareFrpcSecret, renderFrpcManifest, type PublicServiceExposure, type PublicServiceTarget } from "../platform-infra-public-service";
|
|
import { shortSha256Fingerprint } from "../platform-infra-ops-library";
|
|
import {
|
|
codexPoolSentinelSummary,
|
|
codexPoolSentinelRuntimeImage,
|
|
readCodexPoolSentinelConfig,
|
|
renderCodexPoolSentinelManifest,
|
|
type CodexPoolSentinelConfig,
|
|
type CodexPoolSentinelProfileSecret,
|
|
} from "../platform-infra-sub2api-codex-sentinel";
|
|
import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
|
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
|
|
|
|
import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types";
|
|
import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions";
|
|
import { renderCodexPoolPlan } from "./render";
|
|
import { defaultCodexPoolRuntimeTargetId } from "./runtime-target";
|
|
import { codexPoolHelp } from "./types";
|
|
|
|
export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
const [action = "plan"] = args;
|
|
if (action === "plan") {
|
|
const options = parseDisclosureOptions(args.slice(1));
|
|
const result = codexPoolPlan(options);
|
|
return options.full || options.raw ? result : renderCodexPoolPlan(result);
|
|
}
|
|
if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1)));
|
|
if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1)));
|
|
if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1)));
|
|
if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1)));
|
|
if (action === "sentinel-probe") return await codexPoolSentinelProbe(config, parseSentinelProbeOptions(args.slice(1)));
|
|
if (action === "sentinel-report") return await codexPoolSentinelReport(config, parseSentinelReportOptions(args.slice(1)));
|
|
if (action === "cleanup-probes") return await codexPoolCleanupProbes(config, parseConfirmOptions(args.slice(1)));
|
|
if (action === "expose") return await codexPoolExpose(config, parseConfirmOptions(args.slice(1)));
|
|
if (action === "configure-local") return await codexPoolConfigureLocal(config, parseConfirmOptions(args.slice(1)));
|
|
return {
|
|
ok: false,
|
|
error: "unsupported-platform-infra-sub2api-codex-pool-command",
|
|
args,
|
|
help: codexPoolHelp(),
|
|
};
|
|
}
|
|
|
|
export function parseSyncOptions(args: string[]): SyncOptions {
|
|
validateOptions(args, new Set(["--confirm", "--prune-removed", "--full", "--raw", "--target"]));
|
|
const disclosure = parseDisclosureOptions(stripBooleanOptions(args, new Set(["--confirm", "--prune-removed"])));
|
|
return { ...disclosure, confirm: args.includes("--confirm"), pruneRemoved: args.includes("--prune-removed") };
|
|
}
|
|
|
|
export function parseConfirmOptions(args: string[]): ConfirmOptions {
|
|
validateOptions(args, new Set(["--confirm", "--full", "--raw", "--target"]));
|
|
const disclosure = parseDisclosureOptions(stripBooleanOptions(args, new Set(["--confirm"])));
|
|
return { ...disclosure, confirm: args.includes("--confirm") };
|
|
}
|
|
|
|
export function parseSentinelImageOptions(args: string[]): SentinelImageOptions {
|
|
const [actionRaw = "status", ...rest] = args;
|
|
if (actionRaw !== "status" && actionRaw !== "build") throw new Error("sentinel-image usage: status|build [--dry-run|--confirm] [--full|--raw]");
|
|
let confirm = false;
|
|
let explicitDryRun = false;
|
|
const disclosureArgs: string[] = [];
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|
const arg = rest[index]!;
|
|
if (arg === "--confirm") {
|
|
confirm = true;
|
|
continue;
|
|
}
|
|
if (arg === "--dry-run") {
|
|
explicitDryRun = true;
|
|
continue;
|
|
}
|
|
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
|
|
disclosureArgs.push(arg);
|
|
if (arg === "--target") {
|
|
const value = rest[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
|
disclosureArgs.push(value);
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported option: ${arg}`);
|
|
}
|
|
if (confirm && explicitDryRun) throw new Error("sentinel-image accepts only one of --confirm or --dry-run");
|
|
const disclosure = parseDisclosureOptions(disclosureArgs);
|
|
return {
|
|
...disclosure,
|
|
action: actionRaw,
|
|
confirm,
|
|
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
|
};
|
|
}
|
|
|
|
export function parseSentinelProbeOptions(args: string[]): SentinelProbeOptions {
|
|
const accounts: string[] = [];
|
|
const disclosureArgs: string[] = [];
|
|
let confirm = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index]!;
|
|
if (arg === "--confirm") {
|
|
confirm = true;
|
|
continue;
|
|
}
|
|
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
|
|
disclosureArgs.push(arg);
|
|
if (arg === "--target") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
|
disclosureArgs.push(value);
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg === "--account") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--account requires an account name");
|
|
accounts.push(...splitAccountNames(value));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--account=")) {
|
|
accounts.push(...splitAccountNames(arg.slice("--account=".length)));
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported option: ${arg}`);
|
|
}
|
|
const uniqueAccounts = [...new Set(accounts)];
|
|
if (uniqueAccounts.length === 0) throw new Error("sentinel-probe requires --account <accountName>");
|
|
for (const account of uniqueAccounts) validateSub2ApiAccountSelector(account, "--account");
|
|
const disclosure = parseDisclosureOptions(disclosureArgs);
|
|
return { ...disclosure, confirm, accounts: uniqueAccounts };
|
|
}
|
|
|
|
export function parseSentinelReportOptions(args: string[]): SentinelReportOptions {
|
|
let events = 20;
|
|
let explicitEvents = false;
|
|
const disclosureArgs: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index]!;
|
|
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
|
|
disclosureArgs.push(arg);
|
|
if (arg === "--target") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
|
disclosureArgs.push(value);
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg === "--events") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--events requires a positive integer");
|
|
events = readReportEventLimit(value, "--events");
|
|
explicitEvents = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--events=")) {
|
|
events = readReportEventLimit(arg.slice("--events=".length), "--events");
|
|
explicitEvents = true;
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported option: ${arg}`);
|
|
}
|
|
const disclosure = parseDisclosureOptions(disclosureArgs);
|
|
if (disclosure.full && !explicitEvents) events = 80;
|
|
return { ...disclosure, events };
|
|
}
|
|
|
|
export function parseTraceOptions(args: string[]): TraceOptions {
|
|
let requestId: string | null = null;
|
|
let since = "24h";
|
|
let tail = 20_000;
|
|
let contextSeconds = 300;
|
|
let showLines = false;
|
|
const disclosureArgs: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index]!;
|
|
if (arg === "--full" || arg === "--raw" || arg === "--target" || arg.startsWith("--target=")) {
|
|
disclosureArgs.push(arg);
|
|
if (arg === "--target") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
|
disclosureArgs.push(value);
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg === "--show-lines") {
|
|
showLines = true;
|
|
continue;
|
|
}
|
|
if (arg === "--request-id" || arg === "--id") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a request id`);
|
|
requestId = value.trim();
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--request-id=")) {
|
|
requestId = arg.slice("--request-id=".length).trim();
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--id=")) {
|
|
requestId = arg.slice("--id=".length).trim();
|
|
continue;
|
|
}
|
|
if (arg === "--since") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--since requires a kubectl duration such as 24h or 90m");
|
|
since = parseKubectlDuration(value);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--since=")) {
|
|
since = parseKubectlDuration(arg.slice("--since=".length));
|
|
continue;
|
|
}
|
|
if (arg === "--tail") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--tail requires an integer");
|
|
tail = parseTraceLimit(value, "--tail", 100, 200_000);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--tail=")) {
|
|
tail = parseTraceLimit(arg.slice("--tail=".length), "--tail", 100, 200_000);
|
|
continue;
|
|
}
|
|
if (arg === "--context-seconds") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--context-seconds requires an integer");
|
|
contextSeconds = parseTraceLimit(value, "--context-seconds", 0, 3600);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--context-seconds=")) {
|
|
contextSeconds = parseTraceLimit(arg.slice("--context-seconds=".length), "--context-seconds", 0, 3600);
|
|
continue;
|
|
}
|
|
throw new Error(`unsupported option: ${arg}`);
|
|
}
|
|
if (requestId === null || requestId.length === 0) throw new Error("trace requires --request-id <requestId>");
|
|
if (!/^[A-Za-z0-9_.:-]{8,128}$/u.test(requestId)) throw new Error("--request-id has an unsupported format");
|
|
const disclosure = parseDisclosureOptions(disclosureArgs);
|
|
return { ...disclosure, requestId, since, tail, contextSeconds, showLines };
|
|
}
|
|
|
|
export function parseKubectlDuration(raw: string): string {
|
|
const value = raw.trim();
|
|
if (!/^[1-9][0-9]*(?:s|m|h)$/u.test(value)) throw new Error("--since must be a kubectl duration such as 24h, 90m, or 300s");
|
|
return value;
|
|
}
|
|
|
|
export function parseTraceLimit(raw: string, option: string, min: number, max: number): number {
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${option} must be an integer from ${min} to ${max}`);
|
|
return value;
|
|
}
|
|
|
|
export function readReportEventLimit(raw: string, option: string): number {
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value < 1 || value > 200) throw new Error(`${option} must be an integer from 1 to 200`);
|
|
return value;
|
|
}
|
|
|
|
export function parseDisclosureOptions(args: string[]): DisclosureOptions {
|
|
validateOptions(args, new Set(["--full", "--raw", "--target"]));
|
|
const raw = args.includes("--raw");
|
|
return { full: raw || args.includes("--full"), raw, targetId: parseTargetId(args) };
|
|
}
|
|
|
|
export function parseTargetId(args: string[]): string {
|
|
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;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--target=")) targetId = arg.slice("--target=".length);
|
|
}
|
|
const resolvedTargetId = targetId ?? defaultCodexPoolRuntimeTargetId();
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(resolvedTargetId)) throw new Error("--target must be a simple target id");
|
|
return resolvedTargetId;
|
|
}
|
|
|
|
export function splitAccountNames(value: string): string[] {
|
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
export function validateSub2ApiAccountSelector(value: string, option: string): void {
|
|
if (value.length === 0 || value.length > 256) throw new Error(`${option} must be a non-empty account name up to 256 characters`);
|
|
if (/[\r\n]/u.test(value)) throw new Error(`${option} must not contain newlines`);
|
|
if (!/^[^<>"'`\\]+$/u.test(value)) throw new Error(`${option} contains unsupported characters`);
|
|
}
|
|
|
|
export 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}`);
|
|
}
|
|
}
|
|
|
|
export function stripBooleanOptions(args: string[], stripped: Set<string>): string[] {
|
|
return args.filter((arg) => !stripped.has(arg));
|
|
}
|
|
|
|
export interface Sub2ApiRuntimeConfig {
|
|
defaultTargetId: string;
|
|
appSecretName: string;
|
|
secretsRoot: string;
|
|
appSourceRef: string;
|
|
sentinelEnabledOnTargets: string[];
|
|
targets: Record<string, unknown>[];
|
|
}
|