diff --git a/README.md b/README.md index 3a89503a2b..c839f7bfe5 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,8 @@ Le serveur Node.js expose le port de débogage `9229` lorsqu'il est lancé en mo 3. Cliquez sur "Open dedicated DevTools for Node" ou attendez que votre target apparaisse sous "Remote Target". 4. Vous pouvez maintenant mettre des points d'arrêt, inspecter les variables, etc. +La documentation NestJS propose un outil de développement intégré : [NestJS DevTools](https://docs.nestjs.com/devtools/overview) — supervision, profiling et débogage des applications NestJS. + ### Construction des images Ce dépôt utilise des fichiers docker-compose pour construire les images docker: diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index 25bf6f7192..b2ab4670f0 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -61,6 +61,25 @@ server { } # ── Routes migrées vers NestJS ──────────────────────────────────────────────── + location /api/v1/projects-bulk { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Préfixe projets : tout sauf /environments et /logs (restés en legacy) + location ^~ /api/v1/projects { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/v1/service-chains { proxy_pass http://server-nestjs; proxy_http_version 1.1; diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index f9f3a143f9..caabfcad1c 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,7 +43,19 @@ 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) OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_SERVICE_CHAIN=false +USE_ARGOCD=false diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0096e0736e..ff909e138b 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,7 +45,20 @@ 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) OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_SERVICE_CHAIN=false +USE_ARGOCD=false diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 9b1a5cb454..420b514786 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,18 @@ 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 + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_SERVICE_CHAIN=true +USE_ARGOCD=true diff --git a/apps/server-nestjs/src/config/argocd.config.spec.ts b/apps/server-nestjs/src/config/argocd.config.spec.ts new file mode 100644 index 0000000000..10f0407cad --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.config.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { argocdConfigFactory } from './argocd.config' +import { resetEnvs } from './config.spec.utils' + +describe('argocdConfig', () => { + beforeEach(() => { resetEnvs(['ARGO_NAMESPACE', 'ARGOCD_URL', 'ARGOCD_INTERNAL_URL', 'DSO_ENV_CHART_VERSION', 'DSO_NS_CHART_VERSION', 'VAULT__DEPLOY_VAULT_CONNECTION_IN_NS']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes internalOrPublicUrl/probeUrl', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.example.com') + vi.stubEnv('ARGOCD_INTERNAL_URL', 'https://argocd.internal:8080') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1') + vi.stubEnv('VAULT__DEPLOY_VAULT_CONNECTION_IN_NS', 'true') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + expect(argocdConfigFactory()).toMatchObject({ + namespace: 'argocd', + url: 'https://argocd.example.com', + internalUrl: 'https://argocd.internal:8080', + extraRepositories: ['repo1'], + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: true, + internalOrPublicUrl: 'https://argocd.internal:8080', + probeUrl: 'https://argocd.example.com/api/version', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.example.com') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1') + vi.stubEnv('VAULT__DEPLOY_VAULT_CONNECTION_IN_NS', 'true') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + const cfg = argocdConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.internalOrPublicUrl).toBe('https://argocd.example.com') + }) + + it('applies flag defaults', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.example.com') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + expect(argocdConfigFactory().vaultDeployVaultConnectionInNs).toBe(false) + }) + + it('defaults extraRepositories to an empty array', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.example.com') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + expect(argocdConfigFactory().extraRepositories).toEqual([]) + }) + + it('parses with empty vars (ArgoCD disabled, probeUrl undefined)', () => { + expect(argocdConfigFactory().probeUrl).toBeUndefined() + }) +}) 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..92b3285b80 --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,27 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { csv, optionalUrl, truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: optionalUrl, + ARGOCD_INTERNAL_URL: optionalUrl, + ARGOCD_EXTRA_REPOSITORIES: csv(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: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + 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, + probeUrl: raw.ARGOCD_URL ? new URL('/api/version', raw.ARGOCD_URL).toString() : undefined, +})) + +export type ArgocdConfig = z.infer + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/base.config.spec.ts b/apps/server-nestjs/src/config/base.config.spec.ts new file mode 100644 index 0000000000..1bcd2f615b --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from './base.config' +import { resetEnvs } from './config.spec.utils' + +describe('baseConfig', () => { + beforeEach(() => { resetEnvs(['NODE_ENV', 'CI', 'SERVER_HOST', 'SERVER_PORT', 'APP_VERSION', 'DB_URL', 'PROJECTS_ROOT_DIR']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and derives env flags', () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('CI', 'true') + vi.stubEnv('SERVER_HOST', 'host') + vi.stubEnv('SERVER_PORT', '4000') + vi.stubEnv('APP_VERSION', '1.2.3') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('DB_URL', 'postgres://db') + expect(baseConfigFactory()).toEqual({ + isTest: false, + isDev: true, + isCI: true, + isProd: false, + serverHost: 'host', + serverPort: 4000, + appVersion: 'dev', + dbUrl: 'postgres://db', + projectsRootDir: 'forge-test/projects', + }) + }) + + it('uses APP_VERSION in production, "dev" otherwise', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('CI', 'true') + vi.stubEnv('SERVER_HOST', 'host') + vi.stubEnv('SERVER_PORT', '4000') + vi.stubEnv('APP_VERSION', '1.2.3') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('DB_URL', 'postgres://db') + expect(baseConfigFactory().appVersion).toBe('1.2.3') + vi.stubEnv('NODE_ENV', 'test') + vi.stubEnv('CI', 'true') + vi.stubEnv('SERVER_HOST', 'host') + vi.stubEnv('SERVER_PORT', '4000') + vi.stubEnv('APP_VERSION', '1.2.3') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('DB_URL', 'postgres://db') + expect(baseConfigFactory().appVersion).toBe('dev') + }) + + it('applies defaults when optional vars are absent', () => { + vi.stubEnv('PROJECTS_ROOT_DIR', '/r') + vi.stubEnv('NODE_ENV', 'production') + expect(baseConfigFactory()).toMatchObject({ + isDev: false, + isProd: true, + isCI: false, + serverHost: 'localhost', + serverPort: 0, + appVersion: 'unknown', + dbUrl: undefined, + }) + }) + + it('parses with an empty PROJECTS_ROOT_DIR (disabled integrations)', () => { + vi.stubEnv('NODE_ENV', 'production') + expect(baseConfigFactory()).toMatchObject({ projectsRootDir: undefined, isProd: true }) + }) +}) 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..1a182cc299 --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + CI: 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(), + PROJECTS_ROOT_DIR: z.string().optional(), +}).transform((raw) => { + const nodeEnv = raw.NODE_ENV ?? 'production' + return { + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: nodeEnv === 'production', + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/config.spec.utils.ts b/apps/server-nestjs/src/config/config.spec.utils.ts new file mode 100644 index 0000000000..934151374e --- /dev/null +++ b/apps/server-nestjs/src/config/config.spec.utils.ts @@ -0,0 +1,8 @@ +import { vi } from 'vitest' + +// Stub every env key a config reads as undefined (removed) so the test +// runner's real environment cannot leak into a config parse. Tests then +// stub only the keys they exercise. Pair with vi.unstubAllEnvs() in afterEach. +export function resetEnvs(keys: readonly string[]): void { + for (const key of keys) vi.stubEnv(key, undefined) +} 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..155c025349 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, 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('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..5a7192d318 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,108 @@ +import z from 'zod' + +// Harbor retention triggers use 6-field (seconds) Quartz cron expressions. +// Stdlib-only validation, expressed as a Zod schema (no external dependency). +// Months (field 4) and days-of-week (field 5) accept Quartz name tokens too. +const MONTH_NAMES = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] as const +const DOW_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const + +const CRON_FIELD_BOUNDS: ReadonlyArray = [ + [0, 59], // seconds + [0, 59], // minutes + [0, 23], // hours + [1, 31], // day of month + [1, 12], // month + [0, 7], // day of week (0 and 7 = Sunday) +] + +function isNameToken(token: string, names: ReadonlyArray): boolean { + return names.includes(token.toUpperCase()) +} + +function isNameItem(item: string, field: number): boolean { + if (field !== 4 && field !== 5) return false + const names = field === 4 ? MONTH_NAMES : DOW_NAMES + if (isNameToken(item, names)) return true + if (item.includes('-')) { + const [lo, hi] = item.split('-') + return isNameToken(lo, names) && isNameToken(hi, names) + } + return false +} + +function isRangeOrNumber(token: string, min: number, max: number): boolean { + if (token.includes('-')) { + const [lo, hi] = token.split('-').map(Number) + return lo >= min && hi <= max && lo <= hi + } + const n = Number(token) + return Number.isInteger(n) && n >= min && n <= max +} + +function isCronItem(item: string, field: number): boolean { + if (item === '*' || item === '?') return true + if (isNameItem(item, field)) return true + const [min, max] = CRON_FIELD_BOUNDS[field] + const step = /^(.+)\/(\d+)$/.exec(item) + if (step) { + if (Number(step[2]) < 1) return false + const base = step[1] + if (base === '*' || base === '?') return true + if (isNameItem(base, field)) return true + return isRangeOrNumber(base, min, max) + } + return isRangeOrNumber(item, min, max) +} + +export const cronSchema = z + .string() + .superRefine((value, ctx) => { + const fields = value.trim().split(/\s+/) + if (fields.length !== 6) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cron must have 6 fields (seconds required)' }) + return + } + if (!fields.every((field, i) => field.split(',').every(part => isCronItem(part, i)))) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid cron expression' }) + } + }) + +// 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') +} + +// 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)) +} + +// Integration URL/secret fields may legitimately be empty in some environments +// (e.g. the preview container ships empty integration vars). An empty string is +// normalized to undefined so the integration is treated as disabled rather than +// failing validation at bootstrap. +export const optionalUrl = z.preprocess( + val => (typeof val === 'string' && val.trim() === '' ? undefined : val), + z.string().url().optional(), +) + +export const optionalValue = z.preprocess( + val => (typeof val === 'string' && val.trim() === '' ? undefined : val), + z.string().min(1).optional(), +) diff --git a/apps/server-nestjs/src/config/gitlab.config.spec.ts b/apps/server-nestjs/src/config/gitlab.config.spec.ts new file mode 100644 index 0000000000..53357b710f --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.config.spec.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { gitlabConfigFactory } from './gitlab.config' + +describe('gitlabConfig', () => { + beforeEach(() => { resetEnvs(['GITLAB_TOKEN', 'GITLAB_URL', 'GITLAB_INTERNAL_URL', 'GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS', 'GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS', 'GITLAB__SECRET_EXPOSE_INTERNAL_URL', 'PROJECTS_ROOT_DIR']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes internalOrPublicUrl/probeUrl', () => { + vi.stubEnv('GITLAB_TOKEN', 'token') + vi.stubEnv('GITLAB_URL', 'https://gitlab.example.com') + vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal:8080') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1') + expect(gitlabConfigFactory()).toMatchObject({ + token: 'token', + url: 'https://gitlab.example.com', + internalUrl: 'https://gitlab.internal:8080', + secretExposeInternalUrl: true, + mirrorTokenExpirationDays: 180, + mirrorTokenRotationThresholdDays: 90, + projectRootDir: 'forge-test/projects', + internalOrPublicUrl: 'https://gitlab.internal:8080', + probeUrl: 'https://gitlab.internal:8080/-/health', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('GITLAB_TOKEN', 'token') + vi.stubEnv('GITLAB_URL', 'https://gitlab.example.com') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1') + const cfg = gitlabConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.internalOrPublicUrl).toBe('https://gitlab.example.com') + }) + + it('parses with empty vars (GitLab disabled, probeUrl undefined)', () => { + expect(gitlabConfigFactory().probeUrl).toBeUndefined() + }) +}) 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..f0726beeb5 --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { optionalUrl, optionalValue, truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: optionalValue, + GITLAB_URL: optionalUrl, + GITLAB_INTERNAL_URL: optionalUrl, + 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), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().optional(), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_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 type GitlabConfig = z.infer + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/harbor.config.spec.ts b/apps/server-nestjs/src/config/harbor.config.spec.ts new file mode 100644 index 0000000000..080b4d9cfd --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.config.spec.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { harborConfigFactory } from './harbor.config' + +describe('harborConfig', () => { + beforeEach(() => { resetEnvs(['HARBOR_URL', 'HARBOR_INTERNAL_URL', 'HARBOR_ADMIN', 'HARBOR_ADMIN_PASSWORD', 'HARBOR_RULE_TEMPLATE', 'HARBOR_RULE_COUNT', 'HARBOR_RETENTION_CRON', 'HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', 'HARBOR_PROJECT_SLUG_CACHE_TTL_MS']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes internalOrPublicUrl/probeUrl', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.example.com') + vi.stubEnv('HARBOR_INTERNAL_URL', 'https://harbor.internal:8080') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_RULE_TEMPLATE', 'always') + vi.stubEnv('HARBOR_RULE_COUNT', '3') + expect(harborConfigFactory()).toMatchObject({ + url: 'https://harbor.example.com', + internalUrl: 'https://harbor.internal:8080', + admin: 'admin', + adminPassword: 'pw', + ruleTemplate: 'always', + ruleCount: 3, + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + projectSlugCacheTtlMs: 300_000, + internalOrPublicUrl: 'https://harbor.internal:8080', + probeUrl: 'https://harbor.internal:8080/api/v2.0/ping', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.example.com') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_RULE_TEMPLATE', 'latestPushedK') + vi.stubEnv('HARBOR_RULE_COUNT', '3') + const cfg = harborConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.internalOrPublicUrl).toBe('https://harbor.example.com') + }) + + it('applies defaults when optional rule fields are absent', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.example.com') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + const cfg = harborConfigFactory() + expect(cfg.ruleTemplate).toBeUndefined() + expect(cfg.ruleCount).toBeUndefined() + expect(cfg.retentionCron).toBe('0 22 2 * * *') + }) + + it('parses with empty vars (Harbor disabled, probeUrl undefined)', () => { + expect(harborConfigFactory().probeUrl).toBeUndefined() + }) +}) 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..377c8125a0 --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,44 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { cronSchema, optionalUrl, optionalValue } from './config.utils' + +const ruleTemplateSchema = z.enum([ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +]) + +export type RuleTemplate = z.infer + +const harborFeatureSchema = z.object({ + HARBOR_URL: optionalUrl, + HARBOR_INTERNAL_URL: optionalUrl, + HARBOR_ADMIN: optionalValue, + HARBOR_ADMIN_PASSWORD: optionalValue, + HARBOR_RULE_TEMPLATE: ruleTemplateSchema.optional(), + HARBOR_RULE_COUNT: z.coerce.number().int().positive().optional(), + HARBOR_RETENTION_CRON: cronSchema.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 + return { + 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/ping', urlBase).toString() : undefined, + } +}) + +export type HarborConfig = z.infer + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/keycloak.config.spec.ts b/apps/server-nestjs/src/config/keycloak.config.spec.ts new file mode 100644 index 0000000000..97a41e3fe0 --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.config.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { keycloakConfigFactory } from './keycloak.config' + +describe('keycloakConfig', () => { + beforeEach(() => { resetEnvs(['KEYCLOAK_PROTOCOL', 'KEYCLOAK_DOMAIN', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ADMIN', 'KEYCLOAK_ADMIN_PASSWORD', 'KEYCLOAK_ADMIN_CLIENT_ID', 'KEYCLOAK_REDIRECT_URI', 'KEYCLOAK_JWKS_CACHE_TTL_MS', 'KEYCLOAK_JWKS_TIMEOUT_MS', 'KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS', 'ADMIN_KC_USER_ID']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('composes url/realmUrl/openidConfigurationUrl and parses csv admin ids', () => { + vi.stubEnv('KEYCLOAK_DOMAIN', 'kc.example.com') + vi.stubEnv('KEYCLOAK_REALM', 'dso') + vi.stubEnv('KEYCLOAK_CLIENT_ID', 'frontend') + vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') + vi.stubEnv('KEYCLOAK_ADMIN', 'admin') + vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') + vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://localhost:8080') + vi.stubEnv('ADMIN_KC_USER_ID', 'a,b , c') + expect(keycloakConfigFactory()).toMatchObject({ + protocol: 'https', + domain: 'kc.example.com', + realm: 'dso', + adminClientId: 'admin-cli', + url: 'https://kc.example.com', + realmUrl: 'https://kc.example.com/realms/dso', + openidConfigurationUrl: 'https://kc.example.com/realms/dso/.well-known/openid-configuration', + adminKcUserId: ['a', 'b', 'c'], + jwksCacheTtlMs: 300_000, + jwksTimeoutMs: 5_000, + openidConfigurationCacheTtlMs: 300_000, + }) + }) + + it('honours an explicit protocol', () => { + vi.stubEnv('KEYCLOAK_DOMAIN', 'kc.example.com') + vi.stubEnv('KEYCLOAK_REALM', 'dso') + vi.stubEnv('KEYCLOAK_CLIENT_ID', 'frontend') + vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') + vi.stubEnv('KEYCLOAK_ADMIN', 'admin') + vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') + vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://localhost:8080') + vi.stubEnv('ADMIN_KC_USER_ID', 'a,b , c') + vi.stubEnv('KEYCLOAK_PROTOCOL', 'https') + expect(keycloakConfigFactory().url).toBe('https://kc.example.com') + expect(keycloakConfigFactory().protocol).toBe('https') + }) + + it('parses with empty vars (Keycloak disabled, urls undefined)', () => { + vi.stubEnv('KEYCLOAK_PROTOCOL', 'https') + const cfg = keycloakConfigFactory() + expect(cfg.url).toBeUndefined() + expect(cfg.realmUrl).toBeUndefined() + expect(cfg.openidConfigurationUrl).toBeUndefined() + }) +}) 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..d5b1adfd0b --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,47 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { optionalUrl, optionalValue } from './config.utils' + +const keycloakFeatureSchema = z.object({ + KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_DOMAIN: optionalValue, + KEYCLOAK_REALM: optionalValue, + KEYCLOAK_CLIENT_ID: optionalValue, + KEYCLOAK_CLIENT_SECRET: optionalValue, + KEYCLOAK_ADMIN: optionalValue, + KEYCLOAK_ADMIN_PASSWORD: optionalValue, + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: optionalUrl, + 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([]), + ), +}).transform((raw) => { + const keycloakUrl = raw.KEYCLOAK_DOMAIN ? `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` : undefined + const keycloakRealmUrl = keycloakUrl && raw.KEYCLOAK_REALM ? `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` : undefined + return { + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_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 ? `${keycloakRealmUrl}/.well-known/openid-configuration` : undefined, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/nexus.config.spec.ts b/apps/server-nestjs/src/config/nexus.config.spec.ts new file mode 100644 index 0000000000..b067dfd491 --- /dev/null +++ b/apps/server-nestjs/src/config/nexus.config.spec.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { nexusConfigFactory } from './nexus.config' + +describe('nexusConfig', () => { + beforeEach(() => { resetEnvs(['NEXUS_URL', 'NEXUS_INTERNAL_URL', 'NEXUS_ADMIN', 'NEXUS_ADMIN_PASSWORD', 'NEXUS__SECRET_EXPOSE_INTERNAL_URL']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes internalOrPublicUrl/probeUrl', () => { + vi.stubEnv('NEXUS_URL', 'https://nexus.example.com') + vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal:8081') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + expect(nexusConfigFactory()).toMatchObject({ + url: 'https://nexus.example.com', + internalUrl: 'https://nexus.internal:8081', + admin: 'admin', + adminPassword: 'pw', + secretExposeInternalUrl: false, + internalOrPublicUrl: 'https://nexus.internal:8081', + probeUrl: 'https://nexus.internal:8081/service/rest/v1/status', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('NEXUS_URL', 'https://nexus.example.com') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + const cfg = nexusConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.internalOrPublicUrl).toBe('https://nexus.example.com') + }) + + it('coerces the secret-expose flag', () => { + vi.stubEnv('NEXUS_URL', 'https://nexus.example.com') + vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal:8081') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + vi.stubEnv('NEXUS__SECRET_EXPOSE_INTERNAL_URL', 'true') + expect(nexusConfigFactory().secretExposeInternalUrl).toBe(true) + }) + + it('parses with empty vars (Nexus disabled, probeUrl undefined)', () => { + expect(nexusConfigFactory().probeUrl).toBeUndefined() + }) +}) 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..f423b71861 --- /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 { optionalUrl, optionalValue, truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: optionalUrl, + NEXUS_INTERNAL_URL: optionalUrl, + NEXUS_ADMIN: optionalValue, + NEXUS_ADMIN_PASSWORD: optionalValue, + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + 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 type NexusConfig = z.infer + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/service-chain.config.spec.ts b/apps/server-nestjs/src/config/service-chain.config.spec.ts new file mode 100644 index 0000000000..81fc861112 --- /dev/null +++ b/apps/server-nestjs/src/config/service-chain.config.spec.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { serviceChainConfigFactory } from './service-chain.config' + +describe('serviceChainConfig', () => { + beforeEach(() => { resetEnvs(['OPENCDS_URL', 'OPENCDS_INTERNAL_URL', 'OPENCDS_API_TOKEN', 'OPENCDS_API_TLS_REJECT_UNAUTHORIZED']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes probeUrl', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.example.com') + vi.stubEnv('OPENCDS_INTERNAL_URL', 'https://opencds.internal:8080') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', 'true') + expect(serviceChainConfigFactory()).toMatchObject({ + url: 'https://opencds.example.com', + internalUrl: 'https://opencds.internal:8080', + apiToken: 'token', + apiTlsRejectUnauthorized: true, + probeUrl: 'https://opencds.internal:8080/api/v1/health', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.example.com') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + const cfg = serviceChainConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.probeUrl).toBe('https://opencds.example.com/api/v1/health') + }) + + it('honours an explicit false TLS flag', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.example.com') + vi.stubEnv('OPENCDS_INTERNAL_URL', 'https://opencds.internal:8080') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', '0') + expect(serviceChainConfigFactory().apiTlsRejectUnauthorized).toBe(false) + }) + + it('defaults OPENCDS_API_TLS_REJECT_UNAUTHORIZED to true when unset', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.example.com') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + expect(serviceChainConfigFactory().apiTlsRejectUnauthorized).toBe(true) + }) + + it('parses with empty vars (OpenCDS disabled, probeUrl undefined)', () => { + expect(serviceChainConfigFactory().probeUrl).toBeUndefined() + }) +}) diff --git a/apps/server-nestjs/src/config/service-chain.config.ts b/apps/server-nestjs/src/config/service-chain.config.ts new file mode 100644 index 0000000000..3623328238 --- /dev/null +++ b/apps/server-nestjs/src/config/service-chain.config.ts @@ -0,0 +1,23 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { optionalUrl, optionalValue, truthySchema } from './config.utils' + +const serviceChainFeatureSchema = z.object({ + OPENCDS_URL: optionalUrl, + OPENCDS_INTERNAL_URL: optionalUrl, + OPENCDS_API_TOKEN: optionalValue, + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('true').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + 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 type ServiceCHainConfig = z.infer + +export const serviceChainConfigFactory = registerAs('serviceChain', () => serviceChainFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/sonarqube.config.spec.ts b/apps/server-nestjs/src/config/sonarqube.config.spec.ts new file mode 100644 index 0000000000..d6e0938599 --- /dev/null +++ b/apps/server-nestjs/src/config/sonarqube.config.spec.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { sonarqubeConfigFactory } from './sonarqube.config' + +describe('sonarqubeConfig', () => { + beforeEach(() => { resetEnvs(['SONARQUBE_URL', 'SONARQUBE_INTERNAL_URL', 'SONAR_API_TOKEN']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes internalOrPublicUrl/probeUrl', () => { + vi.stubEnv('SONARQUBE_URL', 'https://sonar.example.com') + vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonar.internal:9000') + vi.stubEnv('SONAR_API_TOKEN', 'token') + expect(sonarqubeConfigFactory()).toMatchObject({ + url: 'https://sonar.example.com', + internalUrl: 'https://sonar.internal:9000', + apiToken: 'token', + internalOrPublicUrl: 'https://sonar.internal:9000', + probeUrl: 'https://sonar.internal:9000/api/system/health', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('SONARQUBE_URL', 'https://sonar.example.com') + vi.stubEnv('SONAR_API_TOKEN', 'token') + const cfg = sonarqubeConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.internalOrPublicUrl).toBe('https://sonar.example.com') + }) + + it('parses with empty vars (SonarQube disabled, probeUrl undefined)', () => { + expect(sonarqubeConfigFactory().probeUrl).toBeUndefined() + }) +}) 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..8dfb304caa --- /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 { optionalUrl, optionalValue } from './config.utils' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: optionalUrl, + SONARQUBE_INTERNAL_URL: optionalUrl, + SONAR_API_TOKEN: optionalValue, +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + 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 type SonarqubeConfig = z.infer + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/vault.config.spec.ts b/apps/server-nestjs/src/config/vault.config.spec.ts new file mode 100644 index 0000000000..2b6676903d --- /dev/null +++ b/apps/server-nestjs/src/config/vault.config.spec.ts @@ -0,0 +1,34 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config.spec.utils' +import { vaultConfigFactory } from './vault.config' + +describe('vaultConfig', () => { + beforeEach(() => { resetEnvs(['VAULT_TOKEN', 'VAULT_URL', 'VAULT_INTERNAL_URL', 'VAULT_KV_NAME']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and composes internalOrPublicUrl/probeUrl', () => { + vi.stubEnv('VAULT_URL', 'https://vault.example.com') + vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal:8200') + vi.stubEnv('VAULT_TOKEN', 'token') + expect(vaultConfigFactory()).toMatchObject({ + url: 'https://vault.example.com', + internalUrl: 'https://vault.internal:8200', + token: 'token', + kvName: 'forge-dso', + internalOrPublicUrl: 'https://vault.internal:8200', + probeUrl: 'https://vault.internal:8200/v1/sys/health', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('VAULT_URL', 'https://vault.example.com') + vi.stubEnv('VAULT_TOKEN', 'token') + const cfg = vaultConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.internalOrPublicUrl).toBe('https://vault.example.com') + }) + + it('parses with empty vars (Vault disabled, probeUrl undefined)', () => { + expect(vaultConfigFactory().probeUrl).toBeUndefined() + }) +}) 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..eb925fa9c4 --- /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 { optionalUrl, optionalValue } from './config.utils' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: optionalValue, + VAULT_URL: optionalUrl, + VAULT_INTERNAL_URL: optionalUrl, + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + 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 type VaultConfig = z.infer + +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..d16741000f 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,5 +1,8 @@ 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 { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -16,15 +19,23 @@ import { ProjectModule } from './modules/project/project.module' import { ServiceChainModule } from './modules/service-chain/service-chain.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' +import { optIn } from './utils/config.utils' +import { getDotenvPaths } from './utils/dotenv.utils' @Module({ imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), InfrastructureModule, HealthzModule, KeycloakModule, ScheduleModule.forRoot(), SystemSettingsModule, - ServiceChainModule, + ConditionalModule.registerWhen(ServiceChainModule, optIn('USE_SERVICE_CHAIN')), ProjectModule, ProjectHooksModule, ProjectSecretsModule, diff --git a/apps/server-nestjs/src/main.ts b/apps/server-nestjs/src/main.ts index 28b5180f5e..a5c82a9ef3 100644 --- a/apps/server-nestjs/src/main.ts +++ b/apps/server-nestjs/src/main.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { NestFastifyApplication } from '@nestjs/platform-fastify' import { NestFactory } from '@nestjs/core' import { FastifyAdapter } from '@nestjs/platform-fastify' @@ -8,8 +9,9 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { NodeSDK, resources } from '@opentelemetry/sdk-node' import { Logger } from 'nestjs-pino' +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici' +import { baseConfigFactory } from './config/base.config' import { MainModule } from './main.module' -import { ConfigurationService } from './modules/infrastructure/configuration/configuration.service' const SERVICE_NAME = 'console-pi-native-console' @@ -30,6 +32,8 @@ const telemetry = new NodeSDK({ }) async function bootstrap() { + setGlobalDispatcher(new EnvHttpProxyAgent()) + telemetry.start() const app = await NestFactory.create(MainModule, new FastifyAdapter(), { @@ -43,7 +47,7 @@ async function bootstrap() { await telemetry.shutdown() }) - const config = app.get(ConfigurationService) + const config = app.get>(baseConfigFactory.KEY) // Setup swagger-ui route const swaggerConfig = new DocumentBuilder() @@ -55,7 +59,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..058b35b26f 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -1,21 +1,22 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { argocdConfigFactory } from '../../config/argocd.config' @Injectable() export class ArgoCDHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(argocdConfigFactory.KEY) private readonly argocdConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicArgoCDUrl() - if (!urlBase) return indicator.down('Not configured') - + if (!this.argocdConfig.probeUrl) { + return indicator.down('ArgoCD is not configured') + } try { - const response = await fetch(new URL('/api/version', urlBase).toString()) + const response = await fetch(this.argocdConfig.probeUrl) 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/argocd/argocd.module.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..e3e4064a44 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,38 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + beforeEach(() => { + vi.stubEnv('PROJECTS_ROOT_DIR', 'tmp/projects') + vi.stubEnv('VAULT_URL', 'https://vault.example') + vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') + vi.stubEnv('VAULT_TOKEN', 'token') + vi.stubEnv('ARGOCD_URL', 'https://argocd.example') + vi.stubEnv('ARGOCD_INTERNAL_URL', 'https://argocd.internal') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo') + vi.stubEnv('GITLAB_URL', 'https://gitlab.example') + vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal') + vi.stubEnv('GITLAB_TOKEN', 'token') + }) + + afterEach(() => vi.unstubAllEnvs()) + + it('registers ArgoCDService when USE_ARGOCD=true', async () => { + vi.stubEnv('USE_ARGOCD', 'true') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(module.get(ArgoCDService)).toBeInstanceOf(ArgoCDService) + }) + + it('omits ArgoCDService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).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..e38fad5b8b 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.module.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' 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' @@ -10,7 +13,7 @@ import { ArgoCDPluginService } from './argocd-plugin.service' import { ArgoCDService } from './argocd.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, GitlabModule, TerminusModule, VaultModule], + imports: [DatabaseModule, GitlabModule, TerminusModule, VaultModule, ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], }) 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..521e50dd98 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,12 +1,15 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { generateNamespaceName } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { stringify } from 'yaml' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { GitlabClientService } from '../gitlab/gitlab-client.service' import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' @@ -17,28 +20,41 @@ 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>({ + enabled: true, + 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: argocdConfigFactory.KEY, useValue: argocdConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfig }, { provide: GitlabClientService, useValue: gitlab }, { provide: VaultClientService, useValue: vault }, ], @@ -116,7 +132,6 @@ describe('argoCDService', () => { makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), ], repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], - plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], deployments: [], }) @@ -182,7 +197,6 @@ describe('argoCDService', () => { sourceRepositories: [ 'https://gitlab.internal/group/project-1/**', 'repo3', - 'repo2', ], destination: { namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), @@ -257,7 +271,6 @@ describe('argoCDService', () => { sourceRepositories: [ 'https://gitlab.internal/group/project-1/**', 'repo3', - 'repo2', ], destination: { namespace: generateNamespaceName(mockProject.id, mockProject.environments[1].id), @@ -391,7 +404,6 @@ describe('argoCDService', () => { mockProdEnv, ], repositories: [mockRepo], - plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], deployments: [ makeProjectDeployment({ environment: mockDevEnv, @@ -470,7 +482,6 @@ describe('argoCDService', () => { sourceRepositories: [ 'https://gitlab.internal/group/project-1/**', 'repo3', - 'repo2', ], destination: { namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 7cd254fda4..d7b6628d91 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,4 +1,5 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails } from './argocd-datastore.service' import { createHmac } from 'node:crypto' @@ -7,8 +8,10 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { stringify } from 'yaml' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { GitlabClientService } from '../gitlab/gitlab-client.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -30,8 +33,10 @@ 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(argocdConfigFactory.KEY) private readonly argocdConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { @@ -70,7 +75,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 +252,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 +324,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 +353,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', } @@ -524,23 +529,15 @@ function formatEnvironmentValues( interface FormatSourceRepositoriesValuesOptions { gitlabPublicProjectUrl: string - argocdExtraRepositories?: string - projectPlugins?: ProjectWithDetails['plugins'] + argocdExtraRepositories?: string[] } function formatSourceRepositoriesValues( - { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, + { gitlabPublicProjectUrl, argocdExtraRepositories = [] }: FormatSourceRepositoriesValuesOptions, ): string[] { - let projectExtraRepositories = '' - if (projectPlugins) { - const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') - if (argocdPlugin) projectExtraRepositories = argocdPlugin.value - } - return [ `${gitlabPublicProjectUrl}/**`, - ...splitExtraRepositories(argocdExtraRepositories), - ...splitExtraRepositories(projectExtraRepositories), + ...argocdExtraRepositories, ] } @@ -582,7 +579,7 @@ interface FormatValuesOptions { environment: ProjectWithDetails['environments'][number] cluster: ProjectWithDetails['environments'][number]['cluster'] gitlabPublicProjectUrl: string - argocdExtraRepositories?: string + argocdExtraRepositories?: string[] vaultValues: Record infraProject: SimpleProjectSchema valueFilePath: string @@ -630,7 +627,6 @@ function formatValues({ sourceRepositories: formatSourceRepositoriesValues({ gitlabPublicProjectUrl, argocdExtraRepositories, - projectPlugins: project.plugins, }), destination: { namespace: generateNamespaceName(project.id, environment.id), 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..35fa8759c4 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,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' @@ -5,7 +6,7 @@ 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 { baseConfigFactory } from '../../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: baseConfigFactory.KEY, 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..9b658d59c5 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 { ConfigType } from '@nestjs/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 { baseConfigFactory } from '../../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(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} /** @@ -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..28ad4cb2cb 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 @@ -1,11 +1,12 @@ import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { MockedFunction } from 'vitest' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { gitlabConfigFactory } from '../../config/gitlab.config' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' import { makeAccessTokenExposedSchema, @@ -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: gitlabConfigFactory.KEY, 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..a9fb7028e6 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,12 @@ import type { PipelineTriggerTokenSchema, SimpleUserSchema, } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' 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 { gitlabConfigFactory } from '../../config/gitlab.config' +import { find } from '../../utils/iterable.utils' import { GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, @@ -51,7 +52,7 @@ export class GitlabClientService { private readonly logger = new Logger(GitlabClientService.name) constructor( - @Inject(ConfigurationService) readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) readonly config: ConfigType, @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..d73e440216 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -1,22 +1,22 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' @Injectable() export class GitlabHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) private readonly gitlabConfig: ConfigType, @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 = new URL('/-/health', urlBase).toString() + if (!this.gitlabConfig.probeUrl) { + return indicator.down('GitLab is not configured') + } try { - const response = await fetch(url) + const response = await fetch(this.gitlabConfig.probeUrl) 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/gitlab/gitlab-plugin.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts index 6bd62d87c2..5dd2e73f80 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 { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { gitlabConfigFactory } from '../../config/gitlab.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { GitlabPluginService } from './gitlab-plugin.service' 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: gitlabConfigFactory.KEY, 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..9cc94a15c4 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -1,23 +1,21 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' 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' @Injectable() export class GitlabPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) + private readonly gitlabConfig: ConfigType, ) {} 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() - }, + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), title: 'Gitlab', imgSrc: '/img/gitlab.svg', description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts index 22859472fb..48bcaf7040 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -1,8 +1,9 @@ +import type { ConfigType } from '@nestjs/config' import { Gitlab } from '@gitbeaker/rest' import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' @@ -12,14 +13,14 @@ import { GitlabPluginService } from './gitlab-plugin.service' import { GitlabService } from './gitlab.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(gitlabConfigFactory)], providers: [ { provide: GITLAB_REST_CLIENT, - inject: [ConfigurationService], - useFactory: (config: ConfigurationService) => new Gitlab({ - token: config.gitlabToken, - host: config.getInternalOrPublicGitlabUrl(), + inject: [gitlabConfigFactory.KEY], + useFactory: (config: ConfigType) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, }), }, GitlabClientService, 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..97004b48c4 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' @@ -5,7 +6,7 @@ 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 { gitlabConfigFactory } from '../../config/gitlab.config' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' @@ -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: gitlabConfigFactory.KEY, 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..1eeecc6710 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,4 +1,5 @@ import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' import type { ProjectWithDetails } from './gitlab-datastore.service' @@ -7,8 +8,8 @@ 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 { gitlabConfigFactory } from '../../config/gitlab.config' +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' @@ -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(gitlabConfigFactory.KEY) private readonly gitlabConfig: ConfigType, ) { 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.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts index b8d3e3a4ed..2358ced57a 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -1,57 +1,14 @@ import { Controller, Get, Inject } from '@nestjs/common' -import { HealthCheck, HealthCheckService } from '@nestjs/terminus' -import { ArgoCDHealthService } from '../argocd/argocd-health.service' -import { GitlabHealthService } from '../gitlab/gitlab-health.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { DatabaseHealthService } from '../infrastructure/database/database-health.service' -import { KeycloakHealthService } from '../keycloak/keycloak-health.service' -import { NexusHealthService } from '../nexus/nexus-health.service' -import { OpenCdsHealthService } from '../opencds/opencds-health.service' -import { RegistryHealthService } from '../registry/registry-health.service' -import { VaultHealthService } from '../vault/vault-health.service' +import { HealthCheck } from '@nestjs/terminus' +import { HealthzService } from './healthz.service' @Controller('api/v1/healthz') export class HealthzController { - 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, - ) {} + constructor(@Inject(HealthzService) private readonly healthz: HealthzService) {} @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')) - } - - return this.health.check(checks) + return this.healthz.check() } } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 94b4281941..6cc5b1f96c 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -1,29 +1,31 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { optIn } from 'src/utils/config.utils' import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { KeycloakModule } from '../keycloak/keycloak.module' import { NexusModule } from '../nexus/nexus.module' -import { OpenCdsModule } from '../opencds/opencds.module' import { RegistryModule } from '../registry/registry.module' +import { ServiceChainModule } from '../service-chain/service-chain.module' import { VaultModule } from '../vault/vault.module' import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' @Module({ imports: [ - TerminusModule, + TerminusModule.forRoot(), DatabaseModule, KeycloakModule, - GitlabModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), VaultModule, - NexusModule, - RegistryModule, - ArgoCDModule, - ConfigurationModule, - OpenCdsModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(ServiceChainModule, optIn('USE_SERVICE_CHAIN')), ], controllers: [HealthzController], + providers: [HealthzService], }) export class HealthzModule {} diff --git a/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..a8e459eae9 --- /dev/null +++ b/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { ServiceChainHealthService } from '../service-chain/service-chain-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let healthService: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + healthService = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: ServiceChainHealthService, useValue: healthService }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + 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(healthService.check).toHaveBeenCalledWith('serviceChain') + }) + }) + + describe('without optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..196c4c7ba8 --- /dev/null +++ b/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,40 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { ServiceChainHealthService } from '../service-chain/service-chain-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(ServiceChainHealthService) @Optional() private readonly serviceChainHealth?: ServiceChainHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // 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.serviceChainHealth) checks.push(() => this.serviceChainHealth!.check('serviceChain')) + return this.health.check(checks) + } +} 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..e4084a0d60 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,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' @@ -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 { keycloakConfigFactory } from '../../../../config/keycloak.config' 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: keycloakConfigFactory.KEY, 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..a419e6fa3c 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 { ConfigType } from '@nestjs/config' import type { Cache } from 'cache-manager' 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 { keycloakConfigFactory } from '../../../../config/keycloak.config' import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' const OpenidConfigurationSchema = z.object({ @@ -35,16 +36,16 @@ 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(keycloakConfigFactory.KEY) private readonly keycloakConfig: ConfigType, ) {} async fetchOpenIdConfig(): Promise { - const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.config.getKeycloakOpenidConfigurationUrl()) + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) const cached = await this.cache.get(cacheKey) if (cached) return cached - const response = await fetch(this.config.getKeycloakOpenidConfigurationUrl()) + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) if (!response.ok) { this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) return undefined @@ -57,7 +58,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 +73,10 @@ export class KeycloakSecretProviderService { } private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { - if (!this.config.keycloakDomain) { - this.logger.log(`No internal domain configured, returning original JWKS URI: ${jwksUri}`) - return jwksUri - } + if (!this.keycloakConfig.domain) 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.host = this.keycloakConfig.domain this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) return url.toString() } @@ -88,7 +86,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 +122,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/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..ce8ad381bd 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,13 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../configuration/configuration.module' +import { baseConfigFactory } from '../../../config/base.config' import { DatabaseHealthService } from './database-health.service' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @Module({ - imports: [ConfigurationModule, TerminusModule], + imports: [TerminusModule, ConfigModule.forFeature(baseConfigFactory)], 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..676d44aa7c 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 { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' 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 { baseConfigFactory } from '../../../config/base.config' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @@ -10,17 +11,13 @@ 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: baseConfigFactory.KEY, 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..d493867a94 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 { ConfigType } from '@nestjs/config' import { setTimeout } from 'node:timers/promises' import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigurationService } from '../configuration/configuration.service' +import { baseConfigFactory } from '../../../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(baseConfigFactory.KEY) private readonly configurationService: ConfigType, ) { 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..dc86b8bffc 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 { ConfigType } from '@nestjs/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 { baseConfigFactory } from '../../../config/base.config' @Module({ imports: [ PinoLoggerModule.forRootAsync({ - imports: [ConfigurationModule], - inject: [ConfigurationService], - useFactory: async (configService: ConfigurationService) => { + imports: [], + inject: [baseConfigFactory.KEY], + useFactory: async (baseConfig: ConfigType) => { 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 633ec731a3..61c0aab6f4 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,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import KcAdminClient from '@keycloak/keycloak-admin-client' import { ScheduleModule } from '@nestjs/schedule' @@ -6,7 +7,7 @@ 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 { keycloakConfigFactory } from '../../config/keycloak.config' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' @@ -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: keycloakConfigFactory.KEY, + useValue: mockDeep>({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', ...config, }), }, @@ -159,28 +160,6 @@ describe('keycloakClientService authentication lifecycle', () => { await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) expect(tokenRequests).toHaveLength(1) }) - - 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() - - await expect(module.init()).rejects.toThrow() - - await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) - expect(tokenRequests).toHaveLength(0) - }) - - 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() - - await expect(module.init()).rejects.toThrow() - - await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) - expect(tokenRequests).toHaveLength(0) - }) }) describe('getOrCreateSubGroupByName', () => { 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..762c18f2f1 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -3,14 +3,15 @@ import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/g import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' import type { OnModuleInit } from '@nestjs/common' +import type { ConfigType } from '@nestjs/config' import type { ProjectWithDetails } from './keycloak-datastore.service' 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 { keycloakConfigFactory } from '../../config/keycloak.config' +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' @@ -23,7 +24,7 @@ export class KeycloakClientService implements OnModuleInit { private authenticated = false constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(keycloakConfigFactory.KEY) private readonly keycloakConfig: ConfigType, @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, ) { } @@ -274,17 +275,8 @@ export class KeycloakClientService implements OnModuleInit { } async onModuleInit() { - if (!this.config.keycloakRealm) { - throw new Error('Keycloak realm is not configured') - } - if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { - throw new Error('Keycloak admin username or password is not configured') - } - if (!this.config.keycloakAdminClientId) { - 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 +286,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 @@ -318,27 +310,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) { - throw new Error('KEYCLOAK_ADMIN_CLIENT_ID is not configured') - } - return this.config.keycloakAdminClientId - } - private passwordCredentials(): Credentials { return { - clientId: this.adminClientId, + clientId: this.keycloakConfig.adminClientId, grantType: PASSWORD_GRANT_TYPE, - username: this.config.keycloakAdmin, - password: this.config.keycloakAdminPassword, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, } } private refreshTokenCredentials(): Credentials { return { - clientId: this.adminClientId, + clientId: this.keycloakConfig.adminClientId, grantType: REFRESH_TOKEN_GRANT_TYPE, refreshToken: this.client.refreshToken, } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.spec.ts new file mode 100644 index 0000000000..bf28c9c1de --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.spec.ts @@ -0,0 +1,35 @@ +import { HealthIndicatorService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { KeycloakHealthService } from './keycloak-health.service' + +describe('keycloakHealthService', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('reports not configured when KEYCLOAK_DOMAIN/REALM are empty (no fetch)', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + KeycloakHealthService, + { provide: keycloakConfigFactory.KEY, useValue: keycloakConfigFactory() }, + ], + }).compile() + const service = moduleRef.get(KeycloakHealthService) + + const indicator = moduleRef.get(HealthIndicatorService) as unknown as { + check: (k: string) => { down: (r: string) => unknown } + } + let downReason: string | undefined + vi.spyOn(indicator, 'check').mockImplementation((_k: string) => ({ + down: (reason: string) => { + downReason = reason + return { status: 'down' } + }, + })) + + await service.check('keycloak') + + expect(downReason).toContain('not configured') + }) +}) 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..1c89e14826 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -1,23 +1,24 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { keycloakConfigFactory } from '../../config/keycloak.config' @Injectable() export class KeycloakHealthService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(keycloakConfigFactory.KEY) + private readonly keycloakConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const url = this.config.getKeycloakOpenidConfigurationUrl() - if (!url) return indicator.down('Not configured') - + if (!this.keycloakConfig.openidConfigurationUrl) { + return indicator.down('Keycloak is not configured') + } try { - const response = await fetch(url) + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) 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/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts index 9ae808fc65..76953fcd0e 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -1,8 +1,9 @@ +import type { ConfigType } from '@nestjs/config' import KcAdminClient from '@keycloak/keycloak-admin-client' import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { keycloakConfigFactory } from '../../config/keycloak.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' @@ -11,13 +12,13 @@ import { KeycloakPluginService } from './keycloak-plugin.service' import { KeycloakService } from './keycloak.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [ConfigModule.forFeature(keycloakConfigFactory), DatabaseModule, TerminusModule], providers: [ { - inject: [ConfigurationService], + inject: [keycloakConfigFactory.KEY], provide: KEYCLOAK_ADMIN_CLIENT, - useFactory: (config: ConfigurationService) => new KcAdminClient({ - baseUrl: config.getKeycloakUrl(), + useFactory: (config: ConfigType) => new KcAdminClient({ + baseUrl: config.url, }), }, KeycloakClientService, 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..f83b4a98db 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,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' @@ -6,7 +7,7 @@ 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 { nexusConfigFactory } from '../../config/nexus.config' import { NexusClientService } from './nexus-client.service' import { NexusHttpClientService } from './nexus-http-client.service' @@ -18,18 +19,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 +36,7 @@ describe('nexusClientService', () => { NexusClientService, NexusHttpClientService, { - provide: ConfigurationService, + provide: nexusConfigFactory.KEY, 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..ac51d987a1 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -1,28 +1,26 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' @Injectable() export class NexusHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, @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 = 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}` - const encoded = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${encoded}` + if (!this.nexusConfig.probeUrl) { + return indicator.down('Nexus is not configured') } - + const headers: Record = {} + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` try { - const response = await fetch(url, { headers }) + const response = await fetch(this.nexusConfig.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/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts index e89cc70aa4..b2c9ade121 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,6 +1,7 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' export interface NexusFetchOptions { @@ -44,7 +45,7 @@ export class NexusError extends Error { @Injectable() export class NexusHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, ) {} @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..4c653def71 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,26 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { nexusConfigFactory } from '../../config/nexus.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { NexusPluginService } from './nexus-plugin.service' 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: nexusConfigFactory.KEY, 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..87372785ad 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -1,22 +1,19 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' @Injectable() export class NexusPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, ) {} infos(): ServiceInfos { return { name: 'nexus', - to: () => { - if (!this.config.nexusUrl) return undefined - return this.config.nexusUrl - }, + to: () => this.nexusConfig.url, title: 'Nexus', imgSrc: '/img/nexus.png', description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts index 14d706232d..1b02a8c9a8 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' @@ -11,7 +13,7 @@ import { NexusPluginService } from './nexus-plugin.service' import { NexusService } from './nexus.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ NexusHealthService, NexusPluginService, 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..c3fd960a95 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -1,9 +1,12 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' @@ -26,7 +29,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 +50,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 +60,9 @@ describe('nexusService', () => { { provide: NexusClientService, useValue: client }, { provide: NexusDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: nexusConfigFactory.KEY, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: vaultConfigFactory.KEY, 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..caa89b855f 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { NexusPrivilege } from './nexus-client.service' import type { ProjectWithDetails } from './nexus-datastore.service' @@ -8,7 +9,8 @@ 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 { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -52,10 +54,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(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) { this.logger.log('NexusService initialized') } @@ -71,7 +74,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 +89,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 +98,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 +432,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 +490,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 +514,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 +577,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/nexus/nexus.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts index 1bd4450861..6e9e0e7645 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -10,11 +10,9 @@ export function generateRandomPassword(length: number) { return raw.slice(0, length) } -export function getProjectVaultPath(projectRootDir: string | undefined, projectSlug: string, relativePath: string) { +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath - return projectRootDir - ? `${projectRootDir}/${projectSlug}/${normalized}` - : `${projectSlug}/${normalized}` + return `${projectRootDir}/${projectSlug}/${normalized}` } export type MavenHostedRepoKind = 'release' | 'snapshot' diff --git a/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/apps/server-nestjs/src/modules/opencds/opencds.module.ts deleted file mode 100644 index 8b42ddee91..0000000000 --- a/apps/server-nestjs/src/modules/opencds/opencds.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common' -import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { OpenCdsHealthService } from './opencds-health.service' - -@Module({ - imports: [ConfigurationModule, TerminusModule], - providers: [OpenCdsHealthService], - exports: [OpenCdsHealthService], -}) -export class OpenCdsModule {} 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..d37e4451c2 --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,61 @@ +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule (all gates on)', () => { + beforeEach(() => { + vi.stubEnv('PROJECTS_ROOT_DIR', 'tmp/projects') + vi.stubEnv('KEYCLOAK_PROTOCOL', 'https') + vi.stubEnv('KEYCLOAK_DOMAIN', 'kc.example') + vi.stubEnv('KEYCLOAK_REALM', 'master') + vi.stubEnv('KEYCLOAK_CLIENT_ID', 'id') + vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') + vi.stubEnv('KEYCLOAK_ADMIN', 'admin') + vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') + vi.stubEnv('KEYCLOAK_ADMIN_CLIENT_ID', 'admin-cli') + vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://app.example/callback') + vi.stubEnv('VAULT_TOKEN', 'token') + vi.stubEnv('VAULT_URL', 'https://vault.example') + vi.stubEnv('ARGOCD_URL', 'https://argocd.example') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo') + vi.stubEnv('GITLAB_TOKEN', 'token') + vi.stubEnv('GITLAB_URL', 'https://gitlab.example') + vi.stubEnv('HARBOR_URL', 'https://harbor.example') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('NEXUS_URL', 'https://nexus.example') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + vi.stubEnv('SONARQUBE_URL', 'https://sonar.example') + vi.stubEnv('SONAR_API_TOKEN', 'token') + vi.stubEnv('USE_ARGOCD', 'true') + vi.stubEnv('USE_GITLAB', 'true') + vi.stubEnv('USE_REGISTRY', 'true') + vi.stubEnv('USE_NEXUS', 'true') + vi.stubEnv('USE_SONARQUBE', 'true') + }) + afterEach(() => vi.unstubAllEnvs()) + + it('registers every plugin service', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true }), PluginModule], + }).compile() + expect(moduleRef.get(PluginService, { strict: false })).toBeInstanceOf(PluginService) + expect(moduleRef.get(KeycloakPluginService, { strict: false })).toBeInstanceOf(KeycloakPluginService) + expect(moduleRef.get(VaultPluginService, { strict: false })).toBeInstanceOf(VaultPluginService) + expect(moduleRef.get(ArgoCDPluginService, { strict: false })).toBeInstanceOf(ArgoCDPluginService) + expect(moduleRef.get(GitlabPluginService, { strict: false })).toBeInstanceOf(GitlabPluginService) + expect(moduleRef.get(RegistryPluginService, { strict: false })).toBeInstanceOf(RegistryPluginService) + expect(moduleRef.get(NexusPluginService, { strict: false })).toBeInstanceOf(NexusPluginService) + expect(moduleRef.get(SonarqubePluginService, { strict: false })).toBeInstanceOf(SonarqubePluginService) + }) +}) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.ts index c8faef2fc8..d74a259e4e 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' import { KeycloakModule } from '../keycloak/keycloak.module' @@ -9,7 +10,15 @@ import { VaultModule } from '../vault/vault.module' import { PluginService } from './plugin.service' @Module({ - imports: [ArgoCDModule, GitlabModule, RegistryModule, KeycloakModule, NexusModule, SonarqubeModule, VaultModule], + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + KeycloakModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + VaultModule, + ], providers: [PluginService], exports: [PluginService], }) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/apps/server-nestjs/src/modules/plugin/plugin.service.ts index 1b9303ee53..b1012b8647 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.service.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -1,5 +1,5 @@ import type { ServiceInfos } from '@cpn-console/hooks' -import { Inject, Injectable, Logger } from '@nestjs/common' +import { Inject, Injectable, Logger, Optional } from '@nestjs/common' import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' @@ -12,26 +12,27 @@ import { VaultPluginService } from '../vault/vault-plugin.service' export class PluginService { private readonly logger = new Logger(PluginService.name) + // keycloak/vault are mandatory always-on modules; the other 5 are gated by USE_*. constructor( - @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, - @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, - @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, - @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, - @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + @Inject(ArgoCDPluginService) @Optional() private readonly argoCDPlugin?: ArgoCDPluginService, + @Inject(GitlabPluginService) @Optional() private readonly gitlabPlugin?: GitlabPluginService, + @Inject(RegistryPluginService) @Optional() private readonly registryPlugin?: RegistryPluginService, + @Inject(NexusPluginService) @Optional() private readonly nexusPlugin?: NexusPluginService, + @Inject(SonarqubePluginService) @Optional() private readonly sonarqubePlugin?: SonarqubePluginService, ) {} async infos(projectId: string): Promise { - const plugins = [ - ['argocd', () => this.argoCDPlugin.infos()], - ['gitlab', () => this.gitlabPlugin.infos()], - ['registry', () => this.registryPlugin.infos(projectId)], + const plugins: [string, () => ServiceInfos | Promise][] = [ ['keycloak', () => this.keycloakPlugin.infos()], - ['nexus', () => this.nexusPlugin.infos()], - ['sonarqube', () => this.sonarqubePlugin.infos()], ['vault', () => this.vaultPlugin.infos()], - ] as const + ] + if (this.argoCDPlugin) { plugins.push(['argocd', () => this.argoCDPlugin!.infos()]) } + if (this.gitlabPlugin) { plugins.push(['gitlab', () => this.gitlabPlugin!.infos()]) } + if (this.registryPlugin) { plugins.push(['registry', () => this.registryPlugin!.infos(projectId)]) } + if (this.nexusPlugin) { plugins.push(['nexus', () => this.nexusPlugin!.infos()]) } + if (this.sonarqubePlugin) { plugins.push(['sonarqube', () => this.sonarqubePlugin!.infos()]) } const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) return settled.flatMap((result, index) => { 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..16a6c23b13 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,7 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' 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 +9,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, ConfigModule.forFeature(baseConfigFactory)], 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..ef66ed3549 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,9 +1,11 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { makeProject } from '../project/project-testing.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -17,19 +19,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: baseConfigFactory.KEY, useValue: config }, + { provide: vaultConfigFactory.KEY, 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..2f9525b41f 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,7 +1,8 @@ +import type { ConfigType } from '@nestjs/config' 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 { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultClientService } from '../vault/vault-client.service' @@ -28,7 +29,7 @@ export class ProjectSecretsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultService) private readonly vault: VaultService, @Inject(VaultClientService) private readonly vaultClient: VaultClientService, ) {} @@ -42,7 +43,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..4e50667c67 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.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' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' import { ProjectController } from './project.controller' import { ProjectService } from './project.service' @@ -14,12 +16,13 @@ import { ProjectService } from './project.service' imports: [ AppEventsModule, AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, ProjectPermissionModule, UserPermissionModule, LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), ], 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..e07ae9b900 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { Prisma } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' @@ -11,8 +12,9 @@ import { import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { @@ -33,13 +35,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 +51,8 @@ describe('projectService', () => { ProjectService, { provide: PrismaService, useValue: prisma }, { provide: AppEventsService, useValue: appEvents }, - { provide: ConfigurationService, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: config }, + { provide: vaultConfigFactory.KEY, 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..cbb6a8cf1c 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,12 +1,13 @@ import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' import type { Prisma } from '@prisma/client' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' 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 { baseConfigFactory } from '../../config/base.config' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { LogService } from '../log/log.service' @@ -30,7 +31,7 @@ export class ProjectService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AppEventsService) private readonly appEvents: AppEventsService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(LogService) private readonly logs: LogService, ) {} @@ -62,7 +63,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/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts index f6f8881411..38b84358b0 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 { ConfigType } from '@nestjs/config' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -5,7 +6,7 @@ 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 { harborConfigFactory } from '../../config/harbor.config' import { VaultClientService } from '../vault/vault-client.service' import { RegistryClientService } from './registry-client.service' import { RegistryHttpClientService } from './registry-http-client.service' @@ -22,15 +23,15 @@ 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, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: 10, + retentionCron: '0 22 2 * * *', }) const module = await Test.createTestingModule({ @@ -42,8 +43,8 @@ describe('registryService', () => { useValue: {}, }, { - provide: ConfigurationService, - useValue: config, + provide: harborConfigFactory.KEY, + 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..f6759788d6 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,28 +1,26 @@ +import type { HarborConfig } from '../../config/harbor.config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' @Injectable() export class RegistryHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(harborConfigFactory.KEY) 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') - - const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() - const headers: Record = {} - if (this.config.harborAdmin && this.config.harborAdminPassword) { - const credentials = `${this.config.harborAdmin}:${this.config.harborAdminPassword}` - const base64 = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${base64}` + if (!this.harborConfig.probeUrl) { + return indicator.down('Harbor is not configured') } - + const headers: Record = {} + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` try { - const response = await fetch(url, { method: 'GET', headers }) + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', 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/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts index cfe57239a2..2cb8790d9e 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 { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' import { encodeBasicAuth } from './registry.utils' export type RegistryQuery = Record @@ -46,28 +47,15 @@ export class RegistryError extends Error { @Injectable() export class RegistryHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(harborConfigFactory.KEY) private readonly harborConfig: ConfigType, ) {} - private get baseUrl() { - if (!this.config.harborInternalUrl) { - throw new RegistryError('NotConfigured', 'HARBOR_INTERNAL_URL is required') - } - return this.config.harborInternalUrl - } - private get apiBaseUrl() { - return new URL('api/v2.0/', this.baseUrl).toString() + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() } private get defaultHeaders() { - if (!this.config.harborAdmin) { - throw new RegistryError('NotConfigured', 'HARBOR_ADMIN is required') - } - if (!this.config.harborAdminPassword) { - 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..13aedb5614 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,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { Cache } from 'cache-manager' import type { DeepMockProxy } from 'vitest-mock-extended' import { CACHE_MANAGER } from '@nestjs/cache-manager' @@ -5,7 +6,7 @@ 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 { harborConfigFactory } from '../../config/harbor.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.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: harborConfigFactory.KEY, 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..bb45c70989 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 { ConfigType } from '@nestjs/config' import type { Cache } from 'cache-manager' 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 { harborConfigFactory } from '../../config/harbor.config' 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(harborConfigFactory.KEY) + private readonly harborConfig: ConfigType, @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 } @@ -49,9 +50,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() + private resolveHarborProjectUrl(harborProjectId: number): string { + 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.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts index b205b1babb..c35c543393 100644 --- a/apps/server-nestjs/src/modules/registry/registry.module.ts +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -1,7 +1,9 @@ import { CacheModule } from '@nestjs/cache-manager' 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 { harborConfigFactory } from '../../config/harbor.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { RegistryClientService } from './registry-client.service' @@ -12,7 +14,7 @@ import { RegistryPluginService } from './registry-plugin.service' import { RegistryService } from './registry.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule, CacheModule.register()], + imports: [DatabaseModule, TerminusModule, VaultModule, CacheModule.register(), ConfigModule.forFeature(harborConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], exports: [RegistryHealthService, RegistryPluginService, RegistryService], }) 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..ea2c620017 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' @@ -5,7 +6,9 @@ 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 { baseConfigFactory } from '../../config/base.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { RegistryClientService } from './registry-client.service' @@ -24,7 +27,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 +57,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 +78,9 @@ describe('registryService', () => { { provide: RegistryClientService, useValue: client }, { provide: RegistryDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: harborConfigFactory.KEY, useValue: harborConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: vaultConfigFactory.KEY, 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..d5172fe774 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -1,3 +1,5 @@ +import type { ConfigType } from '@nestjs/config' +import type { RuleTemplate } from '../../config/harbor.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' import type { @@ -9,13 +11,14 @@ import type { HarborRobotCreateRequest, } from './registry-client.service' import type { ProjectWithDetails } from './registry-datastore.service' -import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import type { VaultRobotSecret } from './registry.utils' 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 { baseConfigFactory } from '../../config/base.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { find } from '../../utils/iterable.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -23,7 +26,6 @@ import { VaultError } from '../vault/vault-http-client.service' import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { - ALLOWED_RETENTION_RULE_TEMPLATES, DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, DEFAULT_PLATFORM_GUEST_GROUP_PATHS, DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, @@ -55,18 +57,16 @@ 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(harborConfigFactory.KEY) private readonly harborConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('RegistryService initialized') } private get host() { - if (!this.config.harborUrl) { - 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 +103,8 @@ export class RegistryService { 'project.slug': project.slug, 'registry.robot.name': robotName, }) - if (!this.config.projectRootDir) { - 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 +139,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 +249,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 +343,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) } @@ -471,25 +468,13 @@ function generateRobotPermissions(project: ProjectWithDetails, robotName: string function generateRetentionPolicy( projectId: number, options: { - harborRuleTemplate?: string - harborRuleCount?: string - harborRetentionCron?: string + harborRuleTemplate?: RuleTemplate + harborRuleCount?: number + harborRetentionCron: string }, ): HarborRetentionPolicy { - let template: RuleTemplate = 'latestPushedK' - if (isRuleTemplate(options.harborRuleTemplate)) { - template = options.harborRuleTemplate - } - - const rawCount = Number(options.harborRuleCount) - let count: number - if (Number.isFinite(rawCount) && rawCount > 0) { - count = rawCount - } else if (template === 'always') { - count = 1 - } else { - count = 10 - } + const template: RuleTemplate = options.harborRuleTemplate ?? 'latestPushedK' + const count = options.harborRuleCount ?? (template === 'always' ? 1 : 10) return { algorithm: 'or', @@ -517,11 +502,3 @@ function generateRetentionPolicy( }, } } - -function isRuleTemplate(value: unknown): value is RuleTemplate { - if (typeof value !== 'string') return false - for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { - if (template === value) return true - } - return false -} diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index 2befbdbd12..8bbe97eb7e 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -1,9 +1,6 @@ import type { ProjectWithDetails } from './registry-datastore.service' -import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' import { removeTrailingSlash } from '@cpn-console/shared' -export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] - export function createProjectSlugCacheKey(projectId: string) { return `registry:project-slug:${projectId}` } 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..3883b209cd 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,13 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' -import type { Dispatcher, RequestInit } from 'undici' +import type { RequestInit } from 'undici' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' -import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' +import { Agent, fetch, Headers, 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 { baseConfigFactory } from '../../config/base.config' +import { serviceChainConfigFactory } from '../../config/service-chain.config' import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' vi.mock('undici', async (importOriginal) => { @@ -14,7 +17,6 @@ vi.mock('undici', async (importOriginal) => { ...actual, Agent: vi.fn(), fetch: vi.fn(), - ProxyAgent: vi.fn(), } }) @@ -30,45 +32,32 @@ function getLastFetchCall(): [string, RequestInit] { describe('openCdsClientService', () => { let module: TestingModule let service: OpenCdsClientService - let config: Partial - let tlsDispatcher: Pick - let proxyDispatcher: Pick + let serviceCHainConfig: Partial> + let baseConfig: ReturnType>> beforeEach(async () => { vi.clearAllMocks() vi.unstubAllEnvs() - tlsDispatcher = { dispatch: vi.fn() } - proxyDispatcher = { dispatch: vi.fn() } - - class MockAgent { - dispatch = tlsDispatcher - }; - - class ProxyMockAgent { - dispatch = proxyDispatcher - }; - - 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, + serviceCHainConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, } + baseConfig = mockDeep>() module = await Test.createTestingModule({ providers: [ OpenCdsClientService, - { provide: ConfigurationService, useValue: config }, + { provide: serviceChainConfigFactory.KEY, useValue: serviceCHainConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, ], }).compile() service = module.get(OpenCdsClientService) }) - it('builds GET requests with an Axios-compatible URL, API key header and TLS-aware dispatcher', async () => { + it('builds GET requests with an Axios-compatible URL, API key header and global dispatcher', async () => { mockFetchResponse(new Response(JSON.stringify({ ok: true }), { status: HttpStatus.OK, headers: { @@ -78,45 +67,15 @@ describe('openCdsClientService', () => { const result = await service.get<{ ok: boolean }>('/requests') - expect(Agent).toHaveBeenCalledWith({ - connect: { - rejectUnauthorized: true, - }, - }) const [url, init] = getLastFetchCall() expect(url).toBe('https://opencds.example.com/root/api/requests') - expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.dispatcher).toBeUndefined() expect(init.method).toBe('GET') expect(init.signal).toBeUndefined() expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') expect(result).toEqual({ ok: true }) }) - 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') - mockFetchResponse(new Response(JSON.stringify({ ok: true }), { - status: HttpStatus.OK, - headers: { - 'content-type': 'application/json', - }, - })) - - await service.get('/requests') - - expect(ProxyAgent).toHaveBeenCalledWith({ - requestTls: { - rejectUnauthorized: true, - }, - uri: 'http://proxy.internal:3128', - }) - const [url, init] = getLastFetchCall() - expect(url).toBe('https://opencds.example.com/root/api/requests') - expect(init.dispatcher?.dispatch).toBe(proxyDispatcher) - expect(init.method).toBe('GET') - expect(init.signal).toBeUndefined() - expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') - }) - it('applies query parameters and omits undefined values on GET', async () => { mockFetchResponse(new Response(JSON.stringify({ ok: true }), { status: HttpStatus.OK, @@ -145,7 +104,7 @@ describe('openCdsClientService', () => { const [url, init] = getLastFetchCall() expect(url).toBe('https://opencds.example.com/root/api/validate/id') - expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.dispatcher).toBeUndefined() expect(init.method).toBe('POST') expect(init.signal).toBeUndefined() expect(init.body).toBeUndefined() @@ -167,7 +126,7 @@ describe('openCdsClientService', () => { requestId: '123', enabled: true, })) - expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.dispatcher).toBeUndefined() expect(init.method).toBe('POST') expect(init.signal).toBeUndefined() expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') @@ -175,7 +134,7 @@ describe('openCdsClientService', () => { }) it('throws when OpenCDS is disabled', async () => { - config.openCdsUrl = undefined + serviceCHainConfig.url = undefined await expect(service.get('/requests')).rejects.toThrow('OpenCDS is disabled') }) @@ -194,4 +153,30 @@ describe('openCdsClientService', () => { statusText: 'Bad Gateway', }) }) + + it('uses a local Agent with rejectUnauthorized:false when TLS verification is disabled', async () => { + serviceCHainConfig = { + ...serviceCHainConfig, + apiTlsRejectUnauthorized: false, + } + module = await Test.createTestingModule({ + providers: [ + OpenCdsClientService, + { provide: serviceChainConfigFactory.KEY, useValue: serviceCHainConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + ], + }).compile() + service = module.get(OpenCdsClientService) + + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { 'content-type': 'application/json' }, + })) + + await service.get<{ ok: boolean }>('/requests') + + expect(Agent).toHaveBeenCalledWith({ connect: { rejectUnauthorized: false } }) + const [, init] = getLastFetchCall() + expect(init.dispatcher).toBeDefined() + }) }) 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..a2fc8095ce 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,13 +1,12 @@ import type { HttpStatus } from '@nestjs/common' +import type { ConfigType } from '@nestjs/config' import type { Dispatcher, HeadersInit } from 'undici' import { Inject, Injectable, Logger } from '@nestjs/common' -import { Agent, fetch, Headers, ProxyAgent } from 'undici' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { Agent, fetch, Headers } from 'undici' +import { baseConfigFactory } from '../../config/base.config' +import { serviceChainConfigFactory } from '../../config/service-chain.config' import { throwIfNotOk } from './service-chain.utils' -const openCdsDisabledMessage - = 'OpenCDS is disabled, please set OPENCDS_URL in your relevant .env file. See .env-example' - const URL_REGEX = /^https?:\/\// const START_SLASHES_REGEX = /^\/+/ const END_SLASHES_REGEX = /\/+$/ @@ -25,14 +24,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(serviceChainConfigFactory.KEY) private readonly opencdsConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} private readonly logger = new Logger(OpenCdsClientService.name) @@ -77,13 +76,11 @@ export class OpenCdsClientService { path: string, query?: OpenCdsRequestOptions['query'], ): string { - if (!this.config.openCdsUrl) { - throw new Error(openCdsDisabledMessage) - } + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') 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 +98,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') @@ -110,20 +107,13 @@ export class OpenCdsClientService { return mergedHeaders } - private buildDispatcher(): Dispatcher { - if (process.env.HTTP_PROXY) { - return new ProxyAgent({ - requestTls: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, - }, - uri: process.env.HTTP_PROXY, - }) + private buildDispatcher(): Dispatcher | undefined { + // Only the TLS-verify-disabled case needs a local dispatcher; proxy bypass on + // that rare path is accepted. Security default (apiTlsRejectUnauthorized=true) keeps cert verification ON. + if (!this.opencdsConfig.apiTlsRejectUnauthorized) { + return new Agent({ connect: { rejectUnauthorized: false } }) } - return new Agent({ - connect: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, - }, - }) + return undefined } } diff --git a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts similarity index 56% rename from apps/server-nestjs/src/modules/opencds/opencds-health.service.ts rename to apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts index f91f493f5b..90bf708ef8 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts +++ b/apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts @@ -1,26 +1,24 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { serviceChainConfigFactory } from '../../config/service-chain.config' @Injectable() -export class OpenCdsHealthService { +export class ServiceChainHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(serviceChainConfigFactory.KEY) private readonly opencdsConfig: ConfigType, @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('Service chain (OpenCDS) is 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}` - } - - const response = await fetch(url, { headers }) + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + 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/service-chain/service-chain.module.ts b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts index 537261b97a..cc7cfa8344 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,23 +1,29 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { serviceChainConfigFactory } from '../../config/service-chain.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' import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainHealthService } from './service-chain-health.service' import { ServiceChainController } from './service-chain.controller' import { ServiceChainService } from './service-chain.service' @Module({ imports: [ AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, UserPermissionModule, + TerminusModule, + ConfigModule.forFeature(serviceChainConfigFactory), + ConfigModule.forFeature(baseConfigFactory), ], controllers: [ServiceChainController], - providers: [OpenCdsClientService, ServiceChainService], - exports: [ServiceChainService], + providers: [OpenCdsClientService, ServiceChainHealthService, ServiceChainService], + exports: [ServiceChainService, ServiceChainHealthService], }) export class ServiceChainModule {} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts index 3d8addfdab..aea4735d29 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,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' @@ -5,7 +6,7 @@ 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 { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' @@ -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: sonarqubeConfigFactory.KEY, 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..3988b21e5b 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,29 +1,25 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' @Injectable() export class SonarqubeHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) private readonly sonarqubeConfig: ConfigType, @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 = new URL('/api/system/health', urlBase).toString() - const token = this.config.sonarApiToken - const headers: Record = {} - if (token) { - const bearerToken = Buffer.from(`${token}:`, 'utf8').toString('base64') - headers.Authorization = `Bearer ${bearerToken}` + if (!this.sonarqubeConfig.probeUrl) { + return indicator.down('SonarQube is not configured') } - + const headers: Record = {} + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` try { - const response = await fetch(url, { headers }) + const response = await fetch(this.sonarqubeConfig.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/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts index 9318e5d569..0a32b49076 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 { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' export interface SonarqubeFetchOptions { method?: string @@ -37,23 +38,17 @@ export class SonarqubeError extends Error { @Injectable() export class SonarqubeHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) private readonly sonarqubeConfig: ConfigType, ) {} - private get baseUrl(): string { - const url = this.config.getInternalOrPublicSonarqubeUrl() - if (!url) throw new SonarqubeError('NotConfigured', 'SONARQUBE_URL or SONARQUBE_INTERNAL_URL is required') - return url - } - private get apiBaseUrl(): string { - return new URL('api/', this.baseUrl).toString() + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() } 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..788323985a 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 { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { SonarqubePluginService } from './sonarqube-plugin.service' 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: sonarqubeConfigFactory.KEY, 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..49b5921b17 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -1,21 +1,19 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' @Injectable() export class SonarqubePluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) + private readonly sonarqubeConfig: ConfigType, ) {} infos(): ServiceInfos { return { name: 'sonarqube', - to: () => { - if (!this.config.sonarqubeUrl) return undefined - return new URL('projects', this.config.sonarqubeUrl).toString() - }, + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), title: 'SonarQube', imgSrc: '/img/sonarqube.svg', description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts index 17499fdda4..a1aa961782 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { SonarqubeClientService } from './sonarqube-client.service' @@ -11,7 +12,7 @@ import { SonarqubePluginService } from './sonarqube-plugin.service' import { SonarqubeService } from './sonarqube.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(sonarqubeConfigFactory)], providers: [ SonarqubeHealthService, SonarqubeHttpClientService, 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..960e93ba1d 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,10 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { sonarqubeConfigFactory } from '../../config/sonarqube.config' +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' @@ -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: sonarqubeConfigFactory.KEY, useValue: config }, ], }).compile() @@ -113,14 +112,6 @@ describe('sonarqubeService', () => { expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) }) - it('should skip initialization when URL is not configured', async () => { - config.getInternalOrPublicSonarqubeUrl.mockReturnValue(undefined) - - await service.init() - - expect(client.createPermissionTemplate).not.toHaveBeenCalled() - }) - it('should use custom group paths from admin plugin config', async () => { datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index fc36f6c663..daab56119a 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -1,4 +1,5 @@ import type { OnModuleInit } from '@nestjs/common' +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { SonarqubeUserSecret } from '../vault/vault-client.service' import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' @@ -6,8 +7,8 @@ import type { ProjectWithDetails } from './sonarqube-datastore.service' 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 { sonarqubeConfigFactory } from '../../config/sonarqube.config' +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' @@ -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(sonarqubeConfigFactory.KEY) private readonly sonarqubeConfig: ConfigType, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('SonarqubeService initialized') @@ -69,10 +70,6 @@ export class SonarqubeService implements OnModuleInit { @StartActiveSpan() async init(): Promise { - if (!this.config.getInternalOrPublicSonarqubeUrl()) { - this.logger.warn('SonarQube URL not configured — skipping initialization') - return - } this.logger.log('Initializing SonarQube platform configuration') const adminGroupPath = await this.getAdminGroupPath() const [readonlyGroupPath, securityGroupPath] = await Promise.all([ 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..b5e091e033 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,10 +1,12 @@ +import type { ConfigType } from '@nestjs/config' 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 { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultError, VaultHttpClientService } from './vault-http-client.service' @@ -30,19 +32,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: vaultConfigFactory.KEY, useValue: config }, + { provide: baseConfigFactory.KEY, 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..55a816bd02 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -1,6 +1,8 @@ +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultError, VaultHttpClientService } from './vault-http-client.service' import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' @@ -114,7 +116,8 @@ export class VaultClientService { private readonly logger = new Logger(VaultClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, ) { } @@ -144,13 +147,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 +161,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 +180,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 +205,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 +217,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 +239,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..c39f19de0e 100644 --- a/apps/server-nestjs/src/modules/vault/vault-health.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -1,22 +1,22 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { vaultConfigFactory } from '../../config/vault.config' @Injectable() export class VaultHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, @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 = new URL('/v1/sys/health', urlBase).toString() + if (!this.vaultConfig.probeUrl) { + return indicator.down('Vault is not configured') + } try { - const response = await fetch(url) + const response = await fetch(this.vaultConfig.probeUrl) 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/vault/vault-http-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts index 6cba06e0b5..b5944270d0 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,7 +1,8 @@ +import type { ConfigType } from '@nestjs/config' 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 { vaultConfigFactory } from '../../config/vault.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' export interface VaultFetchOptions { @@ -46,7 +47,7 @@ export class VaultHttpClientService { private readonly logger = new Logger(VaultHttpClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, ) {} @StartActiveSpan() @@ -82,31 +83,15 @@ export class VaultHttpClientService { return parsed } - private get baseUrl() { - const baseUrl = this.config.getInternalOrPublicVaultUrl() - if (!baseUrl) { - throw new VaultError('NotConfigured', 'VAULT_INTERNAL_URL or VAULT_URL is required') - } - return baseUrl - } - private get apiBaseUrl() { - return new URL('v1/', this.baseUrl).toString() - } - - private get token() { - if (!this.config.vaultToken) { - 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 new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() } private createRequest(path: string, method: string, body?: unknown): Request { const url = new URL(path, this.apiBaseUrl).toString() const headers: Record = { 'Content-Type': 'application/json', - 'X-Vault-Token': this.token, + 'X-Vault-Token': this.vaultConfig.token, } return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) 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..d6e536629c 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 { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' 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 { vaultConfigFactory } from '../../config/vault.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { VaultPluginService } from './vault-plugin.service' 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: vaultConfigFactory.KEY, 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..b6fabaab9e 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -1,21 +1,18 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { vaultConfigFactory } from '../../config/vault.config' @Injectable() export class VaultPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, ) {} 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() - }, + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), title: 'Vault', imgSrc: '/img/vault.svg', description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts index 0b1d8281ea..4c7a28ca46 100644 --- a/apps/server-nestjs/src/modules/vault/vault.module.ts +++ b/apps/server-nestjs/src/modules/vault/vault.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 { vaultConfigFactory } from '../../config/vault.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' @@ -10,7 +12,7 @@ import { VaultPluginService } from './vault-plugin.service' import { VaultService } from './vault.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ VaultHealthService, VaultHttpClientService, 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..1d85f13400 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -3,7 +3,8 @@ 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 { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' @@ -15,7 +16,6 @@ describe('vaultService', () => { let service: VaultService let datastore: DeepMockProxy let client: DeepMockProxy - let config: DeepMockProxy beforeEach(async () => { datastore = mockDeep({ @@ -36,17 +36,14 @@ describe('vaultService', () => { listKvMetadata: vi.fn().mockResolvedValue([]), delete: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - vaultKvName: 'kv', - }) const module = await Test.createTestingModule({ providers: [ VaultService, { provide: VaultClientService, useValue: client }, { provide: VaultDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfigFactory }, + { provide: baseConfigFactory.KEY, useValue: baseConfigFactory }, ], }).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..9818318756 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 { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' 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 { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from './vault-client.service' @@ -43,8 +45,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(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, @Inject(VaultClientService) private readonly client: VaultClientService, ) { this.logger.log('VaultService initialized') @@ -115,8 +118,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 +151,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 +494,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 +508,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/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts index 576da08e24..6e170bede2 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -1,23 +1,15 @@ -export function generateProjectPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${projectRootDir}/${projectSlug}` - : projectSlug +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` } -export function generateGitlabMirrorCredPath(projectRootDir: string | undefined, projectSlug: string, repoName: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` - : `${projectSlug}/${repoName}-mirror` +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` } -export function generateTechReadOnlyCredPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` - : `${projectSlug}/tech/GITLAB_MIRROR` +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` } -export function generateSonarqubeCredPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` - : `${projectSlug}/SONAR` +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` } diff --git a/apps/server-nestjs/src/modules/version/version.controller.ts b/apps/server-nestjs/src/modules/version/version.controller.ts index 16ce328f70..8510fa1497 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 { ConfigType } from '@nestjs/config' import { Controller, Get, Inject } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' @Controller('api/v1/version') export class VersionController { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} @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..7f0380a81c 100644 --- a/apps/server-nestjs/src/modules/version/version.module.ts +++ b/apps/server-nestjs/src/modules/version/version.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' import { VersionController } from './version.controller' @Module({ - imports: [ConfigurationModule], + imports: [ConfigModule.forFeature(baseConfigFactory)], controllers: [VersionController], }) export class VersionModule {} diff --git a/apps/server-nestjs/src/utils/config.utils.ts b/apps/server-nestjs/src/utils/config.utils.ts new file mode 100644 index 0000000000..a661ada72a --- /dev/null +++ b/apps/server-nestjs/src/utils/config.utils.ts @@ -0,0 +1,3 @@ +export function optIn(condition: string): (env: NodeJS.ProcessEnv) => boolean { + return (env: NodeJS.ProcessEnv) => env[condition] === 'true' +} 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..bc2d916b48 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -1,22 +1,24 @@ import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' 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 { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' 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' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunArgoCDE2E = Boolean(process.env.E2E) @@ -36,7 +38,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { let gitlabClient: Gitlab let vault: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: ConfigType 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.KEY) 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..e277836db9 100644 --- a/apps/server-nestjs/test/gitlab.e2e-spec.ts +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -1,22 +1,24 @@ import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' 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 z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' 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' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunGitlabE2E = Boolean(process.env.E2E) @@ -36,7 +38,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { let gitlabClient: Gitlab let vaultService: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: ConfigType 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.KEY) 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..9875253678 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -2,11 +2,12 @@ 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 { 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' @@ -16,6 +17,7 @@ import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/key import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunKeycloakE2E = Boolean(process.env.E2E) @@ -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..d70e40428c 100644 --- a/apps/server-nestjs/test/nexus.e2e-spec.ts +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -1,11 +1,12 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' 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 { 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' @@ -20,6 +21,7 @@ import { NexusService } from '../src/modules/nexus/nexus.service' import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunNexusE2E = Boolean(process.env.E2E) @@ -37,7 +39,7 @@ describeWithNexus('NexusController (e2e)', () => { let nexusController: NexusService let nexusClient: NexusClientService let vaultService: VaultClientService - let config: ConfigurationService + let config: ConfigType 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.KEY) 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..3a1e80f39d 100644 --- a/apps/server-nestjs/test/project-bulk.e2e-spec.ts +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -1,10 +1,11 @@ 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 { 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' @@ -12,6 +13,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -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..ab6e75184e 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -1,12 +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 { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' 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' @@ -16,6 +17,7 @@ import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.m import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -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..10dec97860 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -2,12 +2,13 @@ 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 { 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' @@ -16,6 +17,7 @@ import { PermissionModule } from '../src/modules/infrastructure/permission/permi import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -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..0f530ebf53 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -1,11 +1,12 @@ 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 { 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' @@ -13,6 +14,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -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..bb30aff5d7 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -1,9 +1,10 @@ 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 { 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' @@ -13,6 +14,7 @@ import { ProjectSecretsModule } from '../src/modules/project-secrets/project-sec import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectSecretsE2E = Boolean(process.env.E2E) @@ -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..25612a1f99 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -1,10 +1,11 @@ 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 { 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' @@ -12,6 +13,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunServicesE2E = Boolean(process.env.E2E) @@ -20,18 +22,15 @@ const canRunServicesE2E && Boolean(process.env.GITLAB_URL) && Boolean(process.env.GITLAB_TOKEN) && Boolean(process.env.HARBOR_URL) - && Boolean(process.env.HARBOR_INTERNAL_URL) && Boolean(process.env.HARBOR_ADMIN) && Boolean(process.env.HARBOR_ADMIN_PASSWORD) && Boolean(process.env.KEYCLOAK_DOMAIN) && Boolean(process.env.KEYCLOAK_REALM) && Boolean(process.env.KEYCLOAK_PROTOCOL) && Boolean(process.env.NEXUS_URL) - && Boolean(process.env.NEXUS_INTERNAL_URL) && Boolean(process.env.NEXUS_ADMIN) && Boolean(process.env.NEXUS_ADMIN_PASSWORD) && Boolean(process.env.SONARQUBE_URL) - && Boolean(process.env.SONARQUBE_INTERNAL_URL) && Boolean(process.env.SONAR_API_TOKEN) && Boolean(process.env.VAULT_URL) && Boolean(process.env.VAULT_TOKEN) @@ -51,7 +50,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..0aa6b9011d 100644 --- a/apps/server-nestjs/test/project.e2e-spec.ts +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -2,11 +2,12 @@ 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 { 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' @@ -15,6 +16,7 @@ import { PermissionModule } from '../src/modules/infrastructure/permission/permi import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -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..d7cbc7697d 100644 --- a/apps/server-nestjs/test/registry.e2e-spec.ts +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -1,9 +1,11 @@ +import type { ConfigType } from '@nestjs/config' 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 } 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 { 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 +13,8 @@ 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 { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' const canRunRegistryE2E = Boolean(process.env.E2E) @@ -29,12 +32,13 @@ describeWithRegistry('RegistryService (e2e)', () => { let registry: RegistryService let client: RegistryClientService let vault: VaultClientService - let config: ConfigurationService + let config: ConfigType + 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 +47,15 @@ describeWithRegistry('RegistryService (e2e)', () => { registry = moduleRef.get(RegistryService) client = moduleRef.get(RegistryClientService) vault = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory.KEY) + harborConfig = moduleRef.get(harborConfigFactory.KEY) 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 +68,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 +79,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..b8037965ef 100644 --- a/apps/server-nestjs/test/sonarqube.e2e-spec.ts +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -1,10 +1,11 @@ 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 { 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' @@ -17,6 +18,7 @@ import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunSonarqubeE2E = Boolean(process.env.E2E) @@ -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..6947cb9b53 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -1,9 +1,10 @@ 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 { 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' @@ -14,6 +15,7 @@ import { projectSelect } from '../src/modules/vault/vault-datastore.service' import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' import { VaultModule } from '../src/modules/vault/vault.module' import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunVaultE2E = Boolean(process.env.E2E) @@ -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() diff --git a/apps/server/src/runtime.ts b/apps/server/src/runtime.ts index d5b8822533..aa15adc664 100644 --- a/apps/server/src/runtime.ts +++ b/apps/server/src/runtime.ts @@ -1,18 +1,17 @@ -import { Agent, cacheStores, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici' +import { cacheStores, EnvHttpProxyAgent, interceptors, setGlobalDispatcher } from 'undici' -const base = process.env.HTTP_PROXY ? new ProxyAgent(process.env.HTTP_PROXY) : new Agent() +// Undici's EnvHttpProxyAgent reads HTTP_PROXY / HTTPS_PROXY / NO_PROXY from the +// environment and routes per-URL. NO_PROXY supports `host:port` entries and `*` +// (never proxy). Replaces the old `HTTP_PROXY ? ProxyAgent : Agent` branch. +const base = new EnvHttpProxyAgent() -// Undici’s cache interceptor follows RFC 7234: +// Undici's cache interceptor follows RFC 7234: // 1. Only GET/HEAD are cached (configurable via `methods`). -// 2. Cache-Control directives are obeyed: -// - `no-store` → never cache -// - `max-age`, `s-maxage`, `expires` → freshness lifetime -// - `private` → skip if shared store -// - `no-cache` → revalidate with ETag/Last-Modified -// 3. Heuristic caching (status 200 without explicit freshness) -// uses 10 % of time since Last-Modified if present. -// 4. Stale responses are served while revalidating in background -// when `stale-while-revalidate` is present. +// 2. Cache-Control directives are obeyed (no-store, max-age, private, no-cache). +// 3. Heuristic caching (status 200 without explicit freshness) uses 10% of +// time since Last-Modified if present. +// 4. Stale responses are served while revalidating in background when +// `stale-while-revalidate` is present. const client = base.compose( interceptors.cache({ store: new cacheStores.SqliteCacheStore(),