refactor(server): new version check (#9555)
This commit is contained in:
@@ -12,6 +12,7 @@ import { ServerInfoService } from 'src/services/server-info.service';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { StorageService } from 'src/services/storage.service';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { OpenGraphTags } from 'src/utils/misc';
|
||||
|
||||
const render = (index: string, meta: OpenGraphTags) => {
|
||||
@@ -44,6 +45,7 @@ export class ApiService {
|
||||
private sharedLinkService: SharedLinkService,
|
||||
private storageService: StorageService,
|
||||
private databaseService: DatabaseService,
|
||||
private versionService: VersionService,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(ApiService.name);
|
||||
@@ -51,7 +53,7 @@ export class ApiService {
|
||||
|
||||
@Interval(ONE_HOUR.as('milliseconds'))
|
||||
async onVersionCheck() {
|
||||
await this.serverService.handleVersionCheck();
|
||||
await this.versionService.handleQueueVersionCheck();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
@@ -64,6 +66,7 @@ export class ApiService {
|
||||
await this.configService.init();
|
||||
this.storageService.init();
|
||||
await this.serverService.init();
|
||||
await this.versionService.init();
|
||||
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { TagService } from 'src/services/tag.service';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
|
||||
export const services = [
|
||||
ApiService,
|
||||
@@ -68,4 +69,5 @@ export const services = [
|
||||
TimelineService,
|
||||
TrashService,
|
||||
UserService,
|
||||
VersionService,
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||
import { StorageService } from 'src/services/storage.service';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { otelSDK } from 'src/utils/instrumentation';
|
||||
|
||||
@Injectable()
|
||||
@@ -37,6 +38,7 @@ export class MicroservicesService {
|
||||
private storageService: StorageService,
|
||||
private userService: UserService,
|
||||
private duplicateService: DuplicateService,
|
||||
private versionService: VersionService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
@@ -89,6 +91,7 @@ export class MicroservicesService {
|
||||
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
||||
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
|
||||
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
|
||||
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
|
||||
});
|
||||
|
||||
await this.metadataService.init();
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { ServerInfoService } from 'src/services/server-info.service';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(ServerInfoService.name, () => {
|
||||
let sut: ServerInfoService;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let serverInfoMock: Mocked<IServerInfoRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
eventMock = newEventRepositoryMock();
|
||||
serverInfoMock = newServerInfoRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
|
||||
sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock);
|
||||
sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -154,12 +145,6 @@ describe(ServerInfoService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('should respond the server version', () => {
|
||||
expect(sut.getVersion()).toEqual(serverVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatures', () => {
|
||||
it('should respond the server features', async () => {
|
||||
await expect(sut.getFeatures()).resolves.toEqual({
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { isDev, serverVersion } from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import {
|
||||
ServerConfigDto,
|
||||
ServerFeaturesDto,
|
||||
@@ -14,27 +11,20 @@ import {
|
||||
UsageByUserDto,
|
||||
} from 'src/dtos/server-info.dto';
|
||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
|
||||
import { asHumanReadable } from 'src/utils/bytes';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
|
||||
import { Version } from 'src/utils/version';
|
||||
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
private configCore: SystemConfigCore;
|
||||
private releaseVersion = serverVersion;
|
||||
private releaseVersionCheckedAt: DateTime | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@@ -43,11 +33,7 @@ export class ServerInfoService {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
onConnect() {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.handleVersionCheck();
|
||||
|
||||
const featureFlags = await this.getFeatures();
|
||||
if (featureFlags.configFile) {
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
|
||||
@@ -77,10 +63,6 @@ export class ServerInfoService {
|
||||
return { res: 'pong' };
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return serverVersion;
|
||||
}
|
||||
|
||||
async getFeatures(): Promise<ServerFeaturesDto> {
|
||||
const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } =
|
||||
await this.configCore.getConfig();
|
||||
@@ -152,57 +134,4 @@ export class ServerInfoService {
|
||||
sidecar: Object.keys(mimeTypes.sidecar),
|
||||
};
|
||||
}
|
||||
|
||||
async handleVersionCheck(): Promise<boolean> {
|
||||
try {
|
||||
if (isDev) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { newVersionCheck } = await this.configCore.getConfig();
|
||||
if (!newVersionCheck.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check once per hour (max)
|
||||
if (this.releaseVersionCheckedAt && DateTime.now().diff(this.releaseVersionCheckedAt).as('minutes') < 60) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const githubRelease = await this.repository.getGitHubRelease();
|
||||
const githubVersion = Version.fromString(githubRelease.tag_name);
|
||||
const publishedAt = new Date(githubRelease.published_at);
|
||||
this.releaseVersion = githubVersion;
|
||||
this.releaseVersionCheckedAt = DateTime.now();
|
||||
|
||||
if (githubVersion.isNewerThan(serverVersion)) {
|
||||
this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`);
|
||||
this.newReleaseNotification();
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
|
||||
onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
|
||||
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
|
||||
this.newReleaseNotification(userId);
|
||||
}
|
||||
|
||||
private newReleaseNotification(userId?: string) {
|
||||
const event = ClientEvent.NEW_RELEASE;
|
||||
const payload = {
|
||||
isAvailable: this.releaseVersion.isNewerThan(serverVersion),
|
||||
checkedAt: this.releaseVersionCheckedAt,
|
||||
serverVersion,
|
||||
releaseVersion: this.releaseVersion,
|
||||
};
|
||||
|
||||
userId
|
||||
? this.eventRepository.clientSend(event, userId, payload)
|
||||
: this.eventRepository.clientBroadcast(event, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const mockRelease = (version = '100.0.0') => ({
|
||||
id: 1,
|
||||
url: 'https://api.github.com/repos/owner/repo/releases/1',
|
||||
tag_name: 'v' + version,
|
||||
name: 'Release 1000',
|
||||
created_at: DateTime.utc().toISO(),
|
||||
published_at: DateTime.utc().toISO(),
|
||||
body: '',
|
||||
});
|
||||
|
||||
describe(VersionService.name, () => {
|
||||
let sut: VersionService;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let serverMock: Mocked<IServerInfoRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
eventMock = newEventRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
serverMock = newServerInfoRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
|
||||
sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('should respond the server version', () => {
|
||||
expect(sut.getVersion()).toEqual(serverVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handQueueVersionCheck', () => {
|
||||
it('should queue a version check job', async () => {
|
||||
await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handVersionCheck', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
});
|
||||
|
||||
it('should not run in dev mode', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
|
||||
});
|
||||
|
||||
it('should not run if the last check was < 60 minutes ago', async () => {
|
||||
systemMock.get.mockResolvedValue({
|
||||
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
|
||||
releaseVersion: '1.0.0',
|
||||
});
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
|
||||
});
|
||||
|
||||
it('should run if it has been > 60 minutes', async () => {
|
||||
serverMock.getGitHubRelease.mockResolvedValue(mockRelease());
|
||||
systemMock.get.mockResolvedValue({
|
||||
checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(),
|
||||
releaseVersion: '1.0.0',
|
||||
});
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS);
|
||||
expect(systemMock.set).toHaveBeenCalled();
|
||||
expect(loggerMock.log).toHaveBeenCalled();
|
||||
expect(eventMock.clientBroadcast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify if the version is equal', async () => {
|
||||
serverMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString()));
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS);
|
||||
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, {
|
||||
checkedAt: expect.any(String),
|
||||
releaseVersion: serverVersion.toString(),
|
||||
});
|
||||
expect(eventMock.clientBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a github error', async () => {
|
||||
serverMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down'));
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED);
|
||||
expect(systemMock.set).not.toHaveBeenCalled();
|
||||
expect(eventMock.clientBroadcast).not.toHaveBeenCalled();
|
||||
expect(loggerMock.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { isDev, serverVersion } from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { ReleaseNotification } from 'src/dtos/server-info.dto';
|
||||
import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity';
|
||||
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { Version } from 'src/utils/version';
|
||||
|
||||
const asNotification = ({ releaseVersion, checkedAt }: VersionCheckMetadata): ReleaseNotification => {
|
||||
const version = Version.fromString(releaseVersion);
|
||||
return {
|
||||
isAvailable: version.isNewerThan(serverVersion) !== 0,
|
||||
checkedAt,
|
||||
serverVersion,
|
||||
releaseVersion: version,
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class VersionService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
|
||||
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(VersionService.name);
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.handleVersionCheck();
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return serverVersion;
|
||||
}
|
||||
|
||||
async handleQueueVersionCheck() {
|
||||
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
|
||||
}
|
||||
|
||||
async handleVersionCheck(): Promise<JobStatus> {
|
||||
try {
|
||||
this.logger.debug('Running version check');
|
||||
|
||||
if (isDev()) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { newVersionCheck } = await this.configCore.getConfig();
|
||||
if (!newVersionCheck.enabled) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
|
||||
if (versionCheck?.checkedAt) {
|
||||
const lastUpdate = DateTime.fromISO(versionCheck.checkedAt);
|
||||
const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes');
|
||||
// check once per hour (max)
|
||||
if (elapsedTime < 60) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
}
|
||||
|
||||
const githubRelease = await this.repository.getGitHubRelease();
|
||||
const githubVersion = Version.fromString(githubRelease.tag_name);
|
||||
const metadata: VersionCheckMetadata = {
|
||||
checkedAt: DateTime.utc().toISO(),
|
||||
releaseVersion: githubVersion.toString(),
|
||||
};
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata);
|
||||
|
||||
if (githubVersion.isNewerThan(serverVersion)) {
|
||||
const publishedAt = new Date(githubRelease.published_at);
|
||||
this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`);
|
||||
this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata));
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
|
||||
async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
|
||||
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
|
||||
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
|
||||
if (metadata) {
|
||||
this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user