Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DeploymentModule } from './modules/deployment/deployment.module'
import { HealthzModule } from './modules/healthz/healthz.module'
import { KeycloakModule } from './modules/keycloak/keycloak.module'
import { LogModule } from './modules/log/log.module'
import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module'
import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module'
import { ProjectMembersModule } from './modules/project-members/project-members.module'
import { ProjectRolesModule } from './modules/project-roles/project-roles.module'
Expand All @@ -25,6 +26,7 @@ import { VersionModule } from './modules/version/version.module'
ProjectHooksModule,
ProjectSecretsModule,
ProjectServicesModule,
ProjectBulkModule,
ProjectMembersModule,
ProjectRolesModule,
LogModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { FastifyRequest } from 'fastify'
import type { UserContext } from '../infrastructure/auth/auth-user.decorator'
import { projectContract } from '@cpn-console/shared'
import { Body, Controller, HttpCode, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common'
import { AuthUser } from '../infrastructure/auth/auth-user.decorator'
import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
import { ProjectBulkService } from './project-bulk.service'

@Controller('api/v1/projects-bulk')
export class ProjectBulkController {
private readonly logger = new Logger(ProjectBulkController.name)

constructor(
@Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService,
) {}

@Post('')
@HttpCode(202)
@UseGuards(UserGuard)
@RequireAdminPermission('Manage')
async bulkAction(
@Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type,
@AuthUser() user: UserContext,
@Req() request: FastifyRequest,
): Promise<void> {
const target = body.projectIds === 'all'
? 'all'
: `count=${body.projectIds.length}`

this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`)
await this.projectBulk.bulkAction(body, user.userId, request.id)
this.logger.log(`project.bulkAction accepted (action=${body.action})`)
}
}
13 changes: 13 additions & 0 deletions apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { InfrastructureModule } from '../infrastructure/infrastructure.module'
import { ProjectHooksModule } from '../project-hooks/project-hooks.module'
import { ProjectModule } from '../project/project.module'
import { ProjectBulkController } from './project-bulk.controller'
import { ProjectBulkService } from './project-bulk.service'

@Module({
imports: [InfrastructureModule, ProjectModule, ProjectHooksModule],
controllers: [ProjectBulkController],
providers: [ProjectBulkService],
})
export class ProjectBulkModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { ProjectHooksService } from '../project-hooks/project-hooks.service'
import { ProjectService } from '../project/project.service'
import { ProjectBulkService } from './project-bulk.service'

describe('projectBulkService', () => {
let module: TestingModule
let service: ProjectBulkService
let prisma: DeepMockProxy<PrismaService>
let project: DeepMockProxy<ProjectService>
let projectHooks: DeepMockProxy<ProjectHooksService>

beforeEach(async () => {
prisma = mockDeep<PrismaService>()
project = mockDeep<ProjectService>()
projectHooks = mockDeep<ProjectHooksService>()

module = await Test.createTestingModule({
providers: [
ProjectBulkService,
{ provide: PrismaService, useValue: prisma },
{ provide: ProjectService, useValue: project },
{ provide: ProjectHooksService, useValue: projectHooks },
],
}).compile()

service = module.get(ProjectBulkService)
})

it('processes specific project ids', async () => {
const projectIds = [faker.string.uuid(), faker.string.uuid()]

await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id')

expect(project.archive).toHaveBeenCalledTimes(2)
expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id')
expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id')
})

it('resolves "all" to all non-archived project ids', async () => {
const project1Id = faker.string.uuid()
const project2Id = faker.string.uuid()

prisma.project.findMany.mockResolvedValue([{ id: project1Id }, { id: project2Id }] as any)

await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id')

expect(prisma.project.findMany).toHaveBeenCalledWith({
select: { id: true },
where: { status: { not: 'archived' } },
})
expect(project.archive).toHaveBeenCalledTimes(2)
})

it('lock action updates locked to true via project hooks', async () => {
const projectId = faker.string.uuid()

await service.bulkAction({ action: 'lock', projectIds: [projectId] })

expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true)
})

it('unlock action updates locked to false via project hooks', async () => {
const projectId = faker.string.uuid()

await service.bulkAction({ action: 'unlock', projectIds: [projectId] })

expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false)
})

it('replay action triggers hooks', async () => {
const projectId = faker.string.uuid()

await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id')

expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id')
})
})
102 changes: 102 additions & 0 deletions apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { projectContract } from '@cpn-console/shared'
import { Inject, Injectable, Logger } from '@nestjs/common'
import { trace } from '@opentelemetry/api'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { ProjectHooksService } from '../project-hooks/project-hooks.service'
import { ProjectService } from '../project/project.service'

@Injectable()
export class ProjectBulkService {
private readonly logger = new Logger(ProjectBulkService.name)

constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(ProjectService) private readonly project: ProjectService,
@Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService,
) {}

@StartActiveSpan()
async bulkAction(
data: typeof projectContract.bulkActionProject.body._type,
requestorUserId?: string,
requestId?: string,
): Promise<void> {
const span = trace.getActiveSpan()
span?.setAttribute('project.bulk.action', data.action)
const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}`
this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`)

try {
const projectIds = await this.resolveProjectIds(data.projectIds)
span?.setAttribute('project.bulk.count', projectIds.length)

const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId)
const summary = this.summarizeTasks(results)

span?.setAttributes({
'project.bulk.fulfilled': summary.fulfilled,
'project.bulk.rejected': summary.rejected,
})
this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`)
} catch (error) {
const projectIdsLabel = data.projectIds === 'all'
? 'all'
: `count=${data.projectIds.length}`
const errorMessage = error instanceof Error ? error.message : String(error)

this.logger.error(
`project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`,
error instanceof Error ? error.stack : undefined,
)
throw error
}
}

