diff --git a/services/apps/projects/src/container/container.handler.ts b/services/apps/projects/src/container/container.handler.ts new file mode 100644 index 000000000..ed4236860 --- /dev/null +++ b/services/apps/projects/src/container/container.handler.ts @@ -0,0 +1,18 @@ +import {Project} from "../project/project.schema"; +import {OnEvent} from "@nestjs/event-emitter"; +import {Injectable} from "@nestjs/common"; +import {ContainerService} from "./container.service"; + + +@Injectable() +export class ContainerHandler { + constructor( + private containerService: ContainerService, + ) { + } + + @OnEvent('projects.*.deleted') + onProjectDeleted(project: Project) { + this.containerService.deleteStorage(project._id.toString()); + } +} diff --git a/services/apps/projects/src/container/container.module.ts b/services/apps/projects/src/container/container.module.ts index 6e1de28a3..22ed81824 100644 --- a/services/apps/projects/src/container/container.module.ts +++ b/services/apps/projects/src/container/container.module.ts @@ -4,6 +4,7 @@ import {ProjectModule} from '../project/project.module'; import {ContainerController} from './container.controller'; import {ContainerService} from './container.service'; import {HttpModule} from "@nestjs/axios"; +import {ContainerHandler} from "./container.handler"; @Module({ imports: [ @@ -12,7 +13,7 @@ import {HttpModule} from "@nestjs/axios"; HttpModule ], controllers: [ContainerController], - providers: [ContainerService], + providers: [ContainerService, ContainerHandler], }) export class ContainerModule { } diff --git a/services/apps/projects/src/container/container.service.ts b/services/apps/projects/src/container/container.service.ts index 21aed8d23..10f860f06 100644 --- a/services/apps/projects/src/container/container.service.ts +++ b/services/apps/projects/src/container/container.service.ts @@ -17,6 +17,12 @@ import {Project} from '../project/project.schema'; import {ProjectService} from '../project/project.service'; import {allowedFilenameCharacters, ContainerDto, CreateContainerDto} from './container.dto'; +const projectStorageTypes = ['projects', 'config'] as const; +type ProjectStorageType = typeof projectStorageTypes[number]; +const userStorageTypes = ['users'] as const; +type UserStorageType = typeof userStorageTypes[number]; +const bindPrefix = path.resolve(environment.docker.bindPrefix); + @Injectable() export class ContainerService { docker = new Dockerode({ @@ -28,7 +34,6 @@ export class ContainerService { constructor( private readonly httpService: HttpService, - private readonly projectService: ProjectService, ) { } @@ -81,14 +86,14 @@ export class ContainerService { let projectPath: string | undefined; if (dto.projectId) { - projectPath = this.projectService.getStoragePath('projects', dto.projectId); + projectPath = this.getStoragePath('projects', dto.projectId); options.HostConfig!.Binds!.push(`${projectPath}:${workspace}`); options.Env!.push(`PROJECT_ID=${dto.projectId}`); options.Labels!['org.fulib.project'] = dto.projectId; } if (user) { - const usersPath = this.projectService.getStoragePath('users', user.sub); + const usersPath = this.getStoragePath('users', user.sub); /* create 'settings.json' files if they don't exist already code server will write the user/machine settings there @@ -177,7 +182,7 @@ export class ContainerService { } private async getProjectExtensions(projectId: string): Promise { - const extensionsListPath = `${this.projectService.getStoragePath('config', projectId)}/extensions.txt`; + const extensionsListPath = `${this.getStoragePath('config', projectId)}/extensions.txt`; const fileBuffer = await fs.promises.readFile(extensionsListPath).catch(() => ''); if (!fileBuffer) { @@ -308,7 +313,7 @@ ${eofMarker}`]); private async saveExtensions(container: Dockerode.Container, projectId: string) { const stream = await this.containerExec(container, ['code-server', '--list-extensions', '--show-versions']); - const extensionsList = `${this.projectService.getStoragePath('config', projectId)}/extensions.txt`; + const extensionsList = `${this.getStoragePath('config', projectId)}/extensions.txt`; await createFile(extensionsList); const writeStream = fs.createWriteStream(extensionsList); @@ -353,13 +358,29 @@ ${eofMarker}`]); } async unzip(projectId: string, zip: Express.Multer.File): Promise { - const storagePath = this.projectService.getStoragePath('projects', projectId); + const storagePath = this.getStoragePath('projects', projectId); const stream = new Readable(); stream.push(zip.buffer); stream.push(null); await stream.pipe(Extract({path: storagePath})).promise(); await new Promise((resolve, reject) => chownr(storagePath, 1000, 1000, err => err ? reject(err) : resolve(undefined))); } + + deleteStorage(id: string) { + for (const type of projectStorageTypes) { + fs.promises.rm(this.getStoragePath(type, id), {recursive: true}).catch(e => { + console.error(`Failed to remove project '${id}' storage '${type}': ${e.message}`); + }); + } + } + + getStoragePath(type: ProjectStorageType | UserStorageType, projectId: string): string { + return `${bindPrefix}/${type}/${this.idBin(projectId)}/${projectId}/`; + } + + private idBin(projectId: string) { + return projectId.slice(-2); // last 2 hex chars + } } /** https://github.com/apocas/dockerode/issues/534 stream.on("end", resolve) workaround */ diff --git a/services/apps/projects/src/environment.ts b/services/apps/projects/src/environment.ts index 09475fbcc..d7746d682 100644 --- a/services/apps/projects/src/environment.ts +++ b/services/apps/projects/src/environment.ts @@ -29,4 +29,7 @@ export const environment = { algorithms: (process.env.AUTH_ALGORITHMS || 'RS256').split(','), issuer: process.env.AUTH_ISSUER || 'https://se.uniks.de/auth/realms/fulib.org', }, + nats: { + servers: process.env.NATS_URL || 'nats://localhost:4222', + }, }; diff --git a/services/apps/projects/src/project/project.controller.ts b/services/apps/projects/src/project/project.controller.ts index 9935a2656..b9fa0e4ad 100644 --- a/services/apps/projects/src/project/project.controller.ts +++ b/services/apps/projects/src/project/project.controller.ts @@ -28,7 +28,11 @@ export class ProjectController { @Body() dto: CreateProjectDto, @AuthUser() user: UserToken, ): Promise { - return this.projectService.create(dto, user.sub); + return this.projectService.create({ + ...dto, + userId: user.sub, + created: new Date(), + }); } @Get() @@ -38,7 +42,11 @@ export class ProjectController { @AuthUser() user: UserToken, ): Promise { const members = await this.memberService.findAll({user: user.sub}); - return this.projectService.findAll({_id: {$in: members.map(m => m.parent)}}); + return this.projectService.findAll({ + _id: {$in: members.map(m => m.parent)}, + }, { + sort: {name: 1}, + }); } @Get(':id') @@ -69,6 +77,6 @@ export class ProjectController { async remove( @Param('id', ObjectIdPipe) id: Types.ObjectId, ): Promise { - return this.projectService.remove(id); + return this.projectService.delete(id); } } diff --git a/services/apps/projects/src/project/project.service.ts b/services/apps/projects/src/project/project.service.ts index 7c9770723..c9e27d752 100644 --- a/services/apps/projects/src/project/project.service.ts +++ b/services/apps/projects/src/project/project.service.ts @@ -1,71 +1,25 @@ import {UserToken} from '@app/keycloak-auth'; import {Injectable} from '@nestjs/common'; import {InjectModel} from '@nestjs/mongoose'; -import * as fs from 'fs'; -import {FilterQuery, Model, Types} from 'mongoose'; -import * as path from 'path'; -import {environment} from '../environment'; -import {CreateProjectDto, UpdateProjectDto} from './project.dto'; +import {Model} from 'mongoose'; import {Project, ProjectDocument} from './project.schema'; - -const projectStorageTypes = ['projects', 'config'] as const; -type ProjectStorageType = typeof projectStorageTypes[number]; -const userStorageTypes = ['users'] as const; -type UserStorageType = typeof userStorageTypes[number]; -const bindPrefix = path.resolve(environment.docker.bindPrefix); +import {EventRepository, EventService, MongooseRepository} from "@mean-stream/nestx"; @Injectable() -export class ProjectService { +@EventRepository() +export class ProjectService extends MongooseRepository { constructor( - @InjectModel(Project.name) private model: Model, + @InjectModel(Project.name) model: Model, + private eventService: EventService, ) { + super(model); } - async create(dto: CreateProjectDto, userId: string): Promise { - return this.model.create({ - ...dto, - userId, - created: new Date(), - }); - } - - async findAll(where: FilterQuery = {}): Promise { - return this.model.find(where).sort('+name').exec(); - } - - async findOne(id: Types.ObjectId): Promise { - return this.model.findById(id).exec(); - } - - async update(id: Types.ObjectId, dto: UpdateProjectDto): Promise { - return this.model.findByIdAndUpdate(id, dto, {new: true}).exec(); - } - - async remove(id: Types.ObjectId): Promise { - const project = await this.model.findByIdAndDelete(id).exec(); - if (project) { - this.removeStorage(id.toString()); - } - return project; - } - - private removeStorage(id: string) { - for (const type of projectStorageTypes) { - fs.promises.rm(this.getStoragePath(type, id), {recursive: true}).catch(e => { - console.error(`Failed to remove project '${id}' storage '${type}': ${e.message}`); - }); - } + emit(event: string, project: ProjectDocument) { + this.eventService.emit(`projects.${project._id}.${event}`, project); } isAuthorized(project: Project, user: UserToken) { return project.userId === user.sub; } - - getStoragePath(type: ProjectStorageType | UserStorageType, projectId: string): string { - return `${bindPrefix}/${type}/${this.idBin(projectId)}/${projectId}/`; - } - - private idBin(projectId: string) { - return projectId.slice(-2); // last 2 hex chars - } } diff --git a/services/apps/projects/src/projects.module.ts b/services/apps/projects/src/projects.module.ts index 96f0d9aaa..ca8d9589e 100644 --- a/services/apps/projects/src/projects.module.ts +++ b/services/apps/projects/src/projects.module.ts @@ -8,11 +8,13 @@ import {ProjectModule} from './project/project.module'; import {ScheduleModule} from "@nestjs/schedule"; import {SentryInterceptor, SentryModule} from "@ntegral/nestjs-sentry"; import {APP_INTERCEPTOR} from "@nestjs/core"; +import {EventModule} from "@app/event/event.module"; @Module({ imports: [ MongooseModule.forRoot(environment.mongo.uri, environment.mongo.options), AuthModule.register(environment.auth), + EventModule.forRoot({nats: environment.nats}), ProjectModule, MemberModule, ContainerModule,