refactor: move one shot into app.repository

This commit is contained in:
izzy
2025-12-19 15:23:48 +00:00
parent a2d4439e48
commit 23bd27eb30
4 changed files with 41 additions and 44 deletions

View File

@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@Injectable()
export class AppRepository {
@@ -17,4 +22,26 @@ export class AppRepository {
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
}
}

View File

@@ -5,8 +5,6 @@ import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe, it } from 'vitest';
const mockSendRestart = vi.fn();
describe(CliService.name, () => {
let sut: CliService;
let mocks: ServiceMocks;
@@ -87,20 +85,22 @@ describe(CliService.name, () => {
describe('disableMaintenanceMode', () => {
it('should not do anything if not in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.disableMaintenanceMode(mockSendRestart)).resolves.toEqual({
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: true,
});
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should disable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.disableMaintenanceMode(mockSendRestart)).resolves.toEqual({
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: false,
});
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
@@ -110,24 +110,26 @@ describe(CliService.name, () => {
describe('enableMaintenanceMode', () => {
it('should not do anything if in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.enableMaintenanceMode(mockSendRestart)).resolves.toEqual(
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
expect.objectContaining({
alreadyEnabled: true,
}),
);
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should enable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.enableMaintenanceMode(mockSendRestart)).resolves.toEqual(
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
expect.objectContaining({
alreadyEnabled: false,
}),
);
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
@@ -139,7 +141,7 @@ describe(CliService.name, () => {
it('should return a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const result = await sut.enableMaintenanceMode(mockSendRestart);
const result = await sut.enableMaintenanceMode();
expect(result).toEqual(
expect.objectContaining({

View File

@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@Injectable()
@@ -42,7 +42,7 @@ export class CliService extends BaseService {
await this.updateConfig(config);
}
async disableMaintenanceMode(sendAppRestartCallback = sendOneShotAppRestart): Promise<{ alreadyDisabled: boolean }> {
async disableMaintenanceMode(): Promise<{ alreadyDisabled: boolean }> {
const currentState = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
@@ -55,17 +55,14 @@ export class CliService extends BaseService {
const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
await sendAppRestartCallback(state);
await this.appRepository.sendOneShotAppRestart(state);
return {
alreadyDisabled: false,
};
}
async enableMaintenanceMode(
sendAppRestartCallback = sendOneShotAppRestart,
): Promise<{ authUrl: string; alreadyEnabled: boolean }> {
async enableMaintenanceMode(): Promise<{ authUrl: string; alreadyEnabled: boolean }> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
@@ -91,9 +88,7 @@ export class CliService extends BaseService {
secret,
});
await sendAppRestartCallback({
isMaintenanceMode: true,
});
await this.appRepository.sendOneShotAppRestart(state);
return {
authUrl: await createMaintenanceLoginUrl(baseUrl, payload, secret),

View File

@@ -1,33 +1,6 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
export async function sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,