436 lines
19 KiB
TypeScript
436 lines
19 KiB
TypeScript
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<string, string | boolean> {
|
|
const stringOptions = new Set(["--target", ...(spec.stringOptions ?? [])]);
|
|
const flagOptions = new Set(["--full", "--raw", ...(spec.flagOptions ?? [])]);
|
|
const values: Record<string, string | boolean> = { 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<string, string | boolean>;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 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 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, ["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<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);
|
|
}
|