forked from syntaxbullet/aurorabot
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
This commit is contained in:
@@ -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))]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user