feat: boot a stripped down maintenance api if enabled

This commit is contained in:
izzy 2025-10-31 14:47:49 +00:00
parent 78f949afaa
commit 42ac9cf922
No known key found for this signature in database
GPG key ID: 5059F398521BB0F6
10 changed files with 168 additions and 9 deletions

View file

@ -14325,6 +14325,9 @@
"loginPageMessage": {
"type": "string"
},
"maintenanceMode": {
"type": "boolean"
},
"mapDarkStyleUrl": {
"type": "string"
},
@ -14349,6 +14352,7 @@
"isInitialized",
"isOnboarded",
"loginPageMessage",
"maintenanceMode",
"mapDarkStyleUrl",
"mapLightStyleUrl",
"oauthButtonText",

View file

@ -1159,6 +1159,7 @@ export type ServerConfigDto = {
isInitialized: boolean;
isOnboarded: boolean;
loginPageMessage: string;
maintenanceMode: boolean;
mapDarkStyleUrl: string;
mapLightStyleUrl: string;
oauthButtonText: string;

View file

@ -7,7 +7,7 @@ import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { controllers, maintenanceControllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
@ -91,6 +91,13 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...imports, ScheduleModule.forRoot()],
controllers: [...maintenanceControllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Maintenance }],
})
export class MaintenanceModule extends BaseModule {}
@Module({
imports: [...imports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],

View file

@ -19,7 +19,7 @@ import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerController } from 'src/controllers/server.controller';
import { MaintenanceServerController, ServerController } from 'src/controllers/server.controller';
import { SessionController } from 'src/controllers/session.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { StackController } from 'src/controllers/stack.controller';
@ -69,3 +69,5 @@ export const controllers = [
UserController,
ViewController,
];
export const maintenanceControllers = [MaintenanceServerController];

View file

@ -115,3 +115,36 @@ export class ServerController {
return this.systemMetadataService.getVersionCheckState();
}
}
@Controller('server')
export class MaintenanceServerController {
constructor(
private service: ServerService,
private versionService: VersionService,
) {}
@Get('ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('version')
getServerVersion(): ServerVersionResponseDto {
return this.versionService.getVersion();
}
@Get('features')
getServerFeatures(): Promise<ServerFeaturesDto> {
return this.service.getFeatures();
}
@Get('config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig();
}
@Get('media-types')
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();
}
}

View file

@ -154,6 +154,7 @@ export class ServerConfigDto {
publicUsers!: boolean;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
maintenanceMode!: boolean;
}
export class ServerFeaturesDto {

View file

@ -457,6 +457,7 @@ export enum ImmichEnvironment {
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
Microservices = 'microservices',
}

View file

@ -1,10 +1,14 @@
import { NestFactory } from '@nestjs/core';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { ImmichAdminModule } from 'src/app.module';
import { ApiModule, ImmichAdminModule } from 'src/app.module';
import { ImmichWorker, LogLevel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { getConfig } from 'src/utils/config';
/**
* Manages worker lifecycle
@ -22,17 +26,45 @@ class Workers {
/**
* Boot all enabled workers
*/
bootstrap() {
async bootstrap() {
const {
maintenance: { enabled: maintenanceMode },
} = await this.getConfig();
const { workers } = new ConfigRepository().getEnv();
// todo: filter for API if in maintenance
// todo: swap API for maintenance API
for (const worker of workers) {
this.startWorker(worker);
if (maintenanceMode) {
this.startWorker(ImmichWorker.Maintenance);
} else {
for (const worker of workers) {
this.startWorker(worker);
}
}
}
/**
* Initialise a short-lived Nest application to build configuration
* @returns System configuration
*/
private async getConfig() {
const app = await NestFactory.create(ApiModule);
const logger = await app.resolve(LoggingRepository);
const configRepo = app.get(ConfigRepository);
const metadataRepo = app.get(SystemMetadataRepository);
const systemConfig = await getConfig(
{
configRepo,
metadataRepo,
logger,
},
{ withCache: false },
);
await app.close();
return systemConfig;
}
/**
* Start an individual worker
* @param name Worker

View file

@ -130,6 +130,7 @@ export class ServerService extends BaseService {
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: config.maintenance.enabled,
};
}

View file

@ -0,0 +1,77 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { MaintenanceModule } from 'src/app.module';
import { excludePaths, serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { isStartUpError, useSwagger } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-api';
const { telemetry, network } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(SystemConfigService).nestApplication = app;
const { environment, host, port, resourcePaths } = configRepository.getEnv();
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() });
app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ApiService).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});