feat(deployments): repo tabanlı kurulum sistemi ekle ve root taramayı kaldır

Root dizin taraması yerine repo URL tabanlı otomatik kurulum sistemine geçiş yapıldı.
Deploy klasörü artık repo URL'sinden otomatik oluşturuluyor. Remote repo
üzerinden branch ve compose dosyası listelemesi eklendi.

- `deploymentsRoot` konfigürasyonu kaldırıldı
- `/deployments/scan` endpoint'i kaldırıldı
- `/deployments/compose-files` endpoint'i eklendi
- `repoUrl` alanı unique ve index olarak işaretlendi
- Proje oluştururken `rootPath` zorunluluğu kaldırıldı
- Deploy klasörü otomatik `deployments/{slug}` formatında oluşturuluyor
- Frontend'de root tarama UI'ı kaldırıldı, compose dosyası listeleme eklendi

BREAKING CHANGE: Root dizin tarama özelliği ve `rootPath` alanı kaldırıldı.
Artık deploymentlar sadece repo URL ile oluşturulabiliyor.
This commit is contained in:
2026-01-19 12:54:33 +03:00
parent a43042fac1
commit f8d22cc082
7 changed files with 140 additions and 167 deletions

View File

@@ -8,8 +8,7 @@ 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",
deploymentsRoot: "/workspace"
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
};
if (!config.jwtSecret) {

View File

@@ -24,7 +24,7 @@ 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 },
repoUrl: { type: String, required: true, trim: true, unique: true, index: true },
branch: { type: String, required: true, trim: true },
composeFile: {
type: String,

View File

@@ -39,17 +39,6 @@ router.get("/:id/favicon", async (req, 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;
@@ -65,6 +54,22 @@ router.get("/branches", async (req, res) => {
});
});
router.get("/compose-files", async (req, res) => {
authMiddleware(req, res, async () => {
const repoUrl = req.query.repoUrl as string | undefined;
const branch = req.query.branch as string | undefined;
if (!repoUrl || !branch) {
return res.status(400).json({ message: "repoUrl ve branch gerekli" });
}
try {
const files = await deploymentService.listRemoteComposeFiles(repoUrl, branch);
return res.json({ files });
} catch (err) {
return res.status(400).json({ message: "Compose listesi alınamadı", error: (err as Error).message });
}
});
});
router.get("/metrics/summary", async (req, res) => {
authMiddleware(req, res, async () => {
const since = new Date();
@@ -123,14 +128,13 @@ router.get("/:id", async (req, res) => {
router.post("/", async (req, res) => {
authMiddleware(req, res, async () => {
const { name, rootPath, repoUrl, branch, composeFile, port } = req.body;
if (!name || !rootPath || !repoUrl || !branch || !composeFile) {
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 created = await deploymentService.createProject({
name,
rootPath,
repoUrl,
branch,
composeFile,

View File

@@ -2,7 +2,6 @@ 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,
@@ -14,14 +13,18 @@ import { Settings } from "../models/settings.js";
const composeFileCandidates: ComposeFile[] = ["docker-compose.yml", "docker-compose.dev.yml"];
function normalizeRoot(rootPath: string) {
return path.resolve(rootPath);
const deploymentsRoot = path.join(process.cwd(), "deployments");
function slugify(value: string) {
return value
.toLowerCase()
.replace(/\.git$/i, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function isWithinRoot(rootPath: string, targetPath: string) {
const resolvedRoot = normalizeRoot(rootPath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
function normalizeRepoUrl(value: string) {
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "");
}
function generateWebhookToken() {
@@ -132,10 +135,7 @@ async function ensureRepo(project: DeploymentProjectDocument, onData: (line: str
}
}
async function runCompose(
project: DeploymentProjectDocument,
onData: (line: string) => void
) {
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ı");
@@ -153,31 +153,6 @@ async function runCompose(
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
@@ -190,6 +165,24 @@ class DeploymentService {
return branches;
}
async listRemoteComposeFiles(repoUrl: string, branch: string) {
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const tmpBase = await fs.promises.mkdtemp(path.join(deploymentsRoot, ".tmp-"));
try {
await runCommand(
`git clone --depth 1 --single-branch --branch ${branch} ${repoUrl} ${tmpBase}`,
process.cwd(),
() => undefined
);
const available = composeFileCandidates.filter((file) =>
fs.existsSync(path.join(tmpBase, file))
);
return available;
} finally {
await fs.promises.rm(tmpBase, { recursive: true, force: true });
}
}
async ensureSettings() {
const existing = await Settings.findOne();
if (existing) return existing;
@@ -216,27 +209,15 @@ class DeploymentService {
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ş");
const repoUrl = normalizeRepoUrl(input.repoUrl);
const existingRepo = await DeploymentProject.findOne({ repoUrl });
if (existingRepo) {
throw new Error("Bu repo zaten eklenmiş");
}
let webhookToken = generateWebhookToken();
@@ -244,11 +225,23 @@ class DeploymentService {
webhookToken = generateWebhookToken();
}
await fs.promises.mkdir(deploymentsRoot, { recursive: true });
const baseName = slugify(path.basename(repoUrl));
const suffix = crypto.randomBytes(3).toString("hex");
const slug = baseName ? `${baseName}-${suffix}` : `deployment-${suffix}`;
const rootPath = path.join(deploymentsRoot, slug);
await fs.promises.mkdir(rootPath, { recursive: true });
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
}
const env = deriveEnv(input.composeFile);
return DeploymentProject.create({
name: input.name,
rootPath,
repoUrl: input.repoUrl,
repoUrl,
branch: input.branch,
composeFile: input.composeFile,
webhookToken,
@@ -269,16 +262,23 @@ class DeploymentService {
) {
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 repoUrl = normalizeRepoUrl(input.repoUrl);
if (repoUrl !== project.repoUrl) {
const existingRepo = await DeploymentProject.findOne({ repoUrl });
if (existingRepo && existingRepo._id.toString() !== id) {
throw new Error("Bu repo zaten eklenmiş");
}
}
const available = await this.listRemoteComposeFiles(repoUrl, input.branch);
if (!available.includes(input.composeFile)) {
throw new Error("Compose dosyası repoda bulunamadı");
}
const env = deriveEnv(input.composeFile);
const updated = await DeploymentProject.findByIdAndUpdate(
id,
{
name: input.name,
repoUrl: input.repoUrl,
repoUrl,
branch: input.branch,
composeFile: input.composeFile,
env,