Files
pikasTech-unidesk/scripts/src/platform-infra-ops-library.ts
T
Lyon edfddd2445 fix: YAML-first 治理 CI/CD target (#919)
* docs: specify cicd yaml target governance

* fix: resolve cicd targets from yaml

---------

Co-authored-by: Codex <codex@noreply.local>
2026-06-26 01:14:38 +08:00

713 lines
33 KiB
TypeScript

// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join, normalize, relative } from "node:path";
import pathPosix from "node:path/posix";
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { coreInternalFetch } from "./microservices";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
export interface OpsCommonOptions {
targetId: string | null;
full: boolean;
raw: boolean;
}
export interface OpsApplyOptions extends OpsCommonOptions {
confirm: boolean;
dryRun: boolean;
wait: boolean;
}
export interface OpsCommandOptionSpec {
stringOptions?: string[];
flagOptions?: string[];
}
export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise<SshCaptureResult> {
return await runSshCommandCapture(config, route, args, stdin);
}
export 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;
}
}
export 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 ? redactText(result.stdout).slice(-8000) : "",
stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "",
};
}
export function redactText(text: string): string {
return text
.replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_<redacted>")
.replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1<redacted>@")
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1<redacted>")
.replace(/(["']?(?:N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API[_-]?KEY|APIKEY|JWT[_-]?SECRET|DATABASE[_-]?URL)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$2");
}
export function fingerprintValues(values: Record<string, string>, keys: string[]): string {
const hash = createHash("sha256");
for (const key of keys.slice().sort()) {
hash.update(key);
hash.update("\0");
hash.update(values[key] ?? "");
hash.update("\0");
}
return `sha256:${hash.digest("hex")}`;
}
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function sha256Fingerprint(value: string): string {
return `sha256:${sha256Hex(value)}`;
}
export function shortSha256Fingerprint(value: string, chars = 12): string {
return sha256Hex(value).slice(0, chars);
}
export function fingerprintEnvValues(values: Record<string, string>, keys: string[]): string {
const material = keys
.slice()
.sort()
.map((key) => `${key}=${values[key] ?? ""}`)
.join("\n");
return `sha256:${createHash("sha256").update(material).digest("hex")}`;
}
export 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;
}
export function compactText(value: string, maxChars = 500): string {
return value.replace(/\s+/gu, " ").trim().slice(0, maxChars);
}
export function shQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
export function parseOpsCommonOptions(args: string[], spec: OpsCommandOptionSpec = {}): OpsCommonOptions & Record<string, string | boolean | null> {
const stringOptions = new Set(["--target", ...(spec.stringOptions ?? [])]);
const flagOptions = new Set(["--full", "--raw", ...(spec.flagOptions ?? [])]);
const values: Record<string, string | boolean | null> = { full: false, raw: false };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (stringOptions.has(arg)) {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
if (arg === "--target") values.targetId = value;
else values[optionKey(arg)] = value;
index += 1;
} else if (flagOptions.has(arg)) {
if (arg === "--full") values.full = true;
else if (arg === "--raw") {
values.raw = true;
values.full = true;
} else {
values[optionKey(arg)] = true;
}
} else {
throw new Error(`unsupported option: ${arg}`);
}
}
const targetId = values.targetId === undefined ? null : String(values.targetId);
if (targetId !== null && !/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id");
return { ...values, targetId } as OpsCommonOptions & Record<string, string | boolean | null>;
}
export function parseOpsApplyOptions(args: string[]): OpsApplyOptions {
const commonArgs: string[] = [];
let confirm = false;
let dryRun = false;
let wait = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--confirm") confirm = true;
else if (arg === "--dry-run") dryRun = true;
else if (arg === "--wait") wait = true;
else {
commonArgs.push(arg);
if (arg === "--target") {
commonArgs.push(args[index + 1] ?? "");
index += 1;
}
}
}
if (confirm && dryRun) throw new Error("apply accepts only one of --confirm or --dry-run");
return { ...parseOpsCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
}
export function readYamlRecord<T = Record<string, unknown>>(path: string, expectedKind?: string): T {
const parsed = Bun.YAML.parse(readFileSync(path, "utf8")) as unknown;
const record = asRecord(parsed, path);
if (expectedKind !== undefined && record.kind !== expectedKind) throw new Error(`${repoRelative(path)}.kind must be ${expectedKind}`);
return record as T;
}
export function asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
export function recordField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
return asRecord(obj[key], `${path}.${key}`);
}
export function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value.trim();
}
export function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`);
return value.trim();
}
export function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
export function numberField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path}.${key} must be a finite number`);
return value;
}
export function integerField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
return Number(value);
}
export function optionalIntegerField(obj: Record<string, unknown>, key: string, path: string): number | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer when set`);
return Number(value);
}
export function arrayField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>[] {
const value = obj[key];
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`);
return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`));
}
export function stringListField(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path}.${key} must be an array of non-empty strings`);
return value as string[];
}
export function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`);
return value.map((item, index) => {
if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${path}.${key}[${index}] must be a non-empty string`);
return item.trim();
});
}
export function yamlFieldLabel(configLabel: string, path: string, key: string): string {
const prefix = path.length > 0 ? `${path}.` : "";
return `${configLabel}.${prefix}${key}`;
}
export function yamlSubFieldLabel(label: string, key: string): string {
return label.endsWith(".") ? `${label}${key}` : `${label}.${key}`;
}
export function yamlRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`);
return value as Record<string, unknown>;
}
export function yamlObjectField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): Record<string, unknown> {
return yamlRecord(obj[key], yamlFieldLabel(configLabel, path, key));
}
export function yamlArrayOfRecords(value: unknown, configLabel: string, path: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${configLabel}.${path} must be an array`);
return value.map((item, index) => yamlRecord(item, `${configLabel}.${path}[${index}]`));
}
export function yamlStringField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a non-empty string`);
return value.trim();
}
export function yamlIntegerField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): number {
const value = obj[key];
if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an integer`);
return value;
}
export function yamlBooleanField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a boolean`);
return value;
}
export function yamlStringArrayField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a string array`);
return value.map((item) => (item as string).trim());
}
export function yamlIntegerArrayField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): number[] {
const value = obj[key];
if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an integer array`);
return value as number[];
}
export function yamlEnumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, configLabel: string, path: string, values: T): T[number] {
const value = yamlStringField(obj, key, configLabel, path);
if (!(values as readonly string[]).includes(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be one of ${values.join(", ")}`);
return value as T[number];
}
export function yamlKubernetesNameField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a Kubernetes name`);
return value;
}
export function yamlSourceRefField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (value.startsWith("/") || value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a relative source ref without ..`);
return value;
}
export function yamlEnvKeyField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an env key`);
return value;
}
export function yamlPgIdentifierField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a PostgreSQL identifier`);
return value;
}
export function yamlHostField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} has an unsupported host format`);
return value;
}
export function yamlPortField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): number {
const value = yamlIntegerField(obj, key, configLabel, path);
if (value < 1 || value > 65535) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a TCP port`);
return value;
}
export function yamlAbsolutePathField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (!value.startsWith("/")) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be absolute`);
return value;
}
export function yamlApiPathField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
if (!value.startsWith("/") || value.includes("..")) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an absolute path without ..`);
return value;
}
export function yamlHttpsUrlField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
const value = yamlStringField(obj, key, configLabel, path);
const url = new URL(value);
if (url.protocol !== "https:" || url.search || url.hash) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an https URL without query or hash`);
return value.replace(/\/+$/u, "");
}
export interface YamlFieldReader {
asRecord(value: unknown, label: string): Record<string, unknown>;
objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>;
arrayOfRecords(value: unknown, path: string): Record<string, unknown>[];
stringField(obj: Record<string, unknown>, key: string, path: string): string;
integerField(obj: Record<string, unknown>, key: string, path: string): number;
booleanField(obj: Record<string, unknown>, key: string, path: string): boolean;
stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[];
numberArrayField(obj: Record<string, unknown>, key: string, path: string): number[];
enumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, path: string, values: T): T[number];
kubernetesNameField(obj: Record<string, unknown>, key: string, path: string): string;
sourceRefField(obj: Record<string, unknown>, key: string, path: string): string;
envKeyField(obj: Record<string, unknown>, key: string, path: string): string;
pgIdentifierField(obj: Record<string, unknown>, key: string, path: string): string;
hostField(obj: Record<string, unknown>, key: string, path: string): string;
portField(obj: Record<string, unknown>, key: string, path: string): number;
absolutePathField(obj: Record<string, unknown>, key: string, path: string): string;
apiPathField(obj: Record<string, unknown>, key: string, path: string): string;
httpsUrlField(obj: Record<string, unknown>, key: string, path: string): string;
}
export function createYamlFieldReader(configLabel: string): YamlFieldReader {
return {
asRecord: (value, label) => yamlRecord(value, label),
objectField: (obj, key, path) => yamlObjectField(obj, key, configLabel, path),
arrayOfRecords: (value, path) => yamlArrayOfRecords(value, configLabel, path),
stringField: (obj, key, path) => yamlStringField(obj, key, configLabel, path),
integerField: (obj, key, path) => yamlIntegerField(obj, key, configLabel, path),
booleanField: (obj, key, path) => yamlBooleanField(obj, key, configLabel, path),
stringArrayField: (obj, key, path) => yamlStringArrayField(obj, key, configLabel, path),
numberArrayField: (obj, key, path) => yamlIntegerArrayField(obj, key, configLabel, path),
enumField: (obj, key, path, values) => yamlEnumField(obj, key, configLabel, path, values),
kubernetesNameField: (obj, key, path) => yamlKubernetesNameField(obj, key, configLabel, path),
sourceRefField: (obj, key, path) => yamlSourceRefField(obj, key, configLabel, path),
envKeyField: (obj, key, path) => yamlEnvKeyField(obj, key, configLabel, path),
pgIdentifierField: (obj, key, path) => yamlPgIdentifierField(obj, key, configLabel, path),
hostField: (obj, key, path) => yamlHostField(obj, key, configLabel, path),
portField: (obj, key, path) => yamlPortField(obj, key, configLabel, path),
absolutePathField: (obj, key, path) => yamlAbsolutePathField(obj, key, configLabel, path),
apiPathField: (obj, key, path) => yamlApiPathField(obj, key, configLabel, path),
httpsUrlField: (obj, key, path) => yamlHttpsUrlField(obj, key, configLabel, path),
};
}
export function repoRelative(path: string): string {
const rel = relative(rootPath(), path);
return rel.startsWith("..") ? path : rel;
}
export function resolveRepoPath(path: string): string {
if (path.startsWith("/")) throw new Error(`repo-owned path must be relative: ${path}`);
if (path.includes("..")) throw new Error(`repo-owned path must not contain ..: ${path}`);
return rootPath(path);
}
export function sanitizePathSegment(value: string, fallback = "unknown"): string {
const cleaned = value.trim().replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "");
return cleaned.length > 0 ? cleaned.slice(0, 120) : fallback;
}
export function dateInTimeZone(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value ?? "00";
return `${pick("year")}-${pick("month")}-${pick("day")}`;
}
export function sha256File(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
export function ensureFileInside(root: string, relativePath: string): string {
if (relativePath.startsWith("/") || relativePath.includes("..")) throw new Error(`path must be relative inside staging: ${relativePath}`);
const rootAbsolute = hostRootPath(root);
const resolved = normalize(join(rootAbsolute, relativePath));
if (!resolved.startsWith(normalize(rootAbsolute + "/")) && resolved !== normalize(rootAbsolute)) throw new Error(`path escapes staging root: ${relativePath}`);
mkdirSync(dirname(resolved), { recursive: true });
return resolved;
}
export function writeStagingText(params: { hostRoot: string; relativePath: string; content: string }): { hostPath: string; bytes: number; sha256: string } {
const hostPath = ensureFileInside(params.hostRoot, params.relativePath);
writeFileSync(hostPath, params.content, "utf8");
return { hostPath, bytes: Buffer.byteLength(params.content, "utf8"), sha256: sha256File(hostPath) };
}
export function writeStagingBase64(params: { hostRoot: string; relativePath: string; dataBase64: string }): { hostPath: string; bytes: number; sha256: string } {
const buffer = Buffer.from(params.dataBase64, "base64");
const hostPath = ensureFileInside(params.hostRoot, params.relativePath);
writeFileSync(hostPath, buffer);
return { hostPath, bytes: buffer.byteLength, sha256: sha256File(hostPath) };
}
export function containerPathToHostPath(containerRoot: string, hostRoot: string, containerPath: string): string | null {
const cleanContainerRoot = normalize(containerRoot);
const cleanPath = normalize(containerPath);
if (cleanPath !== cleanContainerRoot && !cleanPath.startsWith(`${cleanContainerRoot}/`)) return null;
const rel = relative(cleanContainerRoot, cleanPath);
return join(hostRootPath(hostRoot), rel);
}
export function hostRootPath(root: string): string {
return normalize(root.startsWith("/") ? root : rootPath(root));
}
export interface MicroserviceProxyResponse {
ok: boolean;
status: number | null;
body: unknown;
raw: Record<string, unknown> | null;
}
export function microserviceProxy(serviceId: string, upstreamPath: string, init: { method?: string; body?: unknown; timeoutMs?: number; maxResponseBytes?: number } = {}): MicroserviceProxyResponse {
if (!upstreamPath.startsWith("/")) throw new Error("microservice upstream path must start with /");
const response = coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${upstreamPath}`, {
method: init.method,
body: init.body,
timeoutMs: init.timeoutMs,
maxResponseBytes: init.maxResponseBytes ?? 5_000_000,
});
const raw = typeof response === "object" && response !== null && !Array.isArray(response) ? response as Record<string, unknown> : null;
return {
ok: raw?.ok === true,
status: typeof raw?.status === "number" ? raw.status : null,
body: raw !== null && "body" in raw ? raw.body : response,
raw,
};
}
export function assertProxyOk(response: MicroserviceProxyResponse, context: string): Record<string, unknown> {
if (response.ok && typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)) return response.body as Record<string, unknown>;
throw new Error(`${context} failed: ${JSON.stringify(compactProxyResponse(response)).slice(0, 1200)}`);
}
export function compactProxyResponse(response: MicroserviceProxyResponse): Record<string, unknown> {
return {
ok: response.ok,
status: response.status,
body: compactUnknown(response.body),
};
}
export function compactUnknown(value: unknown, maxString = 400): unknown {
if (typeof value === "string") return redactText(value).slice(0, maxString);
if (Array.isArray(value)) return { arrayPreview: value.slice(0, 5).map((item) => compactUnknown(item, maxString)), count: value.length };
if (typeof value !== "object" || value === null) return value;
const record = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const key of Object.keys(record).slice(0, 20)) output[key] = redactSensitiveKey(key) ? "<redacted>" : compactUnknown(record[key], maxString);
if (Object.keys(record).length > 20) output.omittedKeys = Object.keys(record).length - 20;
return output;
}
export function redactSensitiveUnknown(value: unknown): unknown {
if (typeof value === "string") return redactText(value);
if (Array.isArray(value)) return value.map((item) => redactSensitiveUnknown(item));
if (typeof value !== "object" || value === null) return value;
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) output[key] = redactSensitiveKey(key) ? "<redacted>" : redactSensitiveUnknown(nested);
return output;
}
export async function waitForBaiduTransfer(serviceId: string, jobId: string, options: { timeoutMs: number; pollIntervalMs: number }): Promise<Record<string, unknown>> {
const deadline = Date.now() + options.timeoutMs;
let last: Record<string, unknown> | null = null;
while (Date.now() <= deadline) {
const detail = assertProxyOk(microserviceProxy(serviceId, `/api/transfers/${encodeURIComponent(jobId)}`, { timeoutMs: 30_000 }), `baidu transfer ${jobId}`);
const job = asRecord(detail.job, `transfer ${jobId}.job`);
last = job;
const status = String(job.status || "");
if (status === "succeeded") return { ok: true, job, events: detail.events };
if (status === "failed" || status === "canceled") return { ok: false, job, events: detail.events };
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs));
}
return { ok: false, timeout: true, job: last };
}
export function findBaiduFileByRemotePath(serviceId: string, remotePath: string): Record<string, unknown> | null {
const dir = pathPosix.dirname(remotePath);
const name = pathPosix.basename(remotePath);
const response = assertProxyOk(microserviceProxy(serviceId, `/api/files?dir=${encodeURIComponent(dir)}&limit=500&order=time&desc=1`, { timeoutMs: 60_000 }), "baidu list files");
const files = Array.isArray(response.files) ? response.files : [];
for (const item of files) {
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
if (String(record.path || "") === remotePath || String(record.serverFilename || record.filename || record.name || "") === name) return record;
}
return null;
}
export interface N8nWorkflowSyncResult {
ok: boolean;
mode: "dry-run" | "confirmed";
workflow: { id: string; name: string; webhookPath: string; active: boolean };
remote?: Record<string, unknown>;
manifest?: Record<string, unknown>;
}
export async function syncN8nWorkflow(config: UniDeskConfig, params: {
targetRoute: string;
namespace: string;
deploymentName: string;
workflowJson: Record<string, unknown>;
workflowId: string;
workflowName: string;
webhookPath: string;
active: boolean;
dryRun: boolean;
}): Promise<N8nWorkflowSyncResult> {
if (params.dryRun) {
return {
ok: true,
mode: "dry-run",
workflow: { id: params.workflowId, name: params.workflowName, webhookPath: params.webhookPath, active: params.active },
manifest: {
nodes: Array.isArray(params.workflowJson.nodes) ? params.workflowJson.nodes.length : 0,
active: params.workflowJson.active,
settings: params.workflowJson.settings,
},
};
}
const encoded = Buffer.from(JSON.stringify(params.workflowJson, null, 2), "utf8").toString("base64");
const script = `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
workflow="$tmp/wechat-archive-workflow.json"
printf '%s' '${encoded}' | base64 -d >"$workflow"
kubectl -n ${params.namespace} exec -i deploy/${params.deploymentName} -c n8n -- sh -lc ${shQuote(`
set -u
mkdir -p /tmp/unidesk-n8n
cat >/tmp/unidesk-n8n/wechat-archive-workflow.json
n8n import:workflow --input=/tmp/unidesk-n8n/wechat-archive-workflow.json
`)} <"$workflow" >"$tmp/import.out" 2>"$tmp/import.err"
import_rc=$?
if [ "$import_rc" -eq 0 ] && [ "${params.active ? "1" : "0"}" = "1" ]; then
kubectl -n ${params.namespace} exec deploy/${params.deploymentName} -c n8n -- n8n update:workflow --id ${shQuote(params.workflowId)} --active=true >"$tmp/activate.out" 2>"$tmp/activate.err"
activate_rc=$?
else
: >"$tmp/activate.out"
if [ "$import_rc" -eq 0 ]; then
printf '%s\\n' 'workflow activation not requested by YAML' >"$tmp/activate.err"
activate_rc=0
else
printf '%s\\n' 'skipped because import failed' >"$tmp/activate.err"
activate_rc=1
fi
fi
if [ "$activate_rc" -eq 0 ] && [ "${params.active ? "1" : "0"}" = "1" ]; then
kubectl -n ${params.namespace} rollout restart deployment/${params.deploymentName} >"$tmp/restart.out" 2>"$tmp/restart.err"
restart_rc=$?
if [ "$restart_rc" -eq 0 ]; then
kubectl -n ${params.namespace} rollout status deployment/${params.deploymentName} --timeout=120s >"$tmp/rollout.out" 2>"$tmp/rollout.err"
rollout_rc=$?
else
: >"$tmp/rollout.out"
printf '%s\\n' 'skipped because rollout restart failed' >"$tmp/rollout.err"
rollout_rc=1
fi
else
: >"$tmp/restart.out"
: >"$tmp/rollout.out"
printf '%s\\n' 'skipped because activation failed or workflow is inactive by YAML' >"$tmp/restart.err"
printf '%s\\n' 'skipped because activation failed or workflow is inactive by YAML' >"$tmp/rollout.err"
restart_rc=0
rollout_rc=0
fi
kubectl -n ${params.namespace} exec deploy/${params.deploymentName} -c n8n -- n8n list:workflow >"$tmp/list.out" 2>"$tmp/list.err"
list_rc=$?
python3 - "$import_rc" "$activate_rc" "$restart_rc" "$rollout_rc" "$list_rc" "$tmp/import.out" "$tmp/import.err" "$tmp/activate.out" "$tmp/activate.err" "$tmp/restart.out" "$tmp/restart.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/list.out" "$tmp/list.err" <<'PY'
import json, sys
def text(path, limit=6000):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": int(sys.argv[1]) == 0 and int(sys.argv[2]) == 0 and int(sys.argv[3]) == 0 and int(sys.argv[4]) == 0,
"workflow": {"id": "${params.workflowId}", "name": "${params.workflowName}", "webhookPath": "${params.webhookPath}", "active": ${params.active ? "True" : "False"}},
"steps": {
"import": {"exitCode": int(sys.argv[1]), "stdout": text(sys.argv[6]), "stderr": text(sys.argv[7])},
"activate": {"exitCode": int(sys.argv[2]), "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])},
"restart": {"exitCode": int(sys.argv[3]), "stdout": text(sys.argv[10]), "stderr": text(sys.argv[11])},
"rollout": {"exitCode": int(sys.argv[4]), "stdout": text(sys.argv[12]), "stderr": text(sys.argv[13])},
"list": {"exitCode": int(sys.argv[5]), "stdout": text(sys.argv[14]), "stderr": text(sys.argv[15])},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
const result = await capture(config, params.targetRoute, ["sh"], script);
const parsed = parseJsonOutput(result.stdout);
return {
ok: result.exitCode === 0 && parsed?.ok === true,
mode: "confirmed",
workflow: { id: params.workflowId, name: params.workflowName, webhookPath: params.webhookPath, active: params.active },
remote: parsed ?? compactCapture(result, { full: true }),
};
}
export async function fetchJsonWithTimeout(url: string, body: unknown, timeoutMs: number): Promise<Record<string, unknown>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown = text;
try {
parsed = text.length > 0 ? JSON.parse(text) as unknown : null;
} catch {
parsed = text;
}
return { ok: response.ok, status: response.status, body: parsed };
} finally {
clearTimeout(timer);
}
}
export function renderTemplate(template: string, values: Record<string, string>): string {
return template.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/gu, (_match, key: string) => values[key] ?? "");
}
export function normalizeRemotePath(path: string): string {
const normalized = pathPosix.normalize(path);
if (!normalized.startsWith("/")) return `/${normalized}`;
return normalized;
}
export function relativeStagingPath(dir: string, filename: string): string {
return pathPosix.join(dir.replace(/^\/+|\/+$/gu, ""), basename(filename));
}
function optionKey(arg: string): string {
return arg.replace(/^--/u, "").replace(/-([a-z])/gu, (_match, letter: string) => letter.toUpperCase());
}
function redactSensitiveKey(key: string): boolean {
return /(?:password|secret|token|api[_-]?key|authorization|cookie|qrcode|usercode|verificationurl|dlink|thumbs)/iu.test(key);
}