From ebefd8c0dff1ed0e54b61678082a33abe2fb6d46 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 30 Jan 2026 14:26:38 +0100 Subject: [PATCH] feat: add bot-triggered deployment via /update deploy command - Added Docker socket mount to docker-compose.prod.yml - Added project directory mount for git operations - Added performDeploy, isDeployAvailable methods to UpdateService - Added /update deploy subcommand for Discord-triggered deployments - Added deploy-related embeds to update.view.ts --- bot/commands/admin/update.ts | 60 +++++++++++++- bot/modules/admin/update.view.ts | 63 +++++++++++++++ docker-compose.prod.yml | 10 ++- shared/modules/admin/update.service.ts | 105 +++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 2 deletions(-) diff --git a/bot/commands/admin/update.ts b/bot/commands/admin/update.ts index ab7d05f..f4f8d19 100644 --- a/bot/commands/admin/update.ts +++ b/bot/commands/admin/update.ts @@ -11,7 +11,12 @@ import { getTimeoutEmbed, getErrorEmbed, getRollbackSuccessEmbed, - getRollbackFailedEmbed + getRollbackFailedEmbed, + getDeployNotAvailableEmbed, + getDeployCheckingEmbed, + getDeployProgressEmbed, + getDeploySuccessEmbed, + getDeployErrorEmbed } from "@/modules/admin/update.view"; export const update = createCommand({ @@ -31,6 +36,10 @@ export const update = createCommand({ sub.setName("rollback") .setDescription("Rollback to the previous version") ) + .addSubcommand(sub => + sub.setName("deploy") + .setDescription("Pull latest code and rebuild container (production only)") + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { @@ -38,6 +47,8 @@ export const update = createCommand({ if (subcommand === "rollback") { await handleRollback(interaction); + } else if (subcommand === "deploy") { + await handleDeploy(interaction); } else { await handleUpdate(interaction); } @@ -175,3 +186,50 @@ async function handleRollback(interaction: any) { }); } } + +async function handleDeploy(interaction: any) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + // Check if deploy is available + const available = await UpdateService.isDeployAvailable(); + if (!available) { + await interaction.editReply({ + embeds: [getDeployNotAvailableEmbed()] + }); + return; + } + + // Show checking status + await interaction.editReply({ + embeds: [getDeployCheckingEmbed()] + }); + + // Perform deployment with progress updates + const result = await UpdateService.performDeploy((step) => { + interaction.editReply({ + embeds: [getDeployProgressEmbed(step)] + }); + }); + + if (result.success) { + await interaction.editReply({ + embeds: [getDeploySuccessEmbed( + result.previousCommit, + result.newCommit, + result.output + )] + }); + } else { + await interaction.editReply({ + embeds: [getDeployErrorEmbed(result.error || "Unknown error")] + }); + } + + } catch (error) { + console.error("Deploy failed:", error); + await interaction.editReply({ + embeds: [getDeployErrorEmbed(error instanceof Error ? error.message : String(error))] + }); + } +} diff --git a/bot/modules/admin/update.view.ts b/bot/modules/admin/update.view.ts index bb74e53..1b39013 100644 --- a/bot/modules/admin/update.view.ts +++ b/bot/modules/admin/update.view.ts @@ -354,3 +354,66 @@ export function getRollbackFailedEmbed(error: string) { "❌ Rollback Failed" ); } + +// ============ Deploy Embeds ============ + +export function getDeployNotAvailableEmbed() { + return createErrorEmbed( + "Docker is not available in this environment.\n\n" + + "Deploy via Discord requires Docker socket access. " + + "Use the deploy script manually:\n" + + "```bash\ncd ~/Aurora && bash shared/scripts/deploy.sh\n```", + "❌ Deploy Unavailable" + ); +} + +export function getDeployCheckingEmbed() { + return createInfoEmbed( + "🔍 Checking for updates and preparing deployment...", + "🚀 Deploy" + ); +} + +export function getDeployProgressEmbed(step: string) { + return createInfoEmbed( + step, + "🚀 Deploying" + ); +} + +export function getDeploySuccessEmbed(previousCommit: string, newCommit: string, output: string) { + const embed = new EmbedBuilder() + .setTitle("🚀 Deployment Triggered") + .setColor(0x57F287) + .addFields( + { + name: "Version", + value: `\`${previousCommit}\` → \`${newCommit}\``, + inline: false + }, + { + name: "Status", + value: output || "Container rebuilding...", + inline: false + } + ) + .setFooter({ text: "Container will restart shortly" }) + .setTimestamp(); + + return embed; +} + +export function getDeployNoChangesEmbed(currentCommit: string) { + return createSuccessEmbed( + `Already running the latest version.\n\n**Current:** \`${currentCommit}\``, + "✅ Up to Date" + ); +} + +export function getDeployErrorEmbed(error: string) { + return createErrorEmbed( + `Deployment failed:\n\`\`\`\n${truncate(error, 500)}\n\`\`\``, + "❌ Deploy Failed" + ); +} + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7d0d1ed..581085e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -39,7 +39,13 @@ services: image: aurora-app:latest ports: - "127.0.0.1:3000:3000" - # NO source code volumes - production image is self-contained + # Volumes for bot-triggered deployments + volumes: + # Docker socket - allows bot to run docker compose commands + - /var/run/docker.sock:/var/run/docker.sock + # Project directory - allows git pull and rebuild + - .:/app/deploy + working_dir: /app environment: - NODE_ENV=production - HOST=0.0.0.0 @@ -52,6 +58,8 @@ services: - DISCORD_GUILD_ID=${DISCORD_GUILD_ID} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} + # Deploy directory path for bot-triggered deployments + - DEPLOY_DIR=/app/deploy depends_on: db: condition: service_healthy diff --git a/shared/modules/admin/update.service.ts b/shared/modules/admin/update.service.ts index de74dc6..3568455 100644 --- a/shared/modules/admin/update.service.ts +++ b/shared/modules/admin/update.service.ts @@ -469,4 +469,109 @@ export class UpdateService { } // Don't clear rollback cache here - rollback file persists } + + // ========================================================================= + // Bot-Triggered Deployment Methods + // ========================================================================= + + private static readonly DEPLOY_TIMEOUT_MS = 300_000; // 5 minutes for full deploy + + /** + * Get the deploy directory path from environment or default + */ + static getDeployDir(): string { + return process.env.DEPLOY_DIR || "/app/deploy"; + } + + /** + * Check if deployment is available (docker socket accessible) + */ + static async isDeployAvailable(): Promise { + try { + await execWithTimeout("docker --version", 5000); + return true; + } catch { + return false; + } + } + + /** + * Perform a full deployment: git pull + docker compose rebuild + * This will restart the container with the new code + * + * @param onProgress - Callback for progress updates + * @returns Object with success status, output, and commit info + */ + static async performDeploy(onProgress?: (step: string) => void): Promise<{ + success: boolean; + previousCommit: string; + newCommit: string; + output: string; + error?: string; + }> { + const deployDir = this.getDeployDir(); + let previousCommit = ""; + let newCommit = ""; + let output = ""; + + try { + // 1. Get current commit + onProgress?.("Getting current version..."); + const { stdout: currentSha } = await execWithTimeout( + `cd ${deployDir} && git rev-parse --short HEAD`, + DEFAULT_TIMEOUT_MS + ); + previousCommit = currentSha.trim(); + output += `📍 Current: ${previousCommit}\n`; + + // 2. Pull latest changes + onProgress?.("Pulling latest code..."); + const { stdout: pullOutput } = await execWithTimeout( + `cd ${deployDir} && git pull origin main`, + DEFAULT_TIMEOUT_MS + ); + output += `📥 Pull: ${pullOutput.includes("Already up to date") ? "Already up to date" : "Updated"}\n`; + + // 3. Get new commit + const { stdout: newSha } = await execWithTimeout( + `cd ${deployDir} && git rev-parse --short HEAD`, + DEFAULT_TIMEOUT_MS + ); + newCommit = newSha.trim(); + output += `📍 New: ${newCommit}\n`; + + // 4. Rebuild and restart container (this will kill the current process) + onProgress?.("Rebuilding and restarting..."); + + // Use spawn with detached mode so the command continues after we exit + const { spawn } = await import("child_process"); + const deployProcess = spawn( + "sh", + ["-c", `cd ${deployDir} && docker compose -f docker-compose.prod.yml up -d --build`], + { + detached: true, + stdio: "ignore" + } + ); + deployProcess.unref(); + + output += `🚀 Deploy triggered - container will restart\n`; + + return { + success: true, + previousCommit, + newCommit, + output + }; + + } catch (error) { + return { + success: false, + previousCommit, + newCommit, + output, + error: error instanceof Error ? error.message : String(error) + }; + } + } }