import { existsSync, mkdirSync, rmSync, chmodSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve, dirname, basename, relative } from 'node:path'; import { spawn as nodeSpawn, spawnSync } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { GIT_SSH_KEY_PATH_ENV, makeSshKeyPath, removeSshKey } from './git-ssh-key'; import { getGitRepository, getGitCredential, updateGitRepository, getGitStack, updateGitStack, upsertStackSource, getEnvironment, type GitRepository, type GitCredential, type GitStackWithRepo } from './db'; import { deployStack, getStackDir } from './stacks'; import { sendEventNotification } from './notifications'; import { buildBasicAuthHeader } from './git-auth'; import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath } from './git-url-safety'; import { assertSafeRepoTarget } from './git-branch-lookup'; import { resolveStackBranch } from '../git-stack-branch'; import { parseManifest, serializeManifest, hashDirFiles, computeDeletions, buildNextManifest, buildSyncChangeSummary, formatChangeTable, skipReasonMessage, deletionSafetyCheck, type DeletionPlan, type DeletionApplyResult, type DeletionSkip, type SyncManifest } from './git-deletions'; const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt'; let mergedCaBundleReady = false; /** * Create a merged CA bundle combining system CAs with the custom cert from * NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without * merging, public CAs (GitHub, GitLab) break. */ function getMergedCaBundlePath(): string { if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) { console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`); return MERGED_CA_BUNDLE_PATH; } const customCertPath = process.env.NODE_EXTRA_CA_CERTS!; console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`); const systemCaPaths = [ process.env.SSL_CERT_FILE, '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem' ]; let systemCaContent = ''; let systemCaSource = ''; for (const caPath of systemCaPaths) { if (caPath && existsSync(caPath)) { try { systemCaContent = readFileSync(caPath, 'utf-8'); systemCaSource = caPath; console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`); break; } catch (err) { console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`); } } } if (!systemCaSource) { console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`); } try { const customCaContent = readFileSync(customCertPath, 'utf-8'); const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1; console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`); const merged = systemCaContent ? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n' : customCaContent; writeFileSync(MERGED_CA_BUNDLE_PATH, merged); mergedCaBundleReady = true; const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1; console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`); } catch (err) { console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err); return customCertPath; } return MERGED_CA_BUNDLE_PATH; } /** * Collect stdout, stderr and exit code from a spawned process. */ function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); proc.on('error', reject); proc.on('close', (code) => { resolve({ exitCode: code ?? 1, stdout: Buffer.concat(stdoutChunks).toString(), stderr: Buffer.concat(stderrChunks).toString() }); }); }); } // Directory for storing cloned repositories const dataDir = process.env.DATA_DIR || './data'; const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos')); // Ensure git repos directory exists if (!existsSync(GIT_REPOS_DIR)) { mkdirSync(GIT_REPOS_DIR, { recursive: true }); } export function getGitReposDir(): string { return GIT_REPOS_DIR; } /** * Redact all env var values for safe logging. Only key names are preserved. */ function redactEnvVarsForLog(vars: Record): Record { const redacted: Record = {}; for (const key of Object.keys(vars)) { redacted[key] = '***'; } return redacted; } function getRepoPath(repoId: number): string { return join(GIT_REPOS_DIR, `repo-${repoId}`); } interface GitEnv { [key: string]: string; } const NSS_WRAPPER_LIB = '/usr/lib/libnss_wrapper.so'; const TMP_PASSWD = '/tmp/dockhand-passwd'; const TMP_GROUP = '/tmp/dockhand-group'; // Cache the check so we only do it once per process let _nssWrapperChecked = false; let _nssWrapperNeeded = false; /** * Ensures the current UID exists in /etc/passwd for git/SSH operations. * SSH calls getpwuid() which fails with "No user exists for uid XXXX" if the * UID isn't in /etc/passwd (common with Docker --user or read-only containers). * Creates a temp passwd file and configures LD_PRELOAD with libnss_wrapper. */ async function ensurePasswdEntry(env: GitEnv): Promise { if (_nssWrapperChecked) { if (_nssWrapperNeeded) { env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB; env.NSS_WRAPPER_PASSWD = TMP_PASSWD; env.NSS_WRAPPER_GROUP = TMP_GROUP; } return; } _nssWrapperChecked = true; // Check if current UID is in /etc/passwd const uid = process.getuid?.(); if (uid === undefined || uid === 0) return; // root or not available try { const passwd = readFileSync('/etc/passwd', 'utf-8'); const uidStr = `:${uid}:`; if (passwd.split('\n').some(line => { const parts = line.split(':'); return parts[2] === String(uid); })) { return; // UID exists, nothing to do } } catch { return; // can't read passwd, bail } // UID not found — check if libnss_wrapper is available if (!existsSync(NSS_WRAPPER_LIB)) { console.warn(`[git] UID ${uid} not in /etc/passwd and libnss_wrapper not found — SSH may fail`); return; } // Create temp passwd/group with the missing entry try { const gid = process.getgid?.() ?? uid; const passwd = readFileSync('/etc/passwd', 'utf-8'); const group = readFileSync('/etc/group', 'utf-8'); const passwdEntry = `dockhand:x:${uid}:${gid}:Dockhand:/home/dockhand:/bin/sh`; writeFileSync(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n'); const gidExists = group.split('\n').some(line => line.split(':')[2] === String(gid)); if (gidExists) { writeFileSync(TMP_GROUP, group); } else { writeFileSync(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`); } _nssWrapperNeeded = true; env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB; env.NSS_WRAPPER_PASSWD = TMP_PASSWD; env.NSS_WRAPPER_GROUP = TMP_GROUP; console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`); } catch (err) { console.warn(`[git] Failed to create temp passwd:`, err); } } async function buildGitEnv(credential: GitCredential | null): Promise { const env: GitEnv = { ...process.env as GitEnv, GIT_TERMINAL_PROMPT: '0', // Prevent SSH agent from providing keys automatically SSH_AUTH_SOCK: '' }; // Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js). // GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the // custom cert so both self-signed repos and public repos (GitHub etc.) work (#967). if (process.env.NODE_EXTRA_CA_CERTS) { env.GIT_SSL_CAINFO = getMergedCaBundlePath(); } // Ensure current UID is resolvable for SSH/git operations await ensurePasswdEntry(env); // For HTTPS password/token auth, inject credentials via http.extraHeader env vars // instead of embedding them in the URL (which leaks via /proc//cmdline, #1081). // Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header. if (credential?.authType === 'password' && (credential.username || credential.password)) { // Basic auth (base64 of username:password) — works with GitHub PATs, GitLab // tokens, Gitea tokens, and standard username/password combos. Empty username // is defaulted inside buildBasicAuthHeader (see #1273). env.GIT_CONFIG_COUNT = '1'; env.GIT_CONFIG_KEY_0 = 'http.extraHeader'; env.GIT_CONFIG_VALUE_0 = buildBasicAuthHeader(credential.username || '', credential.password || ''); } if (credential?.authType === 'ssh' && credential.sshPrivateKey) { // Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS, // NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670). // SSH refuses keys that are accessible by others. /tmp is always a proper filesystem. // Unique per operation so concurrent syncs of the SAME credential don't share // one file; otherwise one op's cleanup deletes the key mid-clone of another // (#1413). The path is stashed in env for cleanupSshKey to remove exactly. const sshKeyPath = makeSshKeyPath(credential.id); // Ensure SSH key ends with a newline (newer SSH versions are strict about this) let keyContent = credential.sshPrivateKey; if (!keyContent.endsWith('\n')) { keyContent += '\n'; } writeFileSync(sshKeyPath, keyContent); // Ensure SSH key has correct permissions (0600 = owner read/write only) // writeFileSync's mode option doesn't always work reliably, so use chmodSync chmodSync(sshKeyPath, 0o600); // If key has a passphrase, decrypt it in-place so SSH can use it non-interactively if (credential.sshPassphrase) { const result = spawnSync( 'ssh-keygen', ['-p', '-f', sshKeyPath, '-P', credential.sshPassphrase, '-N', ''], { env, stdio: ['pipe', 'pipe', 'pipe'] } ); if (result.status !== 0) { const stderr = result.stderr.toString().trim(); console.warn(`[git] Failed to decrypt SSH key: ${stderr}`); } } // Configure SSH to use ONLY this key (no agent, no default keys) env.GIT_SSH_COMMAND = `ssh -i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes`; env[GIT_SSH_KEY_PATH_ENV] = sshKeyPath; } else { // No SSH credential - prevent using any keys (IdentitiesOnly=yes with no -i means no keys) env.GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o PasswordAuthentication=no -o PubkeyAuthentication=no'; } return env; } function cleanupSshKey(credential: GitCredential | null, env?: GitEnv): void { if (credential?.authType === 'ssh') { // Removes the exact per-operation key this env created; falls back to the old // deterministic path only if no env is available (legacy callers). See #1413. removeSshKey(credential.id, env); } } function buildRepoUrl(url: string, credential: GitCredential | null): string { assertSafeRepoUrl(url); // Never embed credentials in the URL — they leak via /proc//cmdline (see #1081). // HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv(). // Strip any existing credentials from the URL for safety. if (credential?.authType === 'password' && !url.startsWith('git@')) { try { const parsed = new URL(url); parsed.username = ''; parsed.password = ''; return parsed.toString(); } catch { return url; } } return url; } /** * Hard timeout (ms) for the remote-branch lookup (git ls-remote) ONLY. * The lookup is the single git call whose target URL can be driven by an * attacker-chosen URL (POST /api/git/branches, the new-repository branch * picker), so an unbounded ls-remote is a resource-exhaustion vector. * NOTE: this constant is deliberately NOT applied to ordinary deploy/sync * operations (clone / pull / fetch / rev-parse) — those target the * operator's own repository over a controlled URL, and a legitimate * large-repo clone or slow WAN fetch can exceed this limit, so they stay * UNBOUNDED (execGit without an explicit timeout). */ export const GIT_TIMEOUT_MS = Number(process.env.GIT_TIMEOUT_MS) || 20000; /** * Run a git command. * * Timeouts are OPT-IN: the default is UNBOUNDED. Ordinary deploy/sync * operations (clone, pull, fetch, rev-parse) are unbounded because their * target is the operator's own repository and a slow network or large repo * is a legitimate outcome — they must not be killed mid-flight. Callers * that need a bounded operation (the remote-branch lookup, whose target * URL is attacker-influenced) pass `timeoutMs` explicitly (see * listRemoteBranches). */ function execGit( args: string[], cwd: string, env: GitEnv, timeoutMs?: number ): Promise<{ stdout: string; stderr: string; code: number; timedOut?: boolean }> { return new Promise((resolve) => { let settled = false; const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const proc = nodeSpawn('git', args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); const finish = ( partial: { stdout: string; stderr: string; code: number; timedOut?: boolean } ) => { if (settled) return; settled = true; // Only clear a timer if one was actually created — the default // (no timeout) never creates one. if (timer) clearTimeout(timer); if (partial.timedOut) { // The process is still running — kill it so the git subprocess // (and any child ssh) cannot keep running after we give up. proc.kill('SIGKILL'); } resolve(partial); }; // Only arm a timer when a valid timeout was explicitly provided — // undefined (or non-finite / non-positive) means "run unbounded". const timer: ReturnType | undefined = timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0 ? setTimeout(() => { finish({ stdout: Buffer.concat(stdoutChunks).toString(), stderr: Buffer.concat(stderrChunks).toString(), code: 124, timedOut: true }); }, timeoutMs) : undefined; proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); proc.on('error', (err: any) => { finish({ stdout: '', stderr: err.message, code: 1 }); }); proc.on('close', (code) => { finish({ stdout: Buffer.concat(stdoutChunks).toString(), stderr: Buffer.concat(stderrChunks).toString(), code: code ?? 1 }); }); }); } /** * Get list of files that changed between two commits in a specific directory. * Returns array of changed file paths (relative to repo root). */ async function getChangedFilesInDir( repoPath: string, previousCommit: string, newCommit: string, dirPath: string, env: GitEnv ): Promise<{ changed: boolean; files: string[]; error?: string }> { if (!previousCommit) { // No previous commit means this is a new clone - always deploy return { changed: true, files: ['(new clone - all files)'] }; } // Use git diff --name-only to get all changed files in the directory // The trailing slash ensures we only match files IN that directory (and subdirs) const dirPattern = dirPath.endsWith('/') ? dirPath : `${dirPath}/`; const result = await execGit( ['diff', '--name-only', previousCommit, newCommit, '--', dirPattern], repoPath, env ); // If the command fails (e.g., previousCommit no longer exists after force push), // assume files changed to be safe if (result.code !== 0) { return { changed: true, files: ['(diff failed - assuming changed)'], error: result.stderr }; } // Parse changed files const changedFiles = result.stdout.trim() .split('\n') .filter(f => f.length > 0); return { changed: changedFiles.length > 0, files: changedFiles }; } /** * Compute the deletion plan for a sync: hash the new clone's compose dir and * diff against the manifest from the last sync. Deletions converge the deploy * dir toward the clone state; the applier additionally verifies each file's * disk hash. A sanity guard blocks ALL deletions when the clone walk looks * broken (empty, or missing the compose file). */ async function computeSyncDeletionPlan(options: { logPrefix: string; composeDir: string; // absolute path inside the clone composeFileName: string | undefined; // compose file relative to composeDir rawManifest: string | null | undefined; }): Promise<{ plan: DeletionPlan; newFiles: Record; previousManifest: SyncManifest }> { const { logPrefix, composeDir, composeFileName, rawManifest } = options; const previousManifest = parseManifest(rawManifest); const newFiles = hashDirFiles(composeDir); const manifestSize = Object.keys(previousManifest.files).length; console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`); // First sync / legacy manifest: nothing was recorded, so nothing can be deleted if (manifestSize === 0) { return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest }; } const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName); if (blocked) { console.warn(`${logPrefix} Deletion sync: ${blocked}`); return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest }; } const plan = computeDeletions(previousManifest.files, newFiles); for (const file of plan.toDelete) { console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`); } for (const skip of plan.skipped) { console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`); } return { plan, newFiles, previousManifest }; } /** * Persist the manifest after a deploy and log the per-file change summary. * Called only after a successful deploy (locally applied or agent-confirmed). * Progress popovers show the plan-based change table before the deploy * instead (#1260); this summary (with real apply results) goes to the * server log only. */ async function finalizeDeletionSync(options: { stackId: number; logPrefix: string; previousManifest: SyncManifest; newCommitFull: string; newFiles: Record; plan: DeletionPlan; applyResult: DeletionApplyResult | undefined; }): Promise { const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult } = options; // No apply result means deletions were requested but nothing reported back // (defensive — executors always return one). Logged as skips; skips are final. const effectiveApply: DeletionApplyResult = applyResult ?? { deleted: [], skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' })) }; // Pass only the plan-stage skips; buildSyncChangeSummary already merges // in effectiveApply.skipped itself. Concatenating here duplicated every // apply-stage skip (locally-modified, agent-no-support, apply-failed). const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped); const tableLines = formatChangeTable(summary); console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`); for (const line of tableLines.slice(1)) { console.log(`${logPrefix} ${line}`); } const nextManifest = buildNextManifest(newCommitFull, newFiles); await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) }); console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`); } export interface SyncResult { success: boolean; commit?: string; composeContent?: string; composeDir?: string; // Directory containing the compose file (for copying all files) composeFileName?: string; // Filename of the compose file (e.g., "docker-compose.yaml") envFileVars?: Record; // Variables from .env file in repo envFileContent?: string; // Raw .env file content (for Hawser deployments) envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env") error?: string; updated?: boolean; changedFiles?: string[]; // List of files that changed (for logging/debugging) // Deletion sync (#966/#1162): manifest-vs-clone data deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips newFiles?: Record; // path → sha256 of files in the new clone (next manifest) newCommitFull?: string; // Full 40-char commit hash (manifest commit) previousManifest?: SyncManifest; // Manifest from the last successful sync } export interface TestResult { success: boolean; branch?: string; lastCommit?: string; composeFileExists?: boolean; error?: string; } /** * Clean up git/SSH error messages for user display */ function cleanGitError(stderr: string): string { // Remove SSH warnings and noise const lines = stderr.split('\n').filter(line => { const l = line.trim().toLowerCase(); // Skip SSH warnings if (l.startsWith('warning:')) return false; if (l.includes('added') && l.includes('to the list of known hosts')) return false; // Skip empty lines if (!l) return false; return true; }); // Find the most relevant error const fatalLine = lines.find(l => l.toLowerCase().includes('fatal:')); const permissionLine = lines.find(l => l.toLowerCase().includes('permission denied')); const errorLine = lines.find(l => l.toLowerCase().includes('error:')); // Return cleaner message if (permissionLine) { return 'Permission denied. Check your SSH credentials.'; } if (fatalLine) { // Clean up common fatal messages const msg = fatalLine.replace(/^fatal:\s*/i, '').trim(); if (msg.includes('Could not read from remote repository')) { return 'Could not access repository. Check URL and credentials.'; } return msg; } if (errorLine) { return errorLine.replace(/^error:\s*/i, '').trim(); } // Fallback to original (joined and trimmed) return lines.join(' ').trim() || 'Failed to connect to repository'; } /** * Core function to test a git repository connection. * Tests the URL, branch, and credentials passed directly (not from DB). */ async function testRepositoryConnection(options: { url: string; branch: string; credential: GitCredential | null; }): Promise { const { url, branch, credential } = options; const env = await buildGitEnv(credential); assertSafeGitRef(branch); const repoUrl = buildRepoUrl(url, credential); try { // Use git ls-remote to test connection and verify branch const result = await execGit( ['ls-remote', '--heads', '--refs', repoUrl, branch || 'HEAD'], process.cwd(), env ); if (result.code !== 0) { console.error('[Git] Connection test failed:', result.stderr); return { success: false, error: cleanGitError(result.stderr) }; } // Parse the output to get commit hash const lines = result.stdout.split('\n').filter(l => l.trim()); if (lines.length === 0) { // Branch not found, but connection worked - check if repo has any branches const allBranchesResult = await execGit( ['ls-remote', '--heads', '--refs', repoUrl], process.cwd(), env ); if (allBranchesResult.code !== 0) { return { success: false, error: cleanGitError(allBranchesResult.stderr) }; } const allBranches = allBranchesResult.stdout.split('\n') .filter(l => l.trim()) .map(l => { const m = l.match(/refs\/heads\/(.+)$/); return m ? m[1] : null; }) .filter(Boolean); if (allBranches.length === 0) { return { success: true, branch: '(empty repository)' }; } return { success: false, error: `Branch '${branch}' not found. Available branches: ${allBranches.slice(0, 5).join(', ')}${allBranches.length > 5 ? '...' : ''}` }; } const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/); const lastCommit = match ? match[1].substring(0, 7) : undefined; const foundBranch = match ? match[2] : branch; return { success: true, branch: foundBranch, lastCommit }; } catch (error: any) { return { success: false, error: error.message }; } finally { cleanupSshKey(credential, env); } } /** * Test a saved repository from the database (used by grid test button). */ export async function testRepository(repoId: number): Promise { const repo = await getGitRepository(repoId); if (!repo) { return { success: false, error: 'Repository not found' }; } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; return testRepositoryConnection({ url: repo.url, branch: repo.branch, credential }); } /** * Test a repository configuration before saving (used by modal test button). * Uses credentialId to fetch stored credentials from the database. */ export async function testRepositoryConfig(options: { url: string; branch: string; credentialId?: number | null; }): Promise { const { url, branch, credentialId } = options; if (!url) { return { success: false, error: 'Repository URL is required' }; } // Transport denylist (ext::/fd::/file::/local paths) — applied here so the // denylist holds on every path into a git subprocess, not only via // buildRepoUrl (same pattern as listRemoteBranches). assertSafeRepoUrl(url); // Fetch credential from database if credentialId is provided const credential = credentialId ? await getGitCredential(credentialId) : null; if (credentialId && !credential) { return { success: false, error: 'Credential not found' }; } return testRepositoryConnection({ url, branch: branch || 'main', credential }); } /** * List remote branches for a repository URL using git ls-remote. * Resolves credentials from the database if a credentialId is provided. * Bounded by a hard timeout (default GIT_TIMEOUT_MS, 20s) so a dead or * black-holed host cannot stall the request — see execGit. This is the * only git operation with a timeout: its target URL is the one that can be * attacker-influenced, so an unbounded ls-remote would be a resource- * exhaustion vector. * * The transport denylist (ext::/fd::/file::/local paths) is applied HERE, * explicitly, rather than only via buildRepoUrl — a future parser change in * buildRepoUrl must not be able to reopen command execution on this path. */ export interface RemoteBranch { name: string; /** Short (7-char) commit SHA the branch points at, from ls-remote. */ sha: string; } export async function listRemoteBranches(options: { url: string; credentialId?: number | null; timeoutMs?: number; }): Promise<{ branches: RemoteBranch[]; error?: string }> { const { url, credentialId, timeoutMs } = options; // The effective branch-lookup timeout: an explicitly provided (valid) // override wins; otherwise the default GIT_TIMEOUT_MS. This is the ONLY // bounded git operation — see execGit for why deploy/sync operations are // left unbounded. const effectiveTimeoutMs = timeoutMs ?? GIT_TIMEOUT_MS; // Transport denylist — the same check buildRepoUrl applies, applied // explicitly here so the denylist holds even if buildRepoUrl changes. assertSafeRepoUrl(url); // Fetch credential from database if credentialId is provided const credential = credentialId ? await getGitCredential(credentialId) : null; const env = await buildGitEnv(credential); const repoUrl = buildRepoUrl(url, credential); try { const result = await execGit( ['ls-remote', '--heads', '--refs', repoUrl], process.cwd(), env, effectiveTimeoutMs ); if (result.timedOut) { return { branches: [], error: `git ls-remote timed out after ${Math.round(effectiveTimeoutMs / 1000)}s` }; } if (result.code !== 0) { return { branches: [], error: cleanGitError(result.stderr) }; } // Each line: "\trefs/heads/". Keep the SHA (short) so the // picker can show it; it was previously discarded. const branches = result.stdout.split('\n') .map(l => { const m = l.match(/^([0-9a-f]+)\s+refs\/heads\/(.+)$/); return m ? { name: m[2], sha: m[1].slice(0, 7) } : null; }) .filter(Boolean) as RemoteBranch[]; return { branches }; } catch (error: any) { return { branches: [], error: error.message }; } finally { cleanupSshKey(credential); } } export async function syncRepository(repoId: number): Promise { const repo = await getGitRepository(repoId); if (!repo) { return { success: false, error: 'Repository not found' }; } // Check if sync is already in progress if (repo.syncStatus === 'syncing') { return { success: false, error: 'Sync already in progress' }; } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const repoPath = getRepoPath(repoId); const env = await buildGitEnv(credential); try { // Update sync status await updateGitRepository(repoId, { syncStatus: 'syncing', syncError: null }); let updated = false; let currentCommit = ''; if (!existsSync(repoPath)) { // Clone the repository (blobless clone - fetches all commits but blobs on-demand) assertSafeGitRef(repo.branch); const repoUrl = buildRepoUrl(repo.url, credential); const result = await execGit( ['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath], process.cwd(), env ); if (result.code !== 0) { // Clean up partial clone directory on failure if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } throw new Error(`Git clone failed: ${result.stderr}`); } updated = true; } else { // Get current commit before pull const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const beforeCommit = beforeResult.stdout; // Pull latest changes const result = await execGit(['pull', 'origin', repo.branch], repoPath, env); if (result.code !== 0) { throw new Error(`Git pull failed: ${result.stderr}`); } // Get commit after pull const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const afterCommit = afterResult.stdout; updated = beforeCommit !== afterCommit; } // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); currentCommit = commitResult.stdout.substring(0, 7); // Read the compose file const composePath = repoFilePath(repoPath, repo.composePath, "Compose path"); if (!existsSync(composePath)) { throw new Error(`Compose file not found: ${repo.composePath}`); } const composeContent = readFileSync(composePath, 'utf-8'); // Update repository status await updateGitRepository(repoId, { syncStatus: 'synced', lastSync: new Date().toISOString(), lastCommit: currentCommit, syncError: null }); cleanupSshKey(credential, env); return { success: true, commit: currentCommit, composeContent, updated }; } catch (error: any) { cleanupSshKey(credential, env); await updateGitRepository(repoId, { syncStatus: 'error', syncError: error.message }); return { success: false, error: error.message }; } } export async function deployFromRepository(repoId: number): Promise<{ success: boolean; output?: string; error?: string }> { const repo = await getGitRepository(repoId); if (!repo) { return { success: false, error: 'Repository not found' }; } // Sync first const syncResult = await syncRepository(repoId); if (!syncResult.success) { return { success: false, error: syncResult.error }; } const stackName = repo.name; // Deploy using unified function - handles both new and existing stacks const result = await deployStack({ name: stackName, compose: syncResult.composeContent!, envId: repo.environmentId }); if (result.success) { // Record the stack source await upsertStackSource({ stackName: stackName, environmentId: repo.environmentId, sourceType: 'git', gitRepositoryId: repoId }); } return result; } export async function checkForUpdates(repoId: number): Promise<{ hasUpdates: boolean; currentCommit?: string; latestCommit?: string; error?: string }> { const repo = await getGitRepository(repoId); if (!repo) { return { hasUpdates: false, error: 'Repository not found' }; } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const repoPath = getRepoPath(repoId); const env = await buildGitEnv(credential); try { if (!existsSync(repoPath)) { return { hasUpdates: true, currentCommit: 'none', latestCommit: 'unknown' }; } // Get current commit const currentResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const currentCommit = currentResult.stdout.substring(0, 7); // Fetch latest without merging await execGit(['fetch', 'origin', repo.branch], repoPath, env); // Get remote commit const latestResult = await execGit(['rev-parse', `origin/${repo.branch}`], repoPath, env); const latestCommit = latestResult.stdout.substring(0, 7); cleanupSshKey(credential, env); return { hasUpdates: currentCommit !== latestCommit, currentCommit, latestCommit }; } catch (error: any) { cleanupSshKey(credential, env); return { hasUpdates: false, error: error.message }; } } export function deleteRepositoryFiles(repoId: number): void { const repoPath = getRepoPath(repoId); try { if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Git] Failed to delete repository files:', errorMsg); } } // === Git Stack Functions === export async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise { if (stackName && environmentId) { // Use old path if it already exists (backward compat), otherwise use name-based path const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`); if (existsSync(oldPath)) { return oldPath; } // Format: envName/stackName (e.g. production/webapp) - consistent with internal stacks const env = await getEnvironment(environmentId); const envDir = join(GIT_REPOS_DIR, env ? env.name : String(environmentId)); if (!existsSync(envDir)) { mkdirSync(envDir, { recursive: true }); } return join(envDir, stackName); } return join(GIT_REPOS_DIR, `stack-${stackId}`); } /** * Get the current commit hash from a repo path (if it exists). * Used to detect if repo was updated after re-clone. */ async function getPreviousCommit(repoPath: string, env: GitEnv): Promise { if (!existsSync(repoPath)) { return null; } try { const result = await execGit(['rev-parse', 'HEAD'], repoPath, env); return result.code === 0 ? result.stdout.trim() : null; } catch { return null; } } /** * The effective branch a git stack deploys from is resolved by * resolveStackBranch() (src/lib/git-stack-branch.ts) — a per-stack branch * override (git_stacks.branch) wins; when it is unset, empty, or * whitespace-only, the repository's default branch is used. */ export async function syncGitStack(stackId: number): Promise { const gitStack = await getGitStack(stackId); if (!gitStack) { return { success: false, error: 'Git stack not found' }; } const logPrefix = `[Stack:${gitStack.stackName}]`; console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} SYNC GIT STACK START`); console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} Stack ID:`, stackId); console.log(`${logPrefix} Stack name:`, gitStack.stackName); console.log(`${logPrefix} Repository ID:`, gitStack.repositoryId); console.log(`${logPrefix} Compose path:`, gitStack.composePath); console.log(`${logPrefix} Env file path:`, gitStack.envFilePath || '(none)'); console.log(`${logPrefix} Environment ID:`, gitStack.environmentId); // Check if sync is already in progress if (gitStack.syncStatus === 'syncing') { console.log(`${logPrefix} ERROR: Sync already in progress`); return { success: false, error: 'Sync already in progress' }; } const repo = await getGitRepository(gitStack.repositoryId); if (!repo) { console.log(`${logPrefix} ERROR: Repository not found`); return { success: false, error: 'Repository not found' }; } // Per-stack branch override wins; unset means the repository default const effectiveBranch = resolveStackBranch(gitStack, repo); console.log(`${logPrefix} Repository URL:`, repo.url); console.log(`${logPrefix} Repository branch:`, repo.branch); if (effectiveBranch !== repo.branch) { console.log(`${logPrefix} Stack branch override:`, effectiveBranch); } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); console.log(`${logPrefix} Local repo path:`, repoPath); console.log(`${logPrefix} Has credential:`, !!credential); try { // Update sync status await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null }); let updated = false; let currentCommit = ''; // Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.) // Blobless clones fetch all commits (for git diff) but download blobs on-demand // Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693) const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null; if (existsSync(repoPath)) { console.log(`${logPrefix} Removing existing clone for fresh sync...`); rmSync(repoPath, { recursive: true, force: true }); } console.log(`${logPrefix} Cloning repository...`); assertSafeGitRef(effectiveBranch); const repoUrl = buildRepoUrl(repo.url, credential); const result = await execGit( ['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath], process.cwd(), env ); console.log(`${logPrefix} Clone exit code:`, result.code); if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout); if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr); if (result.code !== 0) { // Clean up partial clone directory on failure if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } throw new Error(`Git clone failed: ${result.stderr}`); } // Check if commit changed const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const newCommit = newCommitResult.stdout.trim(); // Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char) const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7); console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`); // Check if any files in the compose file's directory have changed // This catches changes to the compose file, env files, and any other referenced files // (e.g., config files, scripts, additional env files) let changedFiles: string[] = []; if (commitChanged) { // Use contextDir if set, otherwise fall back to compose file's directory const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath); console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`); const diffResult = await getChangedFilesInDir( repoPath, previousCommit, newCommit, diffDirRelative || '.', env ); updated = diffResult.changed; changedFiles = diffResult.files; if (diffResult.error) { console.log(`${logPrefix} Diff error: ${diffResult.error}`); } if (changedFiles.length > 0) { console.log(`${logPrefix} Changed files (${changedFiles.length}):`); for (const file of changedFiles) { console.log(`${logPrefix} - ${file}`); } } else { console.log(`${logPrefix} No files changed in stack directory`); } } else { updated = false; console.log(`${logPrefix} No commit change, skipping file diff`); } // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); currentCommit = commitResult.stdout.substring(0, 7); console.log(`${logPrefix} Current commit:`, currentCommit); // Read the compose file const composePath = repoFilePath(repoPath, gitStack.composePath, "Compose path"); console.log(`${logPrefix} Reading compose file from:`, composePath); if (!existsSync(composePath)) { console.log(`${logPrefix} ERROR: Compose file not found at:`, composePath); throw new Error(`Compose file not found: ${gitStack.composePath}`); } const composeContent = readFileSync(composePath, 'utf-8'); console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars'); console.log(`${logPrefix} Compose content:`); console.log(composeContent); // Determine the source directory and compose filename // If contextDir is set, use it as the source directory (relative to repo root) // and compute composeFileName as relative path from contextDir to compose file let composeDir: string; let composeFileName: string; if (gitStack.contextDir) { const contextDirAbsolute = resolve(repoPath, gitStack.contextDir); // Validate: context dir must be within repo if (!contextDirAbsolute.startsWith(repoPath)) { throw new Error('Context directory must be within the repository'); } // Validate: compose file must be within context directory const relCompose = relative(contextDirAbsolute, composePath); if (relCompose.startsWith('..')) { throw new Error('Compose file must be within the context directory'); } composeDir = contextDirAbsolute; composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml" } else { composeDir = dirname(composePath); composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml" } console.log(`${logPrefix} Source directory (composeDir):`, composeDir); console.log(`${logPrefix} Compose filename:`, composeFileName); // Read env file if configured (optional - don't fail if missing) let envFileVars: Record | undefined; let envFileContent: string | undefined; let envFileName: string | undefined; if (gitStack.envFilePath) { const envFilePath = repoFilePath(repoPath, gitStack.envFilePath, "Env file path"); console.log(`${logPrefix} Looking for env file at:`, envFilePath); if (existsSync(envFilePath)) { try { console.log(`${logPrefix} Reading env file...`); envFileContent = readFileSync(envFilePath, 'utf-8'); envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName); console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length); // Compute env file path relative to compose directory // This is needed for --env-file flag after files are copied to stack directory envFileName = relative(composeDir, envFilePath); console.log(`${logPrefix} Env filename relative to compose dir:`, envFileName); } catch (err) { // Log but don't fail - env file is optional console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err); } } else { console.warn(`${logPrefix} Configured env file not found:`, gitStack.envFilePath); } } else { console.log(`${logPrefix} No env file path configured`); } // Deletion sync (#966): manifest-vs-clone deletion plan const deletionData = await computeSyncDeletionPlan({ logPrefix, composeDir, composeFileName, rawManifest: gitStack.syncedFiles }); // Update git stack status await updateGitStack(stackId, { syncStatus: 'synced', lastSync: new Date().toISOString(), lastCommit: currentCommit, syncError: null }); cleanupSshKey(credential, env); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} SYNC GIT STACK COMPLETE`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Success: true`); console.log(`${logPrefix} Updated:`, updated); console.log(`${logPrefix} Changed files:`, changedFiles.length > 0 ? changedFiles.join(', ') : '(none)'); console.log(`${logPrefix} Commit:`, currentCommit); console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0); return { success: true, commit: currentCommit, composeContent, composeDir, composeFileName, envFileVars, envFileName, updated, changedFiles, deletionPlan: deletionData.plan, newFiles: deletionData.newFiles, newCommitFull: newCommit, previousManifest: deletionData.previousManifest }; } catch (error: any) { cleanupSshKey(credential, env); await updateGitStack(stackId, { syncStatus: 'error', syncError: error.message }); console.log(`${logPrefix} SYNC ERROR:`, error.message); return { success: false, error: error.message }; } } /** * Fire the git_sync_success / git_sync_failed / git_sync_skipped notification for a * git-stack deploy. Called from deployGitStack so EVERY caller notifies — webhook, * manual API deploy, create/update, and the scheduler alike. Previously the dispatch * lived only in the git-stack-sync scheduler task, so webhook/manual deploys were * silent (#1295). Best-effort: never changes the deploy outcome. */ async function notifyGitSync(stackName: string, envId: number | null | undefined, result: { success: boolean; error?: string; skipped?: boolean }): Promise { try { if (result.success && result.skipped) { await sendEventNotification('git_sync_skipped', { title: 'Git sync skipped', message: `Stack "${stackName}" sync skipped: no changes detected`, type: 'info' }, envId ?? undefined); } else if (result.success) { await sendEventNotification('git_sync_success', { title: 'Git stack deployed', message: `Stack "${stackName}" was synced and deployed successfully`, type: 'success' }, envId ?? undefined); } else { await sendEventNotification('git_sync_failed', { title: 'Git sync failed', message: `Stack "${stackName}" sync failed: ${result.error || 'unknown error'}`, type: 'error' }, envId ?? undefined); } } catch { /* never changes the deploy outcome */ } } export async function deployGitStack(stackId: number, options?: { force?: boolean }): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> { const force = options?.force ?? true; // Default to force for backward compatibility const gitStack = await getGitStack(stackId); if (!gitStack) { return { success: false, error: 'Git stack not found' }; } const logPrefix = `[Stack:${gitStack.stackName}]`; console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} DEPLOY GIT STACK START`); console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} Stack ID:`, stackId); console.log(`${logPrefix} Force deploy:`, force); // Sync first console.log(`${logPrefix} Syncing git repository...`); const syncResult = await syncGitStack(stackId); if (!syncResult.success) { console.log(`${logPrefix} Sync failed:`, syncResult.error); const failResult = { success: false, error: syncResult.error }; await notifyGitSync(gitStack.stackName, gitStack.environmentId, failResult); return failResult; } console.log(`${logPrefix} Sync successful`); console.log(`${logPrefix} Sync result - updated:`, syncResult.updated); console.log(`${logPrefix} Sync result - commit:`, syncResult.commit); console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0); if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) { console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', ')); console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2)); } // Check if there are changes - skip redeploy if no changes and not forced // Note: For new stacks (first deploy), syncResult.updated will be true // forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated; if (!shouldDeploy) { console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`); const skippedResult = { success: true, output: 'No changes detected, skipping redeploy', skipped: true }; await notifyGitSync(gitStack.stackName, gitStack.environmentId, skippedResult); return skippedResult; } const forceRecreate = syncResult.updated; console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`); console.log(`${logPrefix} Build on deploy:`, gitStack.buildOnDeploy); console.log(`${logPrefix} Re-pull images:`, gitStack.repullImages); console.log(`${logPrefix} Force redeploy setting:`, gitStack.forceRedeploy); // Deploy using unified function - handles both new and existing stacks // Uses `docker compose up -d --remove-orphans` which only recreates changed services // Force recreate whenever git detected changes to ensure containers pick up // new env var values even if compose file itself didn't change console.log(`${logPrefix} Calling deployStack...`); console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir); console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName); console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)'); const result = await deployStack({ name: gitStack.stackName, compose: syncResult.composeContent!, envId: gitStack.environmentId, sourceDir: syncResult.composeDir, // Copy entire directory from git repo composeFileName: syncResult.composeFileName, // Use original compose filename from repo envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional) forceRecreate, build: gitStack.buildOnDeploy, noBuildCache: gitStack.noBuildCache, pullPolicy: gitStack.repullImages ? 'always' : undefined, filesToDelete: syncResult.deletionPlan?.toDelete, isGitDeploy: true // suppress stack_* notification; we emit git_sync_* below }); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} DEPLOY GIT STACK RESULT`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Success:`, result.success); if (result.output) console.log(`${logPrefix} Output:`, result.output); if (result.error) console.log(`${logPrefix} Error:`, result.error); if (result.success) { // Deletion sync: persist manifest + log per-file change summary if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) { await finalizeDeletionSync({ stackId, logPrefix, previousManifest: syncResult.previousManifest, newCommitFull: syncResult.newCommitFull, newFiles: syncResult.newFiles, plan: syncResult.deletionPlan, applyResult: result.deletion }); } // Record the stack source with resolved compose path for consistency const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId); const resolvedComposePath = syncResult.composeFileName ? join(stackDir, syncResult.composeFileName) : undefined; console.log(`${logPrefix} Resolved compose path for stack_sources:`, resolvedComposePath); await upsertStackSource({ stackName: gitStack.stackName, environmentId: gitStack.environmentId, sourceType: 'git', gitRepositoryId: gitStack.repositoryId, gitStackId: stackId, composePath: resolvedComposePath }); } // git_sync_success / git_sync_failed for the actual deploy result. deployStack // suppressed its stack_* notification (isGitDeploy), so this is the single one. await notifyGitSync(gitStack.stackName, gitStack.environmentId, result); return result; } export async function testGitStack(stackId: number): Promise { const gitStack = await getGitStack(stackId); if (!gitStack) { return { success: false, error: 'Git stack not found' }; } const repo = await getGitRepository(gitStack.repositoryId); if (!repo) { return { success: false, error: 'Repository not found' }; } // Per-stack branch override wins; unset means the repository default const effectiveBranch = resolveStackBranch(gitStack, repo); const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const env = await buildGitEnv(credential); assertSafeGitRef(effectiveBranch); const repoUrl = buildRepoUrl(repo.url, credential); try { // Use git ls-remote to test connection and get branch info const result = await execGit( ['ls-remote', '--heads', '--refs', repoUrl, effectiveBranch], process.cwd(), env ); cleanupSshKey(credential, env); if (result.code !== 0) { return { success: false, error: result.stderr || 'Failed to connect to repository' }; } // Parse the output to get commit hash const lines = result.stdout.split('\n').filter(l => l.trim()); if (lines.length === 0) { return { success: false, error: `Branch '${effectiveBranch}' not found in repository` }; } const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/); const lastCommit = match ? match[1].substring(0, 7) : undefined; const branch = match ? match[2] : effectiveBranch; cleanupSshKey(credential, env); return { success: true, branch, lastCommit }; } catch (error: any) { cleanupSshKey(credential, env); return { success: false, error: error.message }; } } export async function deleteGitStackFiles(stackId: number, stackName?: string, environmentId?: number | null): Promise { const repoPath = await getStackRepoPath(stackId, stackName, environmentId); try { if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Git] Failed to delete git stack files:', errorMsg); } } // Progress callback type type ProgressCallback = (data: { status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error'; message?: string; step?: number; totalSteps?: number; error?: string; }) => void; export async function deployGitStackWithProgress( stackId: number, onProgress: ProgressCallback ): Promise<{ success: boolean; output?: string; error?: string }> { const gitStack = await getGitStack(stackId); if (!gitStack) { onProgress({ status: 'error', error: 'Git stack not found' }); return { success: false, error: 'Git stack not found' }; } // Check if sync is already in progress if (gitStack.syncStatus === 'syncing') { onProgress({ status: 'error', error: 'Sync already in progress' }); return { success: false, error: 'Sync already in progress' }; } const repo = await getGitRepository(gitStack.repositoryId); if (!repo) { onProgress({ status: 'error', error: 'Repository not found' }); return { success: false, error: 'Repository not found' }; } // Per-stack branch override wins; unset means the repository default const effectiveBranch = resolveStackBranch(gitStack, repo); const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); const totalSteps = 5; try { // Step 1: Connecting onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps }); await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null }); let updated = false; let currentCommit = ''; // Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.) // Shallow clones are fast so this is acceptable // Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693) const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null; // Step 2: Cloning onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps }); if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } assertSafeGitRef(effectiveBranch); const repoUrl = buildRepoUrl(repo.url, credential); // Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand) onProgress({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps }); const cloneResult = await execGit( ['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath], process.cwd(), env ); if (cloneResult.code !== 0) { // Clean up partial clone directory on failure if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); } throw new Error(`Git clone failed: ${cloneResult.stderr}`); } // Check if commit changed const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); const newCommit = newCommitResult.stdout.trim(); // Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char) const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7); // Check if any files in the context/compose directory have changed // (for consistency with syncGitStack, though this function always deploys) if (commitChanged) { const diffDir = gitStack.contextDir || dirname(gitStack.composePath); const diffResult = await getChangedFilesInDir( repoPath, previousCommit, newCommit, diffDir || '.', env ); updated = diffResult.changed; } else { updated = false; } // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); currentCommit = commitResult.stdout.substring(0, 7); // Step 4: Reading compose file onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps }); const composePath = repoFilePath(repoPath, gitStack.composePath, "Compose path"); if (!existsSync(composePath)) { throw new Error(`Compose file not found: ${gitStack.composePath}`); } const composeContent = readFileSync(composePath, 'utf-8'); // Determine the source directory and compose filename let composeDir: string; let progressComposeFileName: string; if (gitStack.contextDir) { const contextDirAbsolute = resolve(repoPath, gitStack.contextDir); if (!contextDirAbsolute.startsWith(repoPath)) { throw new Error('Context directory must be within the repository'); } const relCompose = relative(contextDirAbsolute, composePath); if (relCompose.startsWith('..')) { throw new Error('Compose file must be within the context directory'); } composeDir = contextDirAbsolute; progressComposeFileName = relCompose; } else { composeDir = dirname(composePath); progressComposeFileName = basename(gitStack.composePath); } // Read env file if configured (optional - don't fail if missing) let envFileVars: Record | undefined; if (gitStack.envFilePath) { const envFilePath = repoFilePath(repoPath, gitStack.envFilePath, "Env file path"); if (existsSync(envFilePath)) { try { const envContent = readFileSync(envFilePath, 'utf-8'); envFileVars = parseEnvFileContent(envContent, gitStack.stackName); } catch (err) { // Log but don't fail - env file is optional console.warn(`Failed to read env file ${gitStack.envFilePath}:`, err); } } else { console.warn(`Configured env file not found: ${gitStack.envFilePath}`); } } // Deletion sync (#966): manifest-vs-clone deletion plan const logPrefix = `[Stack:${gitStack.stackName}]`; const deletionData = await computeSyncDeletionPlan({ logPrefix, composeDir, composeFileName: progressComposeFileName, rawManifest: gitStack.syncedFiles }); // Update git stack status await updateGitStack(stackId, { syncStatus: 'synced', lastSync: new Date().toISOString(), lastCommit: currentCommit, syncError: null }); cleanupSshKey(credential, env); // Show the git file changes BEFORE the deploy starts, so the user sees // what changed while the deploy runs and the deploy start/result lines // stay together (#1260). Removals reflect the deletion plan here; // apply-stage divergences (rare) are reported after the deploy. const changeTable = formatChangeTable( buildSyncChangeSummary( deletionData.previousManifest.files, deletionData.newFiles, { deleted: deletionData.plan.toDelete.map((f) => f.path), skipped: [] }, deletionData.plan.skipped ) ); onProgress({ status: 'deploying', message: `File changes: ${changeTable[0]}`, step: 5, totalSteps }); for (const line of changeTable.slice(1)) { onProgress({ status: 'deploying', message: line, step: 5, totalSteps }); } // Step 5: Deploying stack // Uses `docker compose up -d --remove-orphans` which only recreates changed services onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps }); if (deletionData.plan.toDelete.length > 0) { onProgress({ status: 'deploying', message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`, step: 5, totalSteps }); } // Determine env filename relative to compose dir (same logic as syncGitStack) let envFileName: string | undefined; if (gitStack.envFilePath) { const envFilePath = repoFilePath(repoPath, gitStack.envFilePath, "Env file path"); if (existsSync(envFilePath)) { envFileName = relative(composeDir, envFilePath); } } const result = await deployStack({ name: gitStack.stackName, compose: composeContent, envId: gitStack.environmentId, sourceDir: composeDir, // Copy entire directory from git repo composeFileName: progressComposeFileName, // Compose filename relative to source dir envFileName, // Env file relative to compose dir (for --env-file flag, optional) build: gitStack.buildOnDeploy, noBuildCache: gitStack.noBuildCache, pullPolicy: gitStack.repullImages ? 'always' : undefined, filesToDelete: deletionData.plan.toDelete }); if (result.success) { // Deletion sync: persist manifest + log per-file change summary. // The change table was already shown before the deploy (#1260); // report only apply-stage divergences from the plan here. await finalizeDeletionSync({ stackId, logPrefix, previousManifest: deletionData.previousManifest, newCommitFull: newCommit, newFiles: deletionData.newFiles, plan: deletionData.plan, applyResult: result.deletion }); const applySkips = (result.deletion?.skipped ?? []).filter((s) => s.reason !== 'already-absent'); for (const skip of applySkips) { onProgress({ status: 'deploying', message: `Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`, step: 5, totalSteps }); } // Record the stack source with resolved compose path for consistency const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId); const resolvedComposePath = join(stackDir, progressComposeFileName); await upsertStackSource({ stackName: gitStack.stackName, environmentId: gitStack.environmentId, sourceType: 'git', gitRepositoryId: gitStack.repositoryId, gitStackId: stackId, composePath: resolvedComposePath }); onProgress({ status: 'complete', message: `Successfully deployed ${gitStack.stackName}` }); } else { throw new Error(result.error || 'Failed to deploy stack'); } return result; } catch (error: any) { cleanupSshKey(credential, env); await updateGitStack(stackId, { syncStatus: 'error', syncError: error.message }); onProgress({ status: 'error', error: error.message }); return { success: false, error: error.message }; } } // ============================================================================= // ENV FILE OPERATIONS // ============================================================================= /** * List all .env* files in a git stack's repository. * Returns relative paths from the repository root. */ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: string[]; error?: string }> { const gitStack = await getGitStack(stackId); if (!gitStack) { return { files: [], error: 'Git stack not found' }; } const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); if (!existsSync(repoPath)) { return { files: [], error: 'Repository not synced - deploy the stack first' }; } try { // Find all .env* files recursively (but not too deep) const maxDepth = 3; // Use find to locate all .env* files const proc = nodeSpawn('find', [repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], { stdio: ['pipe', 'pipe', 'pipe'] }); const findResult = await collectProcess(proc); const output = findResult.stdout; const files = output.trim().split('\n').filter(f => f); const envFiles: string[] = []; for (const file of files) { // Convert absolute path to relative from repo root const relativePath = file.replace(repoPath + '/', ''); // Skip files in node_modules or .git directories if (!relativePath.includes('node_modules/') && !relativePath.includes('.git/')) { envFiles.push(relativePath); } } return { files: envFiles.sort() }; } catch (error: any) { return { files: [], error: error.message }; } } /** * Parse a .env file content into key-value pairs. * Handles comments, empty lines, and quoted values. */ export function parseEnvFileContent(content: string, stackName?: string): Record { const logPrefix = stackName ? `[Stack:${stackName}]` : '[Git]'; const result: Record = {}; const skippedLines: string[] = []; const invalidKeys: string[] = []; console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} PARSE ENV FILE CONTENT`); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} Raw content length:`, content.length, 'chars'); console.log(`${logPrefix} Raw content:`); console.log(content); const lines = content.split('\n'); console.log(`${logPrefix} Total lines:`, lines.length); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); // Skip empty lines and comments if (!trimmed || trimmed.startsWith('#')) { if (trimmed) skippedLines.push(`Line ${i + 1}: ${trimmed.substring(0, 50)}...`); continue; } // Find the first = sign const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) { skippedLines.push(`Line ${i + 1} (no =): ${trimmed.substring(0, 50)}`); continue; } const key = trimmed.substring(0, eqIndex).trim(); const value = trimmed.substring(eqIndex + 1).trim(); // Only add if key is valid env var name if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { result[key] = value; } else { invalidKeys.push(`Line ${i + 1}: "${key}" (invalid key format)`); } } console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length); console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', ')); console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2)); if (skippedLines.length > 0) { console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; ')); } if (invalidKeys.length > 0) { console.log(`${logPrefix} Invalid keys (${invalidKeys.length}):`, invalidKeys.join('; ')); } return result; } /** * Read and parse a .env file from a git stack's repository. */ export async function readGitStackEnvFile( stackId: number, envFilePath: string ): Promise<{ vars: Record; error?: string }> { const gitStack = await getGitStack(stackId); if (!gitStack) { return { vars: {}, error: 'Git stack not found' }; } const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); if (!existsSync(repoPath)) { return { vars: {}, error: 'Repository not synced - deploy the stack first' }; } // Security check: ensure the path doesn't escape the repo const normalizedPath = envFilePath.replace(/\.\./g, '').replace(/^\//, ''); const fullPath = join(repoPath, normalizedPath); if (!fullPath.startsWith(repoPath)) { return { vars: {}, error: 'Invalid file path' }; } if (!existsSync(fullPath)) { return { vars: {}, error: `File not found: ${envFilePath}` }; } try { const content = readFileSync(fullPath, 'utf-8'); const vars = parseEnvFileContent(content); return { vars }; } catch (error: any) { return { vars: {}, error: error.message }; } } interface PreviewEnvOptions { repoUrl: string; branch: string; credential: { id: number; authType: string; sshPrivateKey?: string | null; username?: string | null; password?: string | null; } | null; composePath: string; envFilePath: string | null; } interface PreviewEnvResult { vars: Record; sources: Record; error?: string; } /** * Clone a repository to a temp directory and read env files for preview. * Used to populate env editor when creating a new git stack. * Cleans up temp directory after reading. */ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise { const { repoUrl, branch, credential, composePath, envFilePath } = options; const logPrefix = '[Git:Preview]'; // Create a unique temp directory const tempId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; const tempDir = join(GIT_REPOS_DIR, tempId); console.log(`${logPrefix} Starting preview for ${repoUrl}`); console.log(`${logPrefix} Temp directory: ${tempDir}`); // Declared outside the try so the finally can pass it to cleanupSshKey (#1413): // a block-scoped `const env` inside the try is out of scope in finally, which // throws "env is not defined" before the real result/error is returned. let env: GitEnv | undefined; try { // Ensure temp directory exists mkdirSync(tempDir, { recursive: true }); // Build git environment with credentials // Cast credential to GitCredential type (only uses id, authType, sshPrivateKey) env = await buildGitEnv(credential as GitCredential | null); // Security: the preview endpoint clones a // USER-SUPPLIED URL and reads env files from it — the same SSRF / RCE / // path-traversal surface as the branches endpoint. Apply the shared guards // BEFORE any git subprocess or file read: // 1. assertSafeRepoTarget — blocks loopback/link-local/metadata/reserved // hosts (SSRF) and the ext::/file:: transport denylist (RCE). Ordinary // private-LAN addresses are intentionally allowed (self-hosted Git). // 2. repoFilePath — keeps composePath/envFilePath INSIDE the temp dir // (path traversal). Without this, `envFilePath: '../../secrets/x'` // reads a file outside the clone. assertSafeRepoTarget(repoUrl); const safeComposePath = repoFilePath(tempDir, composePath, 'Compose path'); const safeEnvFilePath = envFilePath ? repoFilePath(tempDir, envFilePath, 'Env file path') : null; assertSafeGitRef(branch); const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null); // Clone with depth 1 (shallow clone for speed) const cloneProc = nodeSpawn( 'git', ['clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir], { stdio: ['pipe', 'pipe', 'pipe'], env } ); const cloneResult = await collectProcess(cloneProc); const cloneStderr = cloneResult.stderr; const cloneExitCode = cloneResult.exitCode; if (cloneExitCode !== 0) { console.error(`${logPrefix} Clone failed:`, cloneStderr); return { vars: {}, sources: {}, error: `Failed to clone repository: ${cloneStderr.trim()}` }; } console.log(`${logPrefix} Clone successful`); // Determine the compose directory (where .env file should be) — uses the // VALIDATED path (already checked to be inside the temp dir). const composeDir = dirname(safeComposePath); const baseEnvPath = join(tempDir, composeDir, '.env'); const vars: Record = {}; const sources: Record = {}; // Read base .env file if it exists if (existsSync(baseEnvPath)) { console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`); const content = readFileSync(baseEnvPath, 'utf-8'); const baseVars = parseEnvFileContent(content, 'preview'); for (const [key, value] of Object.entries(baseVars)) { vars[key] = value; sources[key] = '.env'; } console.log(`${logPrefix} Found ${Object.keys(baseVars).length} vars in .env`); } else { console.log(`${logPrefix} No .env file at ${baseEnvPath}`); } // Read additional env file if specified — uses the VALIDATED path // (already checked to be inside the temp dir). if (safeEnvFilePath) { const additionalEnvPath = safeEnvFilePath; if (existsSync(additionalEnvPath)) { console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`); const content = readFileSync(additionalEnvPath, 'utf-8'); const additionalVars = parseEnvFileContent(content, 'preview'); for (const [key, value] of Object.entries(additionalVars)) { vars[key] = value; sources[key] = 'envFile'; } console.log(`${logPrefix} Found ${Object.keys(additionalVars).length} vars in ${envFilePath}`); } else { console.log(`${logPrefix} Additional env file not found: ${additionalEnvPath}`); } } console.log(`${logPrefix} Total variables: ${Object.keys(vars).length}`); return { vars, sources }; } catch (error: any) { console.error(`${logPrefix} Error:`, error); return { vars: {}, sources: {}, error: error.message }; } finally { // Always clean up temp directory cleanupSshKey(credential as GitCredential | null, env); try { if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); console.log(`${logPrefix} Cleaned up temp directory`); } } catch (cleanupError) { console.error(`${logPrefix} Failed to cleanup temp directory:`, cleanupError); } } }