From 25f1a76803196ca14f719ac4f2f0d955c918a11d Mon Sep 17 00:00:00 2001 From: Kerod-Fresenbet-Gebremedhin2660 Date: Fri, 30 Jan 2026 17:30:42 +0300 Subject: [PATCH] - --- .../use-bun-instead-of-node-vite-npm-pnpm.mdc | 111 +++++ .gitignore | 34 ++ README.md | 35 ++ bun.lock | 31 ++ components/Layout.ts | 268 +++++++++++++ db.ts | 148 +++++++ index.ts | 379 ++++++++++++++++++ package.json | 15 + pages/DeploymentDetail.ts | 104 +++++ pages/DeploymentsList.ts | 77 ++++ tsconfig.json | 29 ++ utils.ts | 259 ++++++++++++ yimaru-cd.service | 32 ++ 13 files changed, 1522 insertions(+) create mode 100644 .cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 components/Layout.ts create mode 100644 db.ts create mode 100644 index.ts create mode 100644 package.json create mode 100644 pages/DeploymentDetail.ts create mode 100644 pages/DeploymentsList.ts create mode 100644 tsconfig.json create mode 100644 utils.ts create mode 100644 yimaru-cd.service diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1fd20e --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# yimaru-cicd + +Gitea webhook CD server for: +- Yimaru admin repository (git pull) +- Yimaru backend repository (git pull) + +## Setup + +Install dependencies: + +```bash +bun install +``` + +Create a `.env` with at least: + +``` +PORT=3000 +PRODUCTION_BRANCH=main +GITEA_WEBHOOK_SECRET=... +GITEA_WEBHOOK_AUTH_HEADER=... +GITEA_USERNAME=... +GITEA_PASSWORD=... + +YIMARU_ADMIN_PATH=/srv/apps/yimaru_admin +YIMARU_BACKEND_PATH=/srv/apps/Yimaru-BackEnd +``` + +Run locally: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5455a82 --- /dev/null +++ b/bun.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "yimaru-cicd", + "dependencies": { + "hono": "^4.11.7", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + + "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/components/Layout.ts b/components/Layout.ts new file mode 100644 index 0000000..e6f5b21 --- /dev/null +++ b/components/Layout.ts @@ -0,0 +1,268 @@ +interface LayoutProps { + title: string; + children: string; +} + +const styles = ` + :root { + --primary-green: #0F7B4A; + --primary-green-dark: #0a5c37; + --secondary-orange: #FFB668; + --secondary-orange-dark: #e9a050; + --error-red: #EF4444; + --error-red-light: #FEE2E2; + --text-dark: #1a1a1a; + --text-muted: #6b7280; + --bg-white: #ffffff; + --bg-light: #f9fafb; + --border-light: #e5e7eb; + } + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: var(--bg-light); + color: var(--text-dark); + line-height: 1.6; + } + .container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + } + header { + background: var(--primary-green); + color: white; + padding: 20px 0; + margin-bottom: 30px; + box-shadow: 0 2px 8px rgba(15, 123, 74, 0.3); + } + header h1 { + font-size: 24px; + font-weight: 600; + } + nav { + margin-top: 10px; + } + nav a { + color: rgba(255, 255, 255, 0.9); + text-decoration: none; + margin-right: 20px; + padding: 6px 14px; + border-radius: 6px; + transition: all 0.2s ease; + font-weight: 500; + } + nav a:hover { + background: rgba(255, 255, 255, 0.15); + color: white; + } + h2, h3 { + color: var(--primary-green); + } + .card { + background: var(--bg-white); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-light); + } + .badge { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .badge-success { + background: var(--primary-green); + color: white; + } + .badge-failed { + background: var(--error-red); + color: white; + } + .badge-in_progress, .badge-pending { + background: var(--secondary-orange); + color: var(--text-dark); + } + table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + } + th, td { + padding: 14px 12px; + text-align: left; + border-bottom: 1px solid var(--border-light); + } + th { + background: var(--bg-light); + font-weight: 600; + color: var(--text-dark); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + tr:hover { + background: var(--bg-light); + } + .command { + background: var(--bg-light); + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 13px; + border: 1px solid var(--border-light); + } + .command-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; + gap: 8px; + } + .command-header strong { + color: var(--text-dark); + word-break: break-all; + } + .command-text { + color: var(--text-muted); + margin-bottom: 10px; + } + .command-output, .command-error { + margin-top: 12px; + padding: 12px; + border-radius: 6px; + max-height: 300px; + overflow: auto; + } + .command-output { + background: #f0fdf4; + border: 1px solid #bbf7d0; + } + .command-output strong { + color: var(--primary-green); + display: block; + margin-bottom: 8px; + } + .command-output pre { + color: var(--primary-green-dark); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + font-family: inherit; + font-size: 12px; + line-height: 1.5; + } + .command-error { + background: var(--error-red-light); + border: 1px solid #fecaca; + } + .command-error strong { + color: var(--error-red); + display: block; + margin-bottom: 8px; + } + .command-error pre { + color: #991b1b; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + font-family: inherit; + font-size: 12px; + line-height: 1.5; + } + .timestamp { + color: var(--text-muted); + font-size: 13px; + } + a { + color: var(--primary-green); + text-decoration: none; + font-weight: 500; + } + a:hover { + color: var(--primary-green-dark); + text-decoration: underline; + } + code { + background: var(--bg-light); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Courier New', monospace; + font-size: 12px; + border: 1px solid var(--border-light); + } + .btn { + display: inline-block; + padding: 8px 16px; + border-radius: 6px; + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; + cursor: pointer; + border: none; + } + .btn-primary { + background: var(--primary-green); + color: white; + } + .btn-primary:hover { + background: var(--primary-green-dark); + text-decoration: none; + color: white; + } + .btn-secondary { + background: var(--secondary-orange); + color: var(--text-dark); + } + .btn-secondary:hover { + background: var(--secondary-orange-dark); + text-decoration: none; + } + .btn-danger { + background: var(--error-red); + color: white; + } + .btn-danger:hover { + background: #dc2626; + text-decoration: none; + color: white; + } +`; + +export function Layout({ title, children }: LayoutProps): string { + return ` + + + + + ${title} - Yimaru CI/CD + + + +
+
+