private async resolveProjectIds(projectIds: string[] | 'all'): Promise<string[]> {
if (projectIds === 'all') {
return this.listProjectIdsNotArchived()
}
return projectIds
}

private async runTasks(
projectIds: string[],
action: string,
requestorUserId?: string,
requestId?: string,
): Promise<PromiseSettledResult<void>[]> {
const tasks = projectIds.map((projectId) => {
if (action === 'archive') {
return this.project.archive(projectId, requestorUserId, requestId)
}
if (action === 'lock' || action === 'unlock') {
return this.projectHooks.updateProjectLocked(projectId, action === 'lock')
}
if (action === 'replay') {
return this.projectHooks.replay(projectId, requestorUserId, requestId)
}
return Promise.resolve()
})
return Promise.allSettled(tasks)
}

private summarizeTasks(results: PromiseSettledResult<void>[]): { fulfilled: number, rejected: number } {
return results.reduce(
(acc, r) => {
if (r.status === 'fulfilled') acc.fulfilled += 1
else acc.rejected += 1
return acc
},
{ fulfilled: 0, rejected: 0 },
)
}

private async listProjectIdsNotArchived(): Promise<string[]> {
const projects = await this.prisma.project.findMany({
select: { id: true },
where: { status: { not: 'archived' } },
})
return projects.map(({ id }) => id)
}
}
103 changes: 103 additions & 0 deletions apps/server-nestjs/test/project-bulk.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { TestingModule } from '@nestjs/testing'
import { faker } from '@faker-js/faker'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Test } from '@nestjs/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module'
import { PrismaService } from '../src/modules/infrastructure/database/prisma.service'
import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module'
import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module'
import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service'

const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL)

const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E)

describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => {
let moduleRef: TestingModule
let prisma: PrismaService
let service: ProjectBulkService
let eventEmitter: EventEmitter2

let ownerId: string
let projectId: string
let projectSlug: string

beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [ConfigurationModule, InfrastructureModule, ProjectBulkModule],
}).compile()

await moduleRef.init()

prisma = moduleRef.get(PrismaService)
service = moduleRef.get(ProjectBulkService)
eventEmitter = moduleRef.get(EventEmitter2)
vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([])

ownerId = faker.string.uuid()
projectId = faker.string.uuid()
projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`)

await prisma.user.create({
data: {
id: ownerId,
email: faker.internet.email().toLowerCase(),
firstName: 'E2E',
lastName: 'Owner',
type: 'human',
},
})

await prisma.project.create({
data: {
id: projectId,
slug: projectSlug,
name: projectSlug,
ownerId,
description: 'E2E test project',
status: 'created',
locked: false,
limitless: false,
hprodCpu: 0,
hprodGpu: 0,
hprodMemory: 0,
prodCpu: 0,
prodGpu: 0,
prodMemory: 0,
everyonePerms: 0n,
lastSuccessProvisionningVersion: null,
},
})
})

afterAll(async () => {
if (prisma) {
await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {})
await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {})
}

await moduleRef?.close()

vi.restoreAllMocks()
vi.unstubAllEnvs()
})

it('locks and unlocks projects', async () => {
await service.bulkAction({ action: 'lock', projectIds: [projectId] })
const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } })
expect(locked.locked).toBe(true)

await service.bulkAction({ action: 'unlock', projectIds: [projectId] })
const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } })
expect(unlocked.locked).toBe(false)
})

it('replays hooks for projects', async () => {
await service.bulkAction({ action: 'replay', projectIds: [projectId] })
expect(eventEmitter.emitAsync).toHaveBeenCalledWith(
'project.upsert',
expect.objectContaining({ id: projectId, slug: projectSlug }),
)
})
})