edfddd2445
* docs: specify cicd yaml target governance * fix: resolve cicd targets from yaml --------- Co-authored-by: Codex <codex@noreply.local>
713 lines
33 KiB
TypeScript
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);
|
|
}
|