diff --git a/Dockerfile.prod b/Dockerfile.prod index 2f1bcd5..62f7e5b 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -39,7 +39,8 @@ RUN apt-get update && apt-get install -y \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ && apt-get update \ && apt-get install -y docker-ce-cli \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && git config --system --add safe.directory /app/deploy # Copy only what's needed for production COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules diff --git a/shared/modules/admin/update.service.ts b/shared/modules/admin/update.service.ts index 3568455..7b503a9 100644 --- a/shared/modules/admin/update.service.ts +++ b/shared/modules/admin/update.service.ts @@ -19,10 +19,11 @@ const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build */ async function execWithTimeout( cmd: string, - timeoutMs: number = DEFAULT_TIMEOUT_MS + timeoutMs: number = DEFAULT_TIMEOUT_MS, + options: { cwd?: string } = {} ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { - const process = exec(cmd, (error: ExecException | null, stdout: string, stderr: string) => { + const process = exec(cmd, { cwd: options.cwd }, (error: ExecException | null, stdout: string, stderr: string) => { if (error) { reject(error); } else { @@ -51,22 +52,24 @@ export class UpdateService { * Optimized: Parallel git commands and combined requirements check */ static async checkForUpdates(): Promise { + const cwd = this.getDeployDir(); + // Get branch first (needed for subsequent commands) - const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD"); + const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD", DEFAULT_TIMEOUT_MS, { cwd }); const branch = branchName.trim(); // Parallel execution: get current commit while fetching const [currentResult] = await Promise.all([ - execWithTimeout("git rev-parse --short HEAD"), - execWithTimeout(`git fetch origin ${branch} --prune`) // Only fetch current branch + execWithTimeout("git rev-parse --short HEAD", DEFAULT_TIMEOUT_MS, { cwd }), + execWithTimeout(`git fetch origin ${branch} --prune`, DEFAULT_TIMEOUT_MS, { cwd }) // Only fetch current branch ]); const currentCommit = currentResult.stdout.trim(); // After fetch completes, get remote info in parallel const [latestResult, logResult, diffResult] = await Promise.all([ - execWithTimeout(`git rev-parse --short origin/${branch}`), - execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`), - execWithTimeout(`git diff HEAD..origin/${branch} --name-only`) + execWithTimeout(`git rev-parse --short origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }), + execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`, DEFAULT_TIMEOUT_MS, { cwd }), + execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd }) ]); const latestCommit = latestResult.stdout.trim(); @@ -135,8 +138,9 @@ export class UpdateService { * Kept for backwards compatibility */ static async checkUpdateRequirements(branch: string): Promise { + const cwd = this.getDeployDir(); try { - const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`); + const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd }); const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0); return this.analyzeChangedFiles(changedFiles); } catch (e) { @@ -179,7 +183,8 @@ export class UpdateService { * Save the current commit for potential rollback */ static async saveRollbackPoint(): Promise { - const { stdout } = await execWithTimeout("git rev-parse HEAD"); + const cwd = this.getDeployDir(); + const { stdout } = await execWithTimeout("git rev-parse HEAD", DEFAULT_TIMEOUT_MS, { cwd }); const commit = stdout.trim(); await writeFile(this.ROLLBACK_FILE, commit); this.rollbackPointExists = true; // Cache the state @@ -190,9 +195,10 @@ export class UpdateService { * Rollback to the previous commit */ static async rollback(): Promise<{ success: boolean; message: string }> { + const cwd = this.getDeployDir(); try { const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8"); - await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`); + await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`, DEFAULT_TIMEOUT_MS, { cwd }); await unlink(this.ROLLBACK_FILE); this.rollbackPointExists = false; return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` }; @@ -226,7 +232,8 @@ export class UpdateService { * Perform the git update */ static async performUpdate(branch: string): Promise { - await execWithTimeout(`git reset --hard origin/${branch}`); + const cwd = this.getDeployDir(); + await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }); } /** @@ -236,6 +243,11 @@ export class UpdateService { static async installDependencies(options: { root: boolean; web: boolean }): Promise { const tasks: Promise<{ label: string; output: string }>[] = []; + // Install dependencies in the App directory (not deploy dir) because we are updating the RUNNING app + // NOTE: If we hot-reload, we want to install in the current directory. + // If we restart, dependencies should be in the image. + // Assuming this method is used for hot-patching/minor updates without container rebuild. + if (options.root) { tasks.push( execWithTimeout("bun install", INSTALL_TIMEOUT_MS)