Yimaru-CICD/utils.ts
Kerod-Fresenbet-Gebremedhin2660 25f1a76803 -
2026-01-30 17:30:42 +03:00

260 lines
8.2 KiB
TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";
import { insertCommand } from "./db";
export interface EnvConfig {
webhookSecret: string;
webhookAuthHeader: string;
giteaUsername: string;
giteaPassword: string;
yimaruAdminPath: string;
yimaruBackendPath: string;
port: number;
dbPath?: string;
productionBranch: string;
}
/**
* Load and validate environment variables
* Exits the process if any required variable is missing
*/
export function loadEnvConfig(): EnvConfig {
const requiredVars = [
"GITEA_WEBHOOK_SECRET",
"GITEA_WEBHOOK_AUTH_HEADER",
"GITEA_USERNAME",
"GITEA_PASSWORD",
"YIMARU_ADMIN_PATH",
"YIMARU_BACKEND_PATH",
];
const missing: string[] = [];
for (const varName of requiredVars) {
if (!process.env[varName]) {
missing.push(varName);
}
}
if (missing.length > 0) {
console.error("❌ Missing required environment variables:");
missing.forEach((varName) => console.error(` - ${varName}`));
console.error("\nPlease set all required environment variables in your .env file.");
process.exit(1);
}
const port = parseInt(process.env.PORT || "", 10);
if (isNaN(port) || port < 1 || port > 65535) {
console.error(`❌ Invalid PORT value: ${process.env.PORT}`);
process.exit(1);
}
// Parse production branch from environment variable
const productionBranchRaw = process.env.PRODUCTION_BRANCH;
const productionBranch = typeof productionBranchRaw === "string" ? productionBranchRaw.trim() : "";
if (!productionBranch || productionBranch.length === 0) {
console.error("❌ PRODUCTION_BRANCH must contain a valid branch name");
process.exit(1);
}
console.log(`✅ Production branch configured: ${productionBranch}`);
return {
webhookSecret: process.env.GITEA_WEBHOOK_SECRET!,
webhookAuthHeader: process.env.GITEA_WEBHOOK_AUTH_HEADER!,
giteaUsername: process.env.GITEA_USERNAME!,
giteaPassword: process.env.GITEA_PASSWORD!,
yimaruAdminPath: process.env.YIMARU_ADMIN_PATH!,
yimaruBackendPath: process.env.YIMARU_BACKEND_PATH!,
port,
productionBranch,
};
}
/**
* Verify Gitea webhook signature
*/
export function verifySignature(payload: string, signature: string, webhookSecret: string): boolean {
const hmac = createHmac("sha256", webhookSecret);
hmac.update(payload);
const expectedHex = hmac.digest("hex");
const expectedSignature = `sha256=${expectedHex}`;
// Normalize the signature - Gitea may send with or without "sha256=" prefix
const normalizedSignature = signature.startsWith("sha256=")
? signature
: `sha256=${signature}`;
// Ensure both buffers have the same length before comparing
const signatureBuffer = Buffer.from(normalizedSignature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length) {
console.error(`Signature length mismatch: received ${signatureBuffer.length}, expected ${expectedBuffer.length}`);
return false;
}
return timingSafeEqual(signatureBuffer, expectedBuffer);
}
/**
* Execute shell command and return result
*/
export async function execCommand(
command: string,
cwd?: string,
deploymentId?: number
): Promise<{ success: boolean; output: string; error?: string; exitCode?: number }> {
try {
// Build PATH that includes node_modules/.bin for local binaries (like tsc)
const currentPath = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
const nodeModulesBin = cwd ? `${cwd}/node_modules/.bin` : "";
const enhancedPath = nodeModulesBin ? `${nodeModulesBin}:${currentPath}` : currentPath;
// Use shell to properly handle quoted arguments and special characters
const proc = Bun.spawn(["/bin/sh", "-c", command], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
PATH: enhancedPath,
},
});
const output = await new Response(proc.stdout).text();
const error = await new Response(proc.stderr).text();
await proc.exited;
const exitCode = proc.exitCode || 0;
const success = exitCode === 0;
// Store command in database if deploymentId is provided
if (deploymentId !== undefined) {
insertCommand(deploymentId, command, output, error || null, exitCode, success);
}
if (!success) {
return {
success: false,
output,
error: error || `Command failed with exit code ${exitCode}`,
exitCode,
};
}
return {
success: true,
output,
exitCode,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// Store command in database even on exception
if (deploymentId !== undefined) {
insertCommand(deploymentId, command, "", errorMessage, -1, false);
}
return {
success: false,
output: "",
error: errorMessage,
exitCode: -1,
};
}
}
/**
* Build authenticated git URL
*/
export function buildAuthenticatedUrl(
repoPath: string,
username: string,
password: string
): { url: string; redacted: string } {
const escapedUsername = encodeURIComponent(username);
const escapedPassword = encodeURIComponent(password);
const url = `https://${escapedUsername}:${escapedPassword}@${repoPath}`;
const redacted = `https://***:***@${repoPath}`;
return { url, redacted };
}
/**
* Git pull with authentication (for PM2 services with writable .git)
*/
export async function gitPullWithAuth(
repoPath: string,
localPath: string,
deploymentId: number,
productionBranch: string,
username: string,
password: string
): Promise<{ success: boolean; error?: string }> {
const { url, redacted } = buildAuthenticatedUrl(repoPath, username, password);
// Stash local changes
console.log(`Stashing local changes...`);
await execCommand(`git stash save "local changes ${new Date().toISOString()}"`, localPath, deploymentId);
// Reset to HEAD
console.log(`Resetting to HEAD...`);
const resetResult = await execCommand("git reset --hard HEAD", localPath, deploymentId);
if (!resetResult.success) {
return { success: false, error: `Git reset failed: ${resetResult.error || resetResult.output}` };
}
// Fetch
console.log(`Fetching from remote...`);
const fetchProc = Bun.spawn(
["git", "fetch", url, productionBranch],
{ cwd: localPath, stdout: "pipe", stderr: "pipe" }
);
const fetchOutput = await new Response(fetchProc.stdout).text();
const fetchError = await new Response(fetchProc.stderr).text();
await fetchProc.exited;
insertCommand(
deploymentId,
`git fetch ${redacted} ${productionBranch}`,
fetchOutput,
fetchError || null,
fetchProc.exitCode || 0,
fetchProc.exitCode === 0
);
if (fetchProc.exitCode !== 0) {
return { success: false, error: `Git fetch failed: ${fetchError || fetchOutput}` };
}
// Checkout branch
await execCommand(`git checkout ${productionBranch}`, localPath, deploymentId);
// Pull
console.log(`Pulling latest changes...`);
const pullProc = Bun.spawn(
["git", "pull", url, productionBranch],
{ cwd: localPath, stdout: "pipe", stderr: "pipe" }
);
const pullOutput = await new Response(pullProc.stdout).text();
const pullError = await new Response(pullProc.stderr).text();
await pullProc.exited;
insertCommand(
deploymentId,
`git pull ${redacted} ${productionBranch}`,
pullOutput,
pullError || null,
pullProc.exitCode || 0,
pullProc.exitCode === 0
);
if (pullProc.exitCode !== 0) {
return { success: false, error: `Git pull failed: ${pullError || pullOutput}` };
}
console.log(`Git pull successful`);
return { success: true };
}