feat: add bot-triggered deployment via /update deploy command
Some checks failed
Deploy to Production / test (push) Failing after 20s
Deploy to Production / build (push) Has been skipped
Deploy to Production / deploy (push) Has been skipped

- 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
This commit is contained in:
syntaxbullet
2026-01-30 14:26:38 +01:00
parent 73531f38ae
commit ebefd8c0df
4 changed files with 236 additions and 2 deletions

View File

@@ -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))]
});
}
}

View File

@@ -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"
);
}

View File

@@ -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

View File

@@ -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<boolean> {
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)
};
}
}
}