2 Commits

Author SHA1 Message Date
syntaxbullet
422db6479b feat: Store update restart context in the deployment directory and configure Docker to use the default bun user.
Some checks failed
Deploy to Production / test (push) Failing after 24s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped
2026-01-30 15:06:32 +01:00
syntaxbullet
35ecea16f7 feat: Enable Git operations within a specified deployment directory by adding cwd options and configuring Git to trust the deploy directory. 2026-01-30 14:56:29 +01:00
3 changed files with 57 additions and 28 deletions

View File

@@ -27,8 +27,8 @@ RUN cd web && bun run build
FROM oven/bun:latest AS production
WORKDIR /app
# Create non-root user for security
RUN groupadd --system appgroup && useradd --system --gid appgroup appuser
# Create non-root user for security (bun user already exists with 1000:1000)
# No need to create user/group
# Install runtime dependencies for update/deploy commands
RUN apt-get update && apt-get install -y \
@@ -39,21 +39,22 @@ 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
COPY --from=builder --chown=appuser:appgroup /app/web/node_modules ./web/node_modules
COPY --from=builder --chown=appuser:appgroup /app/web/dist ./web/dist
COPY --from=builder --chown=appuser:appgroup /app/web/src ./web/src
COPY --from=builder --chown=appuser:appgroup /app/bot ./bot
COPY --from=builder --chown=appuser:appgroup /app/shared ./shared
COPY --from=builder --chown=appuser:appgroup /app/package.json .
COPY --from=builder --chown=appuser:appgroup /app/drizzle.config.ts .
COPY --from=builder --chown=appuser:appgroup /app/tsconfig.json .
COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules
COPY --from=builder --chown=bun:bun /app/web/node_modules ./web/node_modules
COPY --from=builder --chown=bun:bun /app/web/dist ./web/dist
COPY --from=builder --chown=bun:bun /app/web/src ./web/src
COPY --from=builder --chown=bun:bun /app/bot ./bot
COPY --from=builder --chown=bun:bun /app/shared ./shared
COPY --from=builder --chown=bun:bun /app/package.json .
COPY --from=builder --chown=bun:bun /app/drizzle.config.ts .
COPY --from=builder --chown=bun:bun /app/tsconfig.json .
# Switch to non-root user
USER appuser
USER bun
# Expose web dashboard port
EXPOSE 3000

View File

@@ -213,6 +213,18 @@ async function handleDeploy(interaction: any) {
});
if (result.success) {
// Save restart context so we can notify on startup
await UpdateService.prepareRestartContext({
channelId: interaction.channelId,
userId: interaction.user.id,
timestamp: Date.now(),
runMigrations: true, // Always check migrations on deploy
installDependencies: false, // Handled by Docker build
buildWebAssets: false, // Handled by Docker build
previousCommit: result.previousCommit,
newCommit: result.newCommit
});
await interaction.editReply({
embeds: [getDeploySuccessEmbed(
result.previousCommit,

View File

@@ -1,6 +1,7 @@
import { exec, type ExecException } from "child_process";
import { promisify } from "util";
import { writeFile, readFile, unlink } from "fs/promises";
import * as path from "path";
import { Client, TextChannel } from "discord.js";
import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view";
import type { PostRestartResult } from "@/modules/admin/update.view";
@@ -19,10 +20,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 +53,24 @@ export class UpdateService {
* Optimized: Parallel git commands and combined requirements check
*/
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
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 +139,9 @@ export class UpdateService {
* Kept for backwards compatibility
*/
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
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 +184,8 @@ export class UpdateService {
* Save the current commit for potential rollback
*/
static async saveRollbackPoint(): Promise<string> {
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 +196,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 +233,8 @@ export class UpdateService {
* Perform the git update
*/
static async performUpdate(branch: string): Promise<void> {
await execWithTimeout(`git reset --hard origin/${branch}`);
const cwd = this.getDeployDir();
await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd });
}
/**
@@ -236,6 +244,11 @@ export class UpdateService {
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
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)
@@ -258,7 +271,8 @@ export class UpdateService {
* Prepare restart context with rollback info
*/
static async prepareRestartContext(context: RestartContext): Promise<void> {
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
await writeFile(filePath, JSON.stringify(context));
}
/**
@@ -313,7 +327,8 @@ export class UpdateService {
private static async loadRestartContext(): Promise<RestartContext | null> {
try {
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
const contextData = await readFile(filePath, "utf-8");
return JSON.parse(contextData) as RestartContext;
} catch {
return null;
@@ -463,7 +478,8 @@ export class UpdateService {
private static async cleanupContext(): Promise<void> {
try {
await unlink(this.CONTEXT_FILE);
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
await unlink(filePath);
} catch {
// File may not exist, ignore
}