feat: yucca integration

This commit is contained in:
izzy
2026-04-15 15:17:19 +01:00
parent 8454cb2631
commit 77f9e87bd3
102 changed files with 11198 additions and 164 deletions
+4 -1
View File
@@ -7,7 +7,8 @@ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
RUN npm install --global corepack@latest && \
corepack enable pnpm && \
pnpm config set store-dir "$PNPM_HOME"
pnpm config set store-dir "$PNPM_HOME" && \
pnpm config set registry https://npm.raccoon.sh
FROM builder AS server
@@ -104,6 +105,8 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
RUN apt-get update && apt-get install -y restic
VOLUME /data
EXPOSE 2283
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
+1 -1
View File
@@ -28,7 +28,7 @@ FROM dev AS dev-container-server
RUN apt-get update --allow-releaseinfo-change && \
apt-get install inetutils-ping openjdk-21-jre-headless \
vim nano curl \
vim nano curl restic \
-y --no-install-recommends --fix-missing
RUN mkdir -p /workspaces && \
+1
View File
@@ -99,6 +99,7 @@
"nodemailer": "^8.0.0",
"nestjs-zod": "^5.3.0",
"openid-client": "^6.3.3",
"orchestration-api": "0.1.54",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
"picomatch": "^4.0.2",
+33 -3
View File
@@ -6,6 +6,7 @@ import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { OrchestrationApiModule } from 'orchestration-api/dist';
import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
@@ -20,6 +21,7 @@ import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { YuccaAdminGuard } from 'src/middleware/yucca-admin.guard';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -50,7 +52,12 @@ const commonMiddleware = [
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const apiMiddleware = [
FileUploadInterceptor,
...commonMiddleware,
{ provide: APP_GUARD, useClass: YuccaAdminGuard },
{ provide: APP_GUARD, useClass: AuthGuard },
];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
@@ -102,14 +109,37 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
}
@Module({
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
imports: [
...bullImports,
...commonImports,
ScheduleModule.forRoot(),
OrchestrationApiModule.forRoot({
// TODO: db init must happen elsewhere...
yuccaProductionApi: 'https://staging.fubar.computer',
// yuccaProductionApi: 'http://100.64.0.6:5173', // TODO
statePath: '/yucca', // TODO
requireWsAuth: true,
requireLock: true,
}),
],
controllers: [...controllers],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...commonImports],
imports: [
...commonImports,
OrchestrationApiModule.forRoot({
yuccaProductionApi: 'https://staging.fubar.computer',
// yuccaProductionApi: 'http://100.64.0.6:5173', // TODO
statePath: '/yucca', // TODO
externalBaseUrl: 'https://my.immich.app',
requireWsAuth: true,
requireLock: true,
}),
],
controllers: [MaintenanceWorkerController],
providers: [
ConfigRepository,
+1
View File
@@ -879,6 +879,7 @@ export enum DatabaseLock {
MaintenanceOperation = 621,
MemoryCreation = 777,
VersionCheck = 800,
YuccaModuleConfig = 900,
}
export const DatabaseLockSchema = z.enum(DatabaseLock).describe('Database lock').meta({ id: 'DatabaseLock' });
+1 -1
View File
@@ -125,7 +125,7 @@ class Workers {
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
console.error(`${name} worker error: ${JSON.stringify(error)}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
@@ -41,16 +41,18 @@ export class MaintenanceAuthGuard implements CanActivate {
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
MetadataKey.AuthRoute,
targets,
);
if (!options) {
if (!options && !request.path.startsWith('/api/yucca')) {
return true;
}
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request.auth = await this.service.authenticate(request.headers);
return true;
@@ -39,6 +39,10 @@ describe(MaintenanceWorkerService.name, () => {
strict: false,
});
const eventsGatewayMock = {
setAuthFn: vitest.fn(),
};
sut = new MaintenanceWorkerService(
mocks.logger as never,
mocks.app,
@@ -50,6 +54,7 @@ describe(MaintenanceWorkerService.name, () => {
mocks.process,
mocks.database as never,
databaseBackupServiceMock,
eventsGatewayMock as never,
);
sut.mock({
@@ -4,6 +4,7 @@ import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { EventsGateway } from 'orchestration-api/dist';
import { serverVersion } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import {
@@ -55,6 +56,7 @@ export class MaintenanceWorkerService {
private processRepository: ProcessRepository,
private databaseRepository: DatabaseRepository,
private databaseBackupService: DatabaseBackupService,
private readonly eventsGateway: EventsGateway,
) {
this.logger.setContext(this.constructor.name);
}
@@ -77,6 +79,14 @@ export class MaintenanceWorkerService {
StorageCore.setMediaLocation(this.detectMediaLocation());
this.eventsGateway.setAuthFn(async (client) => {
await this.authenticate(client.request.headers);
return {
user: { isAdmin: true },
};
});
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.authenticate(client.request.headers));
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AuthService } from 'src/services/auth.service';
@Injectable()
export class YuccaAdminGuard implements CanActivate {
constructor(private authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthRequest>();
if (!request.path.startsWith('/api/yucca')) {
return true;
}
request.user = await this.authService.authenticate({
headers: request.headers,
queryParams: request.query as Record<string, string>,
metadata: { adminRoute: true, sharedLinkRoute: false, uri: request.path },
});
return true;
}
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import _ from 'lodash';
import { GatewayEvent as YuccaGatewayEvent } from 'orchestration-api/dist/events/events.gateway';
import { Socket } from 'socket.io';
import { SystemConfig } from 'src/config';
import { Asset } from 'src/database';
@@ -67,6 +68,10 @@ type EventMap = {
/** job finishes with error */
JobError: [JobErrorEvent];
LibraryCreate: [];
LibraryUpdate: [];
LibraryDelete: [];
// queue events
QueueStart: [QueueStartEvent];
@@ -94,6 +99,8 @@ type EventMap = {
// websocket events
WebsocketConnect: [{ userId: string }];
YuccaEvent: [YuccaGatewayEvent];
};
export type AppRestartEvent = {
@@ -16,7 +16,7 @@ import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
export const serverEvents = ['ConfigUpdate', 'AppRestart', 'YuccaEvent'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
+2
View File
@@ -46,6 +46,7 @@ import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { ViewService } from 'src/services/view.service';
import { WorkflowService } from 'src/services/workflow.service';
import { YuccaService } from 'src/services/yucca.service';
export const services = [
ApiKeyService,
@@ -96,4 +97,5 @@ export const services = [
VersionService,
ViewService,
WorkflowService,
YuccaService,
];
+5
View File
@@ -242,6 +242,8 @@ export class LibraryService extends BaseService {
'**/.stfolder/**',
],
});
await this.eventRepository.emit('LibraryCreate');
return mapLibrary(library);
}
@@ -343,6 +345,7 @@ export class LibraryService extends BaseService {
}
const library = await this.libraryRepository.update(id, dto);
await this.eventRepository.emit('LibraryUpdate');
return mapLibrary(library);
}
@@ -355,6 +358,8 @@ export class LibraryService extends BaseService {
await this.libraryRepository.softDelete(id);
await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } });
await this.eventRepository.emit('LibraryDelete');
}
@OnJob({ name: JobName.LibraryDelete, queue: QueueName.Library })
+113
View File
@@ -0,0 +1,113 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
import { EventsGateway, ModuleConfigRepository } from 'orchestration-api/dist';
import { GatewayEvent } from 'orchestration-api/dist/events/events.gateway';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { DatabaseLock, ImmichWorker, StorageFolder } from 'src/enum';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { ArgOf, EventRepository } from 'src/repositories/event.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { AuthService } from 'src/services/auth.service';
import { getExternalDomain } from 'src/utils/misc';
@Injectable()
export class YuccaService implements OnModuleInit, OnModuleDestroy {
private lock = false;
constructor(
private readonly logger: LoggingRepository,
private readonly databaseRepository: DatabaseRepository,
private readonly libraryRepository: LibraryRepository,
private readonly authService: AuthService,
private readonly eventRepository: EventRepository,
private readonly websocketRepository: WebsocketRepository,
@Optional() private readonly moduleConfig: ModuleConfigRepository,
@Optional() private readonly eventsGateway: EventsGateway,
) {
this.onInternalEvent = this.onInternalEvent.bind(this);
}
onModuleInit() {
if (this.eventsGateway) {
this.eventsGateway.setAuthFn(async (client) =>
this.authService.authenticate({
headers: client.request.headers,
queryParams: {},
metadata: { adminRoute: true, sharedLinkRoute: false, uri: '/api/yucca/socket.io' },
}),
);
this.eventsGateway.on(this.onInternalEvent);
}
}
onModuleDestroy() {
if (this.eventsGateway) {
this.eventsGateway.off(this.onInternalEvent);
}
}
private updateSystemConfig({ server }: SystemConfig) {
this.moduleConfig.update({
externalBaseUrl: getExternalDomain(server),
});
}
private async updateLibraryConfig() {
const libraries = await this.libraryRepository.getAll();
this.moduleConfig.update({
immichIntegration: {
dataPath: StorageCore.getMediaLocation(),
dataFolders: Object.values(StorageFolder),
libraries: libraries
.filter((library) => !library.deletedAt)
.map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })),
},
});
}
@OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Api] })
async onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) {
this.updateSystemConfig(newConfig);
void this.updateLibraryConfig();
this.lock = await this.databaseRepository.tryLock(DatabaseLock.YuccaModuleConfig);
if (this.lock) {
this.moduleConfig.acquireLock();
}
}
@OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Api], server: true })
onConfigUpdate({ newConfig }: ArgOf<'ConfigUpdate'>) {
void this.updateSystemConfig(newConfig);
}
@OnEvent({ name: 'LibraryCreate', workers: [ImmichWorker.Api], server: true })
onLibraryCreate() {
void this.updateLibraryConfig();
}
@OnEvent({ name: 'LibraryUpdate', workers: [ImmichWorker.Api], server: true })
onLibraryUpdate() {
void this.updateLibraryConfig();
}
@OnEvent({ name: 'LibraryDelete', workers: [ImmichWorker.Api], server: true })
onLibraryDelete() {
void this.updateLibraryConfig();
}
@OnEvent({ name: 'YuccaEvent', workers: [ImmichWorker.Api], server: true })
onYuccaEvent(event: GatewayEvent) {
this.eventsGateway.emit(event);
}
onInternalEvent(event: GatewayEvent) {
this.websocketRepository.serverSend('YuccaEvent', event);
}
}