🚀 Yimaru CI/CD Dashboard

+ +
+
+
+ ${children} +
+ +`; +} + diff --git a/db.ts b/db.ts new file mode 100644 index 0000000..eb0ea67 --- /dev/null +++ b/db.ts @@ -0,0 +1,148 @@ +import { Database } from "bun:sqlite"; + +export interface Deployment { + id?: number; + created_at?: string; + repository: string; + branch?: string; + commit_hash?: string; + status: string; +} + +export interface Command { + id?: number; + deployment_id: number; + command: string; + stdout?: string; + stderr?: string; + exit_code?: number; + success: boolean; +} + +let db: Database; + +/** + * Initialize SQLite database with schema + */ +export function initializeDatabase(dbPath?: string): Database { + db = new Database(dbPath || "deployments.db"); + + // Create deployments table + db.exec(` + CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + repository TEXT NOT NULL, + branch TEXT, + commit_hash TEXT, + status TEXT NOT NULL + ) + `); + + // Create commands table + db.exec(` + CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deployment_id INTEGER, + command TEXT, + stdout TEXT, + stderr TEXT, + exit_code INTEGER, + success BOOLEAN, + FOREIGN KEY(deployment_id) REFERENCES deployments(id) + ) + `); + + console.log("✅ Database initialized successfully"); + return db; +} + +/** + * Get database instance + */ +export function getDb(): Database { + if (!db) { + throw new Error("Database not initialized. Call initializeDatabase first."); + } + return db; +} + +/** + * Create a deployment record in the database + */ +export function createDeployment( + repository: string, + branch?: string, + commitHash?: string, + status: string = "in_progress" +): number { + const insertDeployment = getDb().prepare(` + INSERT INTO deployments (repository, branch, commit_hash, status) + VALUES (?, ?, ?, ?) + `); + const result = insertDeployment.run(repository, branch || null, commitHash || null, status); + return result.lastInsertRowid as number; +} + +/** + * Update deployment status + */ +export function updateDeploymentStatus(deploymentId: number, status: string): void { + const updateDeployment = getDb().prepare(` + UPDATE deployments SET status = ? WHERE id = ? + `); + updateDeployment.run(status, deploymentId); +} + +/** + * Insert a command record + */ +export function insertCommand( + deploymentId: number, + command: string, + stdout: string, + stderr: string | null, + exitCode: number, + success: boolean +): void { + const insertCmd = getDb().prepare(` + INSERT INTO commands (deployment_id, command, stdout, stderr, exit_code, success) + VALUES (?, ?, ?, ?, ?, ?) + `); + insertCmd.run(deploymentId, command, stdout, stderr, exitCode, success ? 1 : 0); +} + +/** + * Get deployments with optional filtering + */ +export function getDeployments(limit: number = 50, repository?: string): Deployment[] { + let query = "SELECT * FROM deployments"; + const params: any[] = []; + + if (repository) { + query += " WHERE repository = ?"; + params.push(repository); + } + + query += " ORDER BY created_at DESC LIMIT ?"; + params.push(limit); + + const stmt = getDb().prepare(query); + return stmt.all(...params) as Deployment[]; +} + +/** + * Get deployment by ID + */ +export function getDeploymentById(deploymentId: number): Deployment | undefined { + const stmt = getDb().prepare("SELECT * FROM deployments WHERE id = ?"); + return stmt.get(deploymentId) as Deployment | undefined; +} + +/** + * Get commands for a deployment + */ +export function getCommandsByDeploymentId(deploymentId: number): Command[] { + const stmt = getDb().prepare("SELECT * FROM commands WHERE deployment_id = ? ORDER BY id ASC"); + return stmt.all(deploymentId) as Command[]; +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..bff4878 --- /dev/null +++ b/index.ts @@ -0,0 +1,379 @@ +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); + +// ============================================================================ +// Deploy Functions for Each Repository +// ============================================================================ + +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) }; +} + +function createPullOnlyDeploy( + label: string, + repoPath: string, + localPath: string +): DeployFunction { + return async ( + branch?: string, + commitHash?: string, + existingDeploymentId?: number + ): Promise<{ success: boolean; message: string; deploymentId?: number }> => { + const repository = repoPath.replace(/\.git$/, ""); + const deploymentId = existingDeploymentId ?? createDeployment(repository, branch, commitHash, "in_progress"); + if (existingDeploymentId) updateDeploymentStatus(deploymentId, "in_progress"); + + console.log(`Starting deployment for ${label} (Deployment ID: ${deploymentId})...`); + + try { + const gitResult = await gitPullWithAuth( + repoPath, + localPath, + deploymentId, + env.productionBranch, + env.giteaUsername, + env.giteaPassword + ); + + if (!gitResult.success) { + updateDeploymentStatus(deploymentId, "failed"); + return { success: false, message: gitResult.error!, deploymentId }; + } + + console.log(`✅ Deployment successful`); + updateDeploymentStatus(deploymentId, "success"); + return { success: true, message: `Successfully updated ${label}`, deploymentId }; + } catch (error) { + updateDeploymentStatus(deploymentId, "failed"); + return { success: false, message: `Deployment failed: ${error instanceof Error ? error.message : String(error)}`, deploymentId }; + } + }; +} + +// ============================================================================ +// Repository Configuration +// ============================================================================ + +const adminRepo = resolveRepoMetadata(env.yimaruAdminPath, "Yimaru Admin"); +const backendRepo = resolveRepoMetadata(env.yimaruBackendPath, "Yimaru Backend"); + +const REPO_CONFIGS: RepoEntry[] = [ + { + repoName: adminRepo.repoName, + organization: adminRepo.organization, + deploy: createPullOnlyDeploy("Yimaru Admin", adminRepo.repoPath, env.yimaruAdminPath), + }, + { + repoName: backendRepo.repoName, + organization: backendRepo.organization, + deploy: createPullOnlyDeploy("Yimaru Backend", backendRepo.repoPath, env.yimaruBackendPath), + }, +]; + +/** + * 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 { + 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("

Invalid deployment ID

", 400); + } + + const deployment = getDeploymentById(deploymentId); + if (!deployment) { + return c.html("

Deployment not found

", 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"}`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d055232 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "yimaru-cicd", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.11.7" + } +} diff --git a/pages/DeploymentDetail.ts b/pages/DeploymentDetail.ts new file mode 100644 index 0000000..c38a8fc --- /dev/null +++ b/pages/DeploymentDetail.ts @@ -0,0 +1,104 @@ +import { Layout } from "../components/Layout"; +import type { Deployment, Command } from "../index"; + +interface DeploymentDetailProps { + deployment: Deployment; + commands: Command[]; +} + +function getStatusBadge(status: string): string { + const statusClass = status === "success" ? "badge-success" : + status === "failed" ? "badge-failed" : + status === "pending" ? "badge-pending" : + "badge-in_progress"; + return `${status}`; +} + +function formatDate(dateString: string | undefined): string { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleString(); +} + +function escapeHtml(text: string | undefined): string { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function DeploymentDetail({ deployment, commands }: DeploymentDetailProps): string { + const content = ` +
+
+

