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,
|
getTimeoutEmbed,
|
||||||
getErrorEmbed,
|
getErrorEmbed,
|
||||||
getRollbackSuccessEmbed,
|
getRollbackSuccessEmbed,
|
||||||
getRollbackFailedEmbed
|
getRollbackFailedEmbed,
|
||||||
|
getDeployNotAvailableEmbed,
|
||||||
|
getDeployCheckingEmbed,
|
||||||
|
getDeployProgressEmbed,
|
||||||
|
getDeploySuccessEmbed,
|
||||||
|
getDeployErrorEmbed
|
||||||
} from "@/modules/admin/update.view";
|
} from "@/modules/admin/update.view";
|
||||||
|
|
||||||
export const update = createCommand({
|
export const update = createCommand({
|
||||||
@@ -31,6 +36,10 @@ export const update = createCommand({
|
|||||||
sub.setName("rollback")
|
sub.setName("rollback")
|
||||||
.setDescription("Rollback to the previous version")
|
.setDescription("Rollback to the previous version")
|
||||||
)
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName("deploy")
|
||||||
|
.setDescription("Pull latest code and rebuild container (production only)")
|
||||||
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
@@ -38,6 +47,8 @@ export const update = createCommand({
|
|||||||
|
|
||||||
if (subcommand === "rollback") {
|
if (subcommand === "rollback") {
|
||||||
await handleRollback(interaction);
|
await handleRollback(interaction);
|
||||||
|
} else if (subcommand === "deploy") {
|
||||||
|
await handleDeploy(interaction);
|
||||||
} else {
|
} else {
|
||||||
await handleUpdate(interaction);
|
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"
|
"❌ 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
|
image: aurora-app:latest
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "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:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
@@ -52,6 +58,8 @@ services:
|
|||||||
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
- DISCORD_GUILD_ID=${DISCORD_GUILD_ID}
|
||||||
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
- DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
||||||
|
# Deploy directory path for bot-triggered deployments
|
||||||
|
- DEPLOY_DIR=/app/deploy
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -469,4 +469,109 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
// Don't clear rollback cache here - rollback file persists
|
// 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