From f8d22cc082224e88c3866eae216aca7f7ebbc80c Mon Sep 17 00:00:00 2001 From: wisecolt Date: Mon, 19 Jan 2026 12:54:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(deployments):=20repo=20tabanl=C4=B1=20kuru?= =?UTF-8?q?lum=20sistemi=20ekle=20ve=20root=20taramay=C4=B1=20kald=C4=B1r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 8 +- backend/src/config/env.ts | 3 +- backend/src/models/deploymentProject.ts | 2 +- backend/src/routes/deployments.ts | 32 +++--- backend/src/services/deploymentService.ts | 114 ++++++++++---------- frontend/src/api/deployments.ts | 24 ++--- frontend/src/pages/DeploymentsPage.tsx | 124 +++++++++------------- 7 files changed, 140 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index 82e603b..3c4efeb 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ - **Log Akışı**: Gerçek zamanlı test loglarının izlenmesi ### 🚀 Deployment Yönetimi -- **Root Tarama**: `DEPLOYMENTS_ROOT_HOST` altında compose dosyası olan projeleri otomatik bulma +- **Repo Bazlı Kurulum**: Repo URL ile proje oluşturma ve deploy klasörünü otomatik oluşturma - **Webhook Tetikleme**: Gitea push event ile otomatik deploy - **Branch Seçimi**: Repo URL girince branch listesi alınır ve seçim yapılır - **Deploy Geçmişi**: Her deploy için log ve süre kaydı @@ -223,8 +223,8 @@ docker compose up -d --build ### Deployment Yönetimi 1. **Deployments** sayfasına gidin -2. **New Deployment** ile root altında taranan projeyi seçin -3. Repo URL + Branch + Compose dosyasını girin +2. **New Deployment** ile Repo URL girin +3. Branch ve Compose dosyasını seçin 4. Kaydettikten sonra **Webhook URL**’i Gitea’da web istemci olarak tanımlayın #### Webhook Ayarları (Gitea) @@ -251,7 +251,7 @@ docker compose up -d --build ### 📖 API Referansı - **Authentication API'leri**: `/auth/login`, `/auth/me` - **Test Yönetim API'leri**: CRUD operasyonları, manuel çalıştırma -- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/scan`, `/deployments/branches` +- **Deployment API'leri**: `/deployments`, `/deployments/:id`, `/deployments/branches`, `/deployments/compose-files` - **Webhook Endpoint**: `/api/deployments/webhook/:token` - **WebSocket Olayları**: Real-time iletişim ve durum güncellemeleri - **Endpoint Detayları**: Her endpoint için istek/yanıt formatları diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index fdd0eb9..3d38a81 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -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) { diff --git a/backend/src/models/deploymentProject.ts b/backend/src/models/deploymentProject.ts index 0538cff..59b5eef 100644 --- a/backend/src/models/deploymentProject.ts +++ b/backend/src/models/deploymentProject.ts @@ -24,7 +24,7 @@ const DeploymentProjectSchema = new Schema( { 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, diff --git a/backend/src/routes/deployments.ts b/backend/src/routes/deployments.ts index 1b0553e..d2b14bc 100644 --- a/backend/src/routes/deployments.ts +++ b/backend/src/routes/deployments.ts @@ -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, diff --git a/backend/src/services/deploymentService.ts b/backend/src/services/deploymentService.ts index 857637b..870620a 100644 --- a/backend/src/services/deploymentService.ts +++ b/backend/src/services/deploymentService.ts @@ -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 = 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, diff --git a/frontend/src/api/deployments.ts b/frontend/src/api/deployments.ts index fdfe89b..0573197 100644 --- a/frontend/src/api/deployments.ts +++ b/frontend/src/api/deployments.ts @@ -54,15 +54,8 @@ export interface DeploymentMetrics { recentRuns: DeploymentRunWithProject[]; } -export interface DeploymentCandidate { - name: string; - rootPath: string; - composeFiles: ComposeFile[]; -} - export interface DeploymentInput { name: string; - rootPath: string; repoUrl: string; branch: string; composeFile: ComposeFile; @@ -81,17 +74,12 @@ export async function fetchDeploymentBranches(repoUrl: string): Promise { - const { data } = await apiClient.get("/deployments/scan"); - return data as DeploymentCandidate[]; -} - export async function createDeployment(payload: DeploymentInput): Promise { const { data } = await apiClient.post("/deployments", payload); return data as DeploymentProject; } -export async function updateDeployment(id: string, payload: Omit) { +export async function updateDeployment(id: string, payload: DeploymentInput) { const { data } = await apiClient.put(`/deployments/${id}`, payload); return data as DeploymentProject; } @@ -113,3 +101,13 @@ export async function fetchDeploymentMetrics(): Promise { const { data } = await apiClient.get("/deployments/metrics/summary"); return data as DeploymentMetrics; } + +export async function fetchDeploymentComposeFiles( + repoUrl: string, + branch: string +): Promise { + const { data } = await apiClient.get("/deployments/compose-files", { + params: { repoUrl, branch } + }); + return (data as { files: ComposeFile[] }).files; +} diff --git a/frontend/src/pages/DeploymentsPage.tsx b/frontend/src/pages/DeploymentsPage.tsx index 00d49b1..f85af50 100644 --- a/frontend/src/pages/DeploymentsPage.tsx +++ b/frontend/src/pages/DeploymentsPage.tsx @@ -16,13 +16,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { createDeployment, deleteDeployment, - DeploymentCandidate, DeploymentInput, DeploymentProject, + fetchDeploymentComposeFiles, fetchDeploymentBranches, fetchDeployments, runDeployment, - scanDeployments, updateDeployment } from "../api/deployments"; import { JobStatusBadge } from "../components/JobStatusBadge"; @@ -30,7 +29,6 @@ import { JobStatusBadge } from "../components/JobStatusBadge"; type FormState = { _id?: string; name: string; - rootPath: string; repoUrl: string; branch: string; composeFile: DeploymentInput["composeFile"]; @@ -39,7 +37,6 @@ type FormState = { const defaultForm: FormState = { name: "", - rootPath: "", repoUrl: "", branch: "main", composeFile: "docker-compose.yml", @@ -54,19 +51,15 @@ export function DeploymentsPage() { const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [saving, setSaving] = useState(false); - const [scanning, setScanning] = useState(false); - const [candidates, setCandidates] = useState([]); const [form, setForm] = useState(defaultForm); const [pendingEditId, setPendingEditId] = useState(null); const [branchOptions, setBranchOptions] = useState([]); const [branchLoading, setBranchLoading] = useState(false); + const [composeOptions, setComposeOptions] = useState([]); + const [composeLoading, setComposeLoading] = useState(false); const [faviconErrors, setFaviconErrors] = useState>({}); const isEdit = useMemo(() => !!form._id, [form._id]); - const selectedCandidate = useMemo( - () => candidates.find((c) => c.rootPath === form.rootPath), - [candidates, form.rootPath] - ); const loadDeployments = async () => { setLoading(true); @@ -80,18 +73,6 @@ export function DeploymentsPage() { } }; - const loadCandidates = async () => { - setScanning(true); - try { - const data = await scanDeployments(); - setCandidates(data); - } catch { - toast.error("Root taraması yapılamadı"); - } finally { - setScanning(false); - } - }; - useEffect(() => { loadDeployments(); }, []); @@ -100,6 +81,7 @@ export function DeploymentsPage() { const repoUrl = form.repoUrl.trim(); if (!repoUrl) { setBranchOptions([]); + setComposeOptions([]); return; } const timer = setTimeout(async () => { @@ -119,6 +101,30 @@ export function DeploymentsPage() { return () => clearTimeout(timer); }, [form.repoUrl, form.branch]); + useEffect(() => { + const repoUrl = form.repoUrl.trim(); + const branch = form.branch.trim(); + if (!repoUrl || !branch) { + setComposeOptions([]); + return; + } + const timer = setTimeout(async () => { + setComposeLoading(true); + try { + const files = await fetchDeploymentComposeFiles(repoUrl, branch); + setComposeOptions(files); + if (files.length > 0 && !files.includes(form.composeFile)) { + setForm((prev) => ({ ...prev, composeFile: files[0] })); + } + } catch { + setComposeOptions([]); + } finally { + setComposeLoading(false); + } + }, 400); + return () => clearTimeout(timer); + }, [form.repoUrl, form.branch, form.composeFile]); + useEffect(() => { const state = location.state as { editDeploymentId?: string } | null; if (state?.editDeploymentId) { @@ -139,16 +145,15 @@ export function DeploymentsPage() { const handleOpenNew = async () => { setForm(defaultForm); setBranchOptions([]); + setComposeOptions([]); setModalOpen(true); - await loadCandidates(); }; const handleEdit = (deployment: DeploymentProject) => { - const { _id, name, rootPath, repoUrl, branch, composeFile, port } = deployment; + const { _id, name, repoUrl, branch, composeFile, port } = deployment; setForm({ _id, name, - rootPath, repoUrl, branch, composeFile, @@ -166,14 +171,13 @@ export function DeploymentsPage() { try { const payload: DeploymentInput = { name: form.name, - rootPath: form.rootPath, repoUrl: form.repoUrl, branch: form.branch, composeFile: form.composeFile, port: form.port ? Number(form.port) : undefined }; - if (!payload.name || !payload.rootPath || !payload.repoUrl || !payload.branch || !payload.composeFile) { + if (!payload.name || !payload.repoUrl || !payload.branch || !payload.composeFile) { toast.error("Tüm alanları doldurun"); setSaving(false); return; @@ -374,48 +378,8 @@ export function DeploymentsPage() {
{!isEdit && ( -
-
- - -
- -
- {scanning - ? "Root dizin taranıyor..." - : candidates.length === 0 - ? "Root altında compose dosyası bulunan proje yok." - : "Compose dosyası bulunan klasörleri listeler."} -
+
+ Repo URL girildiğinde branch ve compose dosyaları listelenir.
)} @@ -491,15 +455,23 @@ export function DeploymentsPage() { - {(selectedCandidate?.composeFiles || ["docker-compose.yml", "docker-compose.dev.yml"]).map( - (file) => ( - - {file} - - ) - )} + {(composeOptions.length > 0 + ? composeOptions + : ["docker-compose.yml", "docker-compose.dev.yml"] + ).map((file) => ( + + {file} + + ))} +
+ {composeLoading + ? "Compose dosyaları alınıyor..." + : composeOptions.length > 0 + ? "Repo üzerindeki compose dosyaları listelendi." + : "Repo URL ve branch sonrası compose dosyaları listelenir."} +