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:
2026-01-18 16:24:11 +03:00
parent b701d50d4a
commit e5fd3bd9d5
23 changed files with 2005 additions and 33 deletions

View File

@@ -8,7 +8,8 @@ export const config = {
adminUsername: process.env.ADMIN_USERNAME || "admin",
adminPassword: process.env.ADMIN_PASSWORD || "password",
jwtSecret: process.env.JWT_SECRET || "changeme",
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
deploymentsRoot: "/workspace"
};
if (!config.jwtSecret) {

View File

@@ -5,6 +5,9 @@ import mongoose from "mongoose";
import { Server } from "socket.io";
import authRoutes from "./routes/auth.js";
import jobsRoutes from "./routes/jobs.js";
import deploymentsRoutes from "./routes/deployments.js";
import settingsRoutes from "./routes/settings.js";
import webhookRoutes from "./routes/webhooks.js";
import { config } from "./config/env.js";
import jwt from "jsonwebtoken";
import { jobService } from "./services/jobService.js";
@@ -18,7 +21,13 @@ app.use(
credentials: true
})
);
app.use(express.json());
app.use(
express.json({
verify: (req, _res, buf) => {
(req as { rawBody?: Buffer }).rawBody = buf;
}
})
);
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
@@ -26,6 +35,9 @@ app.get("/health", (_req, res) => {
app.use("/api/auth", authRoutes);
app.use("/api/jobs", jobsRoutes);
app.use("/", webhookRoutes);
app.use("/api/deployments", deploymentsRoutes);
app.use("/api/settings", settingsRoutes);
const server = http.createServer(app);

View File

@@ -0,0 +1,49 @@
import mongoose, { Schema, Document } from "mongoose";
export type ComposeFile = "docker-compose.yml" | "docker-compose.dev.yml";
export type DeploymentStatus = "idle" | "running" | "success" | "failed";
export type DeploymentEnv = "dev" | "prod";
export interface DeploymentProjectDocument extends Document {
name: string;
rootPath: string;
repoUrl: string;
branch: string;
composeFile: ComposeFile;
webhookToken: string;
env: DeploymentEnv;
port?: number;
lastDeployAt?: Date;
lastStatus: DeploymentStatus;
lastMessage?: string;
createdAt: Date;
updatedAt: Date;
}
const DeploymentProjectSchema = new Schema<DeploymentProjectDocument>(
{
name: { type: String, required: true, trim: true },
rootPath: { type: String, required: true, trim: true },
repoUrl: { type: String, required: true, trim: true },
branch: { type: String, required: true, trim: true },
composeFile: {
type: String,
required: true,
enum: ["docker-compose.yml", "docker-compose.dev.yml"]
},
webhookToken: { type: String, required: true, unique: true, index: true },
env: { type: String, required: true, enum: ["dev", "prod"] },
port: { type: Number },
lastDeployAt: { type: Date },
lastStatus: { type: String, enum: ["idle", "running", "success", "failed"], default: "idle" },
lastMessage: { type: String }
},
{ timestamps: true }
);
DeploymentProjectSchema.index({ rootPath: 1 });
export const DeploymentProject = mongoose.model<DeploymentProjectDocument>(
"DeploymentProject",
DeploymentProjectSchema
);

View File

@@ -0,0 +1,34 @@
import mongoose, { Schema, Document, Types } from "mongoose";
import { DeploymentProjectDocument } from "./deploymentProject.js";
export interface DeploymentRunDocument extends Document {
project: Types.ObjectId | DeploymentProjectDocument;
status: "running" | "success" | "failed";
message?: string;
logs: string[];
startedAt: Date;
finishedAt?: Date;
durationMs?: number;
createdAt: Date;
updatedAt: Date;
}
const DeploymentRunSchema = new Schema<DeploymentRunDocument>(
{
project: { type: Schema.Types.ObjectId, ref: "DeploymentProject", required: true },
status: { type: String, enum: ["running", "success", "failed"], required: true },
message: { type: String },
logs: { type: [String], default: [] },
startedAt: { type: Date, required: true },
finishedAt: { type: Date },
durationMs: { type: Number }
},
{ timestamps: true }
);
DeploymentRunSchema.index({ project: 1, startedAt: -1 });
export const DeploymentRun = mongoose.model<DeploymentRunDocument>(
"DeploymentRun",
DeploymentRunSchema
);

View File

@@ -0,0 +1,18 @@
import mongoose, { Schema, Document } from "mongoose";
export interface SettingsDocument extends Document {
webhookToken: string;
webhookSecret: string;
createdAt: Date;
updatedAt: Date;
}
const SettingsSchema = new Schema<SettingsDocument>(
{
webhookToken: { type: String, required: true },
webhookSecret: { type: String, required: true }
},
{ timestamps: true }
);
export const Settings = mongoose.model<SettingsDocument>("Settings", SettingsSchema);

View File

@@ -0,0 +1,193 @@
import { Router } from "express";
import fs from "fs";
import path from "path";
import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js";
import { DeploymentProject } from "../models/deploymentProject.js";
import { DeploymentRun } from "../models/deploymentRun.js";
const router = Router();
const faviconCandidates = [
"favicon.ico",
"public/favicon.ico",
"public/favicon.png",
"public/favicon.svg",
"assets/favicon.ico"
];
function getContentType(filePath: string) {
if (filePath.endsWith(".svg")) return "image/svg+xml";
if (filePath.endsWith(".png")) return "image/png";
return "image/x-icon";
}
router.get("/:id/favicon", async (req, res) => {
const { id } = req.params;
const project = await DeploymentProject.findById(id).lean();
if (!project) return res.status(404).end();
const rootPath = path.resolve(project.rootPath);
for (const candidate of faviconCandidates) {
const filePath = path.join(rootPath, candidate);
if (!fs.existsSync(filePath)) continue;
res.setHeader("Content-Type", getContentType(filePath));
res.setHeader("Cache-Control", "public, max-age=300");
return fs.createReadStream(filePath).pipe(res);
}
return res.status(404).end();
});
router.get("/scan", async (req, res) => {
authMiddleware(req, res, async () => {
try {
const candidates = await deploymentService.scanRoot();
return res.json(candidates);
} catch (err) {
return res.status(500).json({ message: "Root taraması yapılamadı" });
}
});
});
router.get("/branches", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
if (!repoUrl) {
return res.status(400).json({ message: "repoUrl gerekli" });
}
try {
const branches = await deploymentService.listRemoteBranches(repoUrl);
return res.json({ branches });
} catch (err) {
return res.status(400).json({ message: "Branch listesi alınamadı", error: (err as Error).message });
}
});
});
router.get("/metrics/summary", async (req, res) => {
authMiddleware(req, res, async () => {
const since = new Date();
since.setDate(since.getDate() - 7);
const dailyStats = await DeploymentRun.aggregate([
{ $match: { startedAt: { $gte: since } } },
{
$group: {
_id: { $dateToString: { format: "%Y-%m-%d", date: "$startedAt" } },
total: { $sum: 1 },
success: {
$sum: {
$cond: [{ $eq: ["$status", "success"] }, 1, 0]
}
},
failed: {
$sum: {
$cond: [{ $eq: ["$status", "failed"] }, 1, 0]
}
},
avgDurationMs: { $avg: "$durationMs" }
}
},
{ $sort: { _id: 1 } }
]);
const recentRuns = await DeploymentRun.find()
.sort({ startedAt: -1 })
.limit(10)
.populate("project", "name repoUrl rootPath")
.lean();
return res.json({ recentRuns, dailyStats });
});
});
router.get("/", async (_req, res) => {
authMiddleware(_req, res, async () => {
const projects = await DeploymentProject.find().sort({ createdAt: -1 }).lean();
return res.json(projects);
});
});
router.get("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
const project = await DeploymentProject.findById(id).lean();
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
const runs = await DeploymentRun.find({ project: id })
.sort({ startedAt: -1 })
.limit(20)
.lean();
return res.json({ project, runs });
});
});
router.post("/", async (req, res) => {
authMiddleware(req, res, async () => {
const { name, rootPath, repoUrl, branch, composeFile, port } = req.body;
if (!name || !rootPath || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" });
}
try {
const created = await deploymentService.createProject({
name,
rootPath,
repoUrl,
branch,
composeFile,
port
});
return res.status(201).json(created);
} catch (err) {
return res.status(400).json({ message: "Deployment oluşturulamadı", error: (err as Error).message });
}
});
});
router.put("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
const { name, repoUrl, branch, composeFile, port } = req.body;
if (!name || !repoUrl || !branch || !composeFile) {
return res.status(400).json({ message: "Tüm alanlar gerekli" });
}
try {
const updated = await deploymentService.updateProject(id, {
name,
repoUrl,
branch,
composeFile,
port
});
if (!updated) return res.status(404).json({ message: "Deployment bulunamadı" });
return res.json(updated);
} catch (err) {
return res.status(400).json({ message: "Deployment güncellenemedi", error: (err as Error).message });
}
});
});
router.delete("/:id", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
try {
const deleted = await DeploymentProject.findByIdAndDelete(id);
if (!deleted) return res.status(404).json({ message: "Deployment bulunamadı" });
await DeploymentRun.deleteMany({ project: id });
return res.json({ success: true });
} catch (err) {
return res.status(400).json({ message: "Deployment silinemedi", error: (err as Error).message });
}
});
});
router.post("/:id/run", async (req, res) => {
authMiddleware(req, res, async () => {
const { id } = req.params;
const project = await DeploymentProject.findById(id);
if (!project) return res.status(404).json({ message: "Deployment bulunamadı" });
deploymentService.runDeployment(id).catch(() => undefined);
return res.json({ queued: true });
});
});
export default router;

