diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md index 011939c593..04e298131f 100644 --- a/ENVIRONMENTS.md +++ b/ENVIRONMENTS.md @@ -166,6 +166,175 @@ Le `nginx-strangler` et `server-nestjs` sont inclus dans ce docker-compose et d pnpm docker:dev ``` +## Référence des variables d'environnement + +### Variables communes + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `NODE_ENV` | `development` | Mode d'exécution : `development`, `production`, `test` | +| `SERVER_HOST` | `localhost` | Interface d'écoute du serveur (NestJS) | +| `SERVER_PORT` | `0` (dynamique) | Port du serveur (NestJS) | +| `SESSION_SECRET` | — | Secret de signature des cookies de session (min 32 caractères) | +| `CONTACT_EMAIL` | `cloudpinative-relations@interieur.gouv.fr` | Email de contact affiché dans l'interface | +| `DEV_SETUP` | — | Active les données de démo au démarrage (`true`) | + +### Intégration / CI + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `INTEGRATION` | — | Active le mode intégration (`true`) | +| `CI` | — | Active le mode CI (`true`) | +| `APP_VERSION` | `unknown` | Version de l'application (prod uniquement) | + +--- + +### Variables Keycloak + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `KEYCLOAK_PROTOCOL` | — | Protocole : `http` ou `https` | +| `KEYCLOAK_DOMAIN` | — | Domaine Keycloak (host:port) | +| `KEYCLOAK_PUBLIC_PROTOCOL` | `KEYCLOAK_PROTOCOL` | Protocole exposé publiquement | +| `KEYCLOAK_PUBLIC_DOMAIN` | `KEYCLOAK_DOMAIN` | Domaine exposé publiquement | +| `KEYCLOAK_REALM` | — | Nom du royaume | +| `KEYCLOAK_CLIENT_ID` | — | Client ID côté backend | +| `KEYCLOAK_CLIENT_SECRET` | — | Secret du client backend | +| `KEYCLOAK_ADMIN` | — | Identifiant admin Keycloak | +| `KEYCLOAK_ADMIN_PASSWORD` | — | Mot de passe admin Keycloak | +| `KEYCLOAK_ADMIN_CLIENT_ID` | `admin-cli` | Client admin pour le royaume master | +| `KEYCLOAK_REDIRECT_URI` | — | URL de redirection post-auth | +| `KEYCLOAK_JWKS_CACHE_TTL_MS` | `300000` (5 min) | Cache TTL des JWKS en ms | +| `KEYCLOAK_JWKS_TIMEOUT_MS` | `5000` (5 s) | Timeout de fetch JWKS en ms | +| `KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS` | `300000` (5 min) | Cache TTL openid-configuration en ms | +| `ADMIN_KC_USER_ID` | — | IDs des admins Keycloak (liste CSV) | + +### Variables Base de données + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `DB_URL` | — | URL PostgreSQL (Prisma) | + +### Variables OpenCDS + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `OPENCDS_URL` | — | URL de l'API OpenCDS (vide = désactivé) | +| `OPENCDS_API_TOKEN` | — | Token d'authentification | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | `true` | Vérification TLS (true / false) | + +### Variables client (Vite) + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `VITE_SERVER_HOST` | — | Hôte du serveur backend (client) | +| `VITE_SERVER_PORT` | `4001` | Port du serveur backend (client) | +| `VITE_KEYCLOAK_DOMAIN` | — | Domaine Keycloak (client) | +| `VITE_KEYCLOAK_REALM` | — | Royaume Keycloak (client) | +| `VITE_KEYCLOAK_PROTOCOL` | — | Protocole Keycloak (client) | +| `VITE_KEYCLOAK_CLIENT_ID` | — | Client ID Keycloak (client) | +| `VITE_KEYCLOAK_REDIRECT_URI` | `http://localhost:8080` | URL de redirection post-auth (client) | +| `VITE_OPENCDS_ENABLED` | `false` | Active le module OpenCDS (client) | +| `VITE_CONTACT_EMAIL` | — | Email de contact (client) | + +--- + +### Variables ArgoCD + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_ARGOCD` | `false` | Active le plugin ArgoCD (`true`) | +| `ARGO_NAMESPACE` | `argocd` | Namespace ArgoCD | +| `ARGOCD_URL` | — | URL publique ArgoCD | +| `ARGOCD_INTERNAL_URL` | — | URL interne ArgoCD | +| `ARGOCD_EXTRA_REPOSITORIES` | — | Répôts additionnels (JSON) | + +### Variables GitLab + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_GITLAB` | `false` | Active le plugin GitLab (`true`) | +| `GITLAB_URL` | — | URL publique GitLab | +| `GITLAB_INTERNAL_URL` | — | URL interne GitLab | +| `GITLAB_TOKEN` | — | Token d'authentification | +| `GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS` | `180` | Durée avant rotation token mirror (jours) | +| `GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS` | `90` | Seuil de rotation token mirror (jours) | + +### Variables Harbor (Registry) + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_HARBOR` | `false` | Active le plugin Harbor (`true`) | +| `HARBOR_URL` | — | URL publique Harbor | +| `HARBOR_INTERNAL_URL` | — | URL interne Harbor | +| `HARBOR_ADMIN` | — | Identifiant administrateur Harbor | +| `HARBOR_ADMIN_PASSWORD` | — | Mot de passe administrateur Harbor | +| `HARBOR_RULE_TEMPLATE` | — | Template de règle de rétention | +| `HARBOR_RULE_COUNT` | — | Nombre de règles de rétention | +| `HARBOR_RETENTION_CRON` | `0 22 2 * * *` | Cron de nettoyage Harbor | +| `HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS` | `90` | Seuil de rotation des robots (jours) | +| `HARBOR_PROJECT_SLUG_CACHE_TTL_MS` | `300000` (5 min) | Cache TTL des slugs projet (ms) | + +### Variables Nexus + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_NEXUS` | `false` | Active le plugin Nexus (`true`) | +| `NEXUS_URL` | — | URL publique Nexus | +| `NEXUS_INTERNAL_URL` | — | URL interne Nexus | +| `NEXUS_ADMIN` | — | Identifiant administrateur Nexus | +| `NEXUS_ADMIN_PASSWORD` | — | Mot de passe administrateur Nexus | +| `NEXUS__SECRET_EXPOSE_INTERNAL_URL` | — | Expose l'URL interne comme URL secrète (`true`) | + +### Variables SonarQube + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_SONARQUBE` | `false` | Active le plugin SonarQube (`true`) | +| `SONARQUBE_URL` | — | URL publique SonarQube | +| `SONARQUBE_INTERNAL_URL` | — | URL interne SonarQube | +| `SONAR_API_TOKEN` | — | Token d'API SonarQube | + +### Variables Service Chain + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_SERVICE_CHAIN` | `false` | Active le plugin Service Chain (`true`) | + +### Variables Vault + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `USE_VAULT` | `false` | Active le plugin Vault (`true`) | +| `VAULT_URL` | — | URL publique Vault | +| `VAULT_INTERNAL_URL` | — | URL interne Vault | +| `VAULT_TOKEN` | — | Token de service Vault | +| `VAULT_KV_NAME` | `forge-dso` | Nom du secret KV Vault | +| `VAULT__DEPLOY_VAULT_CONNECTION_IN_NS` | — | Déploie la connexion Vault dans les namespaces (`true`) | + +### Variables DSO (Environnements) + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `DSO_ENV_CHART_VERSION` | `dso-env-1.6.0` | Version du chart dso-env | +| `DSO_NS_CHART_VERSION` | `dso-ns-1.1.5` | Version du chart dso-ns | + +### Variables plugins (mode mock) + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `MOCK_PLUGINS` | — | Active le mode mock des plugins (`true`) | +| `PROJECTS_ROOT_DIR` | — | Dossier racine des projets | +| `PLUGINS_DIR` | `/plugins` | Dossier des plugins externes | + +### Variables E2E (tests) + +| Variable | Défaut | Description | +|----------|--------|-------------| +| `E2E` | — | Active les tests E2E (`true`) | + +--- + ## Configuration pour le développement hybride avec un environnement d'intégration existant Le développement hybride branché sur l'environnement d'intégration suit cette organisation des composants: diff --git a/README.md b/README.md index 3a89503a2b..47b9f717f0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Afin de pouvoir démarrer l'application, il vous faudra également récupérer ( - `apps/server/.env.docker` - `apps/server/.env.integ` -Si vous ne les avez pas déjà, référez-vous à [./ENVIRONMENTS.md](./ENVIRONMENTS.md) qui est la documentation consacrée au sujet. +Si vous ne les avez pas déjà, référez-vous à [`ENVIRONMENTS.md`](./ENVIRONMENTS.md) (singulier) qui est la documentation consacrée au sujet. ### Lancement rapide utilisant l'application en local et les services sur un environnement distant diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index f9f3a143f9..98b578b339 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -45,3 +45,12 @@ OPENCDS_URL= OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_ARGOCD=false +USE_GITLAB=false +USE_NEXUS=false +USE_HARBOR=false +USE_SONARQUBE=false +USE_VAULT=false diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0096e0736e..2d52aec635 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -47,3 +47,13 @@ OPENCDS_URL= OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_ARGOCD=false +USE_GITLAB=false +USE_NEXUS=false +USE_HARBOR=false +USE_SONARQUBE=false +USE_SERVICE_CHAIN=true +USE_VAULT=false diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 9b1a5cb454..63a50ec807 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -71,7 +71,7 @@ NEXUS_INTERNAL_URL= # --- Divers --- # Répertoire racine des projets dans Gitlab -PROJECTS_ROOT_DIR= +PROJECTS_ROOT_DIR=forge-mi/projects # --- SonarQube --- # Token d'API SonarQube diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index adcd9691a1..d023ec8f5c 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,8 +4,8 @@ import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.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 { PluginModule } from './modules/plugin/plugin.module' import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' import { ProjectMembersModule } from './modules/project-members/project-members.module' @@ -13,28 +13,26 @@ import { ProjectRolesModule } from './modules/project-roles/project-roles.module import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' import { ProjectServicesModule } from './modules/project-services/project-services.module' import { ProjectModule } from './modules/project/project.module' -import { ServiceChainModule } from './modules/service-chain/service-chain.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' @Module({ imports: [ - InfrastructureModule, + DeploymentModule, + EnvironmentModule, HealthzModule, - KeycloakModule, - ScheduleModule.forRoot(), - SystemSettingsModule, - ServiceChainModule, - ProjectModule, - ProjectHooksModule, - ProjectSecretsModule, - ProjectServicesModule, + InfrastructureModule, + LogModule, + PluginModule, ProjectBulkModule, + ProjectHooksModule, ProjectMembersModule, + ProjectModule, ProjectRolesModule, - LogModule, - DeploymentModule, - EnvironmentModule, + ProjectSecretsModule, + ProjectServicesModule, + ScheduleModule.forRoot(), + SystemSettingsModule, VersionModule, ], controllers: [], diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 456d834661..3bb16b9470 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -492,7 +492,7 @@ function formatDeploymentSourceValueSources( }] : [] return [...internalValueSources, ...externalValueSource] - .sort((a, b) => a.order - b.order) + .toSorted((a, b) => a.order - b.order) .map(entry => entry.value) } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index ae3cccd2bb..50141664e4 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { optIn } from 'src/utils/config.utils' import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { KeycloakModule } from '../keycloak/keycloak.module' import { NexusModule } from '../nexus/nexus.module' @@ -17,13 +18,12 @@ import { HealthzService } from './healthz.service' TerminusModule, DatabaseModule, KeycloakModule, - GitlabModule, - VaultModule, - NexusModule, - RegistryModule, - ArgoCDModule, - ConfigurationModule, - ServiceChainModule, + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_HARBOR'), + ConditionalModule.registerWhen(ServiceChainModule, optIn('USE_SERVICE_CHAIN')), + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), ], controllers: [HealthzController], providers: [HealthzService], diff --git a/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..7b03cb8745 --- /dev/null +++ b/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,110 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { ServiceChainHealthService } from '../service-chain/service-chain-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let healthService: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + healthService = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: ServiceChainHealthService, useValue: healthService }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe', async () => { + await service.check() + + expect(database.check).toHaveBeenCalled() + expect(keycloak.check).toHaveBeenCalled() + expect(gitlab.check).toHaveBeenCalled() + expect(vault.check).toHaveBeenCalled() + expect(nexus.check).toHaveBeenCalled() + expect(registry.check).toHaveBeenCalled() + expect(argocd.check).toHaveBeenCalled() + expect(healthService.check).toHaveBeenCalled() + }) + }) + + describe('without optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalled() + expect(keycloak.check).toHaveBeenCalled() + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function), expect.any(Function)]) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/apps/server-nestjs/src/modules/healthz/healthz.service.ts index 81e7a086ab..690d567eac 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.service.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -1,9 +1,8 @@ import type { HealthIndicatorFunction } from '@nestjs/terminus' -import { Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable, Optional } from '@nestjs/common' import { HealthCheckService } from '@nestjs/terminus' import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { DatabaseHealthService } from '../infrastructure/database/database-health.service' import { KeycloakHealthService } from '../keycloak/keycloak-health.service' import { NexusHealthService } from '../nexus/nexus-health.service' @@ -14,30 +13,29 @@ import { VaultHealthService } from '../vault/vault-health.service' @Injectable() export class HealthzService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(HealthCheckService) private readonly health: HealthCheckService, @Inject(DatabaseHealthService) private readonly database: DatabaseHealthService, @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, - @Inject(GitlabHealthService) private readonly gitlab: GitlabHealthService, - @Inject(VaultHealthService) private readonly vault: VaultHealthService, - @Inject(NexusHealthService) private readonly nexus: NexusHealthService, - @Inject(RegistryHealthService) private readonly registry: RegistryHealthService, - @Inject(ArgoCDHealthService) private readonly argocd: ArgoCDHealthService, - @Inject(ServiceChainHealthService) private readonly serviceChainHealth: ServiceChainHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(ServiceChainHealthService) @Optional() private readonly serviceChainHealth?: ServiceChainHealthService, ) {} check() { const checks: HealthIndicatorFunction[] = [ - () => this.database.check(), - () => this.keycloak.check(), + this.database.check.bind(this.database), + this.keycloak.check.bind(this.keycloak), ] - if (this.config.gitlabUrl) checks.push(() => this.gitlab.check()) - if (this.config.vaultUrl) checks.push(() => this.vault.check()) - if (this.config.nexusUrl) checks.push(() => this.nexus.check()) - if (this.config.harborUrl) checks.push(() => this.registry.check()) - if (this.config.argocdUrl) checks.push(() => this.argocd.check()) - if (this.config.openCdsUrl) checks.push(() => this.serviceChainHealth.check()) + if (this.gitlab) checks.push(this.gitlab.check.bind(this.gitlab)) + if (this.vault) checks.push(this.vault.check.bind(this.vault)) + if (this.nexus) checks.push(this.nexus.check.bind(this.nexus)) + if (this.registry) checks.push(this.registry.check.bind(this.registry)) + if (this.argocd) checks.push(this.argocd.check.bind(this.argocd)) + if (this.serviceChainHealth) checks.push(this.serviceChainHealth.check.bind(this.serviceChainHealth)) return this.health.check(checks) } diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..9d92a86af7 --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,25 @@ +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('resolves PluginService with all plugins enabled', async () => { + vi.stubEnv('USE_ARGOCD', 'true') + vi.stubEnv('USE_GITLAB', 'true') + vi.stubEnv('USE_HARBOR', 'true') + vi.stubEnv('USE_NEXUS', 'true') + vi.stubEnv('USE_SONARQUBE', 'true') + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot(), + PluginModule, + ], + }).compile() + const service = module.get(PluginService) + expect(service).toBeInstanceOf(PluginService) + }) +}) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.ts index c8faef2fc8..f2aeee1a67 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -1,15 +1,27 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { optIn } from '../../utils/config.utils' import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' import { KeycloakModule } from '../keycloak/keycloak.module' import { NexusModule } from '../nexus/nexus.module' import { RegistryModule } from '../registry/registry.module' +import { ServiceChainModule } from '../service-chain/service-chain.module' import { SonarqubeModule } from '../sonarqube/sonarqube.module' import { VaultModule } from '../vault/vault.module' import { PluginService } from './plugin.service' @Module({ - imports: [ArgoCDModule, GitlabModule, RegistryModule, KeycloakModule, NexusModule, SonarqubeModule, VaultModule], + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + KeycloakModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_HARBOR'), + ConditionalModule.registerWhen(ServiceChainModule, optIn('USE_SERVICE_CHAIN')), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ], providers: [PluginService], exports: [PluginService], }) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.service.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.service.spec.ts new file mode 100644 index 0000000000..0526dbe09b --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.service.spec.ts @@ -0,0 +1,115 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Logger } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' +import { PluginService } from './plugin.service' + +function makeInfos(name: string): ServiceInfos { + return { name, title: name } +} + +describe('pluginService', () => { + afterEach(() => vi.restoreAllMocks()) + + describe('infos', () => { + it('collects results from all plugins when every plugin is registered', async () => { + const keycloak = mockDeep() + const vault = mockDeep() + const argocd = mockDeep() + const gitlab = mockDeep() + const nexus = mockDeep() + const registry = mockDeep() + const sonarqube = mockDeep() + + keycloak.infos.mockReturnValue(makeInfos('keycloak')) + vault.infos.mockReturnValue(makeInfos('vault')) + argocd.infos.mockReturnValue(makeInfos('argocd')) + gitlab.infos.mockReturnValue(makeInfos('gitlab')) + nexus.infos.mockReturnValue(makeInfos('nexus')) + registry.infos.mockResolvedValue(makeInfos('registry')) + sonarqube.infos.mockReturnValue(makeInfos('sonarqube')) + + const moduleRef = await Test.createTestingModule({ + providers: [ + PluginService, + { provide: KeycloakPluginService, useValue: keycloak }, + { provide: VaultPluginService, useValue: vault }, + { provide: ArgoCDPluginService, useValue: argocd }, + { provide: GitlabPluginService, useValue: gitlab }, + { provide: NexusPluginService, useValue: nexus }, + { provide: RegistryPluginService, useValue: registry }, + { provide: SonarqubePluginService, useValue: sonarqube }, + ], + }).compile() + const service = moduleRef.get(PluginService) + + const result = await service.infos('project-1') + + expect(registry.infos).toHaveBeenCalledWith('project-1') + expect(result.map(r => r.name).toSorted((a, b) => a.localeCompare(b))).toEqual( + ['argocd', 'gitlab', 'keycloak', 'nexus', 'registry', 'sonarqube', 'vault'], + ) + }) + + it('skips absent optional plugins and warns on rejection', async () => { + const keycloak = mockDeep() + const vault = mockDeep() + + keycloak.infos.mockReturnValue(makeInfos('keycloak')) + vault.infos.mockReturnValue(makeInfos('vault')) + + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined) + + const moduleRef = await Test.createTestingModule({ + providers: [ + PluginService, + { provide: KeycloakPluginService, useValue: keycloak }, + { provide: VaultPluginService, useValue: vault }, + ], + }).compile() + const service = moduleRef.get(PluginService) + + const result = await service.infos('project-1') + + expect(result.map(r => r.name).toSorted((a, b) => a.localeCompare(b))).toEqual(['keycloak', 'vault']) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('logs a warning and omits a plugin whose infos() rejects', async () => { + const keycloak = mockDeep() + const vault = mockDeep() + const argocd = mockDeep() + + keycloak.infos.mockReturnValue(makeInfos('keycloak')) + vault.infos.mockReturnValue(makeInfos('vault')) + argocd.infos.mockRejectedValue(new Error('boom')) + + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined) + + const moduleRef = await Test.createTestingModule({ + providers: [ + PluginService, + { provide: KeycloakPluginService, useValue: keycloak }, + { provide: VaultPluginService, useValue: vault }, + { provide: ArgoCDPluginService, useValue: argocd }, + ], + }).compile() + const service = moduleRef.get(PluginService) + + const result = await service.infos('project-1') + + expect(result.map(r => r.name).toSorted((a, b) => a.localeCompare(b))).toEqual(['keycloak', 'vault']) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Skipping project service plugin argocd'), + ) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/apps/server-nestjs/src/modules/plugin/plugin.service.ts index 1b9303ee53..9ae5c0ac78 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.service.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -1,5 +1,5 @@ import type { ServiceInfos } from '@cpn-console/hooks' -import { Inject, Injectable, Logger } from '@nestjs/common' +import { Inject, Injectable, Logger, Optional } from '@nestjs/common' import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' @@ -13,33 +13,30 @@ export class PluginService { private readonly logger = new Logger(PluginService.name) constructor( - @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, - @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, - @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, - @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, - @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, - @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + @Inject(VaultPluginService) @Optional() private readonly vaultPlugin?: VaultPluginService, + @Inject(ArgoCDPluginService) @Optional() private readonly argoCDPlugin?: ArgoCDPluginService, + @Inject(GitlabPluginService) @Optional() private readonly gitlabPlugin?: GitlabPluginService, + @Inject(NexusPluginService) @Optional() private readonly nexusPlugin?: NexusPluginService, + @Inject(RegistryPluginService) @Optional() private readonly registryPlugin?: RegistryPluginService, + @Inject(SonarqubePluginService) @Optional() private readonly sonarqubePlugin?: SonarqubePluginService, ) {} async infos(projectId: string): Promise { - const plugins = [ - ['argocd', () => this.argoCDPlugin.infos()], - ['gitlab', () => this.gitlabPlugin.infos()], - ['registry', () => this.registryPlugin.infos(projectId)], + const plugins: Array<[string, () => ServiceInfos | undefined | Promise]> = [ + ['argocd', () => this.argoCDPlugin?.infos()], + ['gitlab', () => this.gitlabPlugin?.infos()], + ['registry', () => this.registryPlugin?.infos(projectId)], ['keycloak', () => this.keycloakPlugin.infos()], - ['nexus', () => this.nexusPlugin.infos()], - ['sonarqube', () => this.sonarqubePlugin.infos()], - ['vault', () => this.vaultPlugin.infos()], - ] as const + ['nexus', () => this.nexusPlugin?.infos()], + ['sonarqube', () => this.sonarqubePlugin?.infos()], + ['vault', () => this.vaultPlugin?.infos()], + ] - const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) - return settled.flatMap((result, index) => { - const [pluginName] = plugins[index] - if (result.status === 'fulfilled') { - return [result.value] - } - this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) + const settled = await Promise.allSettled(plugins.map(([, fn]) => fn())) + return settled.flatMap((result, i) => { + if (result.status === 'fulfilled') return result.value ? [result.value] : [] + this.logger.warn(`Skipping project service plugin ${plugins[i][0]} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) return [] }) } 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 2909a7009c..1d3e2ef1f0 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,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { AuthModule } from '../infrastructure/auth/auth.module' import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' @@ -8,7 +9,7 @@ import { ProjectSecretsController } from './project-secrets.controller' import { ProjectSecretsService } from './project-secrets.service' @Module({ - imports: [AuthModule, ConfigurationModule, DatabaseModule, ProjectPermissionModule, VaultModule], + imports: [AuthModule, ConfigurationModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(VaultModule, 'USE_VAULT')], controllers: [ProjectSecretsController], providers: [ProjectSecretsService], exports: [ProjectSecretsService], diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts index eb9aeefd50..40ea3beb9e 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -97,4 +97,22 @@ describe('projectSecretsService', () => { expect(result.group1).toEqual({ 's2.key': 'val' }) }) + + describe('without vault configured (USE_VAULT=false)', () => { + it('returns empty result without touching vault', async () => { + const moduleNoVault = await Test.createTestingModule({ + providers: [ + ProjectSecretsService, + { provide: PrismaService, useValue: mockDeep() }, + { provide: ConfigurationService, useValue: mockDeep({ projectRootDir: '/vault' }) }, + ], + }).compile() + + const serviceNoVault = moduleNoVault.get(ProjectSecretsService) + const result = await serviceNoVault.get('project-id') + + expect(result).toEqual({}) + await moduleNoVault.close() + }) + }) }) diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts index 6da2f9e644..f23d184695 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { Inject, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { z } from 'zod' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' @@ -29,8 +29,8 @@ export class ProjectSecretsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(ConfigurationService) private readonly config: ConfigurationService, - @Inject(VaultService) private readonly vault: VaultService, - @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + @Inject(VaultService) @Optional() private readonly vault?: VaultService, + @Inject(VaultClientService) @Optional() private readonly vaultClient?: VaultClientService, ) {} @StartActiveSpan() @@ -38,50 +38,72 @@ export class ProjectSecretsService { const span = trace.getActiveSpan() span?.setAttribute('project.id', projectId) this.logger.log(`project.get started (projectId=${projectId})`) - try { - const project = await getProjectSlug(this.prisma, projectId) - if (!project) throw new NotFoundException('Projet introuvable') - span?.setAttribute('project.slug', project.slug) - const projectPath = generateProjectPath(this.config.projectRootDir, project.slug) - const result: Record> = {} - const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { - this.logger.warn( - `project.get secret listing failed (projectId=${projectId}, slug=${project.slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, - ) - return [] as string[] - }) - span?.setAttribute('vault.secretFiles.count', relativePaths.length) - this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + if (!this.vault || !this.vaultClient) { + this.logger.warn(`project.get returning empty result (projectId=${projectId}): vault not configured`) + return {} + } + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + span?.setAttribute('project.slug', project.slug) - for (const relativePath of relativePaths) { - const fullPath = `${projectPath}/${relativePath}` - const secret = await this.vaultClient.read>(fullPath).catch(() => null) - if (!secret?.data) continue + const relativePaths = await this.listProjectSecretPaths(projectId, project.slug) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) - const [group, ...rest] = relativePath.split('/').filter(Boolean) - if (!group) continue - const prefix = rest.length ? `${rest.join('/')}.` : '' - const groupObj = (result[group] ??= {}) - for (const [key, value] of Object.entries(secret.data)) { - groupObj[`${prefix}${key}`] = parseSecretValue(value) - } - } + if (relativePaths.length === 0) { + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=0, keyCount=0)`) + return {} + } - const groupCount = Object.keys(result).length - const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) - span?.setAttributes({ - 'vault.secretGroups.count': groupCount, - 'vault.secretKeys.count': keyCount, - }) - this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) - return result - } catch (error) { - this.logger.error( - `project.get failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? error.stack : undefined, + const result = await this.aggregateProjectSecrets(projectId, project.slug, relativePaths) + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } + + private async listProjectSecretPaths(projectId: string, slug: string): Promise { + if (!this.vault) { + this.logger.warn(`project.get returning empty result (projectId=${projectId}): vault not configured`) + return [] + } + return this.vault.listProjectSecrets(slug).catch((error) => { + this.logger.warn( + `project.get secret listing failed (projectId=${projectId}, slug=${slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, ) - throw error + return [] as string[] + }) + } + + private async aggregateProjectSecrets( + projectId: string, + slug: string, + relativePaths: string[], + ): Promise>> { + const projectPath = generateProjectPath(this.config.projectRootDir, slug) + + const result: Record> = {} + this.logger.debug(`project.get aggregating (projectId=${projectId}, slug=${slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient?.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } } + + return result } } 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 52ab1011f5..6c0639a55b 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 @@ -7,7 +7,12 @@ import { ProjectServicesController } from './project-services.controller' import { ProjectServicesService } from './project-services.service' @Module({ - imports: [AuthModule, DatabaseModule, PluginModule, ProjectPermissionModule], + imports: [ + AuthModule, + DatabaseModule, + PluginModule, + ProjectPermissionModule, + ], controllers: [ProjectServicesController], providers: [ProjectServicesService], exports: [ProjectServicesService], diff --git a/apps/server-nestjs/src/utils/config.utils.ts b/apps/server-nestjs/src/utils/config.utils.ts new file mode 100644 index 0000000000..a661ada72a --- /dev/null +++ b/apps/server-nestjs/src/utils/config.utils.ts @@ -0,0 +1,3 @@ +export function optIn(condition: string): (env: NodeJS.ProcessEnv) => boolean { + return (env: NodeJS.ProcessEnv) => env[condition] === 'true' +}