- 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
578 lines
21 KiB
TypeScript
578 lines
21 KiB
TypeScript
import { exec, type ExecException } from "child_process";
|
|
import { promisify } from "util";
|
|
import { writeFile, readFile, unlink } from "fs/promises";
|
|
import { Client, TextChannel } from "discord.js";
|
|
import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view";
|
|
import type { PostRestartResult } from "@/modules/admin/update.view";
|
|
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Constants
|
|
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
|
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds for git commands
|
|
const INSTALL_TIMEOUT_MS = 120_000; // 2 minutes for dependency installation
|
|
const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build
|
|
|
|
/**
|
|
* Execute a command with timeout protection
|
|
*/
|
|
async function execWithTimeout(
|
|
cmd: string,
|
|
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const process = exec(cmd, (error: ExecException | null, stdout: string, stderr: string) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve({ stdout, stderr });
|
|
}
|
|
});
|
|
|
|
const timer = setTimeout(() => {
|
|
process.kill("SIGTERM");
|
|
reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`));
|
|
}, timeoutMs);
|
|
|
|
process.on("exit", () => clearTimeout(timer));
|
|
});
|
|
}
|
|
|
|
export class UpdateService {
|
|
private static readonly CONTEXT_FILE = ".restart_context.json";
|
|
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
|
|
|
|
// Cache for rollback state (set when we save, cleared on cleanup)
|
|
private static rollbackPointExists: boolean | null = null;
|
|
|
|
/**
|
|
* Check for available updates with detailed commit information
|
|
* Optimized: Parallel git commands and combined requirements check
|
|
*/
|
|
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
|
|
// Get branch first (needed for subsequent commands)
|
|
const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD");
|
|
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
|
|
]);
|
|
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`)
|
|
]);
|
|
|
|
const latestCommit = latestResult.stdout.trim();
|
|
|
|
// Parse commit log
|
|
const commits: CommitInfo[] = logResult.stdout
|
|
.trim()
|
|
.split("\n")
|
|
.filter(line => line.length > 0)
|
|
.map(line => {
|
|
const [hash, message, author] = line.split("|");
|
|
return { hash: hash || "", message: message || "", author: author || "" };
|
|
});
|
|
|
|
// Parse changed files and analyze requirements in one pass
|
|
const changedFiles = diffResult.stdout.trim().split("\n").filter(f => f.length > 0);
|
|
const requirements = this.analyzeChangedFiles(changedFiles);
|
|
|
|
return {
|
|
hasUpdates: commits.length > 0,
|
|
branch,
|
|
currentCommit,
|
|
latestCommit,
|
|
commitCount: commits.length,
|
|
commits,
|
|
requirements
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze changed files to determine update requirements
|
|
* Extracted for reuse and clarity
|
|
*/
|
|
private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult {
|
|
const needsRootInstall = changedFiles.some(file =>
|
|
file === "package.json" || file === "bun.lock"
|
|
);
|
|
|
|
const needsWebInstall = changedFiles.some(file =>
|
|
file === "web/package.json" || file === "web/bun.lock"
|
|
);
|
|
|
|
// Only rebuild web if essential source files changed
|
|
const needsWebBuild = changedFiles.some(file =>
|
|
file.match(/^web\/src\/(components|pages|lib|index)/) ||
|
|
file === "web/build.ts" ||
|
|
file === "web/tailwind.config.ts" ||
|
|
file === "web/tsconfig.json"
|
|
);
|
|
|
|
const needsMigrations = changedFiles.some(file =>
|
|
file.includes("schema.ts") || file.startsWith("drizzle/")
|
|
);
|
|
|
|
return {
|
|
needsRootInstall,
|
|
needsWebInstall,
|
|
needsWebBuild,
|
|
needsMigrations,
|
|
changedFiles
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use checkForUpdates() which now includes requirements
|
|
* Kept for backwards compatibility
|
|
*/
|
|
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
|
try {
|
|
const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`);
|
|
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
|
return this.analyzeChangedFiles(changedFiles);
|
|
} catch (e) {
|
|
console.error("Failed to check update requirements:", e);
|
|
return {
|
|
needsRootInstall: false,
|
|
needsWebInstall: false,
|
|
needsWebBuild: false,
|
|
needsMigrations: false,
|
|
changedFiles: [],
|
|
error: e instanceof Error ? e : new Error(String(e))
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a summary of changed file categories
|
|
*/
|
|
static categorizeChanges(changedFiles: string[]): Record<string, number> {
|
|
const categories: Record<string, number> = {};
|
|
|
|
for (const file of changedFiles) {
|
|
let category = "Other";
|
|
|
|
if (file.startsWith("bot/commands/")) category = "Commands";
|
|
else if (file.startsWith("bot/modules/")) category = "Modules";
|
|
else if (file.startsWith("web/")) category = "Web Dashboard";
|
|
else if (file.startsWith("bot/lib/") || file.startsWith("shared/lib/")) category = "Library";
|
|
else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database";
|
|
else if (file.endsWith(".test.ts")) category = "Tests";
|
|
else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies";
|
|
|
|
categories[category] = (categories[category] || 0) + 1;
|
|
}
|
|
|
|
return categories;
|
|
}
|
|
|
|
/**
|
|
* Save the current commit for potential rollback
|
|
*/
|
|
static async saveRollbackPoint(): Promise<string> {
|
|
const { stdout } = await execWithTimeout("git rev-parse HEAD");
|
|
const commit = stdout.trim();
|
|
await writeFile(this.ROLLBACK_FILE, commit);
|
|
this.rollbackPointExists = true; // Cache the state
|
|
return commit;
|
|
}
|
|
|
|
/**
|
|
* Rollback to the previous commit
|
|
*/
|
|
static async rollback(): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
|
|
await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`);
|
|
await unlink(this.ROLLBACK_FILE);
|
|
this.rollbackPointExists = false;
|
|
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
|
} catch (e) {
|
|
return {
|
|
success: false,
|
|
message: e instanceof Error ? e.message : "No rollback point available"
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a rollback point exists
|
|
* Uses cache when available to avoid file system access
|
|
*/
|
|
static async hasRollbackPoint(): Promise<boolean> {
|
|
if (this.rollbackPointExists !== null) {
|
|
return this.rollbackPointExists;
|
|
}
|
|
try {
|
|
await readFile(this.ROLLBACK_FILE, "utf-8");
|
|
this.rollbackPointExists = true;
|
|
return true;
|
|
} catch {
|
|
this.rollbackPointExists = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform the git update
|
|
*/
|
|
static async performUpdate(branch: string): Promise<void> {
|
|
await execWithTimeout(`git reset --hard origin/${branch}`);
|
|
}
|
|
|
|
/**
|
|
* Install dependencies for specified projects
|
|
* Optimized: Parallel installation
|
|
*/
|
|
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
|
const tasks: Promise<{ label: string; output: string }>[] = [];
|
|
|
|
if (options.root) {
|
|
tasks.push(
|
|
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
|
|
.then(({ stdout }) => ({ label: "📦 Root", output: stdout.trim() || "Done" }))
|
|
);
|
|
}
|
|
|
|
if (options.web) {
|
|
tasks.push(
|
|
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
|
|
.then(({ stdout }) => ({ label: "🌐 Web", output: stdout.trim() || "Done" }))
|
|
);
|
|
}
|
|
|
|
const results = await Promise.all(tasks);
|
|
return results.map(r => `${r.label}: ${r.output}`).join("\n");
|
|
}
|
|
|
|
/**
|
|
* Prepare restart context with rollback info
|
|
*/
|
|
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
|
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
|
}
|
|
|
|
/**
|
|
* Trigger a restart
|
|
*
|
|
* In production Docker (with restart: unless-stopped), exiting the process
|
|
* will cause Docker to restart the container. For a full rebuild with code changes,
|
|
* use the deploy.sh script or GitHub Actions CI/CD instead of this command.
|
|
*
|
|
* Note: The /update command works for hot-reloading in development and minor
|
|
* restarts in production, but for production deployments with new code,
|
|
* use: `cd ~/Aurora && git pull && docker compose -f docker-compose.prod.yml up -d --build`
|
|
*/
|
|
static async triggerRestart(): Promise<void> {
|
|
if (process.env.RESTART_COMMAND) {
|
|
// Custom restart command from environment
|
|
exec(process.env.RESTART_COMMAND).unref();
|
|
} else {
|
|
// Exit process - Docker will restart container, dev mode will hot-reload
|
|
setTimeout(() => process.exit(0), 100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle post-restart tasks
|
|
*/
|
|
static async handlePostRestart(client: Client): Promise<void> {
|
|
try {
|
|
const context = await this.loadRestartContext();
|
|
if (!context) return;
|
|
|
|
if (this.isContextStale(context)) {
|
|
await this.cleanupContext();
|
|
return;
|
|
}
|
|
|
|
const channel = await this.fetchNotificationChannel(client, context.channelId);
|
|
if (!channel) {
|
|
await this.cleanupContext();
|
|
return;
|
|
}
|
|
|
|
const result = await this.executePostRestartTasks(context, channel);
|
|
await this.notifyPostRestartResult(channel, result);
|
|
await this.cleanupContext();
|
|
} catch (e) {
|
|
console.error("Failed to handle post-restart context:", e);
|
|
}
|
|
}
|
|
|
|
// --- Private Helper Methods ---
|
|
|
|
private static async loadRestartContext(): Promise<RestartContext | null> {
|
|
try {
|
|
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
|
return JSON.parse(contextData) as RestartContext;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static isContextStale(context: RestartContext): boolean {
|
|
return Date.now() - context.timestamp > STALE_CONTEXT_MS;
|
|
}
|
|
|
|
private static async fetchNotificationChannel(client: Client, channelId: string): Promise<TextChannel | null> {
|
|
try {
|
|
const channel = await client.channels.fetch(channelId);
|
|
if (channel && channel.isSendable() && channel instanceof TextChannel) {
|
|
return channel;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async executePostRestartTasks(
|
|
context: RestartContext,
|
|
channel: TextChannel
|
|
): Promise<PostRestartResult> {
|
|
const result: PostRestartResult = {
|
|
installSuccess: true,
|
|
installOutput: "",
|
|
webBuildSuccess: true,
|
|
webBuildOutput: "",
|
|
migrationSuccess: true,
|
|
migrationOutput: "",
|
|
ranInstall: context.installDependencies,
|
|
ranWebBuild: context.buildWebAssets,
|
|
ranMigrations: context.runMigrations,
|
|
previousCommit: context.previousCommit,
|
|
newCommit: context.newCommit
|
|
};
|
|
|
|
// Track progress for consolidated message
|
|
const progress: PostRestartProgress = {
|
|
installDeps: context.installDependencies,
|
|
buildWeb: context.buildWebAssets,
|
|
runMigrations: context.runMigrations,
|
|
currentStep: "starting"
|
|
};
|
|
|
|
// Only send progress message if there are tasks to run
|
|
const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations;
|
|
let progressMessage = hasTasks
|
|
? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] })
|
|
: null;
|
|
|
|
// Helper to update progress message
|
|
const updateProgress = async () => {
|
|
if (progressMessage) {
|
|
await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] });
|
|
}
|
|
};
|
|
|
|
// 1. Install Dependencies if needed (PARALLELIZED)
|
|
if (context.installDependencies) {
|
|
try {
|
|
progress.currentStep = "install";
|
|
await updateProgress();
|
|
|
|
// Parallel installation of root and web dependencies
|
|
const [rootResult, webResult] = await Promise.all([
|
|
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
|
|
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
|
|
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })),
|
|
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
|
|
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
|
|
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) }))
|
|
]);
|
|
|
|
result.installSuccess = rootResult.success && webResult.success;
|
|
result.installOutput = `📦 Root: ${rootResult.output}\n🌐 Web: ${webResult.output}`;
|
|
progress.installDone = true;
|
|
|
|
if (!result.installSuccess) {
|
|
console.error("Dependency Install Failed:", result.installOutput);
|
|
}
|
|
} catch (err: unknown) {
|
|
result.installSuccess = false;
|
|
result.installOutput = err instanceof Error ? err.message : String(err);
|
|
progress.installDone = true;
|
|
console.error("Dependency Install Failed:", err);
|
|
}
|
|
}
|
|
|
|
// 2. Build Web Assets if needed
|
|
if (context.buildWebAssets) {
|
|
try {
|
|
progress.currentStep = "build";
|
|
await updateProgress();
|
|
|
|
const { stdout } = await execWithTimeout("cd web && bun run build", BUILD_TIMEOUT_MS);
|
|
result.webBuildOutput = stdout.trim() || "Build completed successfully";
|
|
progress.buildDone = true;
|
|
} catch (err: unknown) {
|
|
result.webBuildSuccess = false;
|
|
result.webBuildOutput = err instanceof Error ? err.message : String(err);
|
|
progress.buildDone = true;
|
|
console.error("Web Build Failed:", err);
|
|
}
|
|
}
|
|
|
|
// 3. Run Migrations
|
|
if (context.runMigrations) {
|
|
try {
|
|
progress.currentStep = "migrate";
|
|
await updateProgress();
|
|
|
|
const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS);
|
|
result.migrationOutput = stdout;
|
|
progress.migrateDone = true;
|
|
} catch (err: unknown) {
|
|
result.migrationSuccess = false;
|
|
result.migrationOutput = err instanceof Error ? err.message : String(err);
|
|
progress.migrateDone = true;
|
|
console.error("Migration Failed:", err);
|
|
}
|
|
}
|
|
|
|
// Delete progress message before final result
|
|
if (progressMessage) {
|
|
try {
|
|
await progressMessage.delete();
|
|
} catch {
|
|
// Message may already be deleted, ignore
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static async notifyPostRestartResult(
|
|
channel: TextChannel,
|
|
result: PostRestartResult
|
|
): Promise<void> {
|
|
// Use cached rollback state - we just saved it before restart
|
|
const hasRollback = await this.hasRollbackPoint();
|
|
await channel.send(getPostRestartEmbed(result, hasRollback));
|
|
}
|
|
|
|
private static async cleanupContext(): Promise<void> {
|
|
try {
|
|
await unlink(this.CONTEXT_FILE);
|
|
} catch {
|
|
// File may not exist, ignore
|
|
}
|
|
// 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)
|
|
};
|
|
}
|
|
}
|
|
}
|