View File

@@ -0,0 +1,34 @@
import { Router } from "express";
import { authMiddleware } from "../middleware/authMiddleware.js";
import { deploymentService } from "../services/deploymentService.js";
const router = Router();
router.use(authMiddleware);
router.get("/", async (_req, res) => {
const settings = await deploymentService.ensureSettings();
return res.json({
webhookToken: settings.webhookToken,
webhookSecret: settings.webhookSecret,
updatedAt: settings.updatedAt
});
});
router.post("/token/rotate", async (_req, res) => {
const settings = await deploymentService.rotateToken();
return res.json({
webhookToken: settings.webhookToken,
updatedAt: settings.updatedAt
});
});
router.post("/secret/rotate", async (_req, res) => {
const settings = await deploymentService.rotateSecret();
return res.json({
webhookSecret: settings.webhookSecret,
updatedAt: settings.updatedAt
});
});
export default router;

View File

@@ -0,0 +1,66 @@
import { Router, Request } from "express";
import crypto from "crypto";
import { deploymentService } from "../services/deploymentService.js";
const router = Router();
type RawBodyRequest = Request & { rawBody?: Buffer };
function getHeaderValue(value: string | string[] | undefined) {
if (!value) return "";
return Array.isArray(value) ? value[0] : value;
}
function verifySignature(rawBody: Buffer, secret: string, signature: string) {
const cleaned = signature.startsWith("sha256=") ? signature.slice(7) : signature;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
if (cleaned.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected));
}
router.post("/api/deployments/webhook/:token", async (req, res) => {
const { token } = req.params;
const settings = await deploymentService.ensureSettings();
const authHeader = getHeaderValue(req.headers.authorization);
if (!authHeader) {
return res.status(401).json({ message: "Yetkisiz" });
}
const providedToken = authHeader.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
if (providedToken !== settings.webhookToken) {
return res.status(401).json({ message: "Yetkisiz" });
}
const signatureHeader =
getHeaderValue(req.headers["x-gitea-signature"]) ||
getHeaderValue(req.headers["x-gitea-signature-256"]);
const rawBody = (req as RawBodyRequest).rawBody;
if (!rawBody || !signatureHeader) {
return res.status(401).json({ message: "Imza eksik" });
}
if (!verifySignature(rawBody, settings.webhookSecret, signatureHeader)) {
return res.status(401).json({ message: "Imza dogrulanamadi" });
}
const payload = req.body as { ref?: string; head_commit?: { message?: string }; commits?: Array<{ message?: string }> };
const ref = payload?.ref || "";
const branch = ref.startsWith("refs/heads/") ? ref.replace("refs/heads/", "") : ref;
const commitMessage =
payload?.head_commit?.message || payload?.commits?.[payload.commits.length - 1]?.message;
const project = await deploymentService.findByWebhookToken(token);
if (!project) return res.status(404).json({ message: "Deployment bulunamadi" });
if (branch && branch !== project.branch) {
return res.json({ ignored: true });
}
deploymentService
.runDeployment(project._id.toString(), commitMessage ? { message: commitMessage } : undefined)
.catch(() => undefined);
return res.json({ queued: true });
});
export default router;

View 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 };