Deployment #${deployment.id}

+ ← Back to List +
+ +
+
+ Repository: +

${escapeHtml(deployment.repository)}

+
+
+ Branch: +

${escapeHtml(deployment.branch) || "N/A"}

+
+
+ Commit: +

+ ${deployment.commit_hash ? + `${escapeHtml(deployment.commit_hash)}` : + "N/A"} +

+
+
+ Status: +

${getStatusBadge(deployment.status)}

+
+
+ Created At: +

${formatDate(deployment.created_at)}

+
+
+ +

Commands (${commands.length})

+ + ${commands.length === 0 ? + "

No commands executed.

" : + commands.map((command) => ` +
+
+ ${escapeHtml(command.command)} +
+ ${command.success ? + 'Success' : + 'Failed'} + ${command.exit_code !== null ? + `Exit: ${command.exit_code}` : + ""} +
+
+ ${command.stdout ? ` +
+ Output: +
${escapeHtml(command.stdout)}
+
+ ` : ""} + ${command.stderr ? ` +
+ Error: +
${escapeHtml(command.stderr)}
+
+ ` : ""} +
+ `).join("")} +
+ `; + + return Layout({ title: `Deployment #${deployment.id}`, children: content }); +} + diff --git a/pages/DeploymentsList.ts b/pages/DeploymentsList.ts new file mode 100644 index 0000000..9872f9c --- /dev/null +++ b/pages/DeploymentsList.ts @@ -0,0 +1,77 @@ +import { Layout } from "../components/Layout"; +import type { Deployment } from "../index"; + +interface DeploymentsListProps { + deployments: Deployment[]; +} + +function getStatusBadge(status: string): string { + const statusClass = status === "success" ? "badge-success" : + status === "failed" ? "badge-failed" : + status === "pending" ? "badge-pending" : + "badge-in_progress"; + return `${status}`; +} + +function formatDate(dateString: string | undefined): string { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleString(); +} + +function escapeHtml(text: string | undefined): string { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function DeploymentsList({ deployments }: DeploymentsListProps): string { + const content = ` +
+

Deployment History

+ ${deployments.length === 0 ? + "

No deployments yet.

" : + ` + + + + + + + + + + + + + ${deployments.map((deployment) => ` + + + + + + + + + + `).join("")} + +
IDRepositoryBranchCommitStatusCreated AtActions
${deployment.id}${escapeHtml(deployment.repository)}${escapeHtml(deployment.branch) || "N/A"} + ${deployment.commit_hash ? + `${escapeHtml(deployment.commit_hash.substring(0, 7))}` : + "N/A"} + ${getStatusBadge(deployment.status)} + ${formatDate(deployment.created_at)} + + View +
`} +
+ `; + + return Layout({ title: "Deployments", children: content }); +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..9ec859b --- /dev/null +++ b/utils.ts @@ -0,0 +1,259 @@ +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 }; +} diff --git a/yimaru-cd.service b/yimaru-cd.service new file mode 100644 index 0000000..0065744 --- /dev/null +++ b/yimaru-cd.service @@ -0,0 +1,32 @@ +[Unit] +Description=Yimaru CI/CD Webhook Server +After=network.target + +[Service] +Type=simple +User=yimaru +Group=yimaru +WorkingDirectory=/home/yimaru/yimaru_services/yimaru_cicd +Environment="NODE_ENV=production" +Environment="PATH=/home/yimaru/.bun/bin:/home/yimaru/.nvm/versions/node/v20/bin:/home/yimaru/.local/bin:/usr/local/bin:/usr/bin:/bin" +EnvironmentFile=/home/yimaru/yimaru_services/yimaru_cicd/.env +ExecStart=/home/yimaru/.bun/bin/bun dist/index.js +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=yimaru-cd + +# Security settings +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/yimaru/yimaru_services/yimaru_cicd /home/yimaru/.npm /var/www/html + +# Resource limits +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +