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
This commit is contained in:
366
backend/src/services/deploymentService.ts
Normal file
366
backend/src/services/deploymentService.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user