import fs from "fs"; import path from "path"; import crypto from "crypto"; import { spawn } from "child_process"; import { config } from "../config/env.js"; import { DeploymentProject, DeploymentProjectDocument, ComposeFile, DeploymentEnv } from "../models/deploymentProject.js"; import { DeploymentRun } from "../models/deploymentRun.js"; import { Settings } from "../models/settings.js"; const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"]; function normalizeRoot(rootPath: string) { return path.resolve(rootPath); } function isWithinRoot(rootPath: string, targetPath: string) { const resolvedRoot = normalizeRoot(rootPath); const resolvedTarget = path.resolve(targetPath); return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`); } function generateWebhookToken() { return crypto.randomBytes(12).toString("base64url").slice(0, 16); } function generateApiToken() { return crypto.randomBytes(24).toString("base64url"); } function generateSecret() { return crypto.randomBytes(32).toString("base64url"); } function deriveEnv(composeFile: ComposeFile): DeploymentEnv { return composeFile === "docker-compose.dev.yml" ? "dev" : "prod"; } function runCommand(command: string, cwd: string, onData: (chunk: string) => void) { return new Promise((resolve, reject) => { const child = spawn(command, { cwd, shell: true, env: { ...process.env, CI: process.env.CI || "1" } }); const emitLines = (chunk: Buffer) => { const cleaned = chunk.toString().replace(/\r\n|\r/g, "\n"); cleaned.split("\n").forEach((line) => { if (line.trim().length > 0) onData(line); }); }; child.stdout.on("data", emitLines); child.stderr.on("data", emitLines); child.on("error", (err) => { onData(`Hata: ${err.message}`); reject(err); }); child.on("close", (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Komut kod ${code} ile kapandı`)); } }); }); } function runCommandCapture(command: string, args: string[], cwd: string) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (err) => { reject(err); }); child.on("close", (code) => { if (code === 0) { resolve(stdout); } else { reject(new Error(stderr.trim() || `Komut kod ${code} ile kapandı`)); } }); }); } async function ensureSafeDirectory(repoDir: string, onData: (line: string) => void) { onData(`Git safe.directory ekleniyor: ${repoDir}`); await runCommand(`git config --global --add safe.directory ${repoDir}`, process.cwd(), onData); } async function ensureRepo(project: DeploymentProjectDocument, onData: (line: string) => void) { const repoDir = project.rootPath; const gitDir = path.join(repoDir, ".git"); const exists = fs.existsSync(gitDir); await ensureSafeDirectory(repoDir, onData); if (!exists) { const entries = await fs.promises.readdir(repoDir); if (entries.length > 0) { throw new Error("Repo klasoru git olmayan dosyalar iceriyor"); } onData(`Repo klonlanıyor: ${project.repoUrl}`); await runCommand(`git clone --branch ${project.branch} ${project.repoUrl} .`, repoDir, onData); } else { onData("Repo güncelleniyor (git fetch/pull)..."); await runCommand(`git fetch origin ${project.branch}`, repoDir, onData); try { await runCommand(`git checkout ${project.branch}`, repoDir, onData); } catch { await runCommand(`git checkout -b ${project.branch} origin/${project.branch}`, repoDir, onData); } await runCommand(`git pull origin ${project.branch}`, repoDir, onData); } } async function runCompose( project: DeploymentProjectDocument, onData: (line: string) => void ) { const composePath = path.join(project.rootPath, project.composeFile); if (!fs.existsSync(composePath)) { throw new Error("Compose dosyası bulunamadı"); } onData("Docker compose down çalıştırılıyor..."); await runCommand(`docker compose -f ${project.composeFile} down`, project.rootPath, onData); onData("Docker compose up (build) çalıştırılıyor..."); await runCommand( `docker compose -f ${project.composeFile} up -d --build`, project.rootPath, onData ); } class DeploymentService { private running: Map = new Map(); async scanRoot() { const rootPath = normalizeRoot(config.deploymentsRoot); if (!fs.existsSync(rootPath)) { throw new Error("Deployments root bulunamadı"); } const entries = await fs.promises.readdir(rootPath, { withFileTypes: true }); const candidates = []; for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name.startsWith(".")) continue; const folderPath = path.join(rootPath, entry.name); const available = composeFileCandidates.filter((file) => fs.existsSync(path.join(folderPath, file)) ); if (available.length === 0) continue; candidates.push({ name: entry.name, rootPath: folderPath, composeFiles: available }); } return candidates; } async listRemoteBranches(repoUrl: string) { const output = await runCommandCapture("git", ["ls-remote", "--heads", repoUrl], process.cwd()); const branches = output .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => line.split("\t")[1] || "") .filter((ref) => ref.startsWith("refs/heads/")) .map((ref) => ref.replace("refs/heads/", "")); return branches; } async ensureSettings() { const existing = await Settings.findOne(); if (existing) return existing; const created = await Settings.create({ webhookToken: generateApiToken(), webhookSecret: generateSecret() }); return created; } async rotateToken() { const settings = await this.ensureSettings(); settings.webhookToken = generateApiToken(); await settings.save(); return settings; } async rotateSecret() { const settings = await this.ensureSettings(); settings.webhookSecret = generateSecret(); await settings.save(); return settings; } async createProject(input: { name: string; rootPath: string; repoUrl: string; branch: string; composeFile: ComposeFile; port?: number; }) { const rootPath = path.resolve(input.rootPath); if (!isWithinRoot(config.deploymentsRoot, rootPath)) { throw new Error("Root path deployments root dışında"); } if (!fs.existsSync(rootPath)) { throw new Error("Root path bulunamadı"); } const composePath = path.join(rootPath, input.composeFile); if (!fs.existsSync(composePath)) { throw new Error("Compose dosyası bulunamadı"); } const existing = await DeploymentProject.findOne({ rootPath }); if (existing) { throw new Error("Bu klasör zaten eklenmiş"); } let webhookToken = generateWebhookToken(); while (await DeploymentProject.findOne({ webhookToken })) { webhookToken = generateWebhookToken(); } const env = deriveEnv(input.composeFile); return DeploymentProject.create({ name: input.name, rootPath, repoUrl: input.repoUrl, branch: input.branch, composeFile: input.composeFile, webhookToken, env, port: input.port }); } async updateProject( id: string, input: { name: string; repoUrl: string; branch: string; composeFile: ComposeFile; port?: number; } ) { const project = await DeploymentProject.findById(id); if (!project) return null; const composePath = path.join(project.rootPath, input.composeFile); if (!fs.existsSync(composePath)) { throw new Error("Compose dosyası bulunamadı"); } const env = deriveEnv(input.composeFile); const updated = await DeploymentProject.findByIdAndUpdate( id, { name: input.name, repoUrl: input.repoUrl, branch: input.branch, composeFile: input.composeFile, env, port: input.port }, { new: true, runValidators: true } ); return updated; } async runDeployment(projectId: string, options?: { message?: string }) { if (this.running.get(projectId)) { return; } this.running.set(projectId, true); const project = await DeploymentProject.findById(projectId); if (!project) { this.running.delete(projectId); return; } const startedAt = Date.now(); const runLogs: string[] = []; const pushLog = (line: string) => { runLogs.push(line); }; const runDoc = await DeploymentRun.create({ project: projectId, status: "running", startedAt: new Date(), message: options?.message }); await DeploymentProject.findByIdAndUpdate(projectId, { lastStatus: "running", lastMessage: options?.message || "Deploy başlıyor..." }); try { await ensureRepo(project, (line) => pushLog(line)); pushLog("Deploy komutları çalıştırılıyor..."); await runCompose(project, (line) => pushLog(line)); const duration = Date.now() - startedAt; await DeploymentProject.findByIdAndUpdate(projectId, { lastStatus: "success", lastDeployAt: new Date(), lastMessage: options?.message || "Başarılı" }); await DeploymentRun.findByIdAndUpdate(runDoc._id, { status: "success", finishedAt: new Date(), durationMs: duration, logs: runLogs, message: options?.message }); pushLog("Deploy tamamlandı: Başarılı"); } catch (err) { const duration = Date.now() - startedAt; await DeploymentProject.findByIdAndUpdate(projectId, { lastStatus: "failed", lastDeployAt: new Date(), lastMessage: (err as Error).message }); await DeploymentRun.findByIdAndUpdate(runDoc._id, { status: "failed", finishedAt: new Date(), durationMs: duration, logs: runLogs, message: options?.message }); pushLog(`Hata: ${(err as Error).message}`); } finally { this.running.delete(projectId); } } async findByWebhookToken(token: string) { return DeploymentProject.findOne({ webhookToken: token }); } } export const deploymentService = new DeploymentService(); export { generateApiToken, generateSecret };