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
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
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 };
|