diff --git a/AGENTS.md b/AGENTS.md index 77866c7703..f30c16fa11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,3 +83,12 @@ Migrations: standard Prisma Migrate. Major version data migrations in `migration - Template env files use `-example` suffix (not `.example`) - `ci/scripts/init-env.sh` copies `*-example` to active equivalents (non-destructive) + +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0bc0e5a91d..bc6e1c81eb 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common' import { ScheduleModule } from '@nestjs/schedule' import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' +import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' import { LogModule } from './modules/log/log.module' import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' @@ -17,6 +18,7 @@ import { VersionModule } from './modules/version/version.module' @Module({ imports: [ + InfrastructureModule, HealthzModule, KeycloakModule, ScheduleModule.forRoot(), diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts index d3103a2e31..e3b0022a1f 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -1,6 +1,8 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './argocd.constant' export const projectSelect = { id: true, @@ -101,4 +103,46 @@ export class ArgoCDDatastoreService { select: projectSelect, }) } + + async getAutoSyncProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { pluginName_key: { pluginName, key } }, + select: { value: true }, + }) + return result?.value ?? null + } } diff --git a/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts index f0b6965fc6..31abf0fb9c 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -1,16 +1,28 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' import { Injectable } from '@nestjs/common' import { + AUTO_SYNC_PLUGIN_KEY, DEFAULT_DSO_ENV_CHART_VERSION, DEFAULT_DSO_NS_CHART_VERSION, + DSO_ENV_CHART_VERSION_PLUGIN_KEY, + DSO_NS_CHART_VERSION_PLUGIN_KEY, + EXTRA_REPOSITORIES_PLUGIN_KEY, PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, PLATFORM_READONLY_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, PLATFORM_SECURITY_GROUP_PATH, PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_SECURITY_GROUP_PATH_SUFFIX, + SUSPENDED_PLUGIN_KEY, } from './argocd.constant' @Injectable() @@ -29,7 +41,7 @@ export class ArgoCDPluginService { description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', config: { global: [{ - key: 'extraRepositories', + key: EXTRA_REPOSITORIES_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -40,7 +52,7 @@ export class ArgoCDPluginService { description: extraRepositoriesDesc, placeholder: 'https://github.com/', }, { - key: 'platformAdminGroupPath', + key: PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -50,7 +62,7 @@ export class ArgoCDPluginService { value: PLATFORM_ADMIN_GROUP_PATH, description: 'Chemin du groupe administrateur de plateforme', }, { - key: 'platformReadonlyGroupPath', + key: PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -70,7 +82,7 @@ export class ArgoCDPluginService { value: PLATFORM_SECURITY_GROUP_PATH, description: 'Chemin du groupe sécurité de plateforme', }, { - key: 'projectAdminGroupPathSuffix', + key: PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -80,7 +92,7 @@ export class ArgoCDPluginService { value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, description: 'Suffixe du chemin du groupe administrateur de projet', }, { - key: 'projectDevopsGroupPathSuffix', + key: PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -90,7 +102,7 @@ export class ArgoCDPluginService { value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, description: 'Suffixe du chemin du groupe devops de projet', }, { - key: 'projectDevelopperGroupPathSuffix', + key: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -100,7 +112,7 @@ export class ArgoCDPluginService { value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, description: 'Suffixe du chemin du groupe développeur de projet', }, { - key: 'projectReadonlyGroupPathSuffix', + key: PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -120,7 +132,7 @@ export class ArgoCDPluginService { value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, description: 'Suffixe du chemin du groupe sécurité de projet', }, { - key: 'dsoEnvChartVersion', + key: DSO_ENV_CHART_VERSION_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -131,7 +143,7 @@ export class ArgoCDPluginService { description: 'Version du chart Helm dso-env', placeholder: DEFAULT_DSO_ENV_CHART_VERSION, }, { - key: 'dsoNsChartVersion', + key: DSO_NS_CHART_VERSION_PLUGIN_KEY, kind: 'text', permissions: { admin: { read: true, write: true }, @@ -142,18 +154,44 @@ export class ArgoCDPluginService { description: 'Version du chart Helm dso-ns', placeholder: DEFAULT_DSO_NS_CHART_VERSION, }], - project: [{ - key: 'extraRepositories', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: true, write: false }, + project: [ + { + kind: 'switch', + key: SUSPENDED_PLUGIN_KEY, + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Suspendre le projet', + value: ENABLED, + description: 'Suspendre la synchronisation ArgoCD pour ce projet', }, - title: 'Source repositories', - value: '', - description: extraRepositoriesDesc, - placeholder: 'https://github.com/', - }], + { + kind: 'switch', + key: AUTO_SYNC_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Synchronisation automatique ArgoCD', + value: DISABLED, + description: 'Synchroniser automatiquement le projet ArgoCD', + }, + { + key: EXTRA_REPOSITORIES_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, + ], }, } as const satisfies ServiceInfos } diff --git a/apps/server-nestjs/src/modules/argocd/argocd.constant.ts b/apps/server-nestjs/src/modules/argocd/argocd.constant.ts index f11f3cd7f8..775a935274 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.constant.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.constant.ts @@ -14,3 +14,21 @@ export const PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' // Default chart versions export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +// Plugin configuration keys +export const EXTRA_REPOSITORIES_PLUGIN_KEY = 'extraRepositories' +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY = 'platformReadonlyGroupPath' +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectAdminGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevelopperGroupPathSuffix' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReadonlyGroupPathSuffix' +export const DSO_ENV_CHART_VERSION_PLUGIN_KEY = 'dsoEnvChartVersion' +export const DSO_NS_CHART_VERSION_PLUGIN_KEY = 'dsoNsChartVersion' + +// Plugin name +export const PLUGIN_NAME = 'argocd' + +// Plugin synchronization flags (shared plugin config keys) +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts index 46a5e69222..ad51bf62dd 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.module.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { GitlabModule } from '../gitlab/gitlab.module' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { ArgoCDHealthService } from './argocd-health.service' @@ -10,8 +10,8 @@ import { ArgoCDPluginService } from './argocd-plugin.service' import { ArgoCDService } from './argocd.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, GitlabModule, VaultModule], - providers: [HealthIndicatorService, ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], + imports: [ConfigurationModule, DatabaseModule, GitlabModule, TerminusModule, VaultModule], + providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], }) export class ArgoCDModule {} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index 95266ad475..96b1a7d098 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -121,7 +121,7 @@ describe('argoCDService', () => { }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) - datastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAutoSyncProjects.mockResolvedValue([mockProject]) gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') @@ -313,7 +313,7 @@ describe('argoCDService', () => { }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) - datastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAutoSyncProjects.mockResolvedValue([mockProject]) gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') @@ -364,7 +364,7 @@ describe('argoCDService', () => { }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) - datastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAutoSyncProjects.mockResolvedValue([mockProject]) gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') @@ -409,7 +409,7 @@ describe('argoCDService', () => { }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) - datastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAutoSyncProjects.mockResolvedValue([mockProject]) gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 69a861d48d..ef88e1f389 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -24,6 +24,7 @@ import { PROJECT_READONLY_GROUP_PATH_SUFFIX, PROJECT_SECURITY_GROUP_PATH_SUFFIX, } from './argocd.constant' +import { isSuspended } from './argocd.utils' @Injectable() export class ArgoCDService { @@ -45,6 +46,10 @@ export class ArgoCDService { @StartActiveSpan() private async syncProject(project: ProjectWithDetails) { + if (isSuspended(project)) { + this.logger.log(`Skipping ArgoCD sync for suspended project ${project.slug}`) + return + } const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling a project upsert event for ${project.slug}`) @@ -66,11 +71,11 @@ export class ArgoCDService { this.logger.log(`ArgoCD sync completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { this.logger.log('Starting ArgoCD reconciliation') - const projects = await this.argoCDDatastore.getAllProjects() + const projects = await this.argoCDDatastore.getAutoSyncProjects() const span = trace.getActiveSpan() span?.setAttribute('argocd.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts new file mode 100644 index 0000000000..f4221f4dfc --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -0,0 +1,11 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' +import { ENABLED } from '@cpn-console/shared' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './argocd.constant' + +export function isSuspended(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === SUSPENDED_PLUGIN_KEY && p.value === ENABLED) ?? false +} + +export function isAutoSync(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === AUTO_SYNC_PLUGIN_KEY && p.value === ENABLED) ?? false +} diff --git a/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/apps/server-nestjs/src/modules/deployment/deployment.module.ts index 76cd0721df..d3ee154f33 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.module.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -1,12 +1,15 @@ import { Module } from '@nestjs/common' import { AppEventsModule } from '../events/app-events.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectModule } from '../project/project.module' import { DeploymentDatastoreService } from './deployment-datastore.service' import { DeploymentController } from './deployment.controller' import { DeploymentService } from './deployment.service' @Module({ - imports: [AppEventsModule, InfrastructureModule], + imports: [AppEventsModule, DatabaseModule, EventsModule, ProjectModule, ProjectPermissionModule], controllers: [DeploymentController], providers: [ DeploymentDatastoreService, diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts index 55b9ba4392..f0ab6b5b07 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -1,6 +1,8 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './gitlab.constants' export const projectSelect = { id: true, @@ -18,6 +20,7 @@ export const projectSelect = { }, plugins: { select: { + pluginName: true, key: true, value: true, }, @@ -79,13 +82,43 @@ export class GitlabDatastoreService { where: { plugins: { some: { - pluginName: 'gitlab', + pluginName: PLUGIN_NAME, }, }, }, }) } + async getAutoSyncProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], + }, + }) + } + async getProject(id: string): Promise { return this.prisma.project.findUnique({ where: { id }, diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.spec.ts new file mode 100644 index 0000000000..96a28dbf68 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.spec.ts @@ -0,0 +1,257 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ENABLED } from '@cpn-console/shared' +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 { GitlabDatastoreService } from './gitlab-datastore.service' +import { makeAdminPlugin, makeAdminRole, makeProjectWithDetails, makeUser } from './gitlab-testing.utils' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME } from './gitlab.constants' + +describe('gitlabDatastoreService', () => { + let module: TestingModule + let service: GitlabDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + GitlabDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(GitlabDatastoreService) + }) + + describe('getAllProjects', () => { + it('should return all projects with gitlab plugin', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project1', + name: 'Test Project', + slug: 'test-project', + description: 'Test', + owner: { + id: 'user1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + adminRoleIds: [], + }, + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + ], + }) + + prisma.project.findMany.mockResolvedValue([mockProject]) + + const result = await service.getAllProjects() + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: expect.any(Object), + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + expect(result).toEqual([mockProject]) + }) + }) + + describe('getAutoSyncProjects', () => { + it('should return projects with autoSync enabled and not suspended', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project1', + name: 'Test Project', + slug: 'test-project', + description: 'Test', + owner: { + id: 'user1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + adminRoleIds: [], + }, + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + ], + }) + + prisma.project.findMany.mockResolvedValue([mockProject]) + + const result = await service.getAutoSyncProjects() + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: expect.any(Object), + where: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: 'suspended', + value: ENABLED, + }, + }, + }, + }, + ], + }, + }) + expect(result).toEqual([mockProject]) + }) + + it('should return empty array when no projects match criteria', async () => { + prisma.project.findMany.mockResolvedValue([]) + + const result = await service.getAutoSyncProjects() + + expect(result).toEqual([]) + }) + }) + + describe('getProject', () => { + it('should return a project by id', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project1', + name: 'Test Project', + slug: 'test-project', + description: 'Test', + owner: { + id: 'user1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + adminRoleIds: [], + }, + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + ], + }) + + prisma.project.findUnique.mockResolvedValue(mockProject) + + const result = await service.getProject('project1') + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: 'project1' }, + select: expect.any(Object), + }) + expect(result).toEqual(mockProject) + }) + + it('should return null when project not found', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + const result = await service.getProject('nonexistent') + + expect(result).toBeNull() + }) + }) + + describe('getAdminPluginConfig', () => { + it('should return admin plugin config value', async () => { + const config = makeAdminPlugin({ value: 'test-value' }) + prisma.adminPlugin.findUnique.mockResolvedValue(config) + + const result = await service.getAdminPluginConfig(PLUGIN_NAME, 'token') + + expect(prisma.adminPlugin.findUnique).toHaveBeenCalledWith({ + where: { + pluginName_key: { + pluginName: PLUGIN_NAME, + key: 'token', + }, + }, + select: { + value: true, + }, + }) + expect(result).toEqual('test-value') + }) + + it('should return null when config not found', async () => { + prisma.adminPlugin.findUnique.mockResolvedValue(null) + + const result = await service.getAdminPluginConfig(PLUGIN_NAME, 'missing') + + expect(result).toBeNull() + }) + }) + + describe('getAdminRolesByOidcGroups', () => { + it('should return admin roles for given oidc groups', async () => { + const roles = [ + makeAdminRole({ id: 'role1', oidcGroup: 'group1' }), + makeAdminRole({ id: 'role2', oidcGroup: 'group2' }), + ] + + prisma.adminRole.findMany.mockResolvedValue(roles) + + const result = await service.getAdminRolesByOidcGroups(['group1', 'group2']) + + expect(prisma.adminRole.findMany).toHaveBeenCalledWith({ + where: { + oidcGroup: { + in: ['group1', 'group2'], + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + expect(result).toEqual(roles) + }) + }) + + describe('getUser', () => { + it('should return a user by id', async () => { + const user = makeUser({ + id: 'user1', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + }) + + prisma.user.findUnique.mockResolvedValue(user) + + const result = await service.getUser('user1') + + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { + id: 'user1', + }, + }) + expect(result).toEqual(user) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts index 9614fc45c9..826530f842 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -2,7 +2,16 @@ import type { ServiceInfos } from '@cpn-console/hooks' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX } from './gitlab.constants' +import { + AUTO_SYNC_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + PURGE_PLUGIN_KEY, + SUSPENDED_PLUGIN_KEY, +} from './gitlab.constants' @Injectable() export class GitlabPluginService { @@ -25,7 +34,7 @@ export class GitlabPluginService { global: [ { kind: 'switch', - key: 'purge', + key: PURGE_PLUGIN_KEY, initialValue: DISABLED, permissions: { admin: { read: true, write: true }, @@ -108,7 +117,32 @@ export class GitlabPluginService { placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, }, ], - project: [], + project: [ + { + kind: 'switch', + key: SUSPENDED_PLUGIN_KEY, + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Suspendre le projet', + value: ENABLED, + description: 'Suspendre la synchronisation GitLab pour ce projet', + }, + { + kind: 'switch', + key: AUTO_SYNC_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Synchronisation automatique GitLab', + value: DISABLED, + description: 'Synchroniser automatiquement le projet GitLab', + }, + ], }, } as const satisfies ServiceInfos } diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts index 32362d789c..c18a2d099a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -365,3 +365,25 @@ export function makeCommitAction(overrides: Partial = {}) { ...overrides, } satisfies CommitAction } + +export function makeAdminPlugin(overrides: { value?: string } = {}) { + return { + value: overrides.value ?? faker.string.alphanumeric(20), + } +} + +export function makeAdminRole(overrides: { id?: string, oidcGroup?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + oidcGroup: overrides.oidcGroup ?? faker.word.words(), + } +} + +export function makeUser(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts index 8a6e435a2a..3c26454d4b 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -23,9 +23,16 @@ export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperG export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' export const PURGE_PLUGIN_KEY = 'purge' +// Plugin name +export const PLUGIN_NAME = 'gitlab' + // Custom attribute keys used in GitLab groups export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' + +// Plugin synchronization flags (shared plugin config keys) +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts index a5c47613db..22859472fb 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -1,9 +1,9 @@ import { Gitlab } from '@gitbeaker/rest' import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' @@ -12,7 +12,7 @@ import { GitlabPluginService } from './gitlab-plugin.service' import { GitlabService } from './gitlab.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule], + imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], providers: [ { provide: GITLAB_REST_CLIENT, @@ -22,7 +22,6 @@ import { GitlabService } from './gitlab.service' host: config.getInternalOrPublicGitlabUrl(), }), }, - HealthIndicatorService, GitlabClientService, GitlabDatastoreService, GitlabHealthService, diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index ec0b96efdf..fda0e28bb7 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -10,7 +10,7 @@ import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' -import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' import { GitlabService } from './gitlab.service' describe('gitlabService', () => { @@ -81,7 +81,7 @@ describe('gitlabService', () => { it('should remove orphan member if purge enabled', async () => { const project = makeProjectWithDetails({ - plugins: [{ key: 'purge', value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], }) const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) @@ -98,7 +98,7 @@ describe('gitlabService', () => { it('should not remove managed user (bot) even if purge enabled', async () => { const project = makeProjectWithDetails({ - plugins: [{ key: 'purge', value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], }) const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) @@ -130,7 +130,7 @@ describe('gitlabService', () => { it('should delete orphan repositories if purge enabled', async () => { const project = makeProjectWithDetails({ - plugins: [{ key: 'purge', value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], repositories: [], }) const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) @@ -175,7 +175,7 @@ describe('gitlabService', () => { it('should not delete orphan repositories without the correct topic even if purge enabled', async () => { const project = makeProjectWithDetails({ - plugins: [{ key: 'purge', value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], repositories: [], }) const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) @@ -467,7 +467,7 @@ describe('gitlabService', () => { describe('handleCron', () => { it('should reconcile all projects', async () => { const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] - datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAutoSyncProjects.mockResolvedValue(projects) const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) @@ -478,7 +478,7 @@ describe('gitlabService', () => { await service.handleCron() - expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAutoSyncProjects).toHaveBeenCalled() expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') }) }) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 15b00ec86e..822ff36282 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -23,6 +23,7 @@ import { DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, INFRA_APPS_REPO_NAME, + PLUGIN_NAME, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, @@ -40,6 +41,7 @@ import { getProjectPluginConfig, isOwnedRepo, isOwnedUser, + isSuspended, isSystemRepo, } from './gitlab.utils' @@ -65,6 +67,10 @@ export class GitlabService { @StartActiveSpan() private async syncProject(project: ProjectWithDetails) { + if (isSuspended(project)) { + this.logger.warn(`Skipping suspended GitLab reconciliation for ${project.slug}`) + return + } const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling a project upsert event for ${project.slug}`) @@ -86,13 +92,13 @@ export class GitlabService { this.logger.log(`GitLab sync completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { const span = trace.getActiveSpan() span?.setAttribute('gitlab.projects.count', 0) this.logger.log('Starting GitLab reconciliation') - const projects = await this.gitlabDatastore.getAllProjects() + const projects = await this.gitlabDatastore.getAutoSyncProjects() span?.setAttribute('gitlab.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) await this.ensureProjectGroups(projects) @@ -232,7 +238,7 @@ export class GitlabService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.gitlabDatastore.getAdminPluginConfig('gitlab', key) + const adminPluginConfig = await this.gitlabDatastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig if (!project) return undefined return getProjectPluginConfig(project, key) ?? undefined diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts new file mode 100644 index 0000000000..158172ff8c --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.spec.ts @@ -0,0 +1,228 @@ +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { describe, expect, it } from 'vitest' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME } from './gitlab.constants' +import { isAutoSync, isSuspended } from './gitlab.utils' + +describe('gitlab Utils', () => { + describe('isSuspended', () => { + it('should return false when plugins array is empty', () => { + const project = { + id: '1', + plugins: [], + } as ProjectWithDetails + + expect(isSuspended(project)).toBe(false) + }) + + it('should return false when plugins array is undefined', () => { + const project = { + id: '1', + plugins: undefined, + } as ProjectWithDetails + + expect(isSuspended(project)).toBe(false) + }) + + it('should return false when suspended plugin is not present', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isSuspended(project)).toBe(false) + }) + + it('should return false when suspended plugin value is not enabled', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: 'suspended', + value: DISABLED, + }, + ], + } as ProjectWithDetails + + expect(isSuspended(project)).toBe(false) + }) + + it('should return true when suspended plugin is enabled', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: 'suspended', + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isSuspended(project)).toBe(true) + }) + + it('should return false when plugin matches different pluginName', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: 'other-plugin', + key: 'suspended', + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isSuspended(project)).toBe(false) + }) + }) + + describe('isAutoSync', () => { + it('should return false when plugins array is empty', () => { + const project = { + id: '1', + plugins: [], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(false) + }) + + it('should return false when plugins array is undefined', () => { + const project = { + id: '1', + plugins: undefined, + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(false) + }) + + it('should return false when autoSync plugin is not present', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: 'otherKey', + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(false) + }) + + it('should return false when autoSync plugin value is not enabled', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: DISABLED, + }, + ], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(false) + }) + + it('should return true when autoSync plugin is enabled', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(true) + }) + + it('should return true when both autoSync and suspended are enabled', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + { + pluginName: PLUGIN_NAME, + key: 'suspended', + value: ENABLED, + }, + ], + } as ProjectWithDetails + + // isAutoSync only checks for the autoSync flag, not suspended + expect(isAutoSync(project)).toBe(true) + }) + }) + + describe('combined behavior', () => { + it('should correctly identify autoSync non-suspended project', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(true) + expect(isSuspended(project)).toBe(false) + }) + + it('should correctly identify suspended project with autoSync', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + { + pluginName: PLUGIN_NAME, + key: 'suspended', + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(true) + expect(isSuspended(project)).toBe(true) + }) + + it('should correctly identify project with neither flag', () => { + const project = { + id: '1', + plugins: [ + { + pluginName: PLUGIN_NAME, + key: 'otherKey', + value: ENABLED, + }, + ], + } as ProjectWithDetails + + expect(isAutoSync(project)).toBe(false) + expect(isSuspended(project)).toBe(false) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts index 63f574bd3c..639e82f107 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -1,12 +1,21 @@ import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' import type { ProjectWithDetails } from './gitlab-datastore.service' import { createHash } from 'node:crypto' +import { ENABLED } from '@cpn-console/shared' import { AccessLevel } from '@gitbeaker/core' import { stringify } from 'yaml' -import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' export type ProjectAccessLevel = Exclude +export function isSuspended(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === SUSPENDED_PLUGIN_KEY && p.value === ENABLED) ?? false +} + +export function isAutoSync(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === AUTO_SYNC_PLUGIN_KEY && p.value === ENABLED) ?? false +} + export function getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { if (!externalRepoUrl) return undefined try { diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts index 3364ac41ef..85892bd878 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts @@ -31,7 +31,7 @@ describe('authService', () => { it('should authenticate a Fastify request directly', async () => { dsoTokenService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) - const request = { headers: { 'x-dso-token': 'token' } } as Parameters[0] + const request = { headers: { 'x-dso-token': 'token' } } as unknown as Parameters[0] const result = await service.authenticate(request) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts index 5ef0e17d84..abad757be0 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -16,7 +16,8 @@ describe('dsoTokenService', () => { let prisma: DeepMockProxy beforeEach(async () => { - prisma = mockDeep({ adminRole: { findMany() { return [] } } }) + prisma = mockDeep() + prisma.adminRole.findMany.mockResolvedValue([]) module = await Test.createTestingModule({ providers: [ diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts index 9545c2ddd2..9919a78be6 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../configuration/configuration.module' import { DatabaseHealthService } from './database-health.service' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @Module({ - imports: [ConfigurationModule], - providers: [HealthIndicatorService, DatabaseHealthService, DatabaseService, PrismaService], + imports: [ConfigurationModule, TerminusModule], + providers: [DatabaseHealthService, DatabaseService, PrismaService], exports: [DatabaseHealthService, DatabaseService, PrismaService], }) export class DatabaseModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts index 16bf5bb20d..e29dae241f 100644 --- a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -8,7 +8,7 @@ import { PermissionModule } from './permission/permission.module' @Module({ providers: [], - imports: [AuthModule, PermissionModule, DatabaseModule, LoggerModule, ConfigurationModule, EventsModule], - exports: [AuthModule, DatabaseModule, PermissionModule, ConfigurationModule, EventsModule], + imports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], }) export class InfrastructureModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts index 9bc4da8b3d..4ad5b84704 100644 --- a/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts @@ -1,4 +1,4 @@ -import type { UserContext } from '../../auth/auth.service' +import type { UserContext } from '../../auth/auth-user.decorator' import { AdminAuthorized } from '@cpn-console/shared' import { ForbiddenException } from '@nestjs/common' import { Test } from '@nestjs/testing' diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts index 977285da98..3d102d2220 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -14,96 +14,30 @@ const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentI const server = setupServer() -function useTokenEndpoint() { - server.use( - http.post(tokenUrl, () => - HttpResponse.json({ access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 300 })), - ) -} - -function createKeycloakClientServiceTestingModule(config: Partial = {}) { - return Test.createTestingModule({ - providers: [ - KeycloakClientService, - { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, - { - provide: ConfigurationService, - useValue: mockDeep({ - keycloakRealm: projectRealm, - keycloakAdmin: 'admin', - keycloakAdminPassword: 'admin-password', - ...config, - }), - }, - ], - }) -} - -describe('keycloakClientService authentication lifecycle', () => { +describe('getOrCreateSubGroupByName', () => { let service: KeycloakClientService - let client: KcAdminClient beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const module = await createKeycloakClientServiceTestingModule().compile() + const module = await Test.createTestingModule({ + providers: [ + KeycloakClientService, + { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, + { + provide: ConfigurationService, + useValue: mockDeep({ + keycloakRealm: projectRealm, + keycloakAdmin: 'admin', + keycloakAdminPassword: 'admin-password', + }), + }, + ], + }).compile() service = module.get(KeycloakClientService) - client = module.get(KEYCLOAK_ADMIN_CLIENT) - }) - afterEach(() => server.resetHandlers()) - afterAll(() => server.close()) - - it('should authenticate with the password grant then switch to the project realm', async () => { - server.use( - http.post(tokenUrl, async ({ request }) => { - const body = new URLSearchParams(await request.text()) - expect(body.get('grant_type')).toBe('password') - expect(body.get('client_id')).toBe('admin-cli') - expect(body.get('username')).toBe('admin') - expect(body.get('password')).toBe('admin-password') - return HttpResponse.json({ access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 300 }) - }), - ) - - await service.onModuleInit() - - expect(client.accessToken).toBe('access-token') - expect(client.realmName).toBe(projectRealm) - }) - - it('should rethrow when the initial authentication fails', async () => { server.use( http.post(tokenUrl, () => - HttpResponse.json({ error: 'invalid_grant' }, { status: 401 })), + HttpResponse.json({ access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 300 })), ) - - await expect(service.onModuleInit()).rejects.toThrow('Network response was not OK.') - expect(client.realmName).toBe('master') - }) - - it('should not authenticate when the Keycloak realm is not configured', async () => { - const module = await createKeycloakClientServiceTestingModule({ keycloakRealm: undefined }).compile() - const serviceWithoutRealm = module.get(KeycloakClientService) - - // No token endpoint handler: any authentication attempt would fail the test - await expect(serviceWithoutRealm.onModuleInit()).resolves.toBeUndefined() - }) - - it('should not authenticate when the admin credentials are not configured', async () => { - const module = await createKeycloakClientServiceTestingModule({ keycloakAdminPassword: undefined }).compile() - const serviceWithoutCredentials = module.get(KeycloakClientService) - - await expect(serviceWithoutCredentials.onModuleInit()).resolves.toBeUndefined() - }) -}) - -describe('getOrCreateSubGroupByName', () => { - let service: KeycloakClientService - - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - beforeEach(async () => { - const module = await createKeycloakClientServiceTestingModule().compile() - service = module.get(KeycloakClientService) - useTokenEndpoint() await service.onModuleInit() }) afterEach(() => server.resetHandlers()) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts index c7397d8e2e..56aabf1f8c 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -271,12 +271,10 @@ export class KeycloakClientService implements OnModuleInit { async onModuleInit() { if (!this.config.keycloakRealm) { - this.logger.fatal('Keycloak realm is not configured') - return + throw new Error('Keycloak realm is not configured') } if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { - this.logger.fatal('Keycloak admin username or password is not configured') - return + throw new Error('Keycloak admin username or password is not configured') } try { this.logger.log(`Authenticating Keycloak admin client (realm=${this.config.keycloakRealm})`) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts index d534efff8c..b6318ee7fd 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -1,12 +1,21 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './keycloak.constants' export const projectSelect = { id: true, slug: true, ownerId: true, everyonePerms: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, members: { select: { roleIds: true, @@ -32,6 +41,13 @@ export const projectSelect = { name: true, }, }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, } satisfies Prisma.ProjectSelect export type ProjectWithDetails = Prisma.ProjectGetPayload<{ @@ -67,6 +83,36 @@ export class KeycloakDatastoreService { }) } + async getAutoSyncProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], + }, + }) + } + async getAllAdminRoles(): Promise { return this.prisma.adminRole.findMany({ select: adminRoleSelect, diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.spec.ts new file mode 100644 index 0000000000..8afc456cde --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.spec.ts @@ -0,0 +1,102 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ENABLED } from '@cpn-console/shared' +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 { makeProjectWithDetails } from './keycloack-testing.utils' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME } from './keycloak.constants' + +describe('keycloakDatastoreService', () => { + let service: KeycloakDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + const module = await Test.createTestingModule({ + providers: [KeycloakDatastoreService, { provide: PrismaService, useValue: prisma }], + }).compile() + + service = module.get(KeycloakDatastoreService) + }) + + describe('getAutoSyncProjects', () => { + it('should return projects with autoSync enabled and not suspended', async () => { + const project1 = makeProjectWithDetails({ + id: 'project-1', + slug: 'project-1', + plugins: [ + { pluginName: PLUGIN_NAME, key: AUTO_SYNC_PLUGIN_KEY, value: ENABLED }, + ], + }) + const project2 = makeProjectWithDetails({ + id: 'project-2', + slug: 'project-2', + plugins: [ + { pluginName: PLUGIN_NAME, key: AUTO_SYNC_PLUGIN_KEY, value: ENABLED }, + ], + }) + + prisma.project.findMany.mockResolvedValue([project1, project2]) + + const result = await service.getAutoSyncProjects() + + expect(result).toHaveLength(2) + expect(result.map(p => p.id)).toEqual(['project-1', 'project-2']) + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: expect.anything(), + where: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: 'suspended', + value: ENABLED, + }, + }, + }, + }, + ], + }, + }) + }) + + it('should return empty array when no projects match criteria', async () => { + prisma.project.findMany.mockResolvedValue([]) + + const result = await service.getAutoSyncProjects() + + expect(result).toEqual([]) + }) + + it('should exclude suspended projects and projects without autoSync', async () => { + const autoSyncProject = makeProjectWithDetails({ + id: 'project-auto', + slug: 'project-auto', + plugins: [ + { pluginName: PLUGIN_NAME, key: AUTO_SYNC_PLUGIN_KEY, value: ENABLED }, + ], + }) + + prisma.project.findMany.mockResolvedValue([autoSyncProject]) + + const result = await service.getAutoSyncProjects() + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('project-auto') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts index 87e0e46e17..bf2de7d151 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -5,8 +5,10 @@ import { ConfigurationService } from '../infrastructure/configuration/configurat @Injectable() export class KeycloakHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, - @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + @Inject(ConfigurationService) + private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) + private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts similarity index 99% rename from apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts rename to apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts index 9f8062bf27..17fb5c5f0d 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloack-testing.utils.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts @@ -78,6 +78,7 @@ export function makeProjectWithDetails( slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), ownerId: faker.string.uuid(), everyonePerms: 0n, + plugins: [], members: [], roles: [], environments: [], diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts index d0e79d9828..814c1547c6 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -4,3 +4,8 @@ export const CONSOLE_GROUP_NAME = 'console' // Maximum number of entities returned in a paginated query export const GROUPS_PAGINATE_QUERY_MAX = 20 export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 + +// Plugin name and synchronization flags +export const PLUGIN_NAME = 'keycloak' +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts index 6c510357ce..9ae808fc65 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -1,9 +1,9 @@ import KcAdminClient from '@keycloak/keycloak-admin-client' import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' import { KeycloakHealthService } from './keycloak-health.service' @@ -11,16 +11,15 @@ import { KeycloakPluginService } from './keycloak-plugin.service' import { KeycloakService } from './keycloak.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule], + imports: [ConfigurationModule, DatabaseModule, TerminusModule], providers: [ { - provide: KEYCLOAK_ADMIN_CLIENT, inject: [ConfigurationService], + provide: KEYCLOAK_ADMIN_CLIENT, useFactory: (config: ConfigurationService) => new KcAdminClient({ baseUrl: config.getKeycloakUrl(), }), }, - HealthIndicatorService, KeycloakClientService, KeycloakDatastoreService, KeycloakHealthService, diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts index 750fb72164..fefb037f02 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -1,8 +1,11 @@ -import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' import { makeGroupRepresentation, makeProjectEnvironment, @@ -11,62 +14,50 @@ import { makeProjectUser, makeProjectWithDetails, makeUserRepresentation, -} from './keycloack-testing.utils' -import { KeycloakClientService } from './keycloak-client.service' -import { KeycloakDatastoreService } from './keycloak-datastore.service' +} from './keycloak-testing.utils' import { KeycloakService } from './keycloak.service' -function createKeycloakControllerServiceTestingModule() { - return Test.createTestingModule({ - providers: [ - KeycloakService, - { - provide: KeycloakClientService, - useValue: { - getAllGroups: vi.fn().mockImplementation(async function* () {}), - deleteGroup: vi.fn().mockResolvedValue(undefined), - getOrCreateGroupByPath: vi.fn().mockResolvedValue({}), - getGroupMembers: vi.fn().mockResolvedValue([]), - addUserToGroup: vi.fn().mockResolvedValue(undefined), - removeUserFromGroup: vi.fn().mockResolvedValue(undefined), - getOrCreateSubGroupByName: vi.fn().mockResolvedValue({}), - getOrCreateRoleGroup: vi.fn().mockResolvedValue({}), - getSubGroups: vi.fn().mockImplementation(async function* () {}), - getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), - getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ - roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), - rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), - }), - } satisfies Partial, - }, - { - provide: KeycloakDatastoreService, - useValue: { - getAllProjects: vi.fn().mockResolvedValue([]), - getAllAdminRoles: vi.fn().mockResolvedValue([]), - getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), - } satisfies Partial, - }, - { - provide: ConfigurationService, - useValue: { - } satisfies Partial, - }, - ], - }) -} - describe('keycloakService', () => { let service: KeycloakService - let keycloak: Mocked - let keycloakDatastore: Mocked + let client: DeepMockProxy + let datastore: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - vi.clearAllMocks() - const module = await createKeycloakControllerServiceTestingModule().compile() + client = mockDeep() + datastore = mockDeep() + config = mockDeep() + + const module = await Test.createTestingModule({ + providers: [ + KeycloakService, + { provide: KeycloakClientService, useValue: client }, + { provide: KeycloakDatastoreService, useValue: datastore }, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() + service = module.get(KeycloakService) - keycloak = module.get(KeycloakClientService) - keycloakDatastore = module.get(KeycloakDatastoreService) + + // Setup default mocks + client.getAllGroups.mockImplementation(async function* () {}) + client.deleteGroup.mockResolvedValue(undefined) + client.getOrCreateGroupByPath.mockResolvedValue({}) + client.getGroupMembers.mockResolvedValue([]) + client.addUserToGroup.mockResolvedValue(undefined) + client.removeUserFromGroup.mockResolvedValue(undefined) + client.getOrCreateSubGroupByName.mockResolvedValue({}) + client.getOrCreateRoleGroup.mockResolvedValue({}) + client.getSubGroups.mockImplementation(async function* () {}) + client.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })) + client.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }) + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAutoSyncProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue([]) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue([]) }) it('should be defined', () => { @@ -88,16 +79,16 @@ describe('keycloakService', () => { const users: UserWithAdminRoles[] = [ { id: 'user-1', adminRoleIds: ['admin-role-id'] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAutoSyncProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) await service.handleCron() - expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') - expect(keycloak.getGroupMembers).not.toHaveBeenCalled() - expect(keycloak.addUserToGroup).not.toHaveBeenCalled() - expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + expect(client.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') + expect(client.getGroupMembers).not.toHaveBeenCalled() + expect(client.addUserToGroup).not.toHaveBeenCalled() + expect(client.removeUserFromGroup).not.toHaveBeenCalled() }) it('should sync admin role groups', async () => { @@ -108,26 +99,32 @@ describe('keycloakService', () => { { id: 'user-1', adminRoleIds: ['admin-role-id'] }, { id: 'user-2', adminRoleIds: [] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAutoSyncProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) - keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) + client.getOrCreateGroupByPath.mockResolvedValue(adminGroup) - keycloak.getGroupMembers.mockResolvedValue([ + client.getGroupMembers.mockResolvedValue([ makeUserRepresentation({ id: 'user-2' }), ]) await service.handleCron() - expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') - expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') - expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + expect(client.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(client.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(client.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') }) it('should purge orphans', async () => { - keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + const mockProject = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + datastore.getAutoSyncProjects.mockResolvedValue([mockProject]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) const orphanGroup = makeGroupRepresentation({ @@ -136,23 +133,29 @@ describe('keycloakService', () => { subGroups: [makeGroupRepresentation({ name: 'console' })], }) - keycloak.getAllGroups.mockImplementation(async function* () { + client.getAllGroups.mockImplementation(async function* () { yield projectGroup yield orphanGroup }) - keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) - keycloak.getGroupMembers.mockResolvedValue([]) - keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) - keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + client.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + client.getGroupMembers.mockResolvedValue([]) + client.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + client.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() - expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() - expect(keycloak.getAllGroups).toHaveBeenCalled() - expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') - expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + expect(datastore.getAutoSyncProjects).toHaveBeenCalled() + expect(client.getAllGroups).toHaveBeenCalled() + expect(client.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(client.deleteGroup).toHaveBeenCalledWith('orphan-id') }) it('should sync project members', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) const projectWithMembers = makeProjectWithDetails({ ...mockProject, members: [ @@ -162,30 +165,36 @@ describe('keycloakService', () => { }), ], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + datastore.getAutoSyncProjects.mockResolvedValue([projectWithMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) - keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + client.getOrCreateGroupByPath.mockResolvedValue(projectGroup) // Current members: user-2 (extra), missing user-1 - keycloak.getGroupMembers.mockResolvedValue([ + client.getGroupMembers.mockResolvedValue([ makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), ]) - keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) - keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + client.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + client.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() // Should add missing member - expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + expect(client.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') // Should add owner (missing in group members) - expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + expect(client.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') // Should remove extra member - expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + expect(client.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') }) it('should sync OIDC role groups', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) const roleWithOidc = makeProjectRole({ id: 'role-oidc', permissions: 0n, @@ -202,61 +211,67 @@ describe('keycloakService', () => { ], roles: [roleWithOidc], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + datastore.getAutoSyncProjects.mockResolvedValue([projectWithRole]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) - keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + client.getOrCreateGroupByPath.mockImplementation((path) => { if (path === '/test-project') return Promise.resolve(projectGroup) return Promise.resolve({}) }) - keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) - keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + client.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + client.getOrCreateRoleGroup.mockResolvedValue(roleGroup) // Project members: owner - keycloak.getGroupMembers.mockImplementation((groupId) => { + client.getGroupMembers.mockImplementation((groupId) => { if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) // Role group members: user-2 (extra), missing user-1 if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) return Promise.resolve([]) }) - keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + client.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() // Should create/get role group - expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + expect(client.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') // Should add user-1 to role group - expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + expect(client.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') // Should remove user-2 from role group - expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + expect(client.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') }) it('should sync environment groups', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) const projectWithEnv = makeProjectWithDetails({ ...mockProject, environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + datastore.getAutoSyncProjects.mockResolvedValue([projectWithEnv]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], }) - keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) - keycloak.getGroupMembers.mockResolvedValue([]) + client.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + client.getGroupMembers.mockResolvedValue([]) // Mock console group retrieval - keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) - keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + client.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + client.getOrCreateEnvironmentGroups.mockResolvedValue({ roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), }) - keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + client.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) @@ -286,6 +301,12 @@ describe('keycloakService', () => { }) it('should sync environment permissions', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) @@ -312,22 +333,22 @@ describe('keycloakService', () => { ], environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + datastore.getAutoSyncProjects.mockResolvedValue([projectWithEnvAndMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], }) - keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) - keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) - keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + client.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + client.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + client.getOrCreateEnvironmentGroups.mockResolvedValue({ roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), }) // Project group members (assume all are in project group for simplicity) - keycloak.getGroupMembers.mockImplementation((groupId) => { + client.getGroupMembers.mockImplementation((groupId) => { if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) // RO group has userNone (extra), missing userRo if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) @@ -357,6 +378,12 @@ describe('keycloakService', () => { }) it('should handle different role types (managed, external, global)', async () => { + const mockProject = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) @@ -371,7 +398,7 @@ describe('keycloakService', () => { ], roles: [roleManaged, roleExternal, roleGlobal], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAutoSyncProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -438,34 +465,37 @@ describe('keycloakService', () => { ], roles: [roleSystemManaged], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAutoSyncProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } const systemManagedGroup = makeGroupRepresentation({ id: 'system-managed-id', name: 'system-managed-group' }) - keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + client.getOrCreateGroupByPath.mockImplementation((path) => { if (path === '/test-project') return Promise.resolve(projectGroup) - return Promise.resolve({}) + return Promise.resolve(makeGroupRepresentation({ id: 'default-id', name: 'default-group' })) + }) + client.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + client.getOrCreateRoleGroup.mockImplementation(async (consoleGroup, rolePath) => { + if (rolePath === '/system-managed-group') return { ...systemManagedGroup, path: '/console/system-managed-group' } + return makeGroupRepresentation({ id: 'default-role-id', name: 'default-role-group' }) }) - keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) - keycloak.getOrCreateRoleGroup.mockResolvedValue({ ...systemManagedGroup, path: '/console/system-managed-group' }) - keycloak.getGroupMembers.mockImplementation((groupId) => { + client.getGroupMembers.mockImplementation((groupId) => { if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) // has extra user-2, missing user-1 if (groupId === 'system-managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) return Promise.resolve([]) }) - keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + client.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() // system:managed behaves like managed: add user-1, remove user-2 - expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') - expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') - expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') + expect(client.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') + expect(client.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') + expect(client.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') }) }) }) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index f6a3f6c0d5..72df1aaaac 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -53,13 +53,13 @@ export class KeycloakService { this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting periodic Keycloak reconciliation') const [projects, adminRoles, users] = await Promise.all([ - this.keycloakDatastore.getAllProjects(), + this.keycloakDatastore.getAutoSyncProjects(), this.keycloakDatastore.getAllAdminRoles(), this.keycloakDatastore.getAllUsersWithAdminRoleIds(), ]) diff --git a/apps/server-nestjs/src/modules/log/log.module.ts b/apps/server-nestjs/src/modules/log/log.module.ts index 642856c63a..f80194cb54 100644 --- a/apps/server-nestjs/src/modules/log/log.module.ts +++ b/apps/server-nestjs/src/modules/log/log.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { LogController } from './log.controller' import { LogService } from './log.service' @Module({ - imports: [InfrastructureModule], + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, UserPermissionModule], controllers: [LogController], providers: [LogService], exports: [LogService], diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts index 9b2d296ae8..9255aa97df 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -14,32 +14,28 @@ const server = setupServer() const nexusAdminPassword = faker.internet.password() const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` -function createNexusServiceTestingModule() { - return Test.createTestingModule({ - providers: [ - NexusClientService, - NexusHttpClientService, - { - provide: ConfigurationService, - useValue: { - nexusSecretExposedUrl: 'https://nexus.example', - nexusInternalUrl: nexusUrl, - nexusAdmin: 'admin', - nexusAdminPassword, - projectRootDir: 'forge', - } satisfies Partial, - }, - ], - }) -} - describe('nexusClientService', () => { let service: NexusClientService beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const module = await createNexusServiceTestingModule().compile() + const module = await Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: ConfigurationService, + useValue: { + nexusSecretExposedUrl: 'https://nexus.example', + nexusInternalUrl: nexusUrl, + nexusAdmin: 'admin', + nexusAdminPassword, + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }).compile() service = module.get(NexusClientService) }) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts index dcecc8efa9..2d3a4347db 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -1,7 +1,8 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' -import { NEXUS_PLUGIN_NAME } from './nexus.constants' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './nexus.constants' export const projectSelect = { slug: true, @@ -13,10 +14,8 @@ export const projectSelect = { }, }, plugins: { - where: { - pluginName: NEXUS_PLUGIN_NAME, - }, select: { + pluginName: true, key: true, value: true, }, @@ -32,36 +31,49 @@ export class NexusDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAutoSyncProjects(): Promise { return this.prisma.project.findMany({ select: projectSelect, where: { - plugins: { - some: { - pluginName: NEXUS_PLUGIN_NAME, + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, }, - }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], }, }) } async getProject(id: string): Promise { - return this.prisma.project.findUnique({ - where: { id }, - select: projectSelect, - }) + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) } async getAdminPluginConfig(pluginName: string, key: string): Promise { const result = await this.prisma.adminPlugin.findUnique({ - where: { - pluginName_key: { - pluginName, - key, - }, - }, - select: { - value: true, - }, + where: { pluginName_key: { pluginName, key } }, + select: { value: true }, }) return result?.value ?? null } diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts index a1ca54548b..57936e86c3 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -1,6 +1,8 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { AUTO_SYNC_PLUGIN_KEY, SUSPENDED_PLUGIN_KEY } from './nexus.constants' @Injectable() export class NexusPluginService { @@ -21,17 +23,41 @@ export class NexusPluginService { description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', config: { project: [ + { + kind: 'switch', + key: SUSPENDED_PLUGIN_KEY, + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Suspendre le projet', + value: ENABLED, + description: 'Suspendre la synchronisation Nexus pour ce projet', + }, + { + kind: 'switch', + key: AUTO_SYNC_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Synchronisation automatique Nexus', + value: DISABLED, + description: 'Synchroniser automatiquement le projet Nexus', + }, { key: 'activateNpmRepo', section: 'NPM', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: true, write: true }, }, title: 'Activer le dépôt NPM', - value: 'disabled', + value: DISABLED, description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', }, { @@ -50,13 +76,13 @@ export class NexusPluginService { key: 'activateMavenRepo', section: 'Maven', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: true, write: true }, }, title: 'Activer le dépôt Maven', - value: 'disabled', + value: DISABLED, description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', }, { @@ -137,38 +163,38 @@ export class NexusPluginService { key: 'activateNpmRepoDefaultValue', section: 'NPM', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: false, write: false }, }, title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', - value: 'disabled', + value: DISABLED, description: 'Défaut au niveau global signifie: Désactivé', }, { key: 'activateMavenRepoDefaultValue', section: 'Maven', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: false, write: false }, }, title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', - value: 'disabled', + value: DISABLED, description: 'Défaut au niveau global signifie: Désactivé', }, { key: 'enablePlugin', kind: 'switch', - initialValue: 'enabled', + initialValue: ENABLED, permissions: { admin: { read: true, write: true }, user: { read: false, write: false }, }, title: 'Activer/Désactiver entièrement le plugin Nexus', - value: 'enabled', + value: ENABLED, description: 'Défaut: Activé', }, ], diff --git a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts index ba160a47ec..beebb1e84c 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -1,5 +1,5 @@ // Name of the Nexus plugin used throughout the application -export const NEXUS_PLUGIN_NAME = 'nexus' +export const PLUGIN_NAME = 'nexus' // Configuration keys for enabling Maven and NPM repositories export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' @@ -30,3 +30,7 @@ export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' // Plugin configuration keys for project-level group path suffixes export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' + +// Plugin synchronization flags (shared plugin config keys) +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts index 6d423a6d7f..14d706232d 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.module.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { NexusClientService } from './nexus-client.service' import { NexusDatastoreService } from './nexus-datastore.service' @@ -11,8 +11,15 @@ import { NexusPluginService } from './nexus-plugin.service' import { NexusService } from './nexus.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule], - providers: [HealthIndicatorService, NexusHealthService, NexusPluginService, NexusService, NexusDatastoreService, NexusHttpClientService, NexusClientService], + imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + providers: [ + NexusHealthService, + NexusPluginService, + NexusService, + NexusDatastoreService, + NexusHttpClientService, + NexusClientService, + ], exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], }) export class NexusModule {} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts index d0ca366087..924eab7ffc 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -1,7 +1,8 @@ -import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' @@ -14,85 +15,42 @@ import { NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' import { NexusService } from './nexus.service' -function createNexusControllerServiceTestingModule() { - return Test.createTestingModule({ - providers: [ - NexusService, - { - provide: NexusClientService, - useValue: { - getRepositoriesMavenHosted: vi.fn(), - createRepositoriesMavenHosted: vi.fn(), - updateRepositoriesMavenHosted: vi.fn(), - createRepositoriesMavenGroup: vi.fn(), - updateRepositoriesMavenGroup: vi.fn(), - getRepositoriesMavenGroup: vi.fn(), - getRepositoriesNpmHosted: vi.fn(), - createRepositoriesNpmHosted: vi.fn(), - updateRepositoriesNpmHosted: vi.fn(), - getRepositoriesNpmGroup: vi.fn(), - postRepositoriesNpmGroup: vi.fn(), - putRepositoriesNpmGroup: vi.fn(), - getSecurityPrivileges: vi.fn(), - createSecurityPrivilegesRepositoryView: vi.fn(), - updateSecurityPrivilegesRepositoryView: vi.fn(), - deleteSecurityPrivileges: vi.fn(), - getSecurityRoles: vi.fn(), - createSecurityRoles: vi.fn(), - updateSecurityRoles: vi.fn(), - deleteSecurityRoles: vi.fn(), - getSecurityUsers: vi.fn(), - updateSecurityUsersChangePassword: vi.fn(), - createSecurityUsers: vi.fn(), - deleteSecurityUsers: vi.fn(), - deleteRepositoriesByName: vi.fn(), - } satisfies Partial, - }, - { - provide: NexusDatastoreService, - useValue: { - getAllProjects: vi.fn(), - getAdminPluginConfig: vi.fn(), - } satisfies Partial, - }, - { - provide: VaultClientService, - useValue: { - read: vi.fn(), - write: vi.fn(), - delete: vi.fn(), - } satisfies Partial, - }, - { - provide: ConfigurationService, - useValue: { - projectRootDir: 'forge', - } satisfies Partial, - }, - ], - }) -} - describe('nexusService', () => { let service: NexusService - let client: Mocked - let nexusDatastore: Mocked - let vault: Mocked + let client: DeepMockProxy + let datastore: DeepMockProxy + let nexusDatastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - const moduleRef = await createNexusControllerServiceTestingModule().compile() - service = moduleRef.get(NexusService) - client = moduleRef.get(NexusClientService) - nexusDatastore = moduleRef.get(NexusDatastoreService) - vault = moduleRef.get(VaultClientService) + client = mockDeep() + datastore = mockDeep() + nexusDatastore = datastore + vault = mockDeep() + config = mockDeep({ projectRootDir: 'forge' }) + + const module = await Test.createTestingModule({ + providers: [ + NexusService, + { provide: NexusClientService, useValue: client }, + { provide: NexusDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() - nexusDatastore.getAllProjects.mockResolvedValue([]) - nexusDatastore.getAdminPluginConfig.mockResolvedValue(null) + service = module.get(NexusService) + + // Setup default mocks + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAdminPluginConfig.mockResolvedValue(null) client.getRepositoriesMavenHosted.mockResolvedValue(null) client.getRepositoriesMavenGroup.mockResolvedValue(null) @@ -101,6 +59,7 @@ describe('nexusService', () => { client.getSecurityPrivileges.mockResolvedValue(null) client.getSecurityRoles.mockResolvedValue(null) client.getSecurityUsers.mockResolvedValue([]) + vault.read.mockRejectedValue(new VaultError('NotFound', 'Not Found')) }) @@ -112,8 +71,8 @@ describe('nexusService', () => { const project = makeProjectWithDetails({ owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, plugins: [ - { key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, - { key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, ], }) @@ -140,11 +99,11 @@ describe('nexusService', () => { it('handleCron should reconcile all projects', async () => { const projects = [ - makeProjectWithDetails({ plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), - makeProjectWithDetails({ plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), ] - nexusDatastore.getAllProjects.mockResolvedValue(projects) + datastore.getAutoSyncProjects.mockResolvedValue(projects) await service.handleCron() @@ -154,10 +113,10 @@ describe('nexusService', () => { it('reuses existing vault password and does not rotate Nexus user password', async () => { const project = makeProjectWithDetails({ owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, - plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], }) - nexusDatastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' return null @@ -193,13 +152,13 @@ describe('nexusService', () => { it('tolerates platform role failures when ensuring platform roles', async () => { const project = makeProjectWithDetails({ owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, - plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], }) const staleProject = makeProjectWithDetails({ - plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], }) - nexusDatastore.getAllProjects.mockResolvedValue([project, staleProject]) + datastore.getAllProjects.mockResolvedValue([project, staleProject]) client.createSecurityRoles.mockImplementation(async (body) => { if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request') }) @@ -213,13 +172,13 @@ describe('nexusService', () => { const project = makeProjectWithDetails({ owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, plugins: [ - { key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, - { key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, - { key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, ], }) - nexusDatastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' return null diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index cbc6d6ff32..df83bdd4ac 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -28,9 +28,9 @@ import { NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, - NEXUS_PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' @@ -40,6 +40,7 @@ import { generateRandomPassword, getPluginConfig, getProjectVaultPath, + isSuspended, } from './nexus.utils' export interface EnsureMavenReposOptions { @@ -67,6 +68,10 @@ export class NexusService { @StartActiveSpan() private async syncProject(project: ProjectWithDetails) { + if (isSuspended(project)) { + this.logger.log(`Skipping ArgoCD sync for suspended project ${project.slug}`) + return + } const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project upsert for ${project.slug}`) @@ -90,12 +95,12 @@ export class NexusService { await this.ensurePlatformRoles(projects) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Nexus reconciliation') - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.nexusDatastore.getAutoSyncProjects() span?.setAttribute('nexus.projects.count', projects.length) await this.ensureProjects(projects) await this.ensurePlatformRoles(projects) @@ -487,7 +492,7 @@ export class NexusService { private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { const projectValue = getPluginConfig(project, key) if (projectValue) return projectValue - return await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, key) + return await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, key) } private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { @@ -511,10 +516,10 @@ export class NexusService { } private async ensurePlatformRoles(projects: ProjectWithDetails[]) { - const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS - const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_READ_GROUP_PATHS const readonlyPrivileges = new Set() diff --git a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts index 1bd4450861..226fd84614 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -1,10 +1,20 @@ import type { ProjectWithDetails } from './nexus-datastore.service' import { randomBytes } from 'node:crypto' +import { ENABLED } from '@cpn-console/shared' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './nexus.constants' export function getPluginConfig(project: ProjectWithDetails, key: string) { return project.plugins?.find(p => p.key === key)?.value } +export function isSuspended(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === SUSPENDED_PLUGIN_KEY && p.value === ENABLED) ?? false +} + +export function isAutoSync(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === AUTO_SYNC_PLUGIN_KEY && p.value === ENABLED) ?? false +} + export function generateRandomPassword(length: number) { const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') return raw.slice(0, length) diff --git a/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/apps/server-nestjs/src/modules/opencds/opencds.module.ts index 88f1de1de0..8b42ddee91 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds.module.ts +++ b/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { OpenCdsHealthService } from './opencds-health.service' @Module({ - imports: [ConfigurationModule], - providers: [HealthIndicatorService, OpenCdsHealthService], + imports: [ConfigurationModule, TerminusModule], + providers: [OpenCdsHealthService], exports: [OpenCdsHealthService], }) export class OpenCdsModule {} diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts index 23a2b704be..fbb1d9bf76 100644 --- a/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -1,12 +1,22 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.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], + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ProjectModule, + ProjectHooksModule, + ], controllers: [ProjectBulkController], providers: [ProjectBulkService], }) diff --git a/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts b/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts index 1f8ae8aa3e..ff83fb82fb 100644 --- a/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts +++ b/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common' import { AppEventsModule } from '../events/app-events.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { LogModule } from '../log/log.module' import { ProjectHooksController } from './project-hooks.controller' import { ProjectHooksService } from './project-hooks.service' @Module({ - imports: [AppEventsModule, InfrastructureModule], + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, LogModule, ProjectPermissionModule], controllers: [ProjectHooksController], providers: [ProjectHooksService], exports: [ProjectHooksService], diff --git a/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/apps/server-nestjs/src/modules/project-members/project-members.module.ts index 24757e94e2..5e523c6c62 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.module.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common' import { AppEventsModule } from '../events/app-events.module' import { AuthModule } from '../infrastructure/auth/auth.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { KeycloakModule } from '../keycloak/keycloak.module' import { ProjectMembersController } from './project-members.controller' import { ProjectMembersService } from './project-members.service' @Module({ - imports: [AppEventsModule, InfrastructureModule, AuthModule, KeycloakModule], + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule, ProjectPermissionModule], controllers: [ProjectMembersController], providers: [ProjectMembersService], }) diff --git a/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts index 781d6fa791..e6bf20ad5f 100644 --- a/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts +++ b/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common' import { AppEventsModule } from '../events/app-events.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { ProjectRolesController } from './project-roles.controller' import { ProjectRolesService } from './project-roles.service' @Module({ - imports: [AppEventsModule, InfrastructureModule], + imports: [AppEventsModule, AuthModule, DatabaseModule, ProjectPermissionModule], controllers: [ProjectRolesController], providers: [ProjectRolesService], exports: [ProjectRolesService], diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts index ab03c31236..2909a7009c 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { VaultModule } from '../vault/vault.module' import { ProjectSecretsController } from './project-secrets.controller' import { ProjectSecretsService } from './project-secrets.service' @Module({ - imports: [InfrastructureModule, VaultModule], + imports: [AuthModule, ConfigurationModule, DatabaseModule, ProjectPermissionModule, VaultModule], controllers: [ProjectSecretsController], providers: [ProjectSecretsService], exports: [ProjectSecretsService], diff --git a/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/apps/server-nestjs/src/modules/project-services/project-services.module.ts index 9b80800686..52ab1011f5 100644 --- a/apps/server-nestjs/src/modules/project-services/project-services.module.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { PluginModule } from '../plugin/plugin.module' import { ProjectServicesController } from './project-services.controller' import { ProjectServicesService } from './project-services.service' @Module({ - imports: [InfrastructureModule, PluginModule], + imports: [AuthModule, DatabaseModule, PluginModule, ProjectPermissionModule], controllers: [ProjectServicesController], providers: [ProjectServicesService], exports: [ProjectServicesService], diff --git a/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts b/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts index 308ab14e92..0a3a2304f3 100644 --- a/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -1,4 +1,4 @@ -import type { ServiceInfos } from '@cpn-console/hooks' +import type { Plugin, ServiceInfos } from '@cpn-console/hooks' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import type { ProjectWithDetails, PublicCluster } from './project-services-queries.utils' @@ -43,7 +43,7 @@ describe('servicesService', () => { let serviceImgSrc: string let project: ProjectWithDetails let cluster: PublicCluster - let plugin: ServicePlugin + let plugin: Plugin beforeEach(async () => { projectId = faker.string.uuid() @@ -80,12 +80,12 @@ describe('servicesService', () => { }) it('get returns the service with actual hooks data', async () => { - prisma.project.findUnique.mockResolvedValue(project) + prisma.project.findUnique.mockResolvedValue(project as any) prisma.projectPlugin.findMany.mockResolvedValue([ - makeProjectPlugin({ pluginName, value: ENABLED }), + makeProjectPlugin({ projectId, pluginName, value: ENABLED }), ]) prisma.adminPlugin.findMany.mockResolvedValue([]) - prisma.cluster.findMany.mockResolvedValue([cluster]) + prisma.cluster.findMany.mockResolvedValue([cluster as any]) const result = await service.get(projectId, 'user') const e2eService = result.find(entry => entry.name === pluginName) @@ -126,7 +126,7 @@ describe('servicesService', () => { })), }) - prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones) + prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones as any) prisma.projectPlugin.findMany.mockResolvedValue([]) prisma.adminPlugin.findMany.mockResolvedValue([]) prisma.cluster.findMany.mockResolvedValue([]) diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.ts index e1168469de..8469ab9cee 100644 --- a/apps/server-nestjs/src/modules/project/project-queries.utils.ts +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -215,6 +215,13 @@ export const projectForUpsertSelect = { slug: true, status: true, locked: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, } satisfies Prisma.ProjectSelect export type ProjectWithDetails = Prisma.ProjectGetPayload<{ diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts index 130d11d0c7..19e7f0b594 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,12 +1,26 @@ import { Module } from '@nestjs/common' import { AppEventsModule } from '../events/app-events.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { LogModule } from '../log/log.module' import { ProjectController } from './project.controller' import { ProjectService } from './project.service' @Module({ - imports: [AppEventsModule, InfrastructureModule, LogModule], + imports: [ + AppEventsModule, + AuthModule, + ConfigurationModule, + DatabaseModule, + EventsModule, + ProjectPermissionModule, + UserPermissionModule, + LogModule, + ], controllers: [ProjectController], providers: [ProjectService], exports: [ProjectService], diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts index e70e9724dd..4423eada38 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -15,39 +15,35 @@ const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').t const server = setupServer() -function createRegistryServiceTestingModule() { - return Test.createTestingModule({ - providers: [ - RegistryClientService, - RegistryHttpClientService, - { - provide: VaultClientService, - useValue: {}, - }, - { - provide: ConfigurationService, - useValue: { - harborUrl, - harborInternalUrl: harborUrl, - harborAdmin: 'admin', - harborAdminPassword, - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - projectRootDir: 'forge', - } satisfies Partial, - }, - ], - }) -} - describe('registryService', () => { let service: RegistryClientService beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const module = await createRegistryServiceTestingModule().compile() + const module = await Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: ConfigurationService, + useValue: { + harborUrl, + harborInternalUrl: harborUrl, + harborAdmin: 'admin', + harborAdminPassword, + harborRuleTemplate: 'latestPushedK', + harborRuleCount: '10', + harborRetentionCron: '0 22 2 * * *', + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }).compile() service = module.get(RegistryClientService) }) diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.ts index 67c6196dee..609d413cb6 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -2,15 +2,6 @@ import type { RegistryResponse } from './registry-http-client.service' import { Inject, Injectable } from '@nestjs/common' import { RegistryHttpClientService } from './registry-http-client.service' -export interface HarborAccess { - resource: string - action: string -} - -export const roRobotName = 'ro-robot' -export const rwRobotName = 'rw-robot' -export const projectRobotName = 'project-robot' - export const roAccess: HarborAccess[] = [ { resource: 'repository', action: 'pull' }, { resource: 'artifact', action: 'read' }, @@ -30,6 +21,11 @@ export const rwAccess: HarborAccess[] = [ { resource: 'tag', action: 'delete' }, ] +export interface HarborAccess { + resource: string + action: string +} + export interface HarborProject { project_id?: number metadata?: { diff --git a/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts index 24cfbf6f2e..952f4e1204 100644 --- a/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -1,15 +1,14 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' -import { REGISTRY_PLUGIN_NAME } from './registry.constants' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './registry.constants' export const projectSelect = { slug: true, plugins: { - where: { - pluginName: REGISTRY_PLUGIN_NAME, - }, select: { + pluginName: true, key: true, value: true, }, @@ -25,36 +24,49 @@ export class RegistryDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async getAllProjects(): Promise { - return await this.prisma.project.findMany({ + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAutoSyncProjects(): Promise { + return this.prisma.project.findMany({ select: projectSelect, where: { - plugins: { - some: { - pluginName: REGISTRY_PLUGIN_NAME, + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, }, - }, + ], }, }) } async getProject(id: string): Promise { - return await this.prisma.project.findUnique({ - where: { id }, - select: projectSelect, - }) + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) } async getAdminPluginConfig(pluginName: string, key: string): Promise { const result = await this.prisma.adminPlugin.findUnique({ - where: { - pluginName_key: { - pluginName, - key, - }, - }, - select: { - value: true, - }, + where: { pluginName_key: { pluginName, key } }, + select: { value: true }, }) return result?.value ?? null } diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts index 1ac9a3d80d..31a8de8155 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -1,11 +1,12 @@ import type { ServiceInfos } from '@cpn-console/hooks' import type { Cache } from 'cache-manager' -import { DISABLED } from '@cpn-console/shared' +import { DISABLED, ENABLED } from '@cpn-console/shared' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' +import { AUTO_SYNC_PLUGIN_KEY, SUSPENDED_PLUGIN_KEY } from './registry.constants' import { createProjectSlugCacheKey } from './registry.utils' @Injectable() @@ -82,38 +83,66 @@ export class RegistryPluginService { imgSrc: '/img/harbor.svg', description: 'Harbor stocke, analyse et distribue vos images de conteneurs', config: { - project: [{ - permissions: { - admin: { read: false, write: false }, - user: { read: false, write: false }, + project: [ + { + kind: 'switch', + key: SUSPENDED_PLUGIN_KEY, + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Suspendre le projet', + value: ENABLED, + description: 'Suspendre la synchronisation Harbor pour ce projet', }, - key: 'projectId', - kind: 'text', - title: 'Num du projet Harbor', - value: '', - }, { - kind: 'switch', - key: 'publishProjectRobot', - initialValue: DISABLED, - title: 'Publication du robot projet', - description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', - permissions: { - admin: { read: true, write: true }, - user: { read: true, write: false }, + { + kind: 'switch', + key: AUTO_SYNC_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Synchronisation automatique Harbor', + value: DISABLED, + description: 'Synchroniser automatiquement le projet Harbor', }, - value: DISABLED, - }, { - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: true, write: false }, + { + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', }, - key: 'quotaHardLimit', - title: 'Quota', - value: '', - description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, - placeholder: '', - }], + { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, + { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }, + ], global: [{ kind: 'switch', key: 'publishProjectRobot', diff --git a/apps/server-nestjs/src/modules/registry/registry.constants.ts b/apps/server-nestjs/src/modules/registry/registry.constants.ts index d2f6874c16..2faf7709b4 100644 --- a/apps/server-nestjs/src/modules/registry/registry.constants.ts +++ b/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -1,5 +1,5 @@ // Registry plugin identification -export const REGISTRY_PLUGIN_NAME = 'harbor' +export const PLUGIN_NAME = 'harbor' // Registry configuration keys export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' @@ -25,9 +25,27 @@ export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintai export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' +// Registry robot name identifiers +export const ROBOT_NAME_RO = 'ro-robot' +export const ROBOT_NAME_RW = 'rw-robot' +export const ROBOT_NAME_PROJECT = 'project-robot' + +// Allowed Harbor retention rule templates +export const ALLOWED_RETENTION_RULE_TEMPLATES = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + // Harbor role identifiers export const HARBOR_ROLE_PROJECT_ADMIN = 1 export const HARBOR_ROLE_DEVELOPER = 2 export const HARBOR_ROLE_GUEST = 3 export const HARBOR_ROLE_MAINTAINER = 4 export const HARBOR_ROLE_LIMITED_GUEST = 5 + +// Plugin synchronization flags (shared plugin config keys) +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts index 488d2e2a6b..b205b1babb 100644 --- a/apps/server-nestjs/src/modules/registry/registry.module.ts +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -1,8 +1,8 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' @@ -12,8 +12,8 @@ import { RegistryPluginService } from './registry-plugin.service' import { RegistryService } from './registry.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule, CacheModule.register()], - providers: [HealthIndicatorService, RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule, CacheModule.register()], + providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], exports: [RegistryHealthService, RegistryPluginService, RegistryService], }) export class RegistryModule {} diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 4d623d543b..1e409929fd 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -1,95 +1,68 @@ -import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' -import { projectRobotName, RegistryClientService } from './registry-client.service' +import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' import { REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, } from './registry.constants' import { RegistryService } from './registry.service' -function createRegistryControllerServiceTestingModule() { - return Test.createTestingModule({ - providers: [ - RegistryService, - { - provide: RegistryClientService, - useValue: { - getProjectRobots: vi.fn(), - createRobot: vi.fn(), - deleteRobot: vi.fn(), - getGroupMembers: vi.fn(), - addGroupMember: vi.fn(), - removeGroupMember: vi.fn(), - getProjectByName: vi.fn(), - listQuotas: vi.fn(), - updateQuota: vi.fn(), - createProject: vi.fn(), - getRetentionId: vi.fn(), - updateRetention: vi.fn(), - createRetention: vi.fn(), - deleteProjectByName: vi.fn(), - } satisfies Partial, - }, - { - provide: RegistryDatastoreService, - useValue: { - getAdminPluginConfig: vi.fn(), - getAllProjects: vi.fn(), - } satisfies Partial, - }, - { - provide: VaultClientService, - useValue: { - read: vi.fn(), - write: vi.fn(), - delete: vi.fn(), - } satisfies Partial, - }, - { - provide: ConfigurationService, - useValue: { - harborUrl: 'https://harbor.example', - harborInternalUrl: 'https://harbor.example', - harborAdmin: 'admin', - harborAdminPassword: faker.internet.password(), - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - harborRobotRotationThresholdDays: 90, - projectRootDir: 'forge', - } satisfies Partial, - }, - ], - }) -} - describe('registryService', () => { let service: RegistryService - let registry: Mocked - let vault: Mocked - let registryDatastore: Mocked + let client: DeepMockProxy + let datastore: DeepMockProxy + let registry: DeepMockProxy + let registryDatastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - const moduleRef = await createRegistryControllerServiceTestingModule().compile() - service = moduleRef.get(RegistryService) - registry = moduleRef.get(RegistryClientService) - vault = moduleRef.get(VaultClientService) - registryDatastore = moduleRef.get(RegistryDatastoreService) + client = mockDeep() + datastore = mockDeep() + registry = client + registryDatastore = datastore + vault = mockDeep() + config = mockDeep({ + harborUrl: 'https://harbor.example', + harborInternalUrl: 'https://harbor.example', + harborAdmin: 'admin', + harborAdminPassword: faker.internet.password(), + harborRuleTemplate: 'latestPushedK', + harborRuleCount: '10', + harborRetentionCron: '0 22 2 * * *', + harborRobotRotationThresholdDays: 90, + projectRootDir: 'forge', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryService, + { provide: RegistryClientService, useValue: client }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() - registryDatastore.getAdminPluginConfig.mockResolvedValue(null) + service = module.get(RegistryService) - registry.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })) - registry.listQuotas.mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + // Setup default mocks + datastore.getAdminPluginConfig.mockResolvedValue(null) - registry.getRetentionId.mockResolvedValue(null) - registry.createRetention.mockResolvedValue(makeCreatedResponse(null)) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })) + client.listQuotas.mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + client.getRetentionId.mockResolvedValue(null) + client.createRetention.mockResolvedValue(makeCreatedResponse(null)) vault.read.mockResolvedValue(makeVaultSecret({ data: { @@ -101,11 +74,10 @@ describe('registryService', () => { })) vault.write.mockResolvedValue(undefined) - registry.getGroupMembers.mockResolvedValue(makeOkResponse([])) - registry.addGroupMember.mockResolvedValue(makeCreatedResponse(null)) - registry.removeGroupMember.mockResolvedValue(makeNoContent()) - - registry.deleteProjectByName.mockResolvedValue(makeNoContent()) + client.getGroupMembers.mockResolvedValue(makeOkResponse([])) + client.addGroupMember.mockResolvedValue(makeCreatedResponse(null)) + client.removeGroupMember.mockResolvedValue(makeNoContent()) + client.deleteProjectByName.mockResolvedValue(makeNoContent()) }) it('should be defined', () => { @@ -130,9 +102,9 @@ describe('registryService', () => { { groupName: `/${project.slug}/console/admin`, roleId: 2 }, ] - expect(registry.addGroupMember).toHaveBeenCalledTimes(expected.length) + expect(client.addGroupMember).toHaveBeenCalledTimes(expected.length) for (const e of expected) { - expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { role_id: e.roleId, member_group: { group_name: e.groupName, @@ -144,14 +116,14 @@ describe('registryService', () => { it('reconciles an existing group membership when role differs', async () => { const project = makeProjectWithDetails() - registry.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + client.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ { id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 }, ])) await service.handleUpsert(project) - expect(registry.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) - expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, { + expect(client.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { role_id: 2, member_group: { group_name: `/${project.slug}/console/admin`, @@ -162,7 +134,7 @@ describe('registryService', () => { it('returns a KO result when project admin membership creation fails', async () => { const project = makeProjectWithDetails() - registry.addGroupMember.mockImplementation(async (_projectName, body) => { + client.addGroupMember.mockImplementation(async (_projectName, body) => { if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { return { status: 400, data: null } } @@ -176,7 +148,7 @@ describe('registryService', () => { }), }) - expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { role_id: 2, member_group: { group_name: `/${project.slug}/console/admin`, @@ -186,7 +158,7 @@ describe('registryService', () => { }) it('updates quota when it differs', async () => { - registry.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + client.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) await service.handleUpsert(makeProjectWithDetails({ slug: 'myproj', @@ -195,7 +167,7 @@ describe('registryService', () => { ], })) - expect(registry.updateQuota).toHaveBeenCalledWith(123, 1024) + expect(client.updateQuota).toHaveBeenCalledWith(123, 1024) }) it('reuses robot secret when vault secret host matches', async () => { @@ -206,9 +178,9 @@ describe('registryService', () => { expect(vault.read).toHaveBeenCalledTimes(2) expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) - expect(registry.getProjectRobots).not.toHaveBeenCalled() - expect(registry.createRobot).not.toHaveBeenCalled() - expect(registry.deleteRobot).not.toHaveBeenCalled() + expect(client.getProjectRobots).not.toHaveBeenCalled() + expect(client.createRobot).not.toHaveBeenCalled() + expect(client.deleteRobot).not.toHaveBeenCalled() expect(vault.write).not.toHaveBeenCalled() }) @@ -235,14 +207,14 @@ describe('registryService', () => { }) }) - registry.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: `robot$${project.slug}+ro-robot` }])) - registry.deleteRobot.mockResolvedValue(makeNoContent()) - registry.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + client.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: `robot$${project.slug}+ro-robot` }])) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) await service.handleUpsert(project) - expect(registry.deleteRobot).toHaveBeenCalledWith(project.slug, 11) - expect(registry.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(client.deleteRobot).toHaveBeenCalledWith(project.slug, 11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ HOST: 'harbor.example', USERNAME: `robot$${project.slug}+ro-robot`, @@ -274,14 +246,14 @@ describe('registryService', () => { }) }) - registry.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: `robot$${project.slug}+ro-robot` }])) - registry.deleteRobot.mockResolvedValue(makeNoContent()) - registry.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + client.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: `robot$${project.slug}+ro-robot` }])) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) await service.handleUpsert(project) - expect(registry.deleteRobot).toHaveBeenCalledWith(project.slug, 11) - expect(registry.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(client.deleteRobot).toHaveBeenCalledWith(project.slug, 11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ HOST: 'harbor.example', USERNAME: `robot$${project.slug}+ro-robot`, @@ -292,32 +264,32 @@ describe('registryService', () => { it('parses plugin config and enables project robot publishing', async () => { const project = makeProjectWithDetails({ plugins: [ - { key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, - { key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: 'enabled' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: ENABLED }, ], }) - registry.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) await service.handleUpsert(project) - expect(registry.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(client.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) - expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${projectRobotName}`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${ROBOT_NAME_PROJECT}`) }) }) describe('handleCron', () => { it('should reconcile all projects', async () => { - registryDatastore.getAllProjects.mockResolvedValue([ + datastore.getAutoSyncProjects.mockResolvedValue([ makeProjectWithDetails({ slug: 'project-1' }), makeProjectWithDetails({ slug: 'project-2' }), ]) await service.handleCron() - expect(registry.getGroupMembers).toHaveBeenCalledWith('project-1') - expect(registry.getGroupMembers).toHaveBeenCalledWith('project-2') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-2') }) }) @@ -325,13 +297,13 @@ describe('registryService', () => { it('should delete project when it exists', async () => { const project = makeProjectWithDetails() await service.handleDelete(project) - expect(registry.deleteProjectByName).toHaveBeenCalledWith(project.slug) + expect(client.deleteProjectByName).toHaveBeenCalledWith(project.slug) }) it('should not delete project when it does not exist', async () => { - registry.getProjectByName.mockResolvedValueOnce({ status: 404, data: null }) + client.getProjectByName.mockResolvedValueOnce({ status: 404, data: null }) await service.handleDelete(makeProjectWithDetails()) - expect(registry.deleteProjectByName).not.toHaveBeenCalled() + expect(client.deleteProjectByName).not.toHaveBeenCalled() }) }) }) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index e1f13207b4..855033cf4c 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -19,9 +19,10 @@ import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' -import { projectRobotName, RegistryClientService, roAccess, roRobotName, rwAccess, rwRobotName } from './registry-client.service' +import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { + ALLOWED_RETENTION_RULE_TEMPLATES, DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, DEFAULT_PLATFORM_GUEST_GROUP_PATHS, DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, @@ -34,25 +35,20 @@ import { HARBOR_ROLE_PROJECT_ADMIN, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, - REGISTRY_PLUGIN_NAME, + ROBOT_NAME_PROJECT, + ROBOT_NAME_RO, + ROBOT_NAME_RW, } from './registry.constants' -import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, isSuspended, parseBytes } from './registry.utils' -const allowedRuleTemplates = [ - 'always', - 'latestPulledK', - 'latestPushedK', - 'nDaysSinceLastPull', - 'nDaysSinceLastPush', -] as const - -type RuleTemplate = typeof allowedRuleTemplates[number] +type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] @Injectable() export class RegistryService { @@ -285,12 +281,12 @@ export class RegistryService { const harborProjectId = Number(harborProject.project_id) await Promise.all([ - this.ensureRobotSecret(project, roRobotName, roAccess), - this.ensureRobotSecret(project, rwRobotName, rwAccess), + this.ensureRobotSecret(project, ROBOT_NAME_RO, roAccess), + this.ensureRobotSecret(project, ROBOT_NAME_RW, rwAccess), this.ensureProjectGroupMembers(project), Number.isFinite(harborProjectId) ? this.ensureRetentionPolicy(project, harborProjectId) : Promise.resolve(), options.publishProjectRobot - ? this.ensureRobotSecret(project, projectRobotName, roAccess) + ? this.ensureRobotSecret(project, ROBOT_NAME_PROJECT, roAccess) : Promise.resolve(), ]) @@ -322,6 +318,10 @@ export class RegistryService { @StartActiveSpan() private async syncProject(project: ProjectWithDetails) { + if (isSuspended(project)) { + this.logger.log(`Skipping ArgoCD sync for suspended project ${project.slug}`) + return + } const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project upsert for ${project.slug}`) @@ -346,18 +346,18 @@ export class RegistryService { await this.deleteProject(project.slug) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Registry reconciliation') - const projects = await this.registryDatastore.getAllProjects() + const projects = await this.registryDatastore.getAutoSyncProjects() span?.setAttribute('registry.projects.count', projects.length) await Promise.all(projects.map(p => this.ensureProject(p))) } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { - const adminPluginConfig = await this.registryDatastore.getAdminPluginConfig(REGISTRY_PLUGIN_NAME, key) + const adminPluginConfig = await this.registryDatastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return getPluginConfig(project, key) } @@ -525,7 +525,7 @@ function generateRetentionPolicy( function isRuleTemplate(value: unknown): value is RuleTemplate { if (typeof value !== 'string') return false - for (const template of allowedRuleTemplates) { + for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { if (template === value) return true } return false diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index 8bbe97eb7e..eb895dfde9 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -1,10 +1,19 @@ import type { ProjectWithDetails } from './registry-datastore.service' -import { removeTrailingSlash } from '@cpn-console/shared' +import { ENABLED, removeTrailingSlash } from '@cpn-console/shared' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './registry.constants' export function createProjectSlugCacheKey(projectId: string) { return `registry:project-slug:${projectId}` } +export function isSuspended(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === SUSPENDED_PLUGIN_KEY && p.value === ENABLED) ?? false +} + +export function isAutoSync(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === AUTO_SYNC_PLUGIN_KEY && p.value === ENABLED) ?? false +} + const protocolPrefixRegex = /^https?:\/\//u const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u diff --git a/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts index 6c1a320185..537261b97a 100644 --- a/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts +++ b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -1,12 +1,23 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { OpenCdsClientService } from './open-cds-client.service' import { ServiceChainController } from './service-chain.controller' import { ServiceChainService } from './service-chain.service' @Module({ - imports: [InfrastructureModule], + imports: [ + AuthModule, + ConfigurationModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ], controllers: [ServiceChainController], providers: [OpenCdsClientService, ServiceChainService], + exports: [ServiceChainService], }) export class ServiceChainModule {} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts index 66689ec44b..367b990d0f 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -4,6 +4,7 @@ import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' @@ -16,28 +17,25 @@ const sonarAuthHeader = `Bearer ${sonarBearerToken}` const server = setupServer() -function createTestingModule() { - return Test.createTestingModule({ - providers: [ - SonarqubeClientService, - SonarqubeHttpClientService, - { - provide: ConfigurationService, - useValue: { - sonarApiToken: sonarToken, - getInternalOrPublicSonarqubeUrl: () => sonarUrl, - } satisfies Partial, - }, - ], - }) -} - describe('sonarqubeClientService', () => { let service: SonarqubeClientService + let config: ReturnType> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const module = await createTestingModule().compile() + config = mockDeep({ + sonarApiToken: sonarToken, + getInternalOrPublicSonarqubeUrl: () => sonarUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() + service = module.get(SonarqubeClientService) }) afterEach(() => server.resetHandlers()) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts index 07f4445937..c327630f63 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -1,3 +1,14 @@ +import type { + SONARQUBE_PROJECT_QUALIFIER_APPLICATION, + SONARQUBE_PROJECT_QUALIFIER_BRANCH, + SONARQUBE_PROJECT_QUALIFIER_DIRECTORY, + SONARQUBE_PROJECT_QUALIFIER_FILE, + SONARQUBE_PROJECT_QUALIFIER_LIBRARY, + SONARQUBE_PROJECT_QUALIFIER_PROJECT, + SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW, + SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST, + SONARQUBE_PROJECT_QUALIFIER_VIEW, +} from './sonarqube.constants' import { Inject, Injectable } from '@nestjs/common' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' @@ -29,16 +40,6 @@ export interface SonarqubeUser { managed: boolean } -export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' -export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' -export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' -export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' -export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' -export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' -export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' -export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' -export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' - export type SonarqubeProjectQualifier = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts index 3c786a7dc7..84e6958307 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -1,7 +1,8 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' -import { SONARQUBE_PLUGIN_NAME } from './sonarqube.constants' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './sonarqube.constants' export const projectSelect = { id: true, @@ -12,10 +13,8 @@ export const projectSelect = { }, }, plugins: { - where: { - pluginName: SONARQUBE_PLUGIN_NAME, - }, select: { + pluginName: true, key: true, value: true, }, @@ -31,23 +30,43 @@ export class SonarqubeDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAutoSyncProjects(): Promise { return this.prisma.project.findMany({ select: projectSelect, where: { - plugins: { - some: { - pluginName: SONARQUBE_PLUGIN_NAME, + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, }, - }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], }, }) } async getProject(id: string): Promise { - return this.prisma.project.findUnique({ - where: { id }, - select: projectSelect, - }) + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) } async getAdminPluginConfig( diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts index 9a31e60496..ae8932fa64 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -1,6 +1,8 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { AUTO_SYNC_PLUGIN_KEY, SUSPENDED_PLUGIN_KEY } from './sonarqube.constants' @Injectable() export class SonarqubePluginService { @@ -19,6 +21,35 @@ export class SonarqubePluginService { title: 'SonarQube', imgSrc: '/img/sonarqube.svg', description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', + config: { + global: [], + project: [ + { + kind: 'switch', + key: SUSPENDED_PLUGIN_KEY, + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Suspendre le projet', + value: ENABLED, + description: 'Suspendre la synchronisation SonarQube pour ce projet', + }, + { + kind: 'switch', + key: AUTO_SYNC_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Synchronisation automatique SonarQube', + value: DISABLED, + description: 'Synchroniser automatiquement le projet SonarQube', + }, + ], + }, } } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts index d1e78ebea4..0b7e8c0bbc 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -1,7 +1,7 @@ import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' import type { ProjectWithDetails } from './sonarqube-datastore.service' import { faker } from '@faker-js/faker' -import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube-client.service' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' export function makeUserToken(overrides: Partial = {}) { return { diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index dce1b44ec3..7598656971 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -1,4 +1,4 @@ -export const SONARQUBE_PLUGIN_NAME = 'sonarqube' +export const PLUGIN_NAME = 'sonarqube' export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' // SonarQube global permission names @@ -38,3 +38,18 @@ export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' + +// SonarQube project qualifier identifiers +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' + +// Plugin synchronization flags (shared plugin config keys) +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts index ee9e401918..17499fdda4 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeDatastoreService } from './sonarqube-datastore.service' @@ -11,13 +11,12 @@ import { SonarqubePluginService } from './sonarqube-plugin.service' import { SonarqubeService } from './sonarqube.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule], + imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], providers: [ - HealthIndicatorService, + SonarqubeHealthService, SonarqubeHttpClientService, SonarqubeClientService, SonarqubeDatastoreService, - SonarqubeHealthService, SonarqubePluginService, SonarqubeService, ], diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index f5f2e923bb..9ccf1fd015 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -1,10 +1,11 @@ -import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' -import { SONARQUBE_PROJECT_QUALIFIER_PROJECT, SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeDatastoreService } from './sonarqube-datastore.service' import { makeEmptyGroupsResponse, @@ -15,72 +16,36 @@ import { makeSonarqubeUser, makeUserToken, } from './sonarqube-testing.utils' +import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' import { SonarqubeService } from './sonarqube.service' -function createTestingModule() { - return Test.createTestingModule({ - providers: [ - SonarqubeService, - { - provide: SonarqubeClientService, - useValue: { - searchUserGroup: vi.fn(), - createUserGroup: vi.fn(), - createPermissionTemplate: vi.fn(), - setPermissionDefaultTemplate: vi.fn(), - addPermissionProjectCreatorToTemplate: vi.fn(), - addPermissionGroupToTemplate: vi.fn(), - addPermissionGroup: vi.fn(), - addPermissionUser: vi.fn(), - searchUsers: vi.fn(), - createUser: vi.fn(), - deactivateUser: vi.fn(), - revokeUserToken: vi.fn(), - generateUserToken: vi.fn(), - searchProject: vi.fn(), - createProject: vi.fn(), - deleteProject: vi.fn(), - } satisfies Partial, - }, - { - provide: SonarqubeDatastoreService, - useValue: { - getAllProjects: vi.fn(), - getProject: vi.fn(), - getAdminPluginConfig: vi.fn(), - } satisfies Partial, - }, - { - provide: VaultClientService, - useValue: { - readSonarqubeUser: vi.fn(), - writeSonarqubeUser: vi.fn(), - deleteSonarqubeUser: vi.fn(), - } satisfies Partial, - }, - { - provide: ConfigurationService, - useValue: { - projectRootDir: 'forge', - getInternalOrPublicSonarqubeUrl: () => 'https://sonarqube.internal', - } satisfies Partial, - }, - ], - }) -} - describe('sonarqubeService', () => { let service: SonarqubeService - let client: Mocked - let datastore: Mocked - let vault: Mocked + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - const module = await createTestingModule().compile() + client = mockDeep() + datastore = mockDeep() + vault = mockDeep() + config = mockDeep({ + projectRootDir: 'forge', + getInternalOrPublicSonarqubeUrl: () => 'https://sonarqube.internal', + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() + service = module.get(SonarqubeService) - client = module.get(SonarqubeClientService) - datastore = module.get(SonarqubeDatastoreService) - vault = module.get(VaultClientService) datastore.getAdminPluginConfig.mockResolvedValue(null) client.searchUserGroup.mockResolvedValue(makeEmptyGroupsResponse()) @@ -132,15 +97,21 @@ describe('sonarqubeService', () => { }) it('should skip initialization when URL is not configured', async () => { + const localConfig = mockDeep({ + projectRootDir: 'forge', + getInternalOrPublicSonarqubeUrl: () => undefined, + }) + const module = await Test.createTestingModule({ providers: [ SonarqubeService, { provide: SonarqubeClientService, useValue: client }, { provide: SonarqubeDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: { getInternalOrPublicSonarqubeUrl: () => undefined } }, + { provide: ConfigurationService, useValue: localConfig }, ], }).compile() + await module.get(SonarqubeService).init() expect(client.createPermissionTemplate).not.toHaveBeenCalled() }) @@ -246,7 +217,7 @@ describe('sonarqubeService', () => { it('should use comma-separated group path suffixes from project plugin config', async () => { const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }], - plugins: [{ key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], }) client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) @@ -289,7 +260,7 @@ describe('sonarqubeService', () => { makeProjectWithDetails({ repositories: [] }), makeProjectWithDetails({ repositories: [] }), ] - datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAutoSyncProjects.mockResolvedValue(projects) client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) await service.handleCron() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index 3c78c7c83f..be9de619b0 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -26,6 +26,7 @@ import { DEFAULT_SECURITY_GROUP_PATH, DEFAULT_TEMPLATE_PERMISSIONS, GLOBAL_ADMIN_PERMISSIONS, + PLUGIN_NAME, PROJECT_ADMIN_PERMISSIONS, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, PROJECT_DEVELOPER_PERMISSIONS, @@ -39,8 +40,8 @@ import { READONLY_GROUP_PATH_PLUGIN_KEY, ROBOT_PROJECT_PERMISSIONS, SECURITY_GROUP_PATH_PLUGIN_KEY, - SONARQUBE_PLUGIN_NAME, } from './sonarqube.constants' +import { isSuspended } from './sonarqube.utils' interface SonarqubeRolePaths { admin: string[] @@ -95,6 +96,10 @@ export class SonarqubeService implements OnModuleInit { @StartActiveSpan() private async syncProject(project: ProjectWithDetails) { + if (isSuspended(project)) { + this.logger.log(`Skipping ArgoCD sync for suspended project ${project.slug}`) + return + } const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling a project upsert event for ${project.slug}`) @@ -116,13 +121,13 @@ export class SonarqubeService implements OnModuleInit { this.logger.log(`SonarQube deletion completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting SonarQube reconciliation') await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) - const projects = await this.datastore.getAllProjects() + const projects = await this.datastore.getAutoSyncProjects() span?.setAttribute('sonarqube.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) await this.ensureProjectGroups(projects) @@ -309,22 +314,22 @@ export class SonarqubeService implements OnModuleInit { } private async getAdminGroupPath(): Promise { - const config = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) return config ?? DEFAULT_ADMIN_GROUP_PATH } private async getReadonlyGroupPath(): Promise { - const config = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) return config ?? DEFAULT_READONLY_GROUP_PATH } private async getSecurityGroupPath(): Promise { - const config = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) return config ?? DEFAULT_SECURITY_GROUP_PATH } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.datastore.getAdminPluginConfig(SONARQUBE_PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return getProjectPluginConfig(project, key) ?? undefined } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts index 8b8fc8adff..bc09de7162 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -1,6 +1,18 @@ +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { ENABLED } from '@cpn-console/shared' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './sonarqube.constants' + export function sonarProjectPropertiesFile(projectKey: string) { return [ `sonar.projectKey=${projectKey}`, 'sonar.qualitygate.wait=true', ] } + +export function isSuspended(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === SUSPENDED_PLUGIN_KEY && p.value === ENABLED) ?? false +} + +export function isAutoSync(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === AUTO_SYNC_PLUGIN_KEY && p.value === ENABLED) ?? false +} diff --git a/apps/server-nestjs/src/modules/system-config/system-config.module.ts b/apps/server-nestjs/src/modules/system-config/system-config.module.ts index 8436638a38..b0622abf84 100644 --- a/apps/server-nestjs/src/modules/system-config/system-config.module.ts +++ b/apps/server-nestjs/src/modules/system-config/system-config.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { SystemConfigController } from './system-config.controller' import { SystemConfigService } from './system-config.service' @Module({ - imports: [InfrastructureModule, UserPermissionModule], + imports: [DatabaseModule, UserPermissionModule], controllers: [SystemConfigController], providers: [SystemConfigService], }) diff --git a/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts b/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts index 8ec08c58c6..0ba0742991 100644 --- a/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts +++ b/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts @@ -24,9 +24,9 @@ describe('systemConfigService', () => { it('updates plugins config from body via transaction', async () => { prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) - prisma.adminPlugin.upsert.mockResolvedValue(null) + prisma.adminPlugin.upsert.mockResolvedValue({ pluginName: 'argocd', key: 'url', value: 'https://argocd' }) - await service.update({ global: { argocd: { url: 'https://argocd' } } }) + await service.update({ argocd: { url: 'https://argocd' } }) expect(prisma.$transaction).toHaveBeenCalledTimes(1) }) diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts index 50c1ca5ec9..704b575e14 100644 --- a/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { SystemSettingsController } from './system-settings.controller' import { SystemSettingsService } from './system-settings.service' @Module({ - imports: [InfrastructureModule], + imports: [DatabaseModule], controllers: [SystemSettingsController], providers: [SystemSettingsService], exports: [SystemSettingsService], diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts index a81a05b78e..54db1c52b4 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -25,32 +25,27 @@ const server = setupServer( }), ) -function createVaultServiceTestingModule(config: Partial = {}) { - return Test.createTestingModule({ - providers: [ - VaultClientService, - VaultHttpClientService, - { - provide: ConfigurationService, - useValue: mockDeep({ - vaultToken: 'token', - vaultUrl, - vaultInternalUrl: vaultUrl, - vaultKvName: 'kv', - getInternalOrPublicVaultUrl: () => vaultUrl, - ...config, - }), - }, - ], - }) -} - describe('vault', () => { let service: VaultClientService beforeAll(() => server.listen()) beforeEach(async () => { - const module = await createVaultServiceTestingModule().compile() + const config = mockDeep({ + vaultToken: 'token', + vaultUrl, + vaultInternalUrl: vaultUrl, + vaultKvName: 'kv', + getInternalOrPublicVaultUrl: () => vaultUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultClientService, + VaultHttpClientService, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() + service = module.get(VaultClientService) }) afterEach(() => server.resetHandlers()) diff --git a/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts index 6cfae8d9af..ffa452e721 100644 --- a/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -1,6 +1,8 @@ import type { Prisma } from '@prisma/client' +import { ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './vault.constant' export const projectSelect = { id: true, @@ -17,10 +19,8 @@ export const projectSelect = { environments: { select: { id: true, - name: true, clusterId: true, cpu: true, - gpu: true, memory: true, autosync: true, }, @@ -34,6 +34,15 @@ export type ProjectWithDetails = Prisma.ProjectGetPayload<{ export const zoneSelect = { id: true, slug: true, + clusters: { + select: { + projects: { + select: { + id: true, + }, + }, + }, + }, } satisfies Prisma.ZoneSelect export type ZoneWithDetails = Prisma.ZoneGetPayload<{ @@ -44,19 +53,46 @@ export type ZoneWithDetails = Prisma.ZoneGetPayload<{ export class VaultDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} - async getAllProjects(): Promise { + async getAutoSyncProjects(): Promise { return this.prisma.project.findMany({ select: projectSelect, + where: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], + }, }) } - async getProject(id: string): Promise { - return this.prisma.project.findUnique({ - where: { id }, + async getAllProjects(): Promise { + return this.prisma.project.findMany({ select: projectSelect, }) } + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + async getAdminPluginConfig(pluginName: string, key: string): Promise { const result = await this.prisma.adminPlugin.findUnique({ where: { @@ -77,4 +113,44 @@ export class VaultDatastoreService { select: zoneSelect, }) } + + async getAutoSyncZones(): Promise { + return this.prisma.zone.findMany({ + where: { + clusters: { + some: { + environments: { + some: { + project: { + AND: [ + { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: AUTO_SYNC_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + { + NOT: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + key: SUSPENDED_PLUGIN_KEY, + value: ENABLED, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + select: zoneSelect, + }) + } } diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts index 838747d885..661c012bed 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -1,6 +1,8 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { AUTO_SYNC_PLUGIN_KEY, SUSPENDED_PLUGIN_KEY } from './vault.constant' @Injectable() export class VaultPluginService { @@ -19,6 +21,35 @@ export class VaultPluginService { title: 'Vault', imgSrc: '/img/vault.svg', description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', + config: { + global: [], + project: [ + { + kind: 'switch', + key: SUSPENDED_PLUGIN_KEY, + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Suspendre le projet', + value: ENABLED, + description: 'Suspendre la synchronisation Vault pour ce projet', + }, + { + kind: 'switch', + key: AUTO_SYNC_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Synchronisation automatique Vault', + value: DISABLED, + description: 'Synchroniser automatiquement le projet Vault', + }, + ], + }, } } } diff --git a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts index cd9f435435..2397b97bdc 100644 --- a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -18,6 +18,7 @@ export function makeZoneWithDetails(overrides: Partial = {}): Z return { id: faker.string.uuid(), slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], ...overrides, } satisfies ZoneWithDetails } diff --git a/apps/server-nestjs/src/modules/vault/vault.constant.ts b/apps/server-nestjs/src/modules/vault/vault.constant.ts index 8b720572dc..9a5ddbd417 100644 --- a/apps/server-nestjs/src/modules/vault/vault.constant.ts +++ b/apps/server-nestjs/src/modules/vault/vault.constant.ts @@ -1,5 +1,5 @@ // Plugin name used to identify the Vault integration -export const VAULT_PLUGIN_NAME = 'vault' +export const PLUGIN_NAME = 'vault' // Platform-level policy names for RBAC export const PLATFORM_ADMIN_POLICY_NAME = 'platform--admin' @@ -32,3 +32,7 @@ export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperG export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' export const PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectSecurityGroupPathSuffix' + +// Plugin synchronization flags (shared plugin config keys) +export const AUTO_SYNC_PLUGIN_KEY = 'autoSync' +export const SUSPENDED_PLUGIN_KEY = 'suspended' diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts index 99bd187262..0b1d8281ea 100644 --- a/apps/server-nestjs/src/modules/vault/vault.module.ts +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' +import { TerminusModule } from '@nestjs/terminus' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' import { VaultHealthService } from './vault-health.service' @@ -10,9 +10,8 @@ import { VaultPluginService } from './vault-plugin.service' import { VaultService } from './vault.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule], + imports: [ConfigurationModule, DatabaseModule, TerminusModule], providers: [ - HealthIndicatorService, VaultHealthService, VaultHttpClientService, VaultClientService, diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index f5ccad9d0a..caccdc831d 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -1,8 +1,8 @@ -import type { TestingModule } from '@nestjs/testing' -import type { Mocked } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' @@ -11,58 +11,30 @@ import { VaultService } from './vault.service' const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ -function createVaultControllerServiceTestingModule() { - return Test.createTestingModule({ - providers: [ - VaultService, - { - provide: VaultClientService, - useValue: { - createSysMount: vi.fn(), - tuneSysMount: vi.fn(), - deleteSysMounts: vi.fn(), - upsertSysPoliciesAcl: vi.fn(), - deleteSysPoliciesAcl: vi.fn(), - upsertAuthApproleRole: vi.fn(), - deleteAuthApproleRole: vi.fn(), - upsertIdentityGroupName: vi.fn(), - getIdentityGroupName: vi.fn(), - deleteIdentityGroupName: vi.fn(), - getSysAuth: vi.fn(), - createIdentityGroupAlias: vi.fn(), - listKvMetadata: vi.fn(), - delete: vi.fn(), - } satisfies Partial, - }, - { - provide: VaultDatastoreService, - useValue: { - getAllProjects: vi.fn(), - getAllZones: vi.fn(), - getAdminPluginConfig: vi.fn(), - } satisfies Partial, - }, - { - provide: ConfigurationService, - useValue: { - projectRootDir: 'forge', - vaultKvName: 'kv', - } satisfies Partial, - }, - ], - }) -} - describe('vaultService', () => { let service: VaultService - let datastore: Mocked - let client: Mocked + let datastore: DeepMockProxy + let client: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - const module: TestingModule = await createVaultControllerServiceTestingModule().compile() + datastore = mockDeep() + client = mockDeep() + config = mockDeep({ + projectRootDir: 'forge', + vaultKvName: 'kv', + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultService, + { provide: VaultClientService, useValue: client }, + { provide: VaultDatastoreService, useValue: datastore }, + { provide: ConfigurationService, useValue: config }, + ], + }).compile() + service = module.get(VaultService) - datastore = module.get(VaultDatastoreService) - client = module.get(VaultClientService) datastore.getAdminPluginConfig.mockResolvedValue(null) client.createSysMount.mockResolvedValue(undefined) @@ -89,13 +61,13 @@ describe('vaultService', () => { const projects = faker.helpers.multiple(() => makeProjectWithDetails()) const zones = faker.helpers.multiple(() => makeZoneWithDetails()) - datastore.getAllProjects.mockResolvedValue(projects) - datastore.getAllZones.mockResolvedValue(zones) + datastore.getAutoSyncProjects.mockResolvedValue(projects) + datastore.getAutoSyncZones.mockResolvedValue(zones) await service.handleCron() - expect(datastore.getAllProjects).toHaveBeenCalled() - expect(datastore.getAllZones).toHaveBeenCalled() + expect(datastore.getAutoSyncProjects).toHaveBeenCalled() + expect(datastore.getAutoSyncZones).toHaveBeenCalled() expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) projects.forEach((project) => { expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 69a275e6c5..f192fc0351 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -26,15 +26,15 @@ import { PLATFORM_ADMIN_POLICY_NAME, PLATFORM_READONLY_POLICY_NAME, PLATFORM_SECURITY_POLICY_NAME, + PLUGIN_NAME, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, SECURITY_GROUP_PATH_PLUGIN_KEY, - VAULT_PLUGIN_NAME, } from './vault.constant' -import { generateProjectPath } from './vault.utils' +import { generateProjectPath, isSuspended } from './vault.utils' type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' @@ -57,6 +57,10 @@ export class VaultService { @StartActiveSpan() private async syncProject(project: ProjectWithDetails) { + if (isSuspended(project)) { + this.logger.log(`Skipping ArgoCD sync for suspended project ${project.slug}`) + return + } const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling a project upsert event for ${project.slug}`) @@ -109,16 +113,15 @@ export class VaultService { this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) + @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Vault reconciliation') const [projects, zones] = await Promise.all([ - this.vaultDatastore.getAllProjects(), - this.vaultDatastore.getAllZones(), + this.vaultDatastore.getAutoSyncProjects(), + this.vaultDatastore.getAutoSyncZones(), ]) - span?.setAttributes({ 'vault.projects.count': projects.length, 'vault.zones.count': zones.length, @@ -148,9 +151,9 @@ export class VaultService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.vaultDatastore.getAdminPluginConfig(VAULT_PLUGIN_NAME, key) + const adminPluginConfig = await this.vaultDatastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig - return project.plugins?.find(p => p.pluginName === VAULT_PLUGIN_NAME && p.key === key)?.value + return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value } private async getAdminGroupPath(project: ProjectWithDetails) { @@ -321,15 +324,15 @@ export class VaultService { const projectSecurityGroupPaths = generateProjectRoleGroupPaths(project, securityGroupPathSuffix) await Promise.all([ - this.createAppAdminPolicy(appPolicyName, project.slug), - this.createTechReadOnlyPolicy(techPolicyName, project.slug), - this.createProjectDevopsPolicy(projectDevopsPolicyName, project.slug), - this.createProjectDeveloperPolicy(projectDeveloperPolicyName, project.slug), - this.createProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), + this.ensureAppAdminPolicy(appPolicyName, project.slug), + this.ensureTechReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectDevopsPolicy(projectDevopsPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectDeveloperPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), this.createProjectSecurityPolicy(projectSecurityPolicyName, project.slug), - this.createPlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), - this.createPlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), - this.createPlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), + this.ensurePlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), + this.ensurePlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), + this.ensurePlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), @@ -425,13 +428,13 @@ export class VaultService { } } - async createAppAdminPolicy(name: string, projectSlug: string): Promise { + async ensureAppAdminPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, }) } - async createProjectDevopsPolicy(name: string, projectSlug: string): Promise { + async ensureProjectDevopsPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: [ `path "${projectSlug}/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, @@ -443,13 +446,7 @@ export class VaultService { }) } - async createProjectDeveloperPolicy(name: string, projectSlug: string): Promise { - await this.client.upsertSysPoliciesAcl(name, { - policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, - }) - } - - async createProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { + async ensureProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, }) @@ -464,13 +461,13 @@ export class VaultService { }) } - async createPlatformAdminPolicy(name: string): Promise { + async ensurePlatformAdminPolicy(name: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: `path "sys/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }`, }) } - async createPlatformReadOnlyPolicy(name: string): Promise { + async ensurePlatformReadOnlyPolicy(name: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: [ `path "sys/health" { capabilities = ["read"] }`, @@ -483,7 +480,7 @@ export class VaultService { }) } - async createPlatformSecurityPolicy(name: string): Promise { + async ensurePlatformSecurityPolicy(name: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: [ `path "sys/audit" { capabilities = ["read", "list"] }`, @@ -495,7 +492,7 @@ export class VaultService { }) } - async createTechReadOnlyPolicy(name: string, projectSlug: string): Promise { + async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { policy: `path "${this.config.vaultKvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, }) diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts index 576da08e24..6fe79a1908 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -1,3 +1,15 @@ +import type { ProjectWithDetails } from './vault-datastore.service' +import { ENABLED } from '@cpn-console/shared' +import { AUTO_SYNC_PLUGIN_KEY, PLUGIN_NAME, SUSPENDED_PLUGIN_KEY } from './vault.constant' + +export function isSuspended(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === SUSPENDED_PLUGIN_KEY && p.value === ENABLED) ?? false +} + +export function isAutoSync(project: ProjectWithDetails): boolean { + return project.plugins?.some(p => p.pluginName === PLUGIN_NAME && p.key === AUTO_SYNC_PLUGIN_KEY && p.value === ENABLED) ?? false +} + export function generateProjectPath(projectRootDir: string | undefined, projectSlug: string) { return projectRootDir ? `${projectRootDir}/${projectSlug}` diff --git a/apps/server-nestjs/test/argocd.e2e-spec.ts b/apps/server-nestjs/test/argocd.e2e-spec.ts index 953c656f46..381412c492 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -8,10 +8,14 @@ import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' import { ArgoCDModule } from '../src/modules/argocd/argocd.module' import { ArgoCDService } from '../src/modules/argocd/argocd.service' import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' const canRunArgoCDE2E @@ -55,7 +59,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ArgoCDModule, ConfigurationModule, InfrastructureModule], + imports: [ArgoCDModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/gitlab.e2e-spec.ts b/apps/server-nestjs/test/gitlab.e2e-spec.ts index dfe2367b7d..0d65d2fcf3 100644 --- a/apps/server-nestjs/test/gitlab.e2e-spec.ts +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -8,10 +8,14 @@ import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/g import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' import { GitlabModule } from '../src/modules/gitlab/gitlab.module' import { GitlabService } from '../src/modules/gitlab/gitlab.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' const canRunGitlabE2E @@ -41,7 +45,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [GitlabModule, ConfigurationModule, InfrastructureModule], + imports: [GitlabModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts index 518bfaa213..b1bbcc452b 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -5,9 +5,13 @@ import { Logger } from '@nestjs/common' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' @@ -38,7 +42,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [KeycloakModule, ConfigurationModule, InfrastructureModule], + imports: [KeycloakModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/log.e2e-spec.ts b/apps/server-nestjs/test/log.e2e-spec.ts index 0a7fe7f1db..2b5f509b3f 100644 --- a/apps/server-nestjs/test/log.e2e-spec.ts +++ b/apps/server-nestjs/test/log.e2e-spec.ts @@ -2,8 +2,12 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { LogModule } from '../src/modules/log/log.module' import { LogService } from '../src/modules/log/log.service' @@ -26,7 +30,7 @@ describeWithLog('LogService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [LogModule, InfrastructureModule], + imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/nexus.e2e-spec.ts b/apps/server-nestjs/test/nexus.e2e-spec.ts index 6b5c17099c..6a0970e48c 100644 --- a/apps/server-nestjs/test/nexus.e2e-spec.ts +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -1,15 +1,20 @@ import type { TestingModule } from '@nestjs/testing' +import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { NexusClientService } from '../src/modules/nexus/nexus-client.service' import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' -import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, NEXUS_PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' import { NexusModule } from '../src/modules/nexus/nexus.module' import { NexusService } from '../src/modules/nexus/nexus.service' import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' @@ -41,7 +46,7 @@ describeWithNexus('NexusController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [NexusModule, VaultModule, ConfigurationModule, InfrastructureModule], + imports: [NexusModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -99,8 +104,8 @@ describeWithNexus('NexusController (e2e)', () => { prodMemory: 0, plugins: { create: [ - { pluginName: NEXUS_PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: 'enabled' }, - { pluginName: NEXUS_PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: 'enabled' }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }, ], }, }, diff --git a/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/apps/server-nestjs/test/project-bulk.e2e-spec.ts index 5d32e6e2d1..7c5b4cae72 100644 --- a/apps/server-nestjs/test/project-bulk.e2e-spec.ts +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -3,9 +3,13 @@ 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 { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' @@ -25,7 +29,7 @@ describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectBulkModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/apps/server-nestjs/test/project-hooks.e2e-spec.ts index 6d9db11974..546b51b2fe 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -5,9 +5,13 @@ import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' @@ -35,7 +39,7 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { vaultClient = mockDeep() moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectHooksModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], providers: [ { provide: VaultService, useValue: vaultService }, { provide: VaultClientService, useValue: vaultClient }, diff --git a/apps/server-nestjs/test/project-members.e2e-spec.ts b/apps/server-nestjs/test/project-members.e2e-spec.ts index de825cdb79..0e1b16eb1b 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -6,9 +6,13 @@ import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' import { ProjectMembersService } from '../src/modules/project-members/project-members.service' @@ -32,7 +36,7 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { keycloakClient.getUserByEmail.mockResolvedValue(undefined) moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectMembersModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], providers: [ { provide: KeycloakClientService, useValue: keycloakClient }, ], diff --git a/apps/server-nestjs/test/project-roles.e2e-spec.ts b/apps/server-nestjs/test/project-roles.e2e-spec.ts index c7482ff291..49d23d4c5a 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -4,9 +4,13 @@ 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 { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' @@ -26,7 +30,7 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectRolesModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/apps/server-nestjs/test/project-secrets.e2e-spec.ts index 0d35a5a959..9007d876f5 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -2,9 +2,13 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectSecretsModule } from '../src/modules/project-secrets/project-secrets.module' import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' @@ -34,7 +38,7 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectSecretsModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-services.e2e-spec.ts b/apps/server-nestjs/test/project-services.e2e-spec.ts index 69b219e73f..86535aee82 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -3,9 +3,13 @@ import { faker } from '@faker-js/faker' import { NotFoundException } from '@nestjs/common' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' import { ProjectServicesService } from '../src/modules/project-services/project-services.service' @@ -35,7 +39,7 @@ const canRunServicesE2E const describeWithServices = describe.runIf(canRunServicesE2E) -const TEST_PLUGIN_NAME = 'gitlab' +const PLUGIN_NAME = 'gitlab' describeWithServices('ProjectServicesService (e2e)', {}, () => { let moduleRef: TestingModule @@ -47,7 +51,7 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectServicesModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], }).compile() await moduleRef.init() @@ -106,10 +110,10 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { it('get returns services for a project member', async () => { const services = await service.get(projectId, 'user') - const gitlabService = services.find(entry => entry.name === TEST_PLUGIN_NAME) + const gitlabService = services.find(entry => entry.name === PLUGIN_NAME) expect(gitlabService).toBeTruthy() - expect(gitlabService?.name).toBe(TEST_PLUGIN_NAME) + expect(gitlabService?.name).toBe(PLUGIN_NAME) }) it('rejects get when project does not exist', async () => { @@ -127,7 +131,7 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { where: { projectId_pluginName_key: { projectId, - pluginName: TEST_PLUGIN_NAME, + pluginName: PLUGIN_NAME, key: 'user.enabled', }, }, diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts index cc92067f29..a799f372ef 100644 --- a/apps/server-nestjs/test/project.e2e-spec.ts +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -5,11 +5,15 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' -import { ProjectModule } from '../src/modules/project/project.module' import { ProjectService } from '../src/modules/project/project.service' const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -26,7 +30,7 @@ describeWithProject('ProjectService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, InfrastructureModule, ProjectModule], + imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/registry.e2e-spec.ts b/apps/server-nestjs/test/registry.e2e-spec.ts index f970e8a1c8..c671217153 100644 --- a/apps/server-nestjs/test/registry.e2e-spec.ts +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -4,8 +4,9 @@ import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' -import { projectRobotName, RegistryClientService, roRobotName, rwRobotName } from '../src/modules/registry/registry-client.service' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' import { RegistryModule } from '../src/modules/registry/registry.module' import { RegistryService } from '../src/modules/registry/registry.service' import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' @@ -48,7 +49,7 @@ describeWithRegistry('RegistryService (e2e)', () => { afterAll(async () => { if (vault && config && projectSlug) { - const paths = [roRobotName, rwRobotName, projectRobotName].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) } @@ -69,17 +70,17 @@ describeWithRegistry('RegistryService (e2e)', () => { const robots = await client.getProjectRobots(projectSlug) expect(robots.status).toBe(200) const robotNames = (robots.data ?? []).flatMap(r => r.name ? [r.name] : []) - expect(robotNames).toContain(`robot$${projectSlug}+${roRobotName}`) - expect(robotNames).toContain(`robot$${projectSlug}+${rwRobotName}`) - expect(robotNames).toContain(`robot$${projectSlug}+${projectRobotName}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) - const vaultPaths = [roRobotName, rwRobotName, projectRobotName].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) expect(roSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) expect(rwSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) expect(projectSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) - expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${roRobotName}`) - expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${rwRobotName}`) - expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${projectRobotName}`) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) }) }) diff --git a/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/apps/server-nestjs/test/sonarqube.e2e-spec.ts index 3670a5c428..460b916712 100644 --- a/apps/server-nestjs/test/sonarqube.e2e-spec.ts +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -3,9 +3,13 @@ import { generateProjectKey } from '@cpn-console/hooks' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' @@ -38,7 +42,7 @@ describeWithSonarqube('SonarqubeService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [SonarqubeModule, VaultModule, ConfigurationModule, InfrastructureModule], + imports: [SonarqubeModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/vault.e2e-spec.ts b/apps/server-nestjs/test/vault.e2e-spec.ts index 056a9eb46b..3303892032 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -2,9 +2,13 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' -import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { projectSelect } from '../src/modules/vault/vault-datastore.service' import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' @@ -31,7 +35,7 @@ describeWithVault('VaultController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [VaultModule, ConfigurationModule, InfrastructureModule], + imports: [VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init()