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 { capture, compactCapture, parseJsonOutput, redactText, shQuote } from "./platform-infra-public-service"; export interface OpsCommonOptions { targetId: string; full: boolean; raw: boolean; } export interface OpsApplyOptions extends OpsCommonOptions { confirm: boolean; dryRun: boolean; wait: boolean; } export interface OpsCommandOptionSpec { stringOptions?: string[]; flagOptions?: string[]; } export function parseOpsCommonOptions(args: string[], spec: OpsCommandOptionSpec = {}): OpsCommonOptions & Record { const stringOptions = new Set(["--target", ...(spec.stringOptions ?? [])]); const flagOptions = new Set(["--full", "--raw", ...(spec.flagOptions ?? [])]); const values: Record = { targetId: "G14", 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 = String(values.targetId); if (!/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id"); return values as OpsCommonOptions & Record; } 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>(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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value as Record; } export function recordField(obj: Record, key: string, path: string): Record { return asRecord(obj[key], `${path}.${key}`); } export function stringField(obj: Record, 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; } export function optionalStringField(obj: Record, 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; } export function booleanField(obj: Record, 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, 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 arrayField(obj: Record, key: string, path: string): Record[] { 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, 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 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 | 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 : 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 { if (response.ok && typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)) return response.body as Record; throw new Error(`${context} failed: ${JSON.stringify(compactProxyResponse(response)).slice(0, 1200)}`); } export function compactProxyResponse(response: MicroserviceProxyResponse): Record { 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; const output: Record = {}; for (const key of Object.keys(record).slice(0, 20)) output[key] = redactSensitiveKey(key) ? "" : 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 = {}; for (const [key, nested] of Object.entries(value as Record)) output[key] = redactSensitiveKey(key) ? "" : redactSensitiveUnknown(nested); return output; } export async function waitForBaiduTransfer(serviceId: string, jobId: string, options: { timeoutMs: number; pollIntervalMs: number }): Promise> { const deadline = Date.now() + options.timeoutMs; let last: Record | 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 | 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; 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; manifest?: Record; } export async function syncN8nWorkflow(config: UniDeskConfig, params: { targetRoute: string; namespace: string; deploymentName: string; workflowJson: Record; workflowId: string; workflowName: string; webhookPath: string; active: boolean; dryRun: boolean; }): Promise { 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, ["script"], 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> { 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 { 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); }