diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index f9f3a143f9..881b6049e3 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -2,6 +2,8 @@ DEV_SETUP="true" # Mode d'exécution Node.js (development | production | test) NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true # HOME=/home/node # Secret utilisé pour signer les cookies de session (min. 32 caractères) SESSION_SECRET=a-very-strong-secret-with-more-than-32-char @@ -41,6 +43,8 @@ CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr # --- Configuration OpenCDS --- # URL de l'API OpenCDS (laisser vide pour désactiver) OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= # Token d'authentification pour l'API OpenCDS OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0096e0736e..2cfba9c1e8 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -4,6 +4,8 @@ DOCKER=true DEV_SETUP="true" # Mode d'exécution Node.js (development | production | test) NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle # Secret utilisé pour signer les cookies de session (min. 32 caractères) SESSION_SECRET=a-very-strong-secret-with-more-than-32-char @@ -43,6 +45,8 @@ CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr # URL de l'API OpenCDS (laisser vide pour désactiver) # Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= # Token d'authentification pour l'API OpenCDS OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 9b1a5cb454..a8e28c56e8 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -5,6 +5,8 @@ DEV_SETUP="false" # Active le mode intégration (charge ce fichier en surcharge) INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false # --- Keycloak --- # Protocole (généralement https en intégration) @@ -111,5 +113,6 @@ DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 # Configuration OpenCDS OPENCDS_URL= +OPENCDS_INTERNAL_URL= OPENCDS_API_TOKEN=token OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/apps/server-nestjs/src/config/argocd.config.ts b/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..0b1a87ef72 --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,26 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + USE_ARGOCD: flag(truthySchema.default('true')), + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: nonEmpty(z.string().url()), + ARGOCD_INTERNAL_URL: nonEmpty(z.string().url()), + ARGOCD_EXTRA_REPOSITORIES: nonEmpty(z.string()), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: flag(truthySchema.default('false')), +}).transform(raw => ({ + enabled: raw.USE_ARGOCD, + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL || raw.ARGOCD_URL || undefined, +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/base.config.ts b/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..2b8e7af3e8 --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,48 @@ +import type { BaseConfig } from '../modules/infrastructure/config/base.config' +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + INTEGRATION: flag(truthySchema.default('false')), + CI: flag(truthySchema.default('false')), + DEV_SETUP: flag(truthySchema.default('false')), + DOCKER: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flag(truthySchema.default('false')), + PROJECTS_ROOT_DIR: nonEmpty(z.string()), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: nonEmpty(z.string().url()), + HTTPS_PROXY: nonEmpty(z.string().url()), +}).transform((raw): BaseConfig => ({ + nodeEnv: raw.NODE_ENV === 'test' ? 'test' : raw.NODE_ENV === 'development' ? 'development' : 'production', + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: raw.NODE_ENV === 'production', + integration: raw.INTEGRATION, + ci: raw.CI, + devSetup: raw.DEV_SETUP, + docker: raw.DOCKER, + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + sessionSecret: raw.SESSION_SECRET, + contactEmail: raw.CONTACT_EMAIL, + mockPlugins: raw.MOCK_PLUGINS, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + pluginsDir: raw.PLUGINS_DIR, + httpProxy: raw.HTTP_PROXY, + httpsProxy: raw.HTTPS_PROXY, +})) + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) + +export type { BaseConfig } from '../modules/infrastructure/config/base.config' diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..99982c8016 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, nonEmpty, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('nonEmpty', () => { + const schema = nonEmpty(z.string()) + + it('trims and keeps a non-empty value', () => { + expect(schema.parse(' value ')).toBe('value') + }) + + it('maps empty/whitespace/missing to undefined', () => { + expect(schema.parse('')).toBeUndefined() + expect(schema.parse(' ')).toBeUndefined() + expect(schema.parse(undefined)).toBeUndefined() + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/apps/server-nestjs/src/config/config.utils.ts b/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..fafe242d61 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,36 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Trimmed string; empty/whitespace-only -> undefined; missing/empty string -> undefined. +// Pass a refined string schema (e.g. z.string().url()) to validate the non-empty value. +export function nonEmpty(schema: T) { + return schema + .transform(value => value.trim() || undefined) + .optional() + .or(z.literal('').transform(() => undefined)) +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/apps/server-nestjs/src/config/gitlab.config.ts b/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..5982bc5755 --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + USE_GITLAB: flag(truthySchema.default('true')), + GITLAB_TOKEN: z.string().optional(), + GITLAB_URL: nonEmpty(z.string().url()), + GITLAB_INTERNAL_URL: nonEmpty(z.string().url()), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + PROJECTS_ROOT_DIR: nonEmpty(z.string()), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL || raw.GITLAB_URL || undefined + return { + enabled: raw.USE_GITLAB, + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, + internalOrPublicUrl: urlBase, + probeUrl: urlBase ? new URL('/-/health', urlBase).toString() : undefined, + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/harbor.config.ts b/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..a53a09eb86 --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,37 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const harborFeatureSchema = z.object({ + USE_HARBOR: flag(truthySchema.default('true')), + HARBOR_URL: nonEmpty(z.string().url()), + HARBOR_INTERNAL_URL: nonEmpty(z.string().url()), + HARBOR_ADMIN: z.string().min(1, 'HARBOR_ADMIN is required'), + HARBOR_ADMIN_PASSWORD: z.string().min(1, 'HARBOR_ADMIN_PASSWORD is required'), + HARBOR_RULE_TEMPLATE: z.string().min(1).optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL || raw.HARBOR_URL || undefined + return { + enabled: raw.USE_HARBOR, + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: urlBase ? new URL('/api/v2.0/health', urlBase).toString() : undefined, + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/keycloak.config.ts b/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..24f7284666 --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,55 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const keycloakRawSchema = z.object({ + USE_KEYCLOAK: flag(truthySchema.default('true')), + KEYCLOAK_PROTOCOL: z.string().default('https'), + KEYCLOAK_DOMAIN: nonEmpty(z.string()), + KEYCLOAK_PUBLIC_PROTOCOL: z.string().default('https'), + KEYCLOAK_PUBLIC_DOMAIN: nonEmpty(z.string()), + KEYCLOAK_REALM: nonEmpty(z.string()), + KEYCLOAK_CLIENT_ID: nonEmpty(z.string()), + KEYCLOAK_CLIENT_SECRET: nonEmpty(z.string()), + KEYCLOAK_ADMIN: nonEmpty(z.string()), + KEYCLOAK_ADMIN_PASSWORD: nonEmpty(z.string()), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: nonEmpty(z.string().url()), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}) + +function mapKeycloakConfig(raw: z.infer) { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + enabled: raw.USE_KEYCLOAK, + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +} + +const keycloakConfigSchema = keycloakRawSchema.transform(mapKeycloakConfig) + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakConfigSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/nexus.config.ts b/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..1f2017a01d --- /dev/null +++ b/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,26 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + USE_NEXUS: flag(truthySchema.default('true')), + NEXUS_URL: nonEmpty(z.string().url()), + NEXUS_INTERNAL_URL: nonEmpty(z.string().url()), + NEXUS_ADMIN: z.string().min(1, 'NEXUS_ADMIN is required'), + NEXUS_ADMIN_PASSWORD: z.string().min(1, 'NEXUS_ADMIN_PASSWORD is required'), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: flag(truthySchema.default('false')), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL || raw.NEXUS_URL || undefined + return { + enabled: raw.USE_NEXUS, + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: urlBase ? new URL('/service/rest/v1/status', urlBase).toString() : undefined, + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/opencds.config.ts b/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..e1590f9376 --- /dev/null +++ b/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,23 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + USE_OPENCDS: flag(truthySchema.default('true')), + OPENCDS_URL: nonEmpty(z.string().url()), + OPENCDS_INTERNAL_URL: nonEmpty(z.string().url()), + OPENCDS_API_TOKEN: nonEmpty(z.string()), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: flag(truthySchema.default('false')), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL || raw.OPENCDS_URL + return { + enabled: raw.USE_OPENCDS, + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: probeBase ? new URL('/api/v1/health', probeBase).toString() : undefined, + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/plugin.config.ts b/apps/server-nestjs/src/config/plugin.config.ts new file mode 100644 index 0000000000..34695ae139 --- /dev/null +++ b/apps/server-nestjs/src/config/plugin.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const pluginFeatureSchema = z.object({ + USE_PLUGINS: flag(truthySchema.default('true')), + CI: flag(truthySchema.default('false')), + INTEGRATION: flag(truthySchema.default('false')), + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), +}).transform((raw) => { + return { enabled: raw.USE_PLUGINS } +}) + +export type PluginConfig = z.infer + +export const pluginConfigFactory = registerAs('plugin', () => pluginFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/registry.config.ts b/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..8d66581ae3 --- /dev/null +++ b/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const registryFeatureSchema = z.object({ + USE_REGISTRY: flag(truthySchema.default('true')), + REGISTRY_URL: nonEmpty(z.string().url()), + REGISTRY_INTERNAL_URL: nonEmpty(z.string().url()), + REGISTRY_ADMIN: nonEmpty(z.string()), + REGISTRY_ADMIN_PASSWORD: nonEmpty(z.string()), + REGISTRY_RULE_TEMPLATE: nonEmpty(z.string()), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform(raw => ({ + enabled: raw.USE_REGISTRY, + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: raw.REGISTRY_INTERNAL_URL || raw.REGISTRY_URL || undefined, +})) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/sonarqube.config.ts b/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..9cbf78a418 --- /dev/null +++ b/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,22 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const sonarqubeFeatureSchema = z.object({ + USE_SONARQUBE: flag(truthySchema.default('true')), + SONARQUBE_URL: nonEmpty(z.string().url()), + SONARQUBE_INTERNAL_URL: nonEmpty(z.string().url()), + SONAR_API_TOKEN: nonEmpty(z.string()), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL || raw.SONARQUBE_URL || undefined + return { + enabled: raw.USE_SONARQUBE, + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: urlBase ? new URL('/api/system/health', urlBase).toString() : undefined, + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/vault.config.ts b/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..3f6145e4bc --- /dev/null +++ b/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const vaultFeatureSchema = z.object({ + USE_VAULT: flag(truthySchema.default('true')), + VAULT_TOKEN: z.string().optional(), + VAULT_URL: nonEmpty(z.string().url()), + VAULT_INTERNAL_URL: nonEmpty(z.string().url()), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL || raw.VAULT_URL || undefined + return { + enabled: raw.USE_VAULT, + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: urlBase ? new URL('/v1/sys/health', urlBase).toString() : undefined, + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index adcd9691a1..498ae55e1e 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,8 +1,13 @@ import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' +import { keycloakConfigFactory } from './config/keycloak.config' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' +import { BaseModule } from './modules/infrastructure/config/base.module' import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' import { LogModule } from './modules/log/log.module' @@ -19,9 +24,12 @@ import { VersionModule } from './modules/version/version.module' @Module({ imports: [ + ConfigModule.forRoot(), + BaseModule.forRoot(baseConfigFactory.asProvider()), + TerminusModule.forRoot(), InfrastructureModule, HealthzModule, - KeycloakModule, + ConditionalModule.registerWhen(KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), 'USE_KEYCLOAK'), ScheduleModule.forRoot(), SystemSettingsModule, ServiceChainModule, diff --git a/apps/server-nestjs/src/main.ts b/apps/server-nestjs/src/main.ts index 28b5180f5e..600f7232f7 100644 --- a/apps/server-nestjs/src/main.ts +++ b/apps/server-nestjs/src/main.ts @@ -1,4 +1,5 @@ import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' import { NestFactory } from '@nestjs/core' import { FastifyAdapter } from '@nestjs/platform-fastify' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' @@ -9,7 +10,7 @@ import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { NodeSDK, resources } from '@opentelemetry/sdk-node' import { Logger } from 'nestjs-pino' import { MainModule } from './main.module' -import { ConfigurationService } from './modules/infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' const SERVICE_NAME = 'console-pi-native-console' @@ -43,7 +44,7 @@ async function bootstrap() { await telemetry.shutdown() }) - const config = app.get(ConfigurationService) + const config = app.get(BASE_CONFIG) // Setup swagger-ui route const swaggerConfig = new DocumentBuilder() @@ -55,7 +56,7 @@ async function bootstrap() { const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) - await app.listen(config.port, config.host) + await app.listen(config.serverPort, config.serverHost) const serverUrl = await app.getUrl() const logger = app.get(Logger) diff --git a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts index 8c1c1f2b60..2be2182dd8 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -1,17 +1,18 @@ +import type { ArgocdConfig } from './argocd.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { ARGOCD_CONFIG } from './argocd.module-definition' @Injectable() export class ArgoCDHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicArgoCDUrl() + const urlBase = this.argocdConfig.internalOrPublicUrl if (!urlBase) return indicator.down('Not configured') try { diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..91447152ec --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,26 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { argocdConfigFactory } from '../../config/argocd.config' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ArgoCDService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_GITLAB')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) + + it('omits ArgoCDService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_VAULT')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts index ad51bf62dd..82f639940a 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.module.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -1,17 +1,23 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.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' import { ArgoCDPluginService } from './argocd-plugin.service' +import { ConfigurableModuleClass } from './argocd.module-definition' import { ArgoCDService } from './argocd.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, GitlabModule, TerminusModule, VaultModule], + imports: [ + DatabaseModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + TerminusModule, + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ], providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], }) -export class ArgoCDModule {} +export class ArgoCDModule extends ConfigurableModuleClass {} 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..2c0511c4bf 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,4 +1,7 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' import { generateNamespaceName } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -6,10 +9,12 @@ import { mockDeep } from 'vitest-mock-extended' import { stringify } from 'yaml' import { GitlabClientService } from '../gitlab/gitlab-client.service' import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' import { ArgoCDService } from './argocd.service' describe('argoCDService', () => { @@ -17,28 +22,40 @@ describe('argoCDService', () => { let datastore: DeepMockProxy let gitlab: DeepMockProxy let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { datastore = mockDeep() gitlab = mockDeep() vault = mockDeep() - const config = mockDeep({ - argoNamespace: 'argocd', - argocdUrl: 'https://argocd.internal', - argocdExtraRepositories: 'repo3', + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', dsoEnvChartVersion: 'dso-env-1.6.0', dsoNsChartVersion: 'dso-ns-1.1.5', - projectRootDir: 'forge', - vaultUrl: 'https://vault.internal', - vaultKvName: 'kv', - deployVaultConnectionInNamespaces: false, + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, }) const module = await Test.createTestingModule({ providers: [ ArgoCDService, { provide: ArgoCDDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, { provide: GitlabClientService, useValue: gitlab }, { provide: VaultClientService, useValue: vault }, ], diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 7cd254fda4..cbfa862247 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,6 +1,9 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' import { createHmac } from 'node:crypto' import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' import { Inject, Injectable, Logger } from '@nestjs/common' @@ -8,10 +11,11 @@ import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { stringify } from 'yaml' import { GitlabClientService } from '../gitlab/gitlab-client.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { CONSOLE_ADMIN_GROUP_PATH, @@ -24,14 +28,17 @@ import { PROJECT_READONLY_GROUP_PATH_SUFFIX, PROJECT_SECURITY_GROUP_PATH_SUFFIX, } from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' @Injectable() export class ArgoCDService { private readonly logger = new Logger(ArgoCDService.name) constructor( - @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { @@ -70,7 +77,7 @@ export class ArgoCDService { @StartActiveSpan() async handleCron() { this.logger.log('Starting ArgoCD reconciliation') - const projects = await this.argoCDDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() const span = trace.getActiveSpan() span?.setAttribute('argocd.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) @@ -247,13 +254,13 @@ export class ArgoCDService { environment, cluster, gitlabPublicProjectUrl, - argocdExtraRepositories: this.config.argocdExtraRepositories, + argocdExtraRepositories: this.argocdConfig.extraRepositories, infraProject, valueFilePath, vaultValues, - argoNamespace: this.config.argoNamespace, - envChartVersion: this.config.dsoEnvChartVersion, - nsChartVersion: this.config.dsoNsChartVersion, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, }) return this.gitlab.generateCreateOrUpdateAction( @@ -319,13 +326,13 @@ export class ArgoCDService { environment, cluster, gitlabPublicProjectUrl, - argocdExtraRepositories: this.config.argocdExtraRepositories, + argocdExtraRepositories: this.argocdConfig.extraRepositories, infraProject, valueFilePath, vaultValues, - argoNamespace: this.config.argoNamespace, - envChartVersion: this.config.dsoEnvChartVersion, - nsChartVersion: this.config.dsoNsChartVersion, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, deployments, }) @@ -348,9 +355,9 @@ export class ArgoCDService { return undefined }) return { - projectsRootDir: this.config.projectRootDir, - url: this.config.deployVaultConnectionInNamespaces ? this.config.vaultUrl : '', - coreKvName: this.config.vaultKvName, + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, roleId: roleId ?? 'none', secretId: secretId ?? 'none', } diff --git a/apps/server-nestjs/src/modules/configurable-feature-module.ts b/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts index 3a5c955eee..7bd6fa2ea2 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -1,11 +1,12 @@ import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { projectSelect } from '../project/project-queries.utils' @@ -18,13 +19,13 @@ describe('appEventsService', () => { let prisma: DeepMockProxy let eventEmitter: DeepMockProxy let logs: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { prisma = mockDeep() eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) logs = mockDeep() - config = mockDeep({ appVersion: 'test-version' }) + config = mockDeep({ appVersion: 'test-version' }) module = await Test.createTestingModule({ providers: [ @@ -32,7 +33,7 @@ describe('appEventsService', () => { { provide: PrismaService, useValue: prisma }, { provide: EventEmitter2, useValue: eventEmitter }, { provide: LogService, useValue: logs }, - { provide: ConfigurationService, useValue: config }, + { provide: BASE_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 0c0aa94e03..7de8bcf562 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -1,8 +1,9 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { PluginResults } from '../plugin/plugin.utils' import type { ProjectWithDetails } from '../project/project-queries.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' @@ -46,7 +47,7 @@ export class AppEventsService { @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, @Inject(LogService) private readonly logs: LogService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) {} /** @@ -130,7 +131,7 @@ export class AppEventsService { if (event === 'project.upsert') { await this.prisma.project.update({ where: { id: projectId }, - data: { status: 'created', lastSuccessProvisionningVersion: this.config.appVersion }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, }) } } diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.ts b/apps/server-nestjs/src/modules/events/app-events.utils.ts index 48684cb16e..a9cf62706b 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -1,6 +1,6 @@ import type { LogData } from '../log/log.service' import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' -import { getErrorHttpDetails } from '../../utils/http-error' +import { getErrorHttpDetails } from '../../utils/http.utils' import { getFailedPlugins } from '../plugin/plugin.utils' /** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index 3655fc64fe..0db65f4630 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -2,10 +2,10 @@ import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@g import type { TestingModule } from '@nestjs/testing' import type { MockedFunction } from 'vitest' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' import { makeAccessTokenExposedSchema, @@ -28,6 +28,7 @@ import { PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, USER_ID_CUSTOM_ATTRIBUTE_KEY, } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' describe('gitlab-client', () => { let service: GitlabClientService @@ -35,20 +36,20 @@ describe('gitlab-client', () => { beforeEach(async () => { gitlabApi = mockDeep() - const config = mockDeep({ - gitlabUrl: 'https://gitlab.internal', - gitlabToken: 'token', - gitlabInternalUrl: 'https://gitlab.internal', + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', projectRootDir: 'forge', - gitlabMirrorTokenExpirationDays: 30, - getInternalOrPublicGitlabUrl: () => 'https://gitlab.internal', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', }) const module: TestingModule = await Test.createTestingModule({ providers: [ GitlabClientService, { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, - { provide: ConfigurationService, useValue: config }, + { provide: GITLAB_CONFIG, useValue: config }, ], }).compile() service = module.get(GitlabClientService) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 53841d8d13..d1a755fc9a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -14,11 +14,11 @@ import type { PipelineTriggerTokenSchema, SimpleUserSchema, } from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' import { join } from 'node:path' import { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { Inject, Injectable, Logger } from '@nestjs/common' -import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { find } from '../../utils/iterable.utils' import { GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, @@ -30,6 +30,7 @@ import { TOPIC_PLUGIN_MANAGED, USER_ID_CUSTOM_ATTRIBUTE_KEY, } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') @@ -51,7 +52,7 @@ export class GitlabClientService { private readonly logger = new Logger(GitlabClientService.name) constructor( - @Inject(ConfigurationService) readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, ) { } @@ -187,17 +188,17 @@ export class GitlabClientService { async getOrCreateProjectGroupPublicUrl(): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return new URL(projectGroup.full_path, this.config.gitlabUrl).toString() + return new URL(projectGroup.full_path, this.config.url).toString() } async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.gitlabUrl).toString() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() } async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) - const urlBase = this.config.getInternalOrPublicGitlabUrl() + const urlBase = this.config.internalOrPublicUrl if (!urlBase) throw new Error('GITLAB_URL is required') return `${urlBase}/${projectGroup.full_path}/${repoName}.git` } @@ -474,7 +475,7 @@ export class GitlabClientService { async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { const group = await this.getProjectGroup(projectSlug) if (!group) throw new Error('Unable to retrieve gitlab project group') - const expirationDays = Number(this.config.gitlabMirrorTokenExpirationDays) + const expirationDays = Number(this.config.mirrorTokenExpirationDays) const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts index fc50892690..0c28d06368 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -1,20 +1,20 @@ +import type { GitlabConfig } from './gitlab.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' @Injectable() export class GitlabHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicGitlabUrl() - if (!urlBase) return indicator.down('Not configured') + const url = this.gitlabConfig.probeUrl + if (!url) return indicator.down('Not configured') - const url = new URL('/-/health', urlBase).toString() try { const response = await fetch(url) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts index 6bd62d87c2..7f4d85f694 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -1,25 +1,26 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' describe('gitlabPluginService', () => { let service: GitlabPluginService - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - gitlabUrl: 'https://gitlab.public', + config = mockDeep({ + url: 'https://gitlab.public', projectRootDir: 'forge', }) const moduleRef = await Test.createTestingModule({ providers: [ GitlabPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: GITLAB_CONFIG, useValue: config }, ], }).compile() 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 2e4d6f1424..b861981fc6 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -1,22 +1,23 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' 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, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' @Injectable() export class GitlabPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, ) {} infos(): ServiceInfos { return { name: 'gitlab', to: ({ project }) => { - if (!this.config.gitlabUrl || !this.config.projectRootDir) return undefined - return new URL(`${this.config.projectRootDir}/${project.slug}`, this.config.gitlabUrl).toString() + if (!this.gitlabConfig.url || !this.gitlabConfig.projectRootDir) return undefined + return new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString() }, title: 'Gitlab', imgSrc: '/img/gitlab.svg', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts index 22859472fb..f08dc6f312 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -1,25 +1,25 @@ +import type { GitlabConfig } from './gitlab.module-definition' import { Gitlab } from '@gitbeaker/rest' import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' 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' import { GitlabHealthService } from './gitlab-health.service' import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' import { GitlabService } from './gitlab.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule], providers: [ { provide: GITLAB_REST_CLIENT, - inject: [ConfigurationService], - useFactory: (config: ConfigurationService) => new Gitlab({ - token: config.gitlabToken, - host: config.getInternalOrPublicGitlabUrl(), + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, }), }, GitlabClientService, @@ -30,4 +30,4 @@ import { GitlabService } from './gitlab.service' ], exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], }) -export class GitlabModule {} +export class GitlabModule extends ConfigurableModuleClass {} 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 c069385b97..da46c5fe4f 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -1,16 +1,17 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { AccessLevel } from '@gitbeaker/core' 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 { 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 { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' import { GitlabService } from './gitlab.service' describe('gitlabService', () => { @@ -33,7 +34,7 @@ describe('gitlabService', () => { readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), }) - const config = mockDeep({ projectRootDir: 'forge' }) + const config = mockDeep({ projectRootDir: 'forge' }) const moduleRef = await Test.createTestingModule({ providers: [ @@ -41,7 +42,7 @@ describe('gitlabService', () => { { provide: GitlabClientService, useValue: gitlab }, { provide: GitlabDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: GITLAB_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index d8fa3c85de..fd214154c1 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -2,13 +2,13 @@ import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeak import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' import { specificallyEnabled } from '@cpn-console/hooks' import { AccessLevel } from '@gitbeaker/core' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { getAll } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { getAll } from '../../utils/iterable.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -29,6 +29,7 @@ import { PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PURGE_PLUGIN_KEY, } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' import { adminRoleFlag, daysAgoFromNow, @@ -51,10 +52,10 @@ export class GitlabService { private readonly logger = new Logger(GitlabService.name) constructor( - @Inject(GitlabDatastoreService) private readonly gitlabDatastore: GitlabDatastoreService, + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, ) { this.logger.log('GitLabService initialized') } @@ -93,7 +94,7 @@ export class GitlabService { 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.datastore.getAllProjects() span?.setAttribute('gitlab.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) await this.ensureProjectGroups(projects) @@ -220,7 +221,7 @@ export class GitlabService { private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { const adminGroupPath = await this.getAdminGroupPath(project) const auditorGroupPath = await this.getAuditorGroupPath(project) - const roles = await this.gitlabDatastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) } @@ -233,7 +234,7 @@ export class GitlabService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.gitlabDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig if (!project) return undefined return getProjectPluginConfig(project, key) ?? undefined @@ -513,7 +514,7 @@ export class GitlabService { private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { if (!vaultSecret?.metadata?.created_time) return false const createdTime = new Date(vaultSecret.metadata.created_time) - return daysAgoFromNow(createdTime) > this.config.gitlabMirrorTokenRotationThresholdDays + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays } private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { diff --git a/apps/server-nestjs/src/modules/healthz/healthz.controller.spec.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.spec.ts new file mode 100644 index 0000000000..4ee2f1863c --- /dev/null +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.spec.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { HealthzController } from './healthz.controller' + +function fakeHealthService() { + return { check: vi.fn().mockResolvedValue('up') } +} + +const healthCheck = { check: vi.fn() } +const database = fakeHealthService() +const keycloak = fakeHealthService() +const gitlab = fakeHealthService() +const vault = fakeHealthService() +const nexus = fakeHealthService() +const registry = fakeHealthService() +const argocd = fakeHealthService() +const opencds = fakeHealthService() + +function makeController() { + return new HealthzController( + healthCheck as any, + database as any, + keycloak as any, + gitlab as any, + vault as any, + nexus as any, + registry as any, + argocd as any, + opencds as any, + ) +} + +describe('healthzController', () => { + afterEach(() => vi.clearAllMocks()) + + it('checks every injected probe, including those wired via @Optional', async () => { + healthCheck.check.mockImplementation(async (checks: (() => unknown)[]) => (await Promise.all(checks.map(c => c())))) + await makeController().check() + + // Core infra (always present here) + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + // Optional "Service externe" (present in this test wiring) + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + + it('omits a probe whose service is not registered (@Optional -> undefined)', async () => { + healthCheck.check.mockImplementation(async (checks: (() => unknown)[]) => (await Promise.all(checks.map(c => c())))) + const controller = new HealthzController( + healthCheck as any, + database as any, // database present + undefined as any, // keycloak absent + undefined as any, + undefined as any, + undefined as any, + undefined as any, + undefined as any, + undefined as any, + ) + + await controller.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).not.toHaveBeenCalled() + expect(gitlab.check).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts index b8d3e3a4ed..e07dec519a 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get, Inject } from '@nestjs/common' -import { HealthCheck, HealthCheckService } from '@nestjs/terminus' +import type { HealthCheckService, HealthIndicatorFunction } from '@nestjs/terminus' +import { Controller, Get, Optional } from '@nestjs/common' +import { HealthCheck } 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' @@ -12,46 +12,32 @@ import { VaultHealthService } from '../vault/vault-health.service' @Controller('api/v1/healthz') export class HealthzController { + // Optional modules (gated by USE_*) resolve to undefined when not registered. constructor( - @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(OpenCdsHealthService) private readonly opencds: OpenCdsHealthService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + private readonly health: HealthCheckService, + @Optional() private readonly database?: DatabaseHealthService, + @Optional() private readonly keycloak?: KeycloakHealthService, + @Optional() private readonly gitlab?: GitlabHealthService, + @Optional() private readonly vault?: VaultHealthService, + @Optional() private readonly nexus?: NexusHealthService, + @Optional() private readonly registry?: RegistryHealthService, + @Optional() private readonly argocd?: ArgoCDHealthService, + @Optional() private readonly opencds?: OpenCdsHealthService, ) {} @Get() @HealthCheck() check() { - const checks = [ - () => this.database.check('database'), - () => this.keycloak.check('keycloak'), - ] - - if (this.config.openCdsUrl) { - checks.push(() => this.opencds.check('opencds')) - } - if (this.config.gitlabUrl) { - checks.push(() => this.gitlab.check('gitlab')) - } - if (this.config.vaultUrl) { - checks.push(() => this.vault.check('vault')) - } - if (this.config.nexusUrl) { - checks.push(() => this.nexus.check('nexus')) - } - if (this.config.harborUrl) { - checks.push(() => this.registry.check('registry')) - } - if (this.config.argocdUrl) { - checks.push(() => this.argocd.check('argocd')) - } - + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) return this.health.check(checks) } } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 94b4281941..63cbe723d4 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -1,28 +1,36 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { registryConfigFactory } from '../../config/registry.config' +import { vaultConfigFactory } from '../../config/vault.config' 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' import { OpenCdsModule } from '../opencds/opencds.module' -import { RegistryModule } from '../registry/registry.module' +import { HarborModule, RegistryModule } from '../registry/registry.module' import { VaultModule } from '../vault/vault.module' import { HealthzController } from './healthz.controller' @Module({ imports: [ - TerminusModule, + TerminusModule.forRoot(), DatabaseModule, - KeycloakModule, - GitlabModule, - VaultModule, - NexusModule, - RegistryModule, - ArgoCDModule, - ConfigurationModule, - OpenCdsModule, + ConditionalModule.registerWhen(KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), 'USE_KEYCLOAK'), + ConditionalModule.registerWhen(GitlabModule.forRoot(gitlabConfigFactory.asProvider()), 'USE_GITLAB'), + ConditionalModule.registerWhen(VaultModule.forRoot(vaultConfigFactory.asProvider()), 'USE_VAULT'), + ConditionalModule.registerWhen(NexusModule.forRoot(nexusConfigFactory.asProvider()), 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule.forRoot(registryConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(HarborModule.forRoot(harborConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule.forRoot(openCdsConfigFactory.asProvider()), 'USE_OPENCDS'), ], controllers: [HealthzController], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts index 985b381e2b..d170ff59ee 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -4,8 +4,8 @@ import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' import type { FastifyRequest } from 'fastify' import type { DeepMockProxy } from 'vitest-mock-extended' import type { UserContext } from './auth-user.decorator' -import { mockDeep } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { const request = mockDeep() diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts index b0bbbe468e..23f7129d2e 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' import { AuthService } from './auth.service' import { DsoTokenModule } from './dso-token/dso-token.module' import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' @Module({ - imports: [DsoTokenModule, KeycloakJwtModule], + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], providers: [ AuthService, ], diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts index 985a16aa0f..6c7851d92d 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { JwtModule } from '@nestjs/jwt' -import { ConfigurationModule } from '../../configuration/configuration.module' import { DatabaseModule } from '../../database/database.module' import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' @@ -10,7 +9,7 @@ import { KeycloakJwtService } from './keycloak-jwt.service' imports: [ DatabaseModule, JwtModule.registerAsync({ - imports: [ConfigurationModule, KeycloakSecretProviderModule], + imports: [KeycloakSecretProviderModule], inject: [KeycloakSecretProviderService], useFactory: async (client: KeycloakSecretProviderService) => { // The issuer is fetched from the openid-configuration endpoint diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts index bdc7422816..602419cdcf 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -1,10 +1,14 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../../configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' @Module({ - imports: [ConfigurationModule, CacheModule.register()], + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], providers: [KeycloakSecretProviderService], exports: [KeycloakSecretProviderService], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts index b55dbdada3..9d7e632122 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -1,5 +1,6 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' import { faker } from '@faker-js/faker' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { JwtSecretRequestType } from '@nestjs/jwt' @@ -7,7 +8,7 @@ import { Test } from '@nestjs/testing' import { createCache } from 'cache-manager' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../../configuration/configuration.service' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' @@ -15,22 +16,23 @@ import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret describe('keycloakSecretProviderService', () => { let module: TestingModule let service: KeycloakSecretProviderService - let config: DeepMockProxy + let config: DeepMockProxy let fetchMock: ReturnType let cache: ReturnType beforeEach(async () => { - config = mockDeep({ - keycloakProtocol: 'https', - keycloakDomain: faker.internet.domainName(), - keycloakRealm: faker.lorem.word(), - keycloakJwksTimeoutMs: 1_000, - keycloakJwksCacheTtlMs: 300_000, - keycloakOpenidConfigurationCacheTtlMs: 300_000, - - getKeycloakOpenidConfigurationUrl() { - return `https://${this.keycloakDomain}/realms/${this.keycloakRealm}/.well-known/openid-configuration` - }, + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, }) fetchMock = vi.fn() cache = createCache() @@ -42,7 +44,7 @@ describe('keycloakSecretProviderService', () => { module = await Test.createTestingModule({ providers: [ KeycloakSecretProviderService, - { provide: ConfigurationService, useValue: config }, + { provide: KEYCLOAK_CONFIG, useValue: config }, { provide: CACHE_MANAGER, useValue: cache }, ], }).compile() @@ -55,18 +57,16 @@ describe('keycloakSecretProviderService', () => { }) it('should fetch JWKS from Keycloak and parse the response', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` - const internalJwksUri = `${config.keycloakProtocol}://${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) const jwks = await service.fetchSigningKeys() expect(fetchMock).toHaveBeenCalledTimes(2) - expect(fetchMock.mock.calls[0]?.[0]).toBe( - `https://${config.keycloakDomain}/realms/${config.keycloakRealm}/.well-known/openid-configuration`, - ) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) expect(jwks).toEqual({ keys: [ @@ -83,8 +83,8 @@ describe('keycloakSecretProviderService', () => { it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { vi.useFakeTimers() - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) @@ -97,8 +97,8 @@ describe('keycloakSecretProviderService', () => { }) it('should return undefined when Keycloak returns a non-OK response', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) @@ -106,8 +106,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve a PEM public key from the JWKS', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) @@ -131,8 +131,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve the secret directly from the JWT token and request type', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') @@ -166,8 +166,8 @@ describe('keycloakSecretProviderService', () => { }) it('should reject JWTs when the key cannot be resolved', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') @@ -178,8 +178,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve the issuer from openid-configuration', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) await expect(service.fetchIssuer()).resolves.toBe(issuer) @@ -187,20 +187,20 @@ describe('keycloakSecretProviderService', () => { }) it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - issuer: `https://${config.keycloakDomain}/realms/${config.keycloakRealm}`, + issuer: `https://${config.domain}/realms/${config.realm}`, jwks_uri: publicJwksUri, }))) await expect(service.fetchJwksUri()).resolves.toBe( - `https://${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs`, + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, ) }) it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { - config.keycloakDomain = undefined - const publicJwksUri = `https://public.example.test/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer: 'https://public.example.test/realms/test', jwks_uri: publicJwksUri, diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts index f089d4a9a3..f2486c1882 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -1,10 +1,11 @@ import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' import { createPublicKey } from 'node:crypto' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' import { JwtSecretRequestType } from '@nestjs/jwt' import { z } from 'zod' -import { ConfigurationService } from '../../configuration/configuration.service' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' const OpenidConfigurationSchema = z.object({ @@ -35,16 +36,19 @@ export class KeycloakSecretProviderService { private readonly logger = new Logger(KeycloakSecretProviderService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, ) {} async fetchOpenIdConfig(): Promise { - const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.config.getKeycloakOpenidConfigurationUrl()) + const openidUrl = this.keycloakConfig.openidConfigurationUrl + if (!openidUrl) return undefined + + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(openidUrl) const cached = await this.cache.get(cacheKey) if (cached) return cached - const response = await fetch(this.config.getKeycloakOpenidConfigurationUrl()) + const response = await fetch(openidUrl) if (!response.ok) { this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) return undefined @@ -57,7 +61,7 @@ export class KeycloakSecretProviderService { return undefined } - await this.cache.set(cacheKey, config.data, this.config.keycloakOpenidConfigurationCacheTtlMs) + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) return config.data } @@ -72,13 +76,13 @@ export class KeycloakSecretProviderService { } private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { - if (!this.config.keycloakDomain) { + if (!this.keycloakConfig.domain) { this.logger.log(`No internal domain configured, returning original JWKS URI: ${jwksUri}`) return jwksUri } const url = new URL(jwksUri) - url.protocol = this.config.keycloakProtocol ?? url.protocol - url.host = this.config.keycloakDomain ?? url.host + url.protocol = this.keycloakConfig.protocol ?? url.protocol + url.host = this.keycloakConfig.domain ?? url.host this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) return url.toString() } @@ -88,7 +92,7 @@ export class KeycloakSecretProviderService { if (!jwksUri) return undefined const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), this.config.keycloakJwksTimeoutMs) + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) try { const response = await fetch(jwksUri, { signal: controller.signal }) @@ -124,7 +128,7 @@ export class KeycloakSecretProviderService { }) const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string - await this.cache.set(cacheKey, pem, this.config.keycloakJwksCacheTtlMs) + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) return pem } diff --git a/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts deleted file mode 100644 index b563056136..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' - -import { ConfigurationService } from './configuration.service' - -const pathList: string[] = [] - -if (process.env.DOCKER !== 'true') { - pathList.push('.env') -} - -if (process.env.INTEGRATION === 'true') { - pathList.push('.env.integ') -} - -@Module({ - imports: [ - ConfigModule.forRoot({ - envFilePath: pathList, - }), - ], - providers: [ConfigurationService], - exports: [ConfigurationService], -}) -export class ConfigurationModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts deleted file mode 100644 index a7c4c7a245..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { ConfigurationService } from './configuration.service' - -describe('configurationService', () => { - let service: ConfigurationService - - beforeEach(() => { - vi.clearAllMocks() - vi.unstubAllEnvs() - - // KEYCLOAK_PUBLIC_PROTOCOL and KEYCLOAK_PUBLIC_DOMAIN are intentionally absent for these tests - vi.stubEnv('KEYCLOAK_PROTOCOL', 'http') - vi.stubEnv('KEYCLOAK_DOMAIN', 'keycloak.example.com') - vi.stubEnv('KEYCLOAK_REALM', 'cloud-pi-native') - }) - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile() - - service = module.get(ConfigurationService) - }) - - afterEach(() => { - vi.unstubAllEnvs() - vi.restoreAllMocks() - }) - - it('should be defined', () => { - expect(service).toBeDefined() - }) - - describe('keycloak URL derivation', () => { - it('should derive the internal URL from protocol + domain', () => { - expect(service.getKeycloakUrl()).toBe('http://keycloak.example.com') - }) - - it('should derive the realm URL from the internal URL', () => { - expect(service.getKeycloakRealmUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native', - ) - }) - - it('should derive the openid-configuration URL from the realm URL', () => { - expect(service.getKeycloakOpenidConfigurationUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native/.well-known/openid-configuration', - ) - }) - - it('should throw when Keycloak protocol or domain is missing', () => { - service.keycloakProtocol = '' - service.keycloakDomain = 'keycloak.example.com' - expect(() => service.getKeycloakUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - expect(() => service.getKeycloakRealmUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - - service.keycloakProtocol = 'http' - service.keycloakDomain = '' - expect(() => service.getKeycloakUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - expect(() => service.getKeycloakRealmUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - }) - }) - - describe('internal-or-public URL helpers', () => { - it('should prefer internal over public URL for GitLab, Vault, Harbor, Nexus, SonarQube', async () => { - vi.stubEnv('GITLAB_URL', 'https://gitlab.public') - vi.stubEnv('VAULT_URL', 'https://vault.public') - vi.stubEnv('HARBOR_URL', 'https://harbor.public') - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - vi.stubEnv('SONARQUBE_URL', 'https://sonar.public') - vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal') - vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') - vi.stubEnv('HARBOR_INTERNAL_URL', 'https://harbor.internal') - vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonar.internal') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.internal') - expect(testService.getInternalOrPublicVaultUrl()).toBe('https://vault.internal') - expect(testService.getInternalOrPublicHarborUrl()).toBe('https://harbor.internal') - expect(testService.getInternalOrPublicNexusUrl()).toBe('https://nexus.internal') - expect(testService.getInternalOrPublicSonarqubeUrl()).toBe('https://sonar.internal') - }) - - it('should fall back to public URL for GitLab, Vault, Harbor, Nexus, SonarQube when internal is unset', async () => { - vi.stubEnv('GITLAB_URL', 'https://gitlab.public') - vi.stubEnv('GITLAB_INTERNAL_URL', '') - vi.stubEnv('VAULT_URL', 'https://vault.public') - vi.stubEnv('VAULT_INTERNAL_URL', '') - vi.stubEnv('HARBOR_URL', 'https://harbor.public') - vi.stubEnv('HARBOR_INTERNAL_URL', '') - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - vi.stubEnv('NEXUS_INTERNAL_URL', '') - vi.stubEnv('SONARQUBE_URL', 'https://sonar.public') - vi.stubEnv('SONARQUBE_INTERNAL_URL', '') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.public') - expect(testService.getInternalOrPublicVaultUrl()).toBe('https://vault.public') - expect(testService.getInternalOrPublicHarborUrl()).toBe('https://harbor.public') - expect(testService.getInternalOrPublicNexusUrl()).toBe('https://nexus.public') - expect(testService.getInternalOrPublicSonarqubeUrl()).toBe('https://sonar.public') - }) - - it('should return undefined for internal-or-public URL when neither side is configured', async () => { - vi.stubEnv('GITLAB_URL', '') - vi.stubEnv('GITLAB_INTERNAL_URL', '') - vi.stubEnv('VAULT_URL', '') - vi.stubEnv('VAULT_INTERNAL_URL', '') - vi.stubEnv('HARBOR_URL', '') - vi.stubEnv('HARBOR_INTERNAL_URL', '') - vi.stubEnv('NEXUS_URL', '') - vi.stubEnv('NEXUS_INTERNAL_URL', '') - vi.stubEnv('SONARQUBE_URL', '') - vi.stubEnv('SONARQUBE_INTERNAL_URL', '') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBeUndefined() - expect(testService.getInternalOrPublicVaultUrl()).toBeUndefined() - expect(testService.getInternalOrPublicHarborUrl()).toBeUndefined() - expect(testService.getInternalOrPublicNexusUrl()).toBeUndefined() - expect(testService.getInternalOrPublicSonarqubeUrl()).toBeUndefined() - }) - }) - - describe('conditional toggles and computed fields', () => { - it('should default NODE_ENV to production and map explicit test/development values', async () => { - vi.stubEnv('NODE_ENV', '') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(testService.NODE_ENV).toBe('production') - }) - - it('should map NODE_ENV=test to "test" and NODE_ENV=development to "development"', async () => { - vi.stubEnv('NODE_ENV', 'test') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(testService.NODE_ENV).toBe('test') - - vi.stubEnv('NODE_ENV', 'development') - const devService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(devService.NODE_ENV).toBe('development') - }) - - it('should expose the requested app version in production, else "dev"', async () => { - vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('APP_VERSION', '1.2.3') - const prod = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(prod.appVersion).toBe('1.2.3') - - vi.unstubAllEnvs() - vi.stubEnv('NODE_ENV', 'production') - const prodUnset = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(prodUnset.appVersion).toBe('unknown') - - vi.unstubAllEnvs() - vi.stubEnv('NODE_ENV', 'development') - vi.stubEnv('APP_VERSION', '1.2.3') - const dev = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(dev.appVersion).toBe('dev') - }) - - it('should expose nexusSecretExposedUrl based on the internal-url toggle', async () => { - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - const off = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(off.nexusSecretExposedUrl).toBe('https://nexus.public') - - vi.stubEnv('NEXUS__SECRET_EXPOSE_INTERNAL_URL', 'true') - vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - const on = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(on.nexusSecretExposedUrl).toBe('https://nexus.internal') - }) - - it('should disable TLS verification for Open CDS only when explicitly set to false', async () => { - vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', '') - - const defaultService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(defaultService.openCdsApiTlsRejectUnauthorized).toBe(true) - - vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', 'false') - const disabled = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(disabled.openCdsApiTlsRejectUnauthorized).toBe(false) - }) - }) -}) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts deleted file mode 100644 index 67d925fc11..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' - -@Injectable() -export class ConfigurationService { - private readonly logger = new Logger(ConfigurationService.name) - - // application mode - isDev = process.env.NODE_ENV === 'development' - isTest = process.env.NODE_ENV === 'test' - isProd = process.env.NODE_ENV === 'production' - isInt = process.env.INTEGRATION === 'true' - isCI = process.env.CI === 'true' - isDevSetup = process.env.DEV_SETUP === 'true' - - // app - host = process.env.SERVER_HOST ?? 'localhost' - port = process.env.SERVER_PORT ? Number(process.env.SERVER_PORT) : 0 // dynamically allocate an available ephemeral port - appVersion = this.isProd ? (process.env.APP_VERSION ?? 'unknown') : 'dev' - - // db - dbUrl = process.env.DB_URL - - // keycloak - sessionSecret = process.env.SESSION_SECRET - keycloakProtocol = process.env.KEYCLOAK_PROTOCOL - keycloakDomain = process.env.KEYCLOAK_DOMAIN - keycloakPublicProtocol = process.env.KEYCLOAK_PUBLIC_PROTOCOL - keycloakPublicDomain = process.env.KEYCLOAK_PUBLIC_DOMAIN - keycloakRealm = process.env.KEYCLOAK_REALM - keycloakClientId = process.env.KEYCLOAK_CLIENT_ID - keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET - keycloakAdmin = process.env.KEYCLOAK_ADMIN - keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD - keycloakAdminClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID ?? 'admin-cli' - keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI - - // JWKS cache TTL in ms (default 5 min); Keycloak rotates keys periodically - keycloakJwksCacheTtlMs = Number(process.env.KEYCLOAK_JWKS_CACHE_TTL_MS ?? 300_000) - // JWKS fetch timeout in ms (default 5 s); avoids hanging on cache misses - keycloakJwksTimeoutMs = Number(process.env.KEYCLOAK_JWKS_TIMEOUT_MS ?? 5_000) - // openid-configuration cache TTL in ms (default 5 min); avoid repeated discovery lookups - keycloakOpenidConfigurationCacheTtlMs = Number(process.env.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS ?? 300_000) - - adminsUserId = process.env.ADMIN_KC_USER_ID - ? process.env.ADMIN_KC_USER_ID.split(',') - : [] - - contactEmail - = process.env.CONTACT_EMAIL - ?? 'cloudpinative-relations@interieur.gouv.fr' - - // argocd - argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd' - argocdUrl = process.env.ARGOCD_URL - argocdInternalUrl = process.env.ARGOCD_INTERNAL_URL - argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES - - // dso - dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0' - dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5' - - // opencds - openCdsUrl = process.env.OPENCDS_URL - openCdsApiToken = process.env.OPENCDS_API_TOKEN - openCdsApiTlsRejectUnauthorized = process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false' - - // plugins - mockPlugins = process.env.MOCK_PLUGINS === 'true' - projectRootDir = process.env.PROJECTS_ROOT_DIR - pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' - - // gitlab - gitlabToken = process.env.GITLAB_TOKEN - gitlabUrl = process.env.GITLAB_URL - gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL - - gitlabMirrorTokenExpirationDays = Number(process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS ?? 180) - gitlabMirrorTokenRotationThresholdDays = Number(process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS ?? 90) - - // vault - vaultToken = process.env.VAULT_TOKEN - vaultUrl = process.env.VAULT_URL - vaultInternalUrl = process.env.VAULT_INTERNAL_URL - - vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso' - deployVaultConnectionInNamespaces = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' - - // registry (harbor) - harborUrl = process.env.HARBOR_URL - harborInternalUrl = process.env.HARBOR_INTERNAL_URL - harborAdmin = process.env.HARBOR_ADMIN - harborAdminPassword = process.env.HARBOR_ADMIN_PASSWORD - harborRuleTemplate = process.env.HARBOR_RULE_TEMPLATE - harborRuleCount = process.env.HARBOR_RULE_COUNT - harborRetentionCron = process.env.HARBOR_RETENTION_CRON ?? '0 22 2 * * *' - harborRobotRotationThresholdDays = Number(process.env.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS ?? 90) - harborProjectSlugCacheTtlMs = Number(process.env.HARBOR_PROJECT_SLUG_CACHE_TTL_MS ?? 300_000) - - // nexus - nexusUrl = process.env.NEXUS_URL - nexusInternalUrl = process.env.NEXUS_INTERNAL_URL - nexusAdmin = process.env.NEXUS_ADMIN - nexusAdminPassword = process.env.NEXUS_ADMIN_PASSWORD - nexusSecretExposedUrl - = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' - ? process.env.NEXUS_INTERNAL_URL - : process.env.NEXUS_URL - - // sonarqube - sonarqubeUrl = process.env.SONARQUBE_URL - sonarqubeInternalUrl = process.env.SONARQUBE_INTERNAL_URL - sonarApiToken = process.env.SONAR_API_TOKEN - - getKeycloakRealmUrl() { - return `${this.getKeycloakUrl()}/realms/${this.keycloakRealm}` - } - - getKeycloakOpenidConfigurationUrl() { - const url = `${this.getKeycloakRealmUrl()}/.well-known/openid-configuration` - this.logger.log(`Keycloak openid-configuration URL resolved: ${url}`) - return url - } - - getKeycloakUrl() { - if (!this.keycloakProtocol || !this.keycloakDomain) { - throw new Error(`Keycloak protocol or domain is not configured.`) - } - const url = `${this.keycloakProtocol}://${this.keycloakDomain}` - this.logger.log(`Keycloak internal URL resolved: ${url}`) - return url - } - - getInternalOrPublicArgoCDUrl() { - const url = this.argocdInternalUrl ?? this.argocdUrl - this.logger.log(`ArgoCD URL resolved: ${url} (${this.argocdInternalUrl ? 'internal' : 'public'})`) - return url - } - - getInternalOrPublicGitlabUrl() { - return this.getInternalOrPublicUrl('GitLab', this.gitlabUrl, this.gitlabInternalUrl) - } - - getInternalOrPublicVaultUrl() { - return this.getInternalOrPublicUrl('Vault', this.vaultUrl, this.vaultInternalUrl) - } - - getInternalOrPublicHarborUrl() { - return this.getInternalOrPublicUrl('Harbor', this.harborUrl, this.harborInternalUrl) - } - - getInternalOrPublicNexusUrl() { - return this.getInternalOrPublicUrl('Nexus', this.nexusUrl, this.nexusInternalUrl) - } - - getInternalOrPublicSonarqubeUrl() { - return this.getInternalOrPublicUrl('SonarQube', this.sonarqubeUrl, this.sonarqubeInternalUrl) - } - - getInternalOrPublicUrl(name: string, publicUrl: string | undefined, internalUrl: string | undefined): string | undefined { - const trimedInternalUrl = internalUrl?.trim() - const trimmedPublicUrl = publicUrl?.trim() - const url = trimedInternalUrl || trimmedPublicUrl || undefined - let label = 'none' - if (trimedInternalUrl) { - label = 'internal' - } else if (trimmedPublicUrl) { - label = 'public' - } - this.logger.log(`${name} URL resolved: ${url ?? 'none'} (${label})`) - return url - } - - NODE_ENV - = process.env.NODE_ENV === 'test' - ? 'test' - : process.env.NODE_ENV === 'development' - ? 'development' - : 'production' -} diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) 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 9919a78be6..7e909376ec 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common' 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, TerminusModule], + imports: [TerminusModule], providers: [DatabaseHealthService, DatabaseService, PrismaService], exports: [DatabaseHealthService, DatabaseService, PrismaService], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts index 416c82980d..597005be27 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -1,8 +1,9 @@ import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { ConfigurationModule } from '../configuration/configuration.module' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @@ -10,17 +11,12 @@ describe('databaseService', () => { let service: DatabaseService beforeEach(async () => { + const baseConfig = mockDeep() const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigurationModule], providers: [ DatabaseService, - { - provide: PrismaService, - useValue: { - $connect: vi.fn().mockResolvedValue(undefined), - $disconnect: vi.fn().mockResolvedValue(undefined), - }, - }, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts index 695a2daf5a..5d9d0c32f7 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -1,6 +1,7 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' import { setTimeout } from 'node:timers/promises' import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigurationService } from '../configuration/configuration.service' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' import { PrismaService } from './prisma.service' @Injectable() @@ -9,7 +10,7 @@ export class DatabaseService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly configurationService: ConfigurationService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, ) { this.DELAY_BEFORE_RETRY = this.configurationService.isTest || this.configurationService.isCI diff --git a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts index b280084626..8eb62bea67 100644 --- a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' @Module({ - imports: [EventEmitterModule.forRoot()], + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], exports: [EventEmitterModule], }) export class EventsModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts index e29dae241f..25b2a4e695 100644 --- a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { AuthModule } from './auth/auth.module' -import { ConfigurationModule } from './configuration/configuration.module' import { DatabaseModule } from './database/database.module' import { EventsModule } from './events/events.module' import { LoggerModule } from './logger/logger.module' @@ -8,7 +7,7 @@ import { PermissionModule } from './permission/permission.module' @Module({ providers: [], - imports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], - exports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }) export class InfrastructureModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts index 9da4f77584..257ef429f8 100644 --- a/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -1,19 +1,18 @@ +import type { BaseConfig } from '../config/base.config' import { getLoggerOptions } from '@cpn-console/logger' import { Module } from '@nestjs/common' import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' - -import { ConfigurationModule } from '../configuration/configuration.module' -import { ConfigurationService } from '../configuration/configuration.service' +import { BASE_CONFIG } from '../config/base.config' @Module({ imports: [ PinoLoggerModule.forRootAsync({ - imports: [ConfigurationModule], - inject: [ConfigurationService], - useFactory: async (configService: ConfigurationService) => { + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { return { pinoHttp: { - ...getLoggerOptions(configService.isProd ? 'production' : 'development', configService.isTest ? 'info' : 'debug'), + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), customLogLevel: (req, res, err) => { if (err || res.statusCode >= 500) { return 'error' 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 c0fa32e421..c4ef244ee5 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 @@ -1,4 +1,5 @@ import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' import KcAdminClient from '@keycloak/keycloak-admin-client' import { ScheduleModule } from '@nestjs/schedule' import { Test } from '@nestjs/testing' @@ -6,9 +7,9 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' const keycloakUrl = 'https://keycloak.internal' const projectRealm = 'project-realm' @@ -37,19 +38,19 @@ function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantT return tokenRequests } -function createKeycloakClientServiceTestingModule(config: Partial = {}) { +function createKeycloakClientServiceTestingModule(config: Partial = {}) { return Test.createTestingModule({ imports: [ScheduleModule.forRoot()], providers: [ KeycloakClientService, { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, { - provide: ConfigurationService, - useValue: mockDeep({ - keycloakRealm: projectRealm, - keycloakAdmin: 'admin', - keycloakAdminPassword: 'admin-password', - keycloakAdminClientId: 'admin-cli', + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', ...config, }), }, @@ -163,7 +164,7 @@ describe('keycloakClientService authentication lifecycle', () => { it('should not authenticate nor refresh the token when the Keycloak realm is not configured', async () => { const tokenRequests = useTokenEndpoint() await module.close() - module = await createKeycloakClientServiceTestingModule({ keycloakRealm: undefined }).compile() + module = await createKeycloakClientServiceTestingModule({ realm: undefined }).compile() await expect(module.init()).rejects.toThrow() @@ -174,7 +175,7 @@ describe('keycloakClientService authentication lifecycle', () => { it('should not authenticate nor refresh the token when the admin credentials are not configured', async () => { const tokenRequests = useTokenEndpoint() await module.close() - module = await createKeycloakClientServiceTestingModule({ keycloakAdminPassword: undefined }).compile() + module = await createKeycloakClientServiceTestingModule({ adminPassword: undefined }).compile() await expect(module.init()).rejects.toThrow() 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 cbe7c3e2dc..2f51a2edf8 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -4,15 +4,16 @@ import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/us import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' import type { OnModuleInit } from '@nestjs/common' import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' import type { GroupRepresentationWith } from './keycloak.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { Interval } from '@nestjs/schedule' import { trace } from '@opentelemetry/api' import z from 'zod' -import { getErrorResponseStatus } from '../../utils/http-error' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { getErrorResponseStatus } from '../../utils/http.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') @@ -23,7 +24,7 @@ export class KeycloakClientService implements OnModuleInit { private authenticated = false constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, ) { } @@ -274,17 +275,17 @@ export class KeycloakClientService implements OnModuleInit { } async onModuleInit() { - if (!this.config.keycloakRealm) { + if (!this.keycloakConfig.realm) { throw new Error('Keycloak realm is not configured') } - if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { + if (!this.keycloakConfig.admin || !this.keycloakConfig.adminPassword) { throw new Error('Keycloak admin username or password is not configured') } - if (!this.config.keycloakAdminClientId) { + if (!this.keycloakConfig.adminClientId) { throw new Error('Keycloak admin client id is not configured') } try { - this.logger.log(`Authenticating Keycloak admin client (realm=${this.config.keycloakRealm})`) + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) await this.authenticate(this.passwordCredentials()) } catch (err) { if (err instanceof Error) { @@ -294,9 +295,9 @@ export class KeycloakClientService implements OnModuleInit { } throw err } - this.client.setConfig({ realmName: this.config.keycloakRealm }) + this.client.setConfig({ realmName: this.keycloakConfig.realm }) this.authenticated = true - this.logger.log(`Keycloak Admin Client authenticated (realm=${this.config.keycloakRealm})`) + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) } // The admin client never refreshes its token on its own; without this the @@ -321,18 +322,18 @@ export class KeycloakClientService implements OnModuleInit { // Checked by onModuleInit before any authentication; the getter narrows the // config value so Credentials.clientId stays a plain string private get adminClientId(): string { - if (!this.config.keycloakAdminClientId) { + if (!this.keycloakConfig.adminClientId) { throw new Error('KEYCLOAK_ADMIN_CLIENT_ID is not configured') } - return this.config.keycloakAdminClientId + return this.keycloakConfig.adminClientId } private passwordCredentials(): Credentials { return { clientId: this.adminClientId, grantType: PASSWORD_GRANT_TYPE, - username: this.config.keycloakAdmin, - password: this.config.keycloakAdminPassword, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, } } 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 e2d1c31286..051cbee381 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -1,19 +1,20 @@ +import type { KeycloakConfig } from './keycloak.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' @Injectable() export class KeycloakHealthService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const url = this.config.getKeycloakOpenidConfigurationUrl() + const url = this.keycloakConfig.openidConfigurationUrl if (!url) return indicator.down('Not configured') try { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts index 9ae808fc65..7186de782a 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -1,23 +1,23 @@ +import type { KeycloakConfig } from './keycloak.module-definition' import KcAdminClient from '@keycloak/keycloak-admin-client' import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' 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' import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' import { KeycloakService } from './keycloak.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [DatabaseModule, TerminusModule], providers: [ { - inject: [ConfigurationService], + inject: [KEYCLOAK_CONFIG], provide: KEYCLOAK_ADMIN_CLIENT, - useFactory: (config: ConfigurationService) => new KcAdminClient({ - baseUrl: config.getKeycloakUrl(), + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, }), }, KeycloakClientService, @@ -28,4 +28,4 @@ import { KeycloakService } from './keycloak.service' ], exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], }) -export class KeycloakModule {} +export class KeycloakModule extends ConfigurableModuleClass {} 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 4d8d6e726a..e5b116688a 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -19,7 +19,7 @@ import { KeycloakService } from './keycloak.service' describe('keycloakService', () => { let service: KeycloakService let keycloak: DeepMockProxy - let keycloakDatastore: DeepMockProxy + let datastore: DeepMockProxy beforeEach(async () => { keycloak = mockDeep({ @@ -31,7 +31,7 @@ describe('keycloakService', () => { getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), deleteGroup: vi.fn().mockResolvedValue(undefined), }) - keycloakDatastore = mockDeep({ + datastore = mockDeep({ getAllAdminRoles: vi.fn().mockResolvedValue([]), getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), }) @@ -40,7 +40,7 @@ describe('keycloakService', () => { providers: [ KeycloakService, { provide: KeycloakClientService, useValue: keycloak }, - { provide: KeycloakDatastoreService, useValue: keycloakDatastore }, + { provide: KeycloakDatastoreService, useValue: datastore }, ], }).compile() @@ -66,9 +66,9 @@ describe('keycloakService', () => { const users: UserWithAdminRoles[] = [ { id: 'user-1', adminRoleIds: ['admin-role-id'] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) await service.handleCron() @@ -86,9 +86,9 @@ 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.getAllProjects.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) @@ -105,7 +105,7 @@ describe('keycloakService', () => { }) it('should purge orphans', async () => { - keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAllProjects.mockResolvedValue([mockProject]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) const orphanGroup = makeGroupRepresentation({ @@ -124,7 +124,7 @@ describe('keycloakService', () => { keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() - expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllProjects).toHaveBeenCalled() expect(keycloak.getAllGroups).toHaveBeenCalled() expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') @@ -140,7 +140,7 @@ describe('keycloakService', () => { }), ], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) @@ -180,7 +180,7 @@ describe('keycloakService', () => { ], roles: [roleWithOidc], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -218,7 +218,7 @@ describe('keycloakService', () => { ...mockProject, environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) const projectGroup = makeGroupRepresentation({ id: 'group-id', @@ -290,7 +290,7 @@ describe('keycloakService', () => { ], environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', @@ -349,7 +349,7 @@ describe('keycloakService', () => { ], roles: [roleManaged, roleExternal, roleGlobal], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -416,7 +416,7 @@ describe('keycloakService', () => { ], roles: [roleSystemManaged], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index f6a3f6c0d5..f737e77bcd 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -7,7 +7,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import z from 'zod' -import { getErrorResponseStatus } from '../../utils/http-error' +import { getErrorResponseStatus } from '../../utils/http.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { KeycloakClientService } from './keycloak-client.service' @@ -20,7 +20,7 @@ export class KeycloakService { constructor( @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, - @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, ) { this.logger.log('KeycloakService initialized') } @@ -59,9 +59,9 @@ export class KeycloakService { const span = trace.getActiveSpan() this.logger.log('Starting periodic Keycloak reconciliation') const [projects, adminRoles, users] = await Promise.all([ - this.keycloakDatastore.getAllProjects(), - this.keycloakDatastore.getAllAdminRoles(), - this.keycloakDatastore.getAllUsersWithAdminRoleIds(), + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), ]) span?.setAttributes({ 'keycloak.projects.count': projects.length, 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 bd7d4f85bd..5ac2c471b3 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 @@ -1,4 +1,6 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -6,9 +8,9 @@ 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 { NexusClientService } from './nexus-client.service' import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' const nexusUrl = 'https://nexus.internal' @@ -18,18 +20,16 @@ const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').to describe('nexusClientService', () => { let service: NexusClientService - let config: DeepMockProxy + let config: DeepMockProxy> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep({ - nexusSecretExposedUrl: 'https://nexus.example', - nexusInternalUrl: nexusUrl, - nexusAdmin: 'admin', - nexusAdminPassword, - projectRootDir: 'forge', - getInternalOrPublicNexusUrl: () => nexusUrl, + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, }) const module = await Test.createTestingModule({ @@ -37,7 +37,7 @@ describe('nexusClientService', () => { NexusClientService, NexusHttpClientService, { - provide: ConfigurationService, + provide: NEXUS_CONFIG, useValue: config, }, ], diff --git a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts index 9453ee1d10..a883950b2f 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -1,22 +1,23 @@ +import type { NexusConfig } from './nexus.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { NEXUS_CONFIG } from './nexus.module-definition' @Injectable() export class NexusHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.nexusInternalUrl) return indicator.down('Not configured') + const url = this.nexusConfig.probeUrl + if (!url) return indicator.down('Not configured') - const url = new URL('/service/rest/v1/status', this.config.nexusInternalUrl).toString() const headers: Record = {} - if (this.config.nexusAdmin && this.config.nexusAdminPassword) { - const credentials = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + if (this.nexusConfig.admin && this.nexusConfig.adminPassword) { + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` const encoded = Buffer.from(credentials).toString('base64') headers.Authorization = `Basic ${encoded}` } diff --git a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts index e89cc70aa4..8575a4cd18 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -1,7 +1,8 @@ +import type { NexusConfig } from './nexus.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' export interface NexusFetchOptions { method?: string @@ -44,7 +45,7 @@ export class NexusError extends Error { @Injectable() export class NexusHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, ) {} @StartActiveSpan() @@ -76,7 +77,7 @@ export class NexusHttpClientService { } private get baseUrl() { - const url = this.config.getInternalOrPublicNexusUrl() + const url = this.nexusConfig.internalOrPublicUrl if (!url) { throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') } @@ -88,13 +89,13 @@ export class NexusHttpClientService { } private get basicAuth() { - if (!this.config.nexusAdmin) { + if (!this.nexusConfig.admin) { throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') } - if (!this.config.nexusAdminPassword) { + if (!this.nexusConfig.adminPassword) { throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') } - const raw = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` return Buffer.from(raw, 'utf8').toString('base64') } diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts index 43dc717c40..eab6e89046 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -1,25 +1,27 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' describe('nexusPluginService', () => { let service: NexusPluginService - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { - config = mockDeep({ - nexusUrl: 'https://nexus.public/', - nexusInternalUrl: 'https://nexus.internal/', + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ NexusPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: NEXUS_CONFIG, useValue: config }, ], }).compile() 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 c6ccab09bb..30c6214261 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -1,21 +1,21 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { NEXUS_CONFIG } from './nexus.module-definition' @Injectable() export class NexusPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, ) {} infos(): ServiceInfos { return { name: 'nexus', to: () => { - if (!this.config.nexusUrl) return undefined - return this.config.nexusUrl + if (!this.nexusConfig.url) return undefined + return this.nexusConfig.url }, title: 'Nexus', imgSrc: '/img/nexus.png', diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts index 14d706232d..25f0e992ce 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.module.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { NexusClientService } from './nexus-client.service' @@ -8,10 +10,11 @@ import { NexusDatastoreService } from './nexus-datastore.service' import { NexusHealthService } from './nexus-health.service' import { NexusHttpClientService } from './nexus-http-client.service' import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' import { NexusService } from './nexus.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ NexusHealthService, NexusPluginService, @@ -22,4 +25,4 @@ import { NexusService } from './nexus.service' ], exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], }) -export class NexusModule {} +export class NexusModule extends ConfigurableModuleClass {} 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 41674dc730..9963b5e4c5 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -1,12 +1,16 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' import { DISABLED, ENABLED } from '@cpn-console/shared' 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 { BASE_CONFIG } from '../infrastructure/config/base.config' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { NexusClientService } from './nexus-client.service' import { NexusDatastoreService } from './nexus-datastore.service' import { makeProjectWithDetails } from './nexus-testing.utils' @@ -19,6 +23,7 @@ import { PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' import { NexusService } from './nexus.service' describe('nexusService', () => { @@ -26,7 +31,9 @@ describe('nexusService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { client = mockDeep({ @@ -45,7 +52,9 @@ describe('nexusService', () => { vault = mockDeep({ read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), }) - config = mockDeep({ projectRootDir: 'forge' }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) const module = await Test.createTestingModule({ providers: [ @@ -53,7 +62,9 @@ describe('nexusService', () => { { provide: NexusClientService, useValue: client }, { provide: NexusDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index 6b78c7ad25..52061ace32 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -1,6 +1,8 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { NexusPrivilege } from './nexus-client.service' import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' import type { MavenHostedRepoKind, } from './nexus.utils' @@ -8,7 +10,7 @@ import { specificallyEnabled } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -34,6 +36,7 @@ import { PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' import { generateMavenHostedRepoName, generateNpmHostedRepoName, @@ -52,10 +55,11 @@ export class NexusService { private readonly logger = new Logger(NexusService.name) constructor( - @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, @Inject(NexusClientService) private readonly client: NexusClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) { this.logger.log('NexusService initialized') } @@ -71,7 +75,7 @@ export class NexusService { span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project upsert for ${project.slug}`) await this.ensureProject(project) - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() await this.ensurePlatformRoles(projects) } @@ -86,7 +90,7 @@ export class NexusService { span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project delete for ${project.slug}`) await this.deleteProject(project) - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() await this.ensurePlatformRoles(projects) } @@ -95,7 +99,7 @@ export class NexusService { async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Nexus reconciliation') - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('nexus.projects.count', projects.length) await this.ensureProjects(projects) await this.ensurePlatformRoles(projects) @@ -429,7 +433,7 @@ export class NexusService { } private async ensureUser(project: ProjectWithDetails) { - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') let existingPassword: string | undefined try { existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) @@ -487,7 +491,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(PLUGIN_NAME, key) + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) } private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { @@ -511,10 +515,10 @@ export class NexusService { } private async ensurePlatformRoles(projects: ProjectWithDetails[]) { - const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS - const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_READ_GROUP_PATHS const readonlyPrivileges = new Set() @@ -574,7 +578,7 @@ export class NexusService { this.client.deleteSecurityUsers(project.slug), ]) - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') try { await this.vault.delete(vaultPath) } catch (error) { diff --git a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts index f91f493f5b..1b4773bba4 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts +++ b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -1,26 +1,26 @@ +import type { OpenCdsConfig } from './opencds.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { OPENCDS_CONFIG } from './opencds.module-definition' @Injectable() export class OpenCdsHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.openCdsUrl) return indicator.down('Not configured') + if (!this.opencdsConfig.probeUrl) return indicator.down('Not configured') try { - const url = new URL('/api/v1/health', this.config.openCdsUrl).toString() const headers: Record = {} - if (this.config.openCdsApiToken) { - headers.Authorization = `Bearer ${this.config.openCdsApiToken}` + if (this.opencdsConfig.apiToken) { + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` } - const response = await fetch(url, { headers }) + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/apps/server-nestjs/src/modules/opencds/opencds.module.ts index 8b42ddee91..edc85a987a 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 { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' @Module({ - imports: [ConfigurationModule, TerminusModule], + imports: [TerminusModule], providers: [OpenCdsHealthService], exports: [OpenCdsHealthService], }) -export class OpenCdsModule {} +export class OpenCdsModule extends ConfigurableModuleClass {} 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..1ef4cac975 --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, 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('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.ts index c8faef2fc8..b5450adea6 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -1,15 +1,33 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { argocdConfigFactory } from '../../config/argocd.config' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { registryConfigFactory } from '../../config/registry.config' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { vaultConfigFactory } from '../../config/vault.config' 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 { HarborModule, RegistryModule } from '../registry/registry.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.forRoot(argocdConfigFactory.asProvider()), 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule.forRoot(gitlabConfigFactory.asProvider()), 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule.forRoot(registryConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(HarborModule.forRoot(harborConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), 'USE_KEYCLOAK'), + ConditionalModule.registerWhen(NexusModule.forRoot(nexusConfigFactory.asProvider()), 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule.forRoot(sonarqubeConfigFactory.asProvider()), 'USE_SONARQUBE'), + ConditionalModule.registerWhen(VaultModule.forRoot(vaultConfigFactory.asProvider()), 'USE_VAULT'), + ], providers: [PluginService], exports: [PluginService], }) 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 5e523c6c62..481aeddcef 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,4 +1,5 @@ import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' import { AppEventsModule } from '../events/app-events.module' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' @@ -8,7 +9,7 @@ import { ProjectMembersController } from './project-members.controller' import { ProjectMembersService } from './project-members.service' @Module({ - imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule, ProjectPermissionModule], + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], controllers: [ProjectMembersController], providers: [ProjectMembersService], }) diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) 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..1811cf6eed 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,6 +1,5 @@ import { Module } from '@nestjs/common' 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' @@ -8,7 +7,7 @@ import { ProjectSecretsController } from './project-secrets.controller' import { ProjectSecretsService } from './project-secrets.service' @Module({ - imports: [AuthModule, ConfigurationModule, DatabaseModule, ProjectPermissionModule, VaultModule], + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, VaultModule], 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..62586bd954 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 @@ -1,12 +1,15 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { makeProject } from '../project/project-testing.utils' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { VaultService } from '../vault/vault.service' import { makeVaultSecret } from './project-secrets-testing.utils' import { ProjectSecretsService } from './project-secrets.service' @@ -17,19 +20,22 @@ describe('projectSecretsService', () => { let prisma: DeepMockProxy let vault: DeepMockProxy let vaultClient: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { prisma = mockDeep() vault = mockDeep() vaultClient = mockDeep() - config = mockDeep({ projectRootDir: '/vault' }) + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() module = await Test.createTestingModule({ providers: [ ProjectSecretsService, { provide: PrismaService, useValue: prisma }, - { provide: ConfigurationService, useValue: config }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, { provide: VaultService, useValue: vault }, { provide: VaultClientService, useValue: vaultClient }, ], 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..723eea1454 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,10 +1,13 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { z } from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { VaultService } from '../vault/vault.service' import { generateProjectPath } from '../vault/vault.utils' import { getProjectSlug } from './project-secrets-queries.utils' @@ -28,7 +31,8 @@ export class ProjectSecretsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, @Inject(VaultService) private readonly vault: VaultService, @Inject(VaultClientService) private readonly vaultClient: VaultClientService, ) {} @@ -42,7 +46,7 @@ export class ProjectSecretsService { 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 projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) const result: Record> = {} const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { diff --git a/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) 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..599b8cae0a 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,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' @@ -7,7 +8,7 @@ import { ProjectServicesController } from './project-services.controller' import { ProjectServicesService } from './project-services.service' @Module({ - imports: [AuthModule, DatabaseModule, PluginModule, ProjectPermissionModule], + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], controllers: [ProjectServicesController], providers: [ProjectServicesService], exports: [ProjectServicesService], diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts index 19e7f0b594..803271ac5f 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { AppEventsModule } from '../events/app-events.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' @@ -14,12 +16,13 @@ import { ProjectService } from './project.service' imports: [ AppEventsModule, AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, ProjectPermissionModule, UserPermissionModule, LogModule, + ConfigModule.forFeature(baseConfigFactory), + ConfigModule.forFeature(vaultConfigFactory), ], controllers: [ProjectController], providers: [ProjectService], diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index 221ba01abd..76de94e2fa 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -2,6 +2,8 @@ import type { TestingModule } from '@nestjs/testing' import type { Prisma } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' import { faker } from '@faker-js/faker' import { ForbiddenException, @@ -12,9 +14,10 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { makeCreateProjectBody, makeListProjectsQuery, @@ -33,13 +36,15 @@ describe('projectService', () => { let service: ProjectService let prisma: DeepMockProxy let appEvents: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy let logs: DeepMockProxy beforeEach(async () => { prisma = mockDeep() appEvents = mockDeep() - config = mockDeep({ appVersion: 'dev' }) + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() logs = mockDeep() module = await Test.createTestingModule({ @@ -47,7 +52,8 @@ describe('projectService', () => { ProjectService, { provide: PrismaService, useValue: prisma }, { provide: AppEventsService, useValue: appEvents }, - { provide: ConfigurationService, useValue: config }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, { provide: LogService, useValue: logs }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 533be11806..90429f720b 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,16 +1,19 @@ import type { projectContract } from '@cpn-console/shared' import type { Prisma } from '@prisma/client' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' import { AdminAuthorized } from '@cpn-console/shared' import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { LogService } from '../log/log.service' import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { createProject, deleteProjectDependencies, @@ -30,7 +33,8 @@ export class ProjectService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AppEventsService) private readonly appEvents: AppEventsService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, @Inject(LogService) private readonly logs: LogService, ) {} @@ -62,7 +66,7 @@ export class ProjectService { const whereAnd = generateProjectWhereInput({ query, requestorUserId: user.userId, - appVersion: this.config.appVersion, + appVersion: this.baseConfig.appVersion, }) this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) diff --git a/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } 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 f6f8881411..b0fa73354c 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 @@ -1,3 +1,4 @@ +import type { HarborConfig } from './harbor.module-definition' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -5,8 +6,8 @@ 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 { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryHttpClientService } from './registry-http-client.service' @@ -22,15 +23,14 @@ describe('registryService', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const config = mockDeep({ - harborUrl, - harborInternalUrl: harborUrl, - harborAdmin: 'admin', - harborAdminPassword, - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - projectRootDir: 'forge', + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', }) const module = await Test.createTestingModule({ @@ -42,8 +42,8 @@ describe('registryService', () => { useValue: {}, }, { - provide: ConfigurationService, - useValue: config, + provide: HARBOR_CONFIG, + useValue: harborConfig, }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts index 2a1b33454c..07e420fb88 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,22 +1,23 @@ +import type { HarborConfig } from './harbor.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { HARBOR_CONFIG } from './harbor.module-definition' @Injectable() export class RegistryHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.harborInternalUrl) return indicator.down('Not configured') + if (!this.harborConfig.internalUrl) return indicator.down('Not configured') - const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() + const url = new URL('/api/v2.0/ping', this.harborConfig.internalUrl).toString() const headers: Record = {} - if (this.config.harborAdmin && this.config.harborAdminPassword) { - const credentials = `${this.config.harborAdmin}:${this.config.harborAdminPassword}` + if (this.harborConfig.admin && this.harborConfig.adminPassword) { + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` const base64 = Buffer.from(credentials).toString('base64') headers.Authorization = `Basic ${base64}` } diff --git a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts index cfe57239a2..65029ea0bd 100644 --- a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -1,6 +1,7 @@ +import type { HarborConfig } from './harbor.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { HARBOR_CONFIG } from './harbor.module-definition' import { encodeBasicAuth } from './registry.utils' export type RegistryQuery = Record @@ -46,14 +47,14 @@ export class RegistryError extends Error { @Injectable() export class RegistryHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, ) {} private get baseUrl() { - if (!this.config.harborInternalUrl) { + if (!this.harborConfig.internalUrl) { throw new RegistryError('NotConfigured', 'HARBOR_INTERNAL_URL is required') } - return this.config.harborInternalUrl + return this.harborConfig.internalUrl } private get apiBaseUrl() { @@ -61,13 +62,13 @@ export class RegistryHttpClientService { } private get defaultHeaders() { - if (!this.config.harborAdmin) { + if (!this.harborConfig.admin) { throw new RegistryError('NotConfigured', 'HARBOR_ADMIN is required') } - if (!this.config.harborAdminPassword) { + if (!this.harborConfig.adminPassword) { throw new RegistryError('NotConfigured', 'HARBOR_ADMIN_PASSWORD is required') } - return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.config.harborAdmin, this.config.harborAdminPassword)}` } + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } } async fetch( diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts index 3e14300fa2..b4d14b009f 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -1,12 +1,13 @@ import type { Cache } from 'cache-manager' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { RegistryPluginService } from './registry-plugin.service' @@ -14,25 +15,25 @@ import { makeProjectWithDetails } from './registry-testing.utils' describe('registryPluginService', () => { let service: RegistryPluginService - let config: DeepMockProxy - let registryDatastore: DeepMockProxy + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy let registryClient: DeepMockProxy let cache: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - harborUrl: 'https://harbor.example/', - harborProjectSlugCacheTtlMs: 300000, + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, }) - registryDatastore = mockDeep() + datastore = mockDeep() registryClient = mockDeep() cache = mockDeep() const moduleRef = await Test.createTestingModule({ providers: [ RegistryPluginService, - { provide: ConfigurationService, useValue: config }, - { provide: RegistryDatastoreService, useValue: registryDatastore }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, { provide: RegistryClientService, useValue: registryClient }, { provide: CACHE_MANAGER, useValue: cache }, ], @@ -54,13 +55,13 @@ describe('registryPluginService', () => { })) expect(url).toBe('https://harbor.example/harbor/projects/144/') - expect(registryDatastore.getProject).not.toHaveBeenCalled() + expect(datastore.getProject).not.toHaveBeenCalled() expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') }) it('falls back to Harbor lookup when the store is empty', async () => { cache.get.mockResolvedValue(undefined) - registryDatastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) registryClient.getProjectByName.mockResolvedValue({ status: HttpStatus.OK, data: { project_id: 144, metadata: {} }, 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..285efb9ce3 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -1,9 +1,10 @@ import type { ServiceInfos } from '@cpn-console/hooks' import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' import { DISABLED } 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 { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { createProjectSlugCacheKey } from './registry.utils' @@ -13,10 +14,10 @@ export class RegistryPluginService { private readonly logger = new Logger(RegistryPluginService.name) constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, @Inject(RegistryDatastoreService) - private readonly registryDatastore: RegistryDatastoreService, + private readonly datastore: RegistryDatastoreService, @Inject(RegistryClientService) private readonly registryClient: RegistryClientService, @Inject(CACHE_MANAGER) @@ -28,9 +29,9 @@ export class RegistryPluginService { const cached = await this.cache.get(cacheKey) if (cached !== undefined) return cached ?? undefined - const project = await this.registryDatastore.getProject(projectId) + const project = await this.datastore.getProject(projectId) const slug = project?.slug ?? null - await this.cache.set(cacheKey, slug, this.config.harborProjectSlugCacheTtlMs) + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) return slug ?? undefined } @@ -50,8 +51,8 @@ export class RegistryPluginService { } private resolveHarborProjectUrl(harborProjectId: number): string | undefined { - if (!this.config.harborUrl) return undefined - return new URL(`harbor/projects/${harborProjectId}/`, this.config.harborUrl).toString() + if (!this.harborConfig.url) return undefined + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() } private async resolveProjectUrl(projectId: string): Promise { diff --git a/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts index b205b1babb..c93e994bce 100644 --- a/apps/server-nestjs/src/modules/registry/registry.module.ts +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -1,19 +1,23 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { RegistryHealthService } from './registry-health.service' import { RegistryHttpClientService } from './registry-http-client.service' import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' import { RegistryService } from './registry.service' +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule, CacheModule.register()], + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], exports: [RegistryHealthService, RegistryPluginService, RegistryService], }) -export class RegistryModule {} +export class RegistryModule extends ConfigurableModuleClass {} 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 9fde08c567..94cf3b9d05 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -1,13 +1,18 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' 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 { BASE_CONFIG } from '../infrastructure/config/base.config' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' @@ -24,7 +29,9 @@ describe('registryService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { client = mockDeep({ @@ -52,17 +59,20 @@ describe('registryService', () => { })), write: vi.fn().mockResolvedValue(undefined), }) - 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', + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) const module = await Test.createTestingModule({ providers: [ @@ -70,7 +80,9 @@ describe('registryService', () => { { provide: RegistryClientService, useValue: client }, { provide: RegistryDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index cd675f32da..f1c5939da3 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -1,5 +1,7 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' import type { HarborAccess, HarborGroupMemberRequest, @@ -14,12 +16,13 @@ import { specificallyEnabled } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' 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 { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { @@ -55,18 +58,19 @@ export class RegistryService { constructor( @Inject(RegistryClientService) private readonly client: RegistryClientService, - @Inject(RegistryDatastoreService) private readonly registryDatastore: RegistryDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('RegistryService initialized') } private get host() { - if (!this.config.harborUrl) { + if (!this.harborConfig.url) { throw new Error('HARBOR_URL is required') } - return getHostFromUrl(this.config.harborUrl) + return getHostFromUrl(this.harborConfig.url) } private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { @@ -103,11 +107,11 @@ export class RegistryService { 'project.slug': project.slug, 'registry.robot.name': robotName, }) - if (!this.config.projectRootDir) { + if (!this.baseConfig.projectsRootDir) { throw new Error('PROJECTS_ROOT_DIR is required') } const relativeVaultPath = `REGISTRY/${robotName}` - const vaultPath = getProjectVaultPath(project, this.config.projectRootDir, relativeVaultPath) + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null throw error @@ -142,7 +146,7 @@ export class RegistryService { const createdTimeRaw = vaultSecret?.metadata?.created_time if (!createdTimeRaw) return false const createdTime = new Date(createdTimeRaw) - return daysAgoFromNow(createdTime) > this.config.harborRobotRotationThresholdDays + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays } private async ensureProjectGroupMember( @@ -252,9 +256,9 @@ export class RegistryService { 'registry.project.id': harborProjectId, }) const policy = generateRetentionPolicy(harborProjectId, { - harborRuleTemplate: this.config.harborRuleTemplate, - harborRuleCount: this.config.harborRuleCount, - harborRetentionCron: this.config.harborRetentionCron, + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, }) const retentionId = await this.client.getRetentionId(project.slug) span?.setAttribute('registry.retention.exists', !!retentionId) @@ -346,13 +350,13 @@ export class RegistryService { async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Registry reconciliation') - const projects = await this.registryDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() 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(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return getPluginConfig(project, key) } diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts index 26b0cfe6af..0b7ec4153b 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -1,10 +1,15 @@ import type { TestingModule } from '@nestjs/testing' import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' vi.mock('undici', async (importOriginal) => { @@ -30,7 +35,8 @@ function getLastFetchCall(): [string, RequestInit] { describe('openCdsClientService', () => { let module: TestingModule let service: OpenCdsClientService - let config: Partial + let openCdsConfig: Partial + let baseConfig: DeepMockProxy let tlsDispatcher: Pick let proxyDispatcher: Pick @@ -52,16 +58,18 @@ describe('openCdsClientService', () => { vi.mocked(Agent).mockImplementation(MockAgent as any) vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) - config = { - openCdsUrl: 'https://opencds.example.com/root/api/', - openCdsApiToken: 'test-token', - openCdsApiTlsRejectUnauthorized: true, + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, } + baseConfig = mockDeep({ httpProxy: undefined }) module = await Test.createTestingModule({ providers: [ OpenCdsClientService, - { provide: ConfigurationService, useValue: config }, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() @@ -93,7 +101,7 @@ describe('openCdsClientService', () => { }) it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { - vi.stubEnv('HTTP_PROXY', 'http://proxy.internal:3128') + baseConfig.httpProxy = 'http://proxy.internal:3128' mockFetchResponse(new Response(JSON.stringify({ ok: true }), { status: HttpStatus.OK, headers: { @@ -175,7 +183,7 @@ describe('openCdsClientService', () => { }) it('throws when OpenCDS is disabled', async () => { - config.openCdsUrl = undefined + openCdsConfig.url = undefined await expect(service.get('/requests')).rejects.toThrow('OpenCDS is disabled') }) diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts index b018e8d450..6f2220c580 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -1,8 +1,11 @@ import type { HttpStatus } from '@nestjs/common' import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { Agent, fetch, Headers, ProxyAgent } from 'undici' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' import { throwIfNotOk } from './service-chain.utils' const openCdsDisabledMessage @@ -25,14 +28,14 @@ export class OpenCdsClientError extends Error { public readonly body?: string, ) { super(`OpenCDS request failed with ${status} ${statusText}`) - this.name = 'OpenCdsClientError' } } @Injectable() export class OpenCdsClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) {} private readonly logger = new Logger(OpenCdsClientService.name) @@ -77,13 +80,13 @@ export class OpenCdsClientService { path: string, query?: OpenCdsRequestOptions['query'], ): string { - if (!this.config.openCdsUrl) { + if (!this.opencdsConfig.url) { throw new Error(openCdsDisabledMessage) } const resolvedPath = URL_REGEX.test(path) ? path - : `${this.config.openCdsUrl.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` const url = new URL(resolvedPath) @@ -101,7 +104,7 @@ export class OpenCdsClientService { hasJsonBody = false, ): Headers { const mergedHeaders = new Headers(headers) - mergedHeaders.set('X-API-Key', this.config.openCdsApiToken ?? '') + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') if (hasJsonBody) { mergedHeaders.set('Content-Type', 'application/json') @@ -111,18 +114,20 @@ export class OpenCdsClientService { } private buildDispatcher(): Dispatcher { - if (process.env.HTTP_PROXY) { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { return new ProxyAgent({ requestTls: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, }, - uri: process.env.HTTP_PROXY, + uri: httpProxy, }) } return new Agent({ connect: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, }, }) } 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 537261b97a..52964ab4b2 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,6 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' 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' @@ -11,10 +13,11 @@ import { ServiceChainService } from './service-chain.service' @Module({ imports: [ AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), ], controllers: [ServiceChainController], providers: [OpenCdsClientService, ServiceChainService], 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 3d8addfdab..26a5d114c4 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 @@ -1,14 +1,15 @@ import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { faker } from '@faker-js/faker' 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' import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' const sonarUrl = 'https://sonarqube.internal' const sonarToken = 'my-token' @@ -18,20 +19,20 @@ const server = setupServer() describe('sonarqubeClientService', () => { let service: SonarqubeClientService - let config: ReturnType> + let config: ReturnType> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep({ - sonarApiToken: sonarToken, - getInternalOrPublicSonarqubeUrl: () => sonarUrl, + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, }) const module = await Test.createTestingModule({ providers: [ SonarqubeClientService, SonarqubeHttpClientService, - { provide: ConfigurationService, useValue: config }, + { provide: SONARQUBE_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 25f2cc5a11..df369f5cee 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,24 +1,25 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' @Injectable() export class SonarqubeHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicSonarqubeUrl() - if (!urlBase) return indicator.down('Not configured') + const url = this.sonarqubeConfig.probeUrl - const url = new URL('/api/system/health', urlBase).toString() - const token = this.config.sonarApiToken + if (!url) return indicator.down('Not configured') + + const token = this.sonarqubeConfig.apiToken const headers: Record = {} if (token) { - const bearerToken = Buffer.from(`${token}:`, 'utf8').toString('base64') + const bearerToken = Buffer.from(`${token}:`, 'utf-8').toString('base64') headers.Authorization = `Bearer ${bearerToken}` } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts index 9318e5d569..de90fa02e0 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -1,6 +1,7 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' export interface SonarqubeFetchOptions { method?: string @@ -37,11 +38,11 @@ export class SonarqubeError extends Error { @Injectable() export class SonarqubeHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, ) {} private get baseUrl(): string { - const url = this.config.getInternalOrPublicSonarqubeUrl() + const url = this.sonarqubeConfig.internalOrPublicUrl if (!url) throw new SonarqubeError('NotConfigured', 'SONARQUBE_URL or SONARQUBE_INTERNAL_URL is required') return url } @@ -51,9 +52,9 @@ export class SonarqubeHttpClientService { } private get defaultHeaders(): Record { - if (!this.config.sonarApiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') return { - Authorization: `Bearer ${this.config.sonarApiToken}`, + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts index b1f1c5fbda..671a19562a 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -1,25 +1,26 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' describe('sonarqubePluginService', () => { let service: SonarqubePluginService - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - sonarqubeUrl: 'https://sonar.public/', - sonarqubeInternalUrl: 'https://sonar.internal/', + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ SonarqubePluginService, - { provide: ConfigurationService, useValue: config }, + { provide: SONARQUBE_CONFIG, useValue: config }, ], }).compile() 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..2776456919 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -1,20 +1,21 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' @Injectable() export class SonarqubePluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, ) {} infos(): ServiceInfos { return { name: 'sonarqube', to: () => { - if (!this.config.sonarqubeUrl) return undefined - return new URL('projects', this.config.sonarqubeUrl).toString() + if (!this.sonarqubeConfig.url) return undefined + return new URL('projects', this.sonarqubeConfig.url).toString() }, title: 'SonarQube', imgSrc: '/img/sonarqube.svg', diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts index 17499fdda4..f054302ff4 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { SonarqubeClientService } from './sonarqube-client.service' @@ -8,10 +7,11 @@ import { SonarqubeDatastoreService } from './sonarqube-datastore.service' import { SonarqubeHealthService } from './sonarqube-health.service' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' import { SonarqubeService } from './sonarqube.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule], providers: [ SonarqubeHealthService, SonarqubeHttpClientService, @@ -22,4 +22,4 @@ import { SonarqubeService } from './sonarqube.service' ], exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], }) -export class SonarqubeModule {} +export class SonarqubeModule extends ConfigurableModuleClass {} 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 19721a8cc6..3e1a9a2c3e 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -1,9 +1,9 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { generateProjectKey } from '../../utils/crypto' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { generateProjectKey } from '../../utils/crypto.utils' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { SonarqubeClientService } from './sonarqube-client.service' @@ -18,6 +18,7 @@ import { makeUserToken, } from './sonarqube-testing.utils' import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' import { SonarqubeService } from './sonarqube.service' describe('sonarqubeService', () => { @@ -25,7 +26,7 @@ describe('sonarqubeService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { client = mockDeep({ @@ -34,7 +35,6 @@ describe('sonarqubeService', () => { createPermissionTemplate: vi.fn().mockResolvedValue(undefined), searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), - addPermissionProjectCreatorToTemplate: vi.fn().mockResolvedValue(undefined), addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), addPermissionGroup: vi.fn().mockResolvedValue(undefined), addPermissionUser: vi.fn().mockResolvedValue(undefined), @@ -54,9 +54,8 @@ describe('sonarqubeService', () => { writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - getInternalOrPublicSonarqubeUrl: vi.fn().mockReturnValue('https://sonarqube.internal'), + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', }) const moduleRef = await Test.createTestingModule({ @@ -65,7 +64,7 @@ describe('sonarqubeService', () => { { provide: SonarqubeClientService, useValue: client }, { provide: SonarqubeDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: SONARQUBE_CONFIG, useValue: config }, ], }).compile() @@ -114,7 +113,7 @@ describe('sonarqubeService', () => { }) it('should skip initialization when URL is not configured', async () => { - config.getInternalOrPublicSonarqubeUrl.mockReturnValue(undefined) + config.internalOrPublicUrl = undefined await service.init() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index fc36f6c663..6e059a2e9e 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -3,11 +3,11 @@ import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { SonarqubeUserSecret } from '../vault/vault-client.service' import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { generateProjectKey, generateRandomPassword } from '../../utils/crypto' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -41,6 +41,7 @@ import { ROBOT_PROJECT_PERMISSIONS, SECURITY_GROUP_PATH_PLUGIN_KEY, } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' interface SonarqubeRolePaths { admin: string[] @@ -57,7 +58,7 @@ export class SonarqubeService implements OnModuleInit { constructor( @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('SonarqubeService initialized') @@ -69,7 +70,7 @@ export class SonarqubeService implements OnModuleInit { @StartActiveSpan() async init(): Promise { - if (!this.config.getInternalOrPublicSonarqubeUrl()) { + if (!this.sonarqubeConfig.internalOrPublicUrl) { this.logger.warn('SonarQube URL not configured — skipping initialization') return } 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 54db1c52b4..d6cce4272f 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 @@ -1,12 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' import { HttpStatus } from '@nestjs/common' 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 { BASE_CONFIG } from '../infrastructure/config/base.config' import { VaultClientService } from './vault-client.service' import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' const vaultUrl = 'https://vault.internal' @@ -30,19 +33,23 @@ describe('vault', () => { beforeAll(() => server.listen()) beforeEach(async () => { - const config = mockDeep({ - vaultToken: 'token', - vaultUrl, - vaultInternalUrl: vaultUrl, - vaultKvName: 'kv', - getInternalOrPublicVaultUrl: () => vaultUrl, + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', }) const module = await Test.createTestingModule({ providers: [ VaultClientService, VaultHttpClientService, - { provide: ConfigurationService, useValue: config }, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index b49868d93f..dd07abebbd 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -1,8 +1,11 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' export interface VaultSysPoliciesAclUpsertRequest { @@ -114,7 +117,8 @@ export class VaultClientService { private readonly logger = new Logger(VaultClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, ) { } @@ -144,13 +148,13 @@ export class VaultClientService { @StartActiveSpan() async read(path: string): Promise> { this.logger.debug(`Reading Vault KV secret at ${path}`) - return await this.getKvData(this.config.vaultKvName, path) + return await this.getKvData(this.vaultConfig.kvName, path) } @StartActiveSpan() async write(data: T, path: string): Promise { this.logger.debug(`Writing Vault KV secret at ${path}`) - await this.upsertKvData(this.config.vaultKvName, path, { data }) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) } @StartActiveSpan() @@ -158,18 +162,16 @@ export class VaultClientService { this.logger.debug(`Deleting Vault KV secret at ${path}`) const span = trace.getActiveSpan() span?.setAttribute('vault.kv.path', path) - return await this.deleteKvMetadata(this.config.vaultKvName, path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) } @StartActiveSpan() async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) return await this.read(vaultCredsPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null @@ -179,26 +181,22 @@ export class VaultClientService { @StartActiveSpan() async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) await this.write(data, vaultCredsPath) } @StartActiveSpan() async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) await this.delete(vaultCredsPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return @@ -208,12 +206,10 @@ export class VaultClientService { @StartActiveSpan() async readTechnReadOnlyCreds(projectSlug: string): Promise { - const vaultPath = generateTechReadOnlyCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) return await this.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null throw error @@ -222,23 +218,19 @@ export class VaultClientService { @StartActiveSpan() async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { - const vaultPath = generateTechReadOnlyCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) await this.write(creds, vaultPath) } @StartActiveSpan() async readSonarqubeUser(projectSlug: string): Promise | null> { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) return await this.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null @@ -248,24 +240,20 @@ export class VaultClientService { @StartActiveSpan() async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) await this.write(secret, vaultPath) } @StartActiveSpan() async deleteSonarqubeUser(projectSlug: string): Promise { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) await this.delete(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return diff --git a/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/apps/server-nestjs/src/modules/vault/vault-health.service.ts index 649770a558..3f5efb6043 100644 --- a/apps/server-nestjs/src/modules/vault/vault-health.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -1,20 +1,20 @@ +import type { VaultConfig } from './vault.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VAULT_CONFIG } from './vault.module-definition' @Injectable() export class VaultHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicVaultUrl() - if (!urlBase) return indicator.down('Not configured') + const url = this.vaultConfig.probeUrl + if (!url) return indicator.down('Not configured') - const url = new URL('/v1/sys/health', urlBase).toString() try { const response = await fetch(url) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts index 6cba06e0b5..92d4f525d0 100644 --- a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -1,8 +1,9 @@ +import type { VaultConfig } from './vault.module-definition' import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import z from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' export interface VaultFetchOptions { method?: string @@ -46,7 +47,7 @@ export class VaultHttpClientService { private readonly logger = new Logger(VaultHttpClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, ) {} @StartActiveSpan() @@ -83,7 +84,7 @@ export class VaultHttpClientService { } private get baseUrl() { - const baseUrl = this.config.getInternalOrPublicVaultUrl() + const baseUrl = this.vaultConfig.internalUrl || this.vaultConfig.url if (!baseUrl) { throw new VaultError('NotConfigured', 'VAULT_INTERNAL_URL or VAULT_URL is required') } @@ -95,11 +96,11 @@ export class VaultHttpClientService { } private get token() { - if (!this.config.vaultToken) { + if (!this.vaultConfig.token) { this.logger.warn('Vault token is not configured (VAULT_TOKEN is missing)') throw new VaultError('NotConfigured', 'VAULT_TOKEN is required') } - return this.config.vaultToken + return this.vaultConfig.token } private createRequest(path: string, method: string, body?: unknown): Request { diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts index 1d482d0a66..a1d5e06eea 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -1,25 +1,26 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' describe('vaultPluginService', () => { let service: VaultPluginService - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - vaultUrl: 'https://vault.public/', - vaultInternalUrl: 'https://vault.internal/', + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ VaultPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: VAULT_CONFIG, useValue: config }, ], }).compile() 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..5ae13d9908 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -1,20 +1,20 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VAULT_CONFIG } from './vault.module-definition' @Injectable() export class VaultPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, ) {} infos(): ServiceInfos { return { name: 'vault', to: ({ project }) => { - if (!this.config.vaultUrl) return undefined - return new URL(`ui/vault/secrets/${project.slug}`, this.config.vaultUrl).toString() + if (!this.vaultConfig.url) return undefined + return new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString() }, title: 'Vault', imgSrc: '/img/vault.svg', diff --git a/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts index 0b1d8281ea..367a1527a2 100644 --- a/apps/server-nestjs/src/modules/vault/vault.module.ts +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -1,16 +1,19 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' 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' import { VaultHttpClientService } from './vault-http-client.service' import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' import { VaultService } from './vault.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ VaultHealthService, VaultHttpClientService, @@ -21,4 +24,4 @@ import { VaultService } from './vault.service' ], exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], }) -export class VaultModule {} +export class VaultModule extends ConfigurableModuleClass {} 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 4a61c8f3c2..d46cb7b75c 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -1,12 +1,15 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' import { faker } from '@faker-js/faker' 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 { BASE_CONFIG } from '../infrastructure/config/base.module' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' import { VaultService } from './vault.service' const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ @@ -15,7 +18,6 @@ describe('vaultService', () => { let service: VaultService let datastore: DeepMockProxy let client: DeepMockProxy - let config: DeepMockProxy beforeEach(async () => { datastore = mockDeep({ @@ -36,17 +38,17 @@ describe('vaultService', () => { listKvMetadata: vi.fn().mockResolvedValue([]), delete: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - vaultKvName: 'kv', - }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) const module = await Test.createTestingModule({ providers: [ VaultService, { provide: VaultClientService, useValue: client }, { provide: VaultDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 486c97cbd3..7128c24963 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -1,9 +1,11 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from './vault-client.service' @@ -34,6 +36,7 @@ import { PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, SECURITY_GROUP_PATH_PLUGIN_KEY, } from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' import { generateProjectPath } from './vault.utils' type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' @@ -43,8 +46,9 @@ export class VaultService { private readonly logger = new Logger(VaultService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, - @Inject(VaultDatastoreService) private readonly vaultDatastore: VaultDatastoreService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, @Inject(VaultClientService) private readonly client: VaultClientService, ) { this.logger.log('VaultService initialized') @@ -115,8 +119,8 @@ export class VaultService { const span = trace.getActiveSpan() this.logger.log('Starting Vault reconciliation') const [projects, zones] = await Promise.all([ - this.vaultDatastore.getAllProjects(), - this.vaultDatastore.getAllZones(), + this.datastore.getAllProjects(), + this.datastore.getAllZones(), ]) span?.setAttributes({ @@ -148,7 +152,7 @@ export class VaultService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.vaultDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value } @@ -491,13 +495,13 @@ export class VaultService { async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { - policy: `path "${this.config.vaultKvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, }) } async listProjectSecrets(projectSlug: string): Promise { - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) - return this.listRecursive(this.config.vaultKvName, projectPath, '') + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') } @StartActiveSpan() @@ -505,12 +509,12 @@ export class VaultService { const span = trace.getActiveSpan() span?.setAttributes({ 'project.slug': projectSlug, - 'vault.kv.name': this.config.vaultKvName, + 'vault.kv.name': this.vaultConfig.kvName, }) const secrets = await this.listProjectSecrets(projectSlug) span?.setAttribute('vault.secrets.count', secrets.length) - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) await Promise.allSettled(secrets.map(async (relativePath) => { const fullPath = `${projectPath}/${relativePath}` try { diff --git a/apps/server-nestjs/src/modules/version/version.controller.ts b/apps/server-nestjs/src/modules/version/version.controller.ts index 16ce328f70..7dc6a91019 100644 --- a/apps/server-nestjs/src/modules/version/version.controller.ts +++ b/apps/server-nestjs/src/modules/version/version.controller.ts @@ -1,15 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import { Controller, Get, Inject } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' @Controller('api/v1/version') export class VersionController { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) {} @Get() getVersion() { - return { version: this.config.appVersion } + return { version: this.baseConfig.appVersion } } } diff --git a/apps/server-nestjs/src/modules/version/version.module.ts b/apps/server-nestjs/src/modules/version/version.module.ts index cdf9b30c39..c537037b5b 100644 --- a/apps/server-nestjs/src/modules/version/version.module.ts +++ b/apps/server-nestjs/src/modules/version/version.module.ts @@ -1,9 +1,7 @@ import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { VersionController } from './version.controller' @Module({ - imports: [ConfigurationModule], controllers: [VersionController], }) export class VersionModule {} diff --git a/apps/server-nestjs/src/utils/crypto.spec.ts b/apps/server-nestjs/src/utils/crypto.spec.ts index 19bad4e44c..090c58583a 100644 --- a/apps/server-nestjs/src/utils/crypto.spec.ts +++ b/apps/server-nestjs/src/utils/crypto.spec.ts @@ -1,6 +1,6 @@ import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' import { describe, expect, it } from 'vitest' -import { generateProjectKey } from './crypto' +import { generateProjectKey } from './crypto.utils' describe('generateProjectKey', () => { it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { diff --git a/apps/server-nestjs/src/utils/crypto.ts b/apps/server-nestjs/src/utils/crypto.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/crypto.ts rename to apps/server-nestjs/src/utils/crypto.utils.ts diff --git a/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/apps/server-nestjs/src/utils/dotenv.utils.ts b/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/apps/server-nestjs/src/utils/http-error.ts b/apps/server-nestjs/src/utils/http.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/http-error.ts rename to apps/server-nestjs/src/utils/http.utils.ts diff --git a/apps/server-nestjs/src/utils/iterable.ts b/apps/server-nestjs/src/utils/iterable.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/iterable.ts rename to apps/server-nestjs/src/utils/iterable.utils.ts diff --git a/apps/server-nestjs/test/argocd.e2e-spec.ts b/apps/server-nestjs/test/argocd.e2e-spec.ts index 381412c492..41db4d27f0 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -1,16 +1,18 @@ import type { CommitAction, Gitlab } from '@gitbeaker/core' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -36,7 +38,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { let gitlabClient: Gitlab let vault: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: BaseConfig let ownerId: string let testProjectId: string @@ -59,7 +61,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ArgoCDModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -69,7 +71,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vault = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) ownerId = faker.string.uuid() testProjectId = faker.string.uuid() @@ -186,7 +188,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { }, }) - infraRepoPath = `${config.projectRootDir}/infra/${zoneSlug}` + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` try { const existing = await gitlabClient.Projects.show(infraRepoPath) if (existing.empty_repo || existing.default_branch !== 'main') { @@ -214,7 +216,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { infraRepoId = created.id } - vaultProjectValuesPath = `${config.projectRootDir}/${testProjectId}` + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` await vault.write({ e2e: true }, vaultProjectValuesPath) }) diff --git a/apps/server-nestjs/test/gitlab.e2e-spec.ts b/apps/server-nestjs/test/gitlab.e2e-spec.ts index 0d65d2fcf3..a054c49f1f 100644 --- a/apps/server-nestjs/test/gitlab.e2e-spec.ts +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -1,16 +1,18 @@ import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -36,7 +38,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { let gitlabClient: Gitlab let vaultService: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: BaseConfig let testProjectId: string let testProjectSlug: string @@ -45,7 +47,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [GitlabModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -55,7 +57,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vaultService = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) ownerId = faker.string.uuid() testProjectId = faker.string.uuid() @@ -111,8 +113,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { afterAll(async () => { // Clean GitLab group - if (testProjectSlug && config.projectRootDir) { - const fullPath = `${config.projectRootDir}/${testProjectSlug}` + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` const group = await gitlabService.getGroupByPath(fullPath) if (group) { await gitlabService.deleteGroup(group).catch(() => {}) @@ -120,8 +122,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { } // Clean Vault - if (testProjectSlug && config.projectRootDir) { - const vaultPath = `${config.projectRootDir}/${testProjectSlug}` + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) } @@ -153,7 +155,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await gitlabController.handleUpsert(project) // Assert - const groupPath = `${config.projectRootDir}/${testProjectSlug}` + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), @@ -167,7 +169,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { const isMember = members.some(m => m.id === ownerUser.id) expect(isMember).toBe(true) - const repoVaultPath = `${config.projectRootDir}/${testProjectSlug}/app-mirror` + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` const repoSecret = await vaultService.read(repoVaultPath) expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() @@ -225,7 +227,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await gitlabController.handleUpsert(project) - const groupPath = `${config.projectRootDir}/${testProjectSlug}` + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts index b1bbcc452b..8854d1e010 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -2,11 +2,13 @@ import type KcAdminClient from '@keycloak/keycloak-admin-client' import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -42,7 +44,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [KeycloakModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 6a0970e48c..f2503cd3a4 100644 --- a/apps/server-nestjs/test/nexus.e2e-spec.ts +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -1,11 +1,13 @@ import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -37,7 +39,7 @@ describeWithNexus('NexusController (e2e)', () => { let nexusController: NexusService let nexusClient: NexusClientService let vaultService: VaultClientService - let config: ConfigurationService + let config: BaseConfig let prisma: PrismaService let ownerId: string @@ -46,7 +48,7 @@ describeWithNexus('NexusController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [NexusModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -54,7 +56,7 @@ describeWithNexus('NexusController (e2e)', () => { nexusController = moduleRef.get(NexusService) nexusClient = moduleRef.get(NexusClientService) vaultService = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) prisma = moduleRef.get(PrismaService) ownerId = faker.string.uuid() @@ -146,7 +148,7 @@ describeWithNexus('NexusController (e2e)', () => { const users = await nexusClient.getSecurityUsers(testProjectSlug) expect(users.some(u => u.userId === testProjectSlug)).toBe(true) - const vaultPath = getProjectVaultPath(config.projectRootDir, testProjectSlug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') const secret = await vaultService.read(vaultPath) expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() diff --git a/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/apps/server-nestjs/test/project-bulk.e2e-spec.ts index 7c5b4cae72..bede899844 100644 --- a/apps/server-nestjs/test/project-bulk.e2e-spec.ts +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -1,10 +1,12 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -29,7 +31,7 @@ describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 546b51b2fe..9383576d9e 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -1,12 +1,14 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' 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 { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -39,7 +41,7 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { vaultClient = mockDeep() moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 0e1b16eb1b..b6f2bacdb6 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -2,12 +2,14 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' 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 { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -36,7 +38,7 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { keycloakClient.getUserByEmail.mockResolvedValue(undefined) moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 49d23d4c5a..ec17ef4f43 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -1,11 +1,13 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -30,7 +32,7 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 9007d876f5..5a47ef5b45 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -1,9 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -38,7 +40,7 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 86535aee82..663fd0c5db 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -1,10 +1,12 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -51,7 +53,7 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts index a799f372ef..ab0565e276 100644 --- a/apps/server-nestjs/test/project.e2e-spec.ts +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -2,11 +2,13 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -30,7 +32,7 @@ describeWithProject('ProjectService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 0e4d1946c4..749ab48600 100644 --- a/apps/server-nestjs/test/registry.e2e-spec.ts +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -1,9 +1,13 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' 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 { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { harborConfigFactory } from '../src/config/harbor.config' 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' @@ -11,7 +15,7 @@ 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' import { VaultClientService } from '../src/modules/vault/vault-client.service' -import { getAll } from '../src/utils/iterable' +import { getAll } from '../src/utils/iterable.utils' const canRunRegistryE2E = Boolean(process.env.E2E) @@ -29,12 +33,13 @@ describeWithRegistry('RegistryService (e2e)', () => { let registry: RegistryService let client: RegistryClientService let vault: VaultClientService - let config: ConfigurationService + let config: BaseConfig + let harborConfig: ConfigType let projectSlug: string beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, RegistryModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], }) .compile() @@ -43,14 +48,15 @@ describeWithRegistry('RegistryService (e2e)', () => { registry = moduleRef.get(RegistryService) client = moduleRef.get(RegistryClientService) vault = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) }) afterAll(async () => { if (vault && config && projectSlug) { - const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].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.projectsRootDir, `REGISTRY/${name}`)) await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) } @@ -63,7 +69,7 @@ describeWithRegistry('RegistryService (e2e)', () => { it('should provision project in Harbor and write robot secrets to Vault', async () => { const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) - expect(result.basePath).toBe(`${getHostFromUrl(config.harborUrl!)}/${projectSlug}/`) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url!)}/${projectSlug}/`) const project = await client.getProjectByName(projectSlug) expect(project.status).toBe(200) @@ -74,11 +80,11 @@ describeWithRegistry('RegistryService (e2e)', () => { expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) - const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].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.projectsRootDir, `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?.HOST).toBe(getHostFromUrl(harborConfig.url!)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url!)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url!)) 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 460b916712..5dab0957e4 100644 --- a/apps/server-nestjs/test/sonarqube.e2e-spec.ts +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -1,10 +1,12 @@ import type { TestingModule } from '@nestjs/testing' import { generateProjectKey } from '@cpn-console/hooks' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -42,7 +44,7 @@ describeWithSonarqube('SonarqubeService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [SonarqubeModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), 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 3303892032..1c70c81487 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -1,9 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { getDotenvPaths } from '../src/utils/dotenv.utils' 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 { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -35,7 +37,7 @@ describeWithVault('VaultController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init()