Yimaru-CICD/index.ts
Kerod-Fresenbet-Gebremedhin2660 a52f68fb31 fix: go path and go root
2026-02-24 19:53:15 +03:00

527 lines
20 KiB
TypeScript

import { Hono } from "hono";
import type { Context } from "hono";
import {
initializeDatabase,
createDeployment,
updateDeploymentStatus,
getDeployments,
getDeploymentById,
getCommandsByDeploymentId,
type Deployment,
type Command,
} from "./db";
import {
loadEnvConfig,
verifySignature,
execCommand,
gitPullWithAuth,
type EnvConfig,
} from "./utils";
import { basename } from "node:path";
interface GiteaWebhookPayload {
repository?: {
name?: string;
owner?: {
login?: string;
};
full_name?: string;
};
ref?: string;
commits?: Array<{
id?: string;
message?: string;
}>;
}
// Deploy function signature
type DeployFunction = (
branch?: string,
commitHash?: string,
existingDeploymentId?: number
) => Promise<{ success: boolean; message: string; deploymentId?: number }>;
// Repository configuration - maps repo to its deploy function
interface RepoEntry {
repoName: string;
organization: string;
deploy: DeployFunction;
}
// Load environment configuration at startup
const env: EnvConfig = loadEnvConfig();
// Initialize database
initializeDatabase(process.env.DB_PATH);
// ============================================================================
// Repository Metadata Resolution
// ============================================================================
interface RepoMetadata {
repoPath: string;
organization: string;
repoName: string;
}
function resolveRepoMetadata(localPath: string, label: string): RepoMetadata {
const proc = Bun.spawnSync(["git", "config", "--get", "remote.origin.url"], {
cwd: localPath,
stdout: "pipe",
stderr: "pipe",
});
const decoder = new TextDecoder();
const remoteUrl = decoder.decode(proc.stdout).trim();
if (proc.exitCode !== 0 || !remoteUrl) {
console.error(`❌ Unable to resolve remote.origin.url for ${label} at ${localPath}`);
console.error(decoder.decode(proc.stderr).trim() || "No git remote configured");
process.exit(1);
}
const repoPath = normalizeRepoPath(remoteUrl);
const { organization, repoName } = extractOrganizationAndRepo(repoPath, localPath);
return { repoPath, organization, repoName };
}
function normalizeRepoPath(remoteUrl: string): string {
if (remoteUrl.startsWith("http://") || remoteUrl.startsWith("https://")) {
const url = new URL(remoteUrl);
return `${url.host}${url.pathname}`.replace(/^\/+/, "");
}
const sshMatch = remoteUrl.match(/^(?:.+@)?([^:]+):(.+)$/);
if (sshMatch) {
return `${sshMatch[1]}/${sshMatch[2]}`.replace(/^\/+/, "");
}
return remoteUrl.replace(/^\/+/, "");
}
function extractOrganizationAndRepo(repoPath: string, localPath: string): { organization: string; repoName: string } {
const cleaned = repoPath.replace(/\.git$/, "");
const parts = cleaned.split("/").filter(Boolean);
const organization = parts.at(-2);
const repoName = parts.at(-1);
if (organization && repoName) {
return { organization, repoName };
}
return { organization: "", repoName: basename(localPath) };
}
const adminRepo = resolveRepoMetadata(env.yimaruAdminPath, "Yimaru Admin");
const backendRepo = resolveRepoMetadata(env.yimaruBackendPath, "Yimaru Backend");
// ============================================================================
// Deploy Functions for Each Repository
// ============================================================================
async function deployYimaruAdmin(
branch?: string,
commitHash?: string,
existingDeploymentId?: number
): Promise<{ success: boolean; message: string; deploymentId?: number }> {
const repository = `${adminRepo.organization}/${adminRepo.repoName}`;
const repoPath = env.yimaruAdminPath;
const targetPath = "/var/www/html/yimaru_admin";
const deploymentId = existingDeploymentId ?? createDeployment(repository, branch, commitHash, "in_progress");
if (existingDeploymentId) updateDeploymentStatus(deploymentId, "in_progress");
console.log(`Starting deployment for Yimaru Admin (Deployment ID: ${deploymentId})...`);
try {
const gitResult = await gitPullWithAuth(
adminRepo.repoPath,
repoPath,
deploymentId,
env.productionBranch,
env.giteaUsername,
env.giteaPassword
);
if (!gitResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: gitResult.error!, deploymentId };
}
console.log(`Installing dependencies...`);
const installResult = await execCommand("bun install", repoPath, deploymentId);
if (!installResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `bun install failed: ${installResult.error || installResult.output}`, deploymentId };
}
console.log(`Building project...`);
const buildResult = await execCommand("bun run build", repoPath, deploymentId);
if (!buildResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `bun run build failed: ${buildResult.error || buildResult.output}`, deploymentId };
}
console.log(`Removing existing ${targetPath}...`);
const deleteResult = await execCommand(`sudo rm -rf "${targetPath}"`, undefined, deploymentId);
if (!deleteResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Failed to delete ${targetPath}: ${deleteResult.error || deleteResult.output}`, deploymentId };
}
console.log(`Moving dist to ${targetPath}...`);
const moveResult = await execCommand(`sudo mv "${repoPath}/dist" "${targetPath}"`, undefined, deploymentId);
if (!moveResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Failed to move dist: ${moveResult.error || moveResult.output}`, deploymentId };
}
console.log(`Restarting nginx...`);
const nginxResult = await execCommand("sudo systemctl restart nginx", undefined, deploymentId);
if (!nginxResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Failed to restart nginx: ${nginxResult.error || nginxResult.output}`, deploymentId };
}
console.log(`✅ Deployment successful`);
updateDeploymentStatus(deploymentId, "success");
return { success: true, message: "Successfully deployed Yimaru Admin", deploymentId };
} catch (error) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Deployment failed: ${error instanceof Error ? error.message : String(error)}`, deploymentId };
}
}
async function deployYimaruBackend(
branch?: string,
commitHash?: string,
existingDeploymentId?: number
): Promise<{ success: boolean; message: string; deploymentId?: number }> {
const repository = `${backendRepo.organization}/${backendRepo.repoName}`;
const repoPath = env.yimaruBackendPath;
const home = "/home/yimaru";
const goEnv = `export GOPATH=${home}/go GOROOT=/usr/lib/go-1.24 PATH=/usr/bin:${home}/go/bin:$PATH`;
const deploymentId = existingDeploymentId ?? createDeployment(repository, branch, commitHash, "in_progress");
if (existingDeploymentId) updateDeploymentStatus(deploymentId, "in_progress");
console.log(`Starting deployment for Yimaru Backend (Deployment ID: ${deploymentId})...`);
try {
// Git pull
const gitResult = await gitPullWithAuth(
backendRepo.repoPath,
repoPath,
deploymentId,
env.productionBranch,
env.giteaUsername,
env.giteaPassword
);
if (!gitResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: gitResult.error!, deploymentId };
}
// Go mod tidy
console.log(`Tidying Go modules...`);
const tidyResult = await execCommand(`${goEnv} && go mod tidy`, repoPath, deploymentId);
if (!tidyResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `go mod tidy failed: ${tidyResult.error || tidyResult.output}`, deploymentId };
}
// Stop service before DB operations
console.log(`Stopping Yimaru Backend service...`);
await execCommand("sudo systemctl stop yimaru-backend.service", undefined, deploymentId);
// Database backup → migrate → restore → seed
console.log(`Backing up database...`);
const backupResult = await execCommand("make backup", repoPath, deploymentId);
if (!backupResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `make backup failed: ${backupResult.error || backupResult.output}`, deploymentId };
}
console.log(`Running db-down...`);
const dbDownResult = await execCommand("make db-down", repoPath, deploymentId);
if (!dbDownResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `make db-down failed: ${dbDownResult.error || dbDownResult.output}`, deploymentId };
}
console.log(`Running db-up...`);
const dbUpResult = await execCommand("make db-up", repoPath, deploymentId);
if (!dbUpResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `make db-up failed: ${dbUpResult.error || dbUpResult.output}`, deploymentId };
}
// Wait for DB to be ready before restore
await execCommand("sleep 5", repoPath, deploymentId);
console.log(`Restoring database...`);
const restoreResult = await execCommand("make restore", repoPath, deploymentId);
if (!restoreResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `make restore failed: ${restoreResult.error || restoreResult.output}`, deploymentId };
}
await execCommand("sleep 2", repoPath, deploymentId);
console.log(`Seeding data...`);
const seedResult = await execCommand("make seed_data", repoPath, deploymentId);
if (!seedResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `make seed_data failed: ${seedResult.error || seedResult.output}`, deploymentId };
}
// Build Go binary
console.log(`Building Go binary...`);
const buildResult = await execCommand(
`${goEnv} && go build -ldflags='-s' -o ./bin/web ./cmd/main.go`,
repoPath,
deploymentId
);
if (!buildResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `go build failed: ${buildResult.error || buildResult.output}`, deploymentId };
}
// Restart service
console.log(`Restarting Yimaru Backend service...`);
const restartResult = await execCommand("sudo systemctl restart yimaru-backend.service", undefined, deploymentId);
if (!restartResult.success) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Failed to restart service: ${restartResult.error || restartResult.output}`, deploymentId };
}
console.log(`✅ Deployment successful`);
updateDeploymentStatus(deploymentId, "success");
return { success: true, message: "Successfully deployed Yimaru Backend", deploymentId };
} catch (error) {
updateDeploymentStatus(deploymentId, "failed");
return { success: false, message: `Deployment failed: ${error instanceof Error ? error.message : String(error)}`, deploymentId };
}
}
// ============================================================================
// Repository Configuration
// ============================================================================
const REPO_CONFIGS: RepoEntry[] = [
{
repoName: adminRepo.repoName,
organization: adminRepo.organization,
deploy: deployYimaruAdmin,
},
{
repoName: backendRepo.repoName,
organization: backendRepo.organization,
deploy: deployYimaruBackend,
},
];
/**
* Find repository entry by name
*/
function findRepoEntry(repoName: string, organization: string): RepoEntry | undefined {
return REPO_CONFIGS.find(
(entry) =>
entry.repoName === repoName &&
(entry.organization.length === 0 || entry.organization === organization)
);
}
// ============================================================================
// HTTP Handlers
// ============================================================================
/**
* Handle webhook request
*/
async function handleWebhook(c: Context): Promise<Response> {
try {
// Get signature and auth header from headers
const signature = c.req.header("X-Gitea-Signature") || "";
const authHeader = c.req.header("Authorization") || c.req.header("X-Gitea-Auth") || "";
const payload = await c.req.text();
// Verify auth header
if (authHeader !== env.webhookAuthHeader) {
console.error("Invalid webhook auth header");
return c.json({ error: "Invalid authorization" }, 401);
}
// Verify signature
if (!verifySignature(payload, signature, env.webhookSecret)) {
console.error("Invalid webhook signature");
return c.json({ error: "Invalid signature" }, 401);
}
// Parse payload
const data: GiteaWebhookPayload = JSON.parse(payload);
// Extract repository information
const repoName = data.repository?.name;
const organization = data.repository?.owner?.login || data.repository?.full_name?.split("/")[0];
const ref = data.ref || "";
const commitHash = data.commits?.[0]?.id || "";
if (!repoName || !organization) {
return c.json({ error: "Invalid webhook payload: missing repository information" }, 400);
}
// Extract branch name from ref (e.g., "refs/heads/main" -> "main")
const branch = ref.replace("refs/heads/", "") || undefined;
// Only process pushes to the production branch
if (ref !== `refs/heads/${env.productionBranch}`) {
console.log(`⚠️ Ignoring push to non-production branch: ${ref}`);
return c.json({
message: `Ignoring push to ${ref}. Only the production branch (${env.productionBranch}) triggers deployments.`,
}, 200);
}
console.log(`Received webhook for ${organization}/${repoName} on ${ref} (commit: ${commitHash})`);
// Find repository entry
const entry = findRepoEntry(repoName, organization);
if (!entry) {
console.warn(`No configuration found for ${organization}/${repoName}`);
return c.json({ message: `Repository ${organization}/${repoName} not configured` }, 200);
}
// Create deployment record immediately
const deploymentId = createDeployment(
`${entry.organization}/${entry.repoName}`,
branch,
commitHash,
"pending"
);
// Run deployment in background (don't await)
entry.deploy(branch, commitHash, deploymentId)
.then((result) => {
if (result.success) {
console.log(`✅ Deployment ${deploymentId} completed: ${result.message}`);
} else {
console.error(`❌ Deployment ${deploymentId} failed: ${result.message}`);
}
})
.catch((error) => {
console.error(`❌ Deployment ${deploymentId} error:`, error);
updateDeploymentStatus(deploymentId, "failed");
});
// Immediately respond to webhook
return c.json({
message: "Deployment started",
deploymentId,
repository: `${entry.organization}/${entry.repoName}`,
branch,
}, 202);
} catch (error) {
console.error("Error processing webhook:", error);
return c.json({
error: "Internal server error",
message: error instanceof Error ? error.message : String(error),
}, 500);
}
}
function handleHealthCheck(c: Context): Response {
return c.json({
status: "ok",
timestamp: new Date().toISOString(),
repositories: REPO_CONFIGS.map((entry) => ({
repo: `${entry.organization}/${entry.repoName}`,
})),
});
}
function handleGetDeployments(c: Context): Response {
const limit = parseInt(c.req.query("limit") || "50", 10);
const repository = c.req.query("repository");
const deployments = getDeployments(limit, repository);
return c.json({ deployments });
}
function handleGetDeployment(c: Context): Response {
const deploymentId = parseInt(c.req.param("id"), 10);
if (isNaN(deploymentId)) {
return c.json({ error: "Invalid deployment ID" }, 400);
}
const deployment = getDeploymentById(deploymentId);
if (!deployment) {
return c.json({ error: "Deployment not found" }, 404);
}
const commands = getCommandsByDeploymentId(deploymentId);
return c.json({ deployment, commands });
}
// ============================================================================
// Hono App Setup
// ============================================================================
const app = new Hono();
// HTML Pages
app.get("/", async (c: Context) => {
const limit = parseInt(c.req.query("limit") || "50", 10);
const repository = c.req.query("repository");
const deployments = getDeployments(limit, repository);
const { DeploymentsList } = await import("./pages/DeploymentsList");
const html = DeploymentsList({ deployments });
return c.html(html);
});
app.get("/deployments/:id", async (c: Context) => {
const deploymentId = parseInt(c.req.param("id"), 10);
if (isNaN(deploymentId)) {
return c.html("<html><body><h1>Invalid deployment ID</h1></body></html>", 400);
}
const deployment = getDeploymentById(deploymentId);
if (!deployment) {
return c.html("<html><body><h1>Deployment not found</h1></body></html>", 404);
}
const commands = getCommandsByDeploymentId(deploymentId);
const { DeploymentDetail } = await import("./pages/DeploymentDetail");
const html = DeploymentDetail({ deployment, commands });
return c.html(html);
});
// Health check endpoint
app.get("/health", handleHealthCheck);
// API endpoints (JSON)
app.get("/api/deployments", handleGetDeployments);
app.get("/api/deployments/:id", handleGetDeployment);
// Webhook endpoints
app.post("/webhook", handleWebhook);
app.post("/webhook/gitea", handleWebhook);
// 404 handler
app.notFound((c: Context) => {
return c.json({ error: "Not found" }, 404);
});
// Start server
const server = Bun.serve({
port: env.port,
fetch: app.fetch,
});
console.log(`🚀 Gitea Webhook CD Server running on http://localhost:${server.port}`);
console.log(`📡 Webhook endpoint: http://localhost:${server.port}/webhook`);
console.log(`❤️ Health check: http://localhost:${server.port}/health`);
console.log(`📊 Deployments API: http://localhost:${server.port}/deployments`);
console.log(`💾 Database: ${process.env.DB_PATH || "deployments.db"}`);