Files
Wisecolt-CI/backend/src/services/deploymentService.ts
wisecolt e5fd3bd9d5 feat(deployments): docker tabanlı proje yönetim ve otomatik deploy sistemi ekle
Docker Compose projeleri için tam kapsamlı yönetim paneli ve otomatik deployment altyapısı eklendi.

Sistem özellikleri:
- Belirtilen root dizin altındaki docker-compose dosyası içeren projeleri tarama
- Git repo bağlantısı ile branch yönetimi ve klonlama/pull işlemleri
- Docker compose up/down komutları ile otomatik deploy
- Gitea webhook entegrasyonu ile commit bazlı tetikleme
- Deploy geçmişi, log kayıtları ve durum takibi (running/success/failed)
- Deploy metrikleri ve dashboard görselleştirmesi
- Webhook token ve secret yönetimi ile güvenlik
- Proje favicon servisi

Teknik değişiklikler:
- Backend: deploymentProject, deploymentRun ve settings modelleri eklendi
- Backend: deploymentService ile git ve docker işlemleri otomatize edildi
- Backend: webhook doğrulaması için signature kontrolü eklendi
- Docker: docker-cli ve docker-compose bağımlılıkları eklendi
- Frontend: deployments ve settings sayfaları eklendi
- Frontend: dashboard'a deploy metrikleri ve aktivite akışı eklendi
- API: /api/deployments ve /api/settings yolları eklendi
2026-01-18 16:24:11 +03:00

367 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>((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<string>((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<string, boolean> = 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 };