feat: implement a dedicated update service to centralize bot update logic, dependency checks, and post-restart handling.
This commit is contained in:
@@ -1,36 +1,30 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed, createInfoEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed, createInfoEmbed } from "@lib/embeds";
|
||||||
|
import { UpdateService } from "@/modules/admin/update.service";
|
||||||
|
|
||||||
export const update = createCommand({
|
export const update = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName("update")
|
.setName("update")
|
||||||
.setDescription("Check for updates and restart the bot")
|
.setDescription("Check for updates and restart the bot")
|
||||||
|
.addBooleanOption(option =>
|
||||||
|
option.setName("force")
|
||||||
|
.setDescription("Force update even if checks fail (not recommended)")
|
||||||
|
.setRequired(false)
|
||||||
|
)
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
const force = interaction.options.getBoolean("force") || false;
|
||||||
const { exec } = await import("child_process");
|
|
||||||
const { promisify } = await import("util");
|
|
||||||
const { writeFile, appendFile } = await import("fs/promises");
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current branch
|
|
||||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
|
||||||
const branch = branchName.trim();
|
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createInfoEmbed("Fetching latest changes...", "Checking for Updates")]
|
embeds: [createInfoEmbed("Checking for updates...", "System Update")]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch remote
|
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
|
||||||
await execAsync("git fetch --all");
|
|
||||||
|
|
||||||
// Check for potential changes
|
if (!hasUpdates && !force) {
|
||||||
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
|
||||||
|
|
||||||
if (!logOutput.trim()) {
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")]
|
embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")]
|
||||||
});
|
});
|
||||||
@@ -40,8 +34,8 @@ export const update = createCommand({
|
|||||||
// Prepare confirmation UI
|
// Prepare confirmation UI
|
||||||
const confirmButton = new ButtonBuilder()
|
const confirmButton = new ButtonBuilder()
|
||||||
.setCustomId("confirm_update")
|
.setCustomId("confirm_update")
|
||||||
.setLabel("Update & Restart")
|
.setLabel(force ? "Force Update & Restart" : "Update & Restart")
|
||||||
.setStyle(ButtonStyle.Success);
|
.setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success);
|
||||||
|
|
||||||
const cancelButton = new ButtonBuilder()
|
const cancelButton = new ButtonBuilder()
|
||||||
.setCustomId("cancel_update")
|
.setCustomId("cancel_update")
|
||||||
@@ -52,7 +46,7 @@ export const update = createCommand({
|
|||||||
.addComponents(confirmButton, cancelButton);
|
.addComponents(confirmButton, cancelButton);
|
||||||
|
|
||||||
const updateEmbed = createInfoEmbed(
|
const updateEmbed = createInfoEmbed(
|
||||||
`The following changes are available:\n\`\`\`\n${logOutput.substring(0, 1000)}${logOutput.length > 1000 ? "\n...and more" : ""}\n\`\`\`\n**Do you want to update and restart?**`,
|
`**Branch:** \`${branch}\`\n\n**Pending Changes:**\n\`\`\`\n${log.substring(0, 1000)}${log.length > 1000 ? "\n...and more" : ""}\n\`\`\`\n**Do you want to proceed?**`,
|
||||||
"Updates Available"
|
"Updates Available"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,39 +59,51 @@ export const update = createCommand({
|
|||||||
const confirmation = await response.awaitMessageComponent({
|
const confirmation = await response.awaitMessageComponent({
|
||||||
filter: (i) => i.user.id === interaction.user.id,
|
filter: (i) => i.user.id === interaction.user.id,
|
||||||
componentType: ComponentType.Button,
|
componentType: ComponentType.Button,
|
||||||
time: 30000 // 30 seconds timeout
|
time: 30000
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmation.customId === "confirm_update") {
|
if (confirmation.customId === "confirm_update") {
|
||||||
await confirmation.update({
|
await confirmation.update({
|
||||||
embeds: [createWarningEmbed("Applying updates and restarting...\nThe bot will run database migrations on next startup.", "Update In Progress")],
|
embeds: [createInfoEmbed("⏳ Pulling latest changes...", "Update In Progress")],
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write context BEFORE reset, because reset -> watcher restart
|
// 1. Check dependencies before pulling to know if we need to install
|
||||||
await writeFile(".restart_context.json", JSON.stringify({
|
// Actually, we need to pull first to get the new package.json, then check diff?
|
||||||
|
// UpdateService.checkDependencies uses git diff HEAD..origin/branch.
|
||||||
|
// This works BEFORE we pull/reset.
|
||||||
|
const needsDependencyInstall = await UpdateService.checkDependencies(branch);
|
||||||
|
|
||||||
|
// 2. Perform Update
|
||||||
|
await UpdateService.performUpdate(branch);
|
||||||
|
|
||||||
|
let installLog = "";
|
||||||
|
if (needsDependencyInstall) {
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createInfoEmbed("⏳ Installing dependencies...", "Update In Progress")]
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
installLog = await UpdateService.installDependencies();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!force) throw new Error(`Dependency installation failed: ${e.message}`);
|
||||||
|
installLog = `Failed: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Schedule Restart
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [createWarningEmbed(
|
||||||
|
`Update applied successfully.\n${needsDependencyInstall ? `Dependencies installed.\n` : ""}Restarting system...`,
|
||||||
|
"Restarting"
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
|
||||||
|
await UpdateService.scheduleRestart({
|
||||||
channelId: interaction.channelId,
|
channelId: interaction.channelId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
runMigrations: true
|
runMigrations: true
|
||||||
}));
|
});
|
||||||
|
|
||||||
const { stdout } = await execAsync(`git reset --hard origin/${branch}`);
|
|
||||||
|
|
||||||
// In case we are not running with a watcher, or if no files were changed (unlikely given log check),
|
|
||||||
// we might need to manually trigger restart.
|
|
||||||
// But if files changed, watcher kicks in here or slightly after.
|
|
||||||
// If we are here, we can try to force a touch or just exit.
|
|
||||||
|
|
||||||
// Trigger restart just in case watcher didn't catch it or we are in a mode without watcher (though update implies source change)
|
|
||||||
try {
|
|
||||||
await appendFile("src/index.ts", " ");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to touch triggers:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The process should die now or soon.
|
|
||||||
// We do NOT run migrations here anymore.
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
await confirmation.update({
|
await confirmation.update({
|
||||||
@@ -107,17 +113,20 @@ export const update = createCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Timeout
|
if (e instanceof Error && e.message.includes("time")) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")],
|
embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")],
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error("Update failed:", error);
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(`Failed to check for updates:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Check Failed")]
|
embeds: [createErrorEmbed(`Failed to update:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Failed")]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,54 +9,9 @@ const event: Event<Events.ClientReady> = {
|
|||||||
console.log(`Ready! Logged in as ${c.user.tag}`);
|
console.log(`Ready! Logged in as ${c.user.tag}`);
|
||||||
schedulerService.start();
|
schedulerService.start();
|
||||||
|
|
||||||
// Check for restart context
|
// Handle post-update tasks
|
||||||
const { readFile, unlink } = await import("fs/promises");
|
const { UpdateService } = await import("@/modules/admin/update.service");
|
||||||
const { createSuccessEmbed } = await import("@lib/embeds");
|
await UpdateService.handlePostRestart(c);
|
||||||
|
|
||||||
try {
|
|
||||||
const contextData = await readFile(".restart_context.json", "utf-8");
|
|
||||||
const context = JSON.parse(contextData);
|
|
||||||
|
|
||||||
// Validate context freshness (e.g., ignore if older than 5 minutes)
|
|
||||||
if (Date.now() - context.timestamp < 5 * 60 * 1000) {
|
|
||||||
const channel = await c.channels.fetch(context.channelId);
|
|
||||||
|
|
||||||
if (channel && channel.isSendable()) {
|
|
||||||
let migrationOutput = "";
|
|
||||||
let success = true;
|
|
||||||
|
|
||||||
if (context.runMigrations) {
|
|
||||||
try {
|
|
||||||
const { exec } = await import("child_process");
|
|
||||||
const { promisify } = await import("util");
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
// Send intermediate update if possible, though ready event should be fast.
|
|
||||||
const { stdout: dbOut } = await execAsync("bun run db:push:local");
|
|
||||||
migrationOutput = dbOut;
|
|
||||||
} catch (dbErr: any) {
|
|
||||||
success = false;
|
|
||||||
migrationOutput = `Migration Failed: ${dbErr.message}`;
|
|
||||||
console.error("Migration Error:", dbErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.runMigrations) {
|
|
||||||
await channel.send({
|
|
||||||
embeds: [createSuccessEmbed(`Bot is back online!\n\n**DB Migration Output:**\n\`\`\`\n${migrationOutput}\n\`\`\``, success ? "Update Successful" : "Update Completed with/Errors")]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await channel.send({
|
|
||||||
embeds: [createSuccessEmbed("Bot is back online! Redeploy successful.", "System Online")]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await unlink(".restart_context.json");
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors (file not found, etc.)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
72
src/modules/admin/update.service.test.ts
Normal file
72
src/modules/admin/update.service.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
||||||
|
|
||||||
|
// Mock child_process BEFORE importing the service
|
||||||
|
const mockExec = mock((cmd: string, callback: any) => {
|
||||||
|
// console.log("Mock Exec Called with:", cmd);
|
||||||
|
if (cmd.includes("git rev-parse")) {
|
||||||
|
callback(null, { stdout: "main\n" });
|
||||||
|
} else if (cmd.includes("git fetch")) {
|
||||||
|
callback(null, { stdout: "" });
|
||||||
|
} else if (cmd.includes("git log")) {
|
||||||
|
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
|
||||||
|
} else if (cmd.includes("git diff")) {
|
||||||
|
callback(null, { stdout: "package.json\nsrc/index.ts" });
|
||||||
|
} else if (cmd.includes("bun install")) {
|
||||||
|
callback(null, { stdout: "Installed dependencies" });
|
||||||
|
} else {
|
||||||
|
callback(null, { stdout: "" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("child_process", () => ({
|
||||||
|
exec: mockExec
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("UpdateService", () => {
|
||||||
|
let UpdateService: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockExec.mockClear();
|
||||||
|
// Dynamically import to ensure mock is used
|
||||||
|
const module = await import("./update.service");
|
||||||
|
UpdateService = module.UpdateService;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkForUpdates should return updates if log is not empty", async () => {
|
||||||
|
const result = await UpdateService.checkForUpdates();
|
||||||
|
expect(result.hasUpdates).toBe(true);
|
||||||
|
expect(result.branch).toBe("main");
|
||||||
|
// Check calls. Note: promisify wraps exec, so expecting specific arguments might be tricky if promisify adds options.
|
||||||
|
// But the command string should be there.
|
||||||
|
// calls[0] -> rev-parse
|
||||||
|
// calls[1] -> fetch
|
||||||
|
// calls[2] -> log
|
||||||
|
expect(mockExec).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkDependencies should detect package.json change", async () => {
|
||||||
|
const changed = await UpdateService.checkDependencies("main");
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
// Note: checking args on mockExec when called via promisify:
|
||||||
|
// promisify passes (command, callback) or (command, options, callback).
|
||||||
|
// call arguments: [cmd, callback]
|
||||||
|
const lastCall = mockExec.mock.lastCall;
|
||||||
|
expect(lastCall).toBeDefined();
|
||||||
|
if (lastCall) {
|
||||||
|
expect(lastCall[0]).toContain("git diff");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("installDependencies should run bun install", async () => {
|
||||||
|
await UpdateService.installDependencies();
|
||||||
|
const lastCall = mockExec.mock.lastCall;
|
||||||
|
expect(lastCall).toBeDefined();
|
||||||
|
if (lastCall) {
|
||||||
|
expect(lastCall[0]).toContain("bun install");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/modules/admin/update.service.ts
Normal file
116
src/modules/admin/update.service.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { writeFile, readFile, unlink, appendFile } from "fs/promises";
|
||||||
|
import { Client, TextChannel } from "discord.js";
|
||||||
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export interface RestartContext {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
timestamp: number;
|
||||||
|
runMigrations: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateService {
|
||||||
|
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||||
|
|
||||||
|
static async checkForUpdates(): Promise<{ hasUpdates: boolean; log: string; branch: string }> {
|
||||||
|
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
||||||
|
const branch = branchName.trim();
|
||||||
|
|
||||||
|
await execAsync("git fetch --all");
|
||||||
|
const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUpdates: !!logOutput.trim(),
|
||||||
|
log: logOutput.trim(),
|
||||||
|
branch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async performUpdate(branch: string): Promise<void> {
|
||||||
|
await execAsync(`git reset --hard origin/${branch}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkDependencies(branch: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if package.json has changed between HEAD and upstream
|
||||||
|
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
||||||
|
return stdout.includes("package.json");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to check dependencies:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async installDependencies(): Promise<string> {
|
||||||
|
const { stdout } = await execAsync("bun install");
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async scheduleRestart(context: RestartContext): Promise<void> {
|
||||||
|
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
||||||
|
|
||||||
|
// Use custom restart command if available, otherwise fallback to touch
|
||||||
|
if (process.env.RESTART_COMMAND) {
|
||||||
|
// We run this without awaiting because it might kill the process immediately
|
||||||
|
exec(process.env.RESTART_COMMAND).unref();
|
||||||
|
} else {
|
||||||
|
// Fallback to touch
|
||||||
|
try {
|
||||||
|
await appendFile("src/index.ts", " ");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to touch trigger:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async handlePostRestart(client: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
||||||
|
const context: RestartContext = JSON.parse(contextData);
|
||||||
|
|
||||||
|
if (Date.now() - context.timestamp > 10 * 60 * 1000) {
|
||||||
|
// Ignore stale contexts (> 10 mins)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await client.channels.fetch(context.channelId);
|
||||||
|
if (channel && channel.isSendable() && channel instanceof TextChannel) {
|
||||||
|
let migrationOutput = "";
|
||||||
|
let migrationSuccess = true;
|
||||||
|
|
||||||
|
if (context.runMigrations) {
|
||||||
|
try {
|
||||||
|
// Use drizzle-kit migrate
|
||||||
|
// Ensure migrations are generated
|
||||||
|
await execAsync("bun run generate");
|
||||||
|
|
||||||
|
// Apply migrations using drizzle-kit
|
||||||
|
// We use `bun x` to run the local binary directly, avoiding docker-in-docker issues
|
||||||
|
const { stdout: migOut } = await execAsync("bun x drizzle-kit migrate");
|
||||||
|
migrationOutput = migOut;
|
||||||
|
} catch (err: any) {
|
||||||
|
migrationSuccess = false;
|
||||||
|
migrationOutput = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await channel.send({
|
||||||
|
embeds: [
|
||||||
|
createSuccessEmbed(
|
||||||
|
`System updated successfully.${context.runMigrations ? `\n\n**Migration Output:**\n\`\`\`\n${migrationOutput.substring(0, 1000)}\n\`\`\`` : ""}`,
|
||||||
|
migrationSuccess ? "Update Complete" : "Update Complete (Migration Failed)"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await unlink(this.CONTEXT_FILE);
|
||||||
|
} catch (e) {
|
||||||
|
// No context or read error, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user