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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
49
backend/src/models/deploymentProject.ts
Normal file
49
backend/src/models/deploymentProject.ts
Normal 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
|
||||
);
|
||||
34
backend/src/models/deploymentRun.ts
Normal file
34
backend/src/models/deploymentRun.ts
Normal 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
|
||||
);
|
||||
18
backend/src/models/settings.ts
Normal file
18
backend/src/models/settings.ts
Normal 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);
|
||||
193
backend/src/routes/deployments.ts
Normal file
193
backend/src/routes/deployments.ts
Normal 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;
|
||||
34
backend/src/routes/settings.ts
Normal file
34
backend/src/routes/settings.ts
Normal 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;
|
||||
66
backend/src/routes/webhooks.ts
Normal file
66
backend/src/routes/webhooks.ts
Normal 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;
|
||||
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