diff --git a/apps/server-nestjs/src/config/argocd.ts b/apps/server-nestjs/src/config/argocd.ts new file mode 100644 index 0000000000..22b654f40f --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.ts @@ -0,0 +1,42 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const argocdFeatureSchema = z.object({ + USE_ARGOCD: flagSchema.default(true), + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: optionalUrl(z.string()).optional(), + ARGOCD_INTERNAL_URL: optionalUrl(z.string()).optional(), + ARGOCD_EXTRA_REPOSITORIES: nonEmptyString.optional(), + 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: flagSchema.default(false), +}) + +export type ArgoCDConfig = z.infer + +export const KEY = 'argocd' as const + +export type ArgoCDAppConfig = ArgoCDConfig + +export const argocdConfigFactory = registerAs(KEY, () => + argocdFeatureSchema.parse({ + USE_ARGOCD: process.env.USE_ARGOCD, + ARGO_NAMESPACE: process.env.ARGO_NAMESPACE, + ARGOCD_URL: process.env.ARGOCD_URL, + ARGOCD_INTERNAL_URL: process.env.ARGOCD_INTERNAL_URL, + ARGOCD_EXTRA_REPOSITORIES: process.env.ARGOCD_EXTRA_REPOSITORIES, + DSO_ENV_CHART_VERSION: process.env.DSO_ENV_CHART_VERSION, + DSO_NS_CHART_VERSION: process.env.DSO_NS_CHART_VERSION, + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + })) + +export default argocdConfigFactory diff --git a/apps/server-nestjs/src/config/base.ts b/apps/server-nestjs/src/config/base.ts new file mode 100644 index 0000000000..6bd5969aed --- /dev/null +++ b/apps/server-nestjs/src/config/base.ts @@ -0,0 +1,64 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const baseConfigurationSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + INTEGRATION: flagSchema.optional(), + CI: flagSchema.optional(), + DEV_SETUP: flagSchema.optional(), + DOCKER: flagSchema.optional(), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional(), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flagSchema.optional(), + PROJECTS_ROOT_DIR: nonEmptyString.optional(), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: optionalUrl(z.string()).optional(), + HTTPS_PROXY: optionalUrl(z.string()).optional(), + }) + .passthrough() + .transform(config => ({ + ...config, + NODE_ENV: config.NODE_ENV === 'test' ? 'test' : config.NODE_ENV === 'development' ? 'development' : 'production', + })) + +export type BaseConfig = z.infer + +export const KEY = 'base' as const + +export type BaseAppConfig = BaseConfig + +export const baseConfigFactory = registerAs(KEY, () => + baseConfigurationSchema.parse({ + NODE_ENV: process.env.NODE_ENV, + INTEGRATION: process.env.INTEGRATION, + CI: process.env.CI, + DEV_SETUP: process.env.DEV_SETUP, + DOCKER: process.env.DOCKER, + SERVER_HOST: process.env.SERVER_HOST, + SERVER_PORT: process.env.SERVER_PORT, + APP_VERSION: process.env.APP_VERSION, + DB_URL: process.env.DB_URL, + SESSION_SECRET: process.env.SESSION_SECRET, + CONTACT_EMAIL: process.env.CONTACT_EMAIL, + MOCK_PLUGINS: process.env.MOCK_PLUGINS, + PROJECTS_ROOT_DIR: process.env.PROJECTS_ROOT_DIR, + PLUGINS_DIR: process.env.PLUGINS_DIR, + HTTP_PROXY: process.env.HTTP_PROXY, + HTTPS_PROXY: process.env.HTTPS_PROXY, + })) + +export default baseConfigFactory diff --git a/apps/server-nestjs/src/config/configuration.utils.ts b/apps/server-nestjs/src/config/configuration.utils.ts new file mode 100644 index 0000000000..c90c3adba8 --- /dev/null +++ b/apps/server-nestjs/src/config/configuration.utils.ts @@ -0,0 +1,9 @@ +export type { ArgoCDConfig } from './argocd' +export type { BaseConfig } from './base' +export type { GitlabConfig } from './gitlab' +export type { HarborConfig } from './harbor' +export type { KeycloakConfig } from './keycloak' +export type { NexusConfig } from './nexus' +export type { OpenCdsConfig } from './opencds' +export type { SonarqubeConfig } from './sonarqube' +export type { VaultConfig } from './vault' diff --git a/apps/server-nestjs/src/config/gitlab.ts b/apps/server-nestjs/src/config/gitlab.ts new file mode 100644 index 0000000000..0c18ed8684 --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.ts @@ -0,0 +1,36 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const gitlabFeatureSchema = z.object({ + USE_GITLAB: flagSchema.default(true), + GITLAB_TOKEN: z.string().min(1, 'GITLAB_TOKEN is required'), + GITLAB_URL: optionalUrl(z.string()).optional(), + GITLAB_INTERNAL_URL: optionalUrl(z.string()).optional(), + 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), +}) + +export type GitlabConfig = z.infer + +export const KEY = 'gitlab' as const + +export type GitlabAppConfig = GitlabConfig + +export const gitlabConfigFactory = registerAs(KEY, () => + gitlabFeatureSchema.parse({ + USE_GITLAB: process.env.USE_GITLAB, + GITLAB_TOKEN: process.env.GITLAB_TOKEN, + GITLAB_URL: process.env.GITLAB_URL, + GITLAB_INTERNAL_URL: process.env.GITLAB_INTERNAL_URL, + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + })) + +export default gitlabConfigFactory diff --git a/apps/server-nestjs/src/config/harbor.ts b/apps/server-nestjs/src/config/harbor.ts new file mode 100644 index 0000000000..8ceda8079c --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.ts @@ -0,0 +1,44 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const harborFeatureSchema = z.object({ + USE_HARBOR: flagSchema.default(true), + HARBOR_URL: optionalUrl(z.string()).optional(), + HARBOR_INTERNAL_URL: optionalUrl(z.string()).optional(), + HARBOR_ADMIN: z.string().min(1, 'HARBOR_ADMIN is required'), + HARBOR_ADMIN_PASSWORD: z.string().min(1, 'HARBOR_ADMIN_PASSWORD is required'), + HARBOR_RULE_TEMPLATE: z.string().min(1).optional(), + HARBOR_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}) + +export type HarborConfig = z.infer + +export const KEY = 'harbor' as const + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs(KEY, () => + harborFeatureSchema.parse({ + USE_HARBOR: process.env.USE_HARBOR, + HARBOR_URL: process.env.HARBOR_URL, + HARBOR_INTERNAL_URL: process.env.HARBOR_INTERNAL_URL, + HARBOR_ADMIN: process.env.HARBOR_ADMIN, + HARBOR_ADMIN_PASSWORD: process.env.HARBOR_ADMIN_PASSWORD, + HARBOR_RULE_TEMPLATE: process.env.HARBOR_RULE_TEMPLATE, + HARBOR_RULE_COUNT: process.env.HARBOR_RULE_COUNT, + HARBOR_RETENTION_CRON: process.env.HARBOR_RETENTION_CRON, + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: process.env.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: process.env.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + })) + +export default harborConfigFactory diff --git a/apps/server-nestjs/src/config/keycloak.ts b/apps/server-nestjs/src/config/keycloak.ts new file mode 100644 index 0000000000..6bc135aad7 --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.ts @@ -0,0 +1,59 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const keycloakFeatureSchema = z.object({ + USE_KEYCLOAK: flagSchema.default(true), + KEYCLOAK_PROTOCOL: z.string().default('https'), + KEYCLOAK_DOMAIN: nonEmptyString, + KEYCLOAK_PUBLIC_PROTOCOL: z.string().default('https'), + KEYCLOAK_PUBLIC_DOMAIN: nonEmptyString, + KEYCLOAK_REALM: nonEmptyString, + KEYCLOAK_CLIENT_ID: nonEmptyString, + KEYCLOAK_CLIENT_SECRET: nonEmptyString, + KEYCLOAK_ADMIN: nonEmptyString, + KEYCLOAK_ADMIN_PASSWORD: nonEmptyString, + KEYCLOAK_REDIRECT_URI: optionalUrl(z.string()).optional(), + 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([]), + ), +}) + +export type KeycloakConfig = z.infer + +export const KEY = 'keycloak' as const + +export type KeycloakAppConfig = KeycloakConfig + +export const keycloakConfigFactory = registerAs(KEY, () => + keycloakFeatureSchema.parse({ + USE_KEYCLOAK: process.env.USE_KEYCLOAK, + KEYCLOAK_PROTOCOL: process.env.KEYCLOAK_PROTOCOL, + KEYCLOAK_DOMAIN: process.env.KEYCLOAK_DOMAIN, + KEYCLOAK_PUBLIC_PROTOCOL: process.env.KEYCLOAK_PUBLIC_PROTOCOL, + KEYCLOAK_PUBLIC_DOMAIN: process.env.KEYCLOAK_PUBLIC_DOMAIN, + KEYCLOAK_REALM: process.env.KEYCLOAK_REALM, + KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID, + KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET, + KEYCLOAK_ADMIN: process.env.KEYCLOAK_ADMIN, + KEYCLOAK_ADMIN_PASSWORD: process.env.KEYCLOAK_ADMIN_PASSWORD, + KEYCLOAK_REDIRECT_URI: process.env.KEYCLOAK_REDIRECT_URI, + KEYCLOAK_JWKS_CACHE_TTL_MS: process.env.KEYCLOAK_JWKS_CACHE_TTL_MS, + KEYCLOAK_JWKS_TIMEOUT_MS: process.env.KEYCLOAK_JWKS_TIMEOUT_MS, + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: process.env.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + ADMIN_KC_USER_ID: process.env.ADMIN_KC_USER_ID, + })) + +export default keycloakConfigFactory diff --git a/apps/server-nestjs/src/config/nexus.ts b/apps/server-nestjs/src/config/nexus.ts new file mode 100644 index 0000000000..4c14d6dcaa --- /dev/null +++ b/apps/server-nestjs/src/config/nexus.ts @@ -0,0 +1,36 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nexusFeatureSchema = z.object({ + USE_NEXUS: flagSchema.default(true), + NEXUS_URL: optionalUrl(z.string()).optional(), + NEXUS_INTERNAL_URL: optionalUrl(z.string()).optional(), + NEXUS_ADMIN: z.string().min(1, 'NEXUS_ADMIN is required'), + NEXUS_ADMIN_PASSWORD: z.string().min(1, 'NEXUS_ADMIN_PASSWORD is required'), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: flagSchema.default(false), +}) + +export type NexusConfig = z.infer + +export const KEY = 'nexus' as const + +export type NexusAppConfig = NexusConfig + +export const nexusConfigFactory = registerAs(KEY, () => + nexusFeatureSchema.parse({ + USE_NEXUS: process.env.USE_NEXUS, + NEXUS_URL: process.env.NEXUS_URL, + NEXUS_INTERNAL_URL: process.env.NEXUS_INTERNAL_URL, + NEXUS_ADMIN: process.env.NEXUS_ADMIN, + NEXUS_ADMIN_PASSWORD: process.env.NEXUS_ADMIN_PASSWORD, + NEXUS__SECRET_EXPOSE_INTERNAL_URL: process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + })) + +export default nexusConfigFactory diff --git a/apps/server-nestjs/src/config/opencds.ts b/apps/server-nestjs/src/config/opencds.ts new file mode 100644 index 0000000000..7ca7750dbe --- /dev/null +++ b/apps/server-nestjs/src/config/opencds.ts @@ -0,0 +1,34 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const opencdsFeatureSchema = z.object({ + USE_OPENCDS: flagSchema.default(true), + OPENCDS_URL: optionalUrl(z.string()).optional(), + OPENCDS_API_TOKEN: nonEmptyString, + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: flagSchema.default(false), +}) + +export type OpenCdsConfig = z.infer + +export const KEY = 'opencds' as const + +export type OpenCdsAppConfig = OpenCdsConfig + +export const openCdsConfigFactory = registerAs(KEY, () => + opencdsFeatureSchema.parse({ + USE_OPENCDS: process.env.USE_OPENCDS, + OPENCDS_URL: process.env.OPENCDS_URL, + OPENCDS_API_TOKEN: process.env.OPENCDS_API_TOKEN, + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + })) + +export default openCdsConfigFactory diff --git a/apps/server-nestjs/src/config/registry.ts b/apps/server-nestjs/src/config/registry.ts new file mode 100644 index 0000000000..35c80be02a --- /dev/null +++ b/apps/server-nestjs/src/config/registry.ts @@ -0,0 +1,40 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const registryFeatureSchema = z.object({ + USE_REGISTRY: flagSchema.default(true), + REGISTRY_URL: optionalUrl(z.string()).optional(), + REGISTRY_INTERNAL_URL: optionalUrl(z.string()).optional(), + REGISTRY_ADMIN: nonEmptyString, + REGISTRY_ADMIN_PASSWORD: nonEmptyString, + REGISTRY_RULE_TEMPLATE: nonEmptyString.optional(), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}) + +export type RegistryConfig = z.infer + +export default registerAs('registry', () => + registryFeatureSchema.parse({ + USE_REGISTRY: process.env.USE_REGISTRY, + REGISTRY_URL: process.env.REGISTRY_URL, + REGISTRY_INTERNAL_URL: process.env.REGISTRY_INTERNAL_URL, + REGISTRY_ADMIN: process.env.REGISTRY_ADMIN, + REGISTRY_ADMIN_PASSWORD: process.env.REGISTRY_ADMIN_PASSWORD, + REGISTRY_RULE_TEMPLATE: process.env.REGISTRY_RULE_TEMPLATE, + REGISTRY_RULE_COUNT: process.env.REGISTRY_RULE_COUNT, + REGISTRY_RETENTION_CRON: process.env.REGISTRY_RETENTION_CRON, + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: process.env.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: process.env.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + })) diff --git a/apps/server-nestjs/src/config/sonarqube.ts b/apps/server-nestjs/src/config/sonarqube.ts new file mode 100644 index 0000000000..dccce05b45 --- /dev/null +++ b/apps/server-nestjs/src/config/sonarqube.ts @@ -0,0 +1,34 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const sonarqubeFeatureSchema = z.object({ + USE_SONARQUBE: flagSchema.default(true), + SONARQUBE_URL: optionalUrl(z.string()).optional(), + SONARQUBE_INTERNAL_URL: optionalUrl(z.string()).optional(), + SONAR_API_TOKEN: nonEmptyString, +}) + +export type SonarqubeConfig = z.infer + +export const KEY = 'sonarqube' as const + +export type SonarqubeAppConfig = SonarqubeConfig + +export const sonarqubeConfigFactory = registerAs(KEY, () => + sonarqubeFeatureSchema.parse({ + USE_SONARQUBE: process.env.USE_SONARQUBE, + SONARQUBE_URL: process.env.SONARQUBE_URL, + SONARQUBE_INTERNAL_URL: process.env.SONARQUBE_INTERNAL_URL, + SONAR_API_TOKEN: process.env.SONAR_API_TOKEN, + })) + +export default sonarqubeConfigFactory diff --git a/apps/server-nestjs/src/config/vault.ts b/apps/server-nestjs/src/config/vault.ts new file mode 100644 index 0000000000..7ca14421d7 --- /dev/null +++ b/apps/server-nestjs/src/config/vault.ts @@ -0,0 +1,34 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const vaultFeatureSchema = z.object({ + USE_VAULT: flagSchema.default(true), + VAULT_TOKEN: z.string().min(1, 'VAULT_TOKEN is required'), + VAULT_URL: optionalUrl(z.string()).optional(), + VAULT_INTERNAL_URL: optionalUrl(z.string()).optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}) + +export type VaultConfig = z.infer + +export const KEY = 'vault' as const + +export type VaultAppConfig = VaultConfig + +export const vaultConfigFactory = registerAs(KEY, () => + vaultFeatureSchema.parse({ + USE_VAULT: process.env.USE_VAULT, + VAULT_TOKEN: process.env.VAULT_TOKEN, + VAULT_URL: process.env.VAULT_URL, + VAULT_INTERNAL_URL: process.env.VAULT_INTERNAL_URL, + VAULT_KV_NAME: process.env.VAULT_KV_NAME, + })) + +export default vaultConfigFactory diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0bc0e5a91d..4f7a76cb0f 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,39 +1,37 @@ import { Module } from '@nestjs/common' -import { ScheduleModule } from '@nestjs/schedule' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from './config/base' +import { ArgoCDModule } from './modules/argocd/argocd.module' import { DeploymentModule } from './modules/deployment/deployment.module' +import { GitlabModule } from './modules/gitlab/gitlab.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' import { LogModule } from './modules/log/log.module' -import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' -import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' -import { ProjectMembersModule } from './modules/project-members/project-members.module' -import { ProjectRolesModule } from './modules/project-roles/project-roles.module' -import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' -import { ProjectServicesModule } from './modules/project-services/project-services.module' +import { NexusModule } from './modules/nexus/nexus.module' +import { PluginModule } from './modules/plugin/plugin.module' import { ProjectModule } from './modules/project/project.module' -import { ServiceChainModule } from './modules/service-chain/service-chain.module' -import { SystemSettingsModule } from './modules/system-settings/system-settings.module' -import { VersionModule } from './modules/version/version.module' +import { RegistryModule } from './modules/registry/registry.module' +import { SonarqubeModule } from './modules/sonarqube/sonarqube.module' +import { VaultModule } from './modules/vault/vault.module' @Module({ imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [baseConfigFactory], + }), + DeploymentModule, HealthzModule, - KeycloakModule, - ScheduleModule.forRoot(), - SystemSettingsModule, - ServiceChainModule, - ProjectModule, - ProjectHooksModule, - ProjectSecretsModule, - ProjectServicesModule, - ProjectBulkModule, - ProjectMembersModule, - ProjectRolesModule, LogModule, - DeploymentModule, - VersionModule, + ProjectModule, + PluginModule, + ArgoCDModule, + GitlabModule, + KeycloakModule, + NexusModule, + RegistryModule, + SonarqubeModule, + VaultModule, ], - controllers: [], - providers: [], }) export class MainModule {} diff --git a/apps/server-nestjs/src/main.ts b/apps/server-nestjs/src/main.ts index 28b5180f5e..904570acc0 100644 --- a/apps/server-nestjs/src/main.ts +++ b/apps/server-nestjs/src/main.ts @@ -8,8 +8,8 @@ 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 baseConfigFactory from './config/base' import { MainModule } from './main.module' -import { ConfigurationService } from './modules/infrastructure/configuration/configuration.service' const SERVICE_NAME = 'console-pi-native-console' @@ -43,7 +43,7 @@ async function bootstrap() { await telemetry.shutdown() }) - const config = app.get(ConfigurationService) + const config = baseConfigFactory() // Setup swagger-ui route const swaggerConfig = new DocumentBuilder() @@ -55,7 +55,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.SERVER_PORT, config.SERVER_HOST) 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 0a929bd6e4..fbd7dfbcde 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -1,11 +1,12 @@ +import type { ArgoCDConfig } from '../../../config/configuration.utils' import { Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { argocdConfigFactory } from '../../../config/argocd' @Injectable() export class ArgoCDHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(argocdConfigFactory.KEY) private readonly config: ArgoCDConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} diff --git a/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts deleted file mode 100644 index f0b6965fc6..0000000000 --- a/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { ServiceInfos } from '@cpn-console/hooks' -import { Injectable } from '@nestjs/common' -import { - DEFAULT_DSO_ENV_CHART_VERSION, - DEFAULT_DSO_NS_CHART_VERSION, - PLATFORM_ADMIN_GROUP_PATH, - PLATFORM_READONLY_GROUP_PATH, - PLATFORM_SECURITY_GROUP_PATH, - PROJECT_ADMIN_GROUP_PATH_SUFFIX, - PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, - PROJECT_DEVOPS_GROUP_PATH_SUFFIX, - PROJECT_READONLY_GROUP_PATH_SUFFIX, - PROJECT_SECURITY_GROUP_PATH_SUFFIX, -} from './argocd.constant' - -@Injectable() -export class ArgoCDPluginService { - infos(): ServiceInfos { - const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' - - return { - name: 'argocd', - to: ({ zones, project }) => zones.map(z => ({ - to: new URL(`applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, z.argocdUrl).toString(), - title: `ArgoCD ${z.label}`, - })), - title: 'ArgoCD', - imgSrc: '/img/argocd.svg', - description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', - config: { - global: [{ - key: 'extraRepositories', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Source repositories', - value: '', - description: extraRepositoriesDesc, - placeholder: 'https://github.com/', - }, { - key: 'platformAdminGroupPath', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Platform Admin Group Path', - value: PLATFORM_ADMIN_GROUP_PATH, - description: 'Chemin du groupe administrateur de plateforme', - }, { - key: 'platformReadonlyGroupPath', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Platform Readonly Group Path', - value: PLATFORM_READONLY_GROUP_PATH, - description: 'Chemin du groupe lecture seule de plateforme', - }, { - key: 'platformSecurityGroupPath', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Platform Security Group Path', - value: PLATFORM_SECURITY_GROUP_PATH, - description: 'Chemin du groupe sécurité de plateforme', - }, { - key: 'projectAdminGroupPathSuffix', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Project Admin Group Path Suffix', - value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, - description: 'Suffixe du chemin du groupe administrateur de projet', - }, { - key: 'projectDevopsGroupPathSuffix', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Project DevOps Group Path Suffix', - value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, - description: 'Suffixe du chemin du groupe devops de projet', - }, { - key: 'projectDevelopperGroupPathSuffix', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Project Developer Group Path Suffix', - value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, - description: 'Suffixe du chemin du groupe développeur de projet', - }, { - key: 'projectReadonlyGroupPathSuffix', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Project Readonly Group Path Suffix', - value: PROJECT_READONLY_GROUP_PATH_SUFFIX, - description: 'Suffixe du chemin du groupe lecture seule de projet', - }, { - key: 'projectSecurityGroupPathSuffix', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'Project Security Group Path Suffix', - value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, - description: 'Suffixe du chemin du groupe sécurité de projet', - }, { - key: 'dsoEnvChartVersion', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'DSO Env Chart Version', - value: DEFAULT_DSO_ENV_CHART_VERSION, - description: 'Version du chart Helm dso-env', - placeholder: DEFAULT_DSO_ENV_CHART_VERSION, - }, { - key: 'dsoNsChartVersion', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: false, write: false }, - }, - title: 'DSO Namespace Chart Version', - value: DEFAULT_DSO_NS_CHART_VERSION, - description: 'Version du chart Helm dso-ns', - placeholder: DEFAULT_DSO_NS_CHART_VERSION, - }], - project: [{ - key: 'extraRepositories', - kind: 'text', - permissions: { - admin: { read: true, write: true }, - user: { read: true, write: false }, - }, - title: 'Source repositories', - value: '', - description: extraRepositoriesDesc, - placeholder: 'https://github.com/', - }], - }, - } as const satisfies ServiceInfos - } -} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts index 46a5e69222..bba14a11c2 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.module.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -1,17 +1,17 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { argocdConfigFactory } from '../../config/argocd' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { VaultModule } from '../vault/vault.module' import { ArgoCDDatastoreService } from './argocd-datastore.service' -import { ArgoCDHealthService } from './argocd-health.service' -import { ArgoCDPluginService } from './argocd-plugin.service' +import { ArgoCDPluginService } from './argocd.plugin' import { ArgoCDService } from './argocd.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, GitlabModule, VaultModule], - providers: [HealthIndicatorService, ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], - exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], + imports: [ + InfrastructureModule, + ConfigModule.forFeature(argocdConfigFactory.asProvider()), + ], + providers: [ArgoCDService, ArgoCDPluginService, ArgoCDDatastoreService], + exports: [ArgoCDService, ArgoCDPluginService, ArgoCDDatastoreService], }) export class ArgoCDModule {} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.plugin.ts b/apps/server-nestjs/src/modules/argocd/argocd.plugin.ts new file mode 100644 index 0000000000..00cd01efac --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.plugin.ts @@ -0,0 +1,24 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' +import { Plugin } from '../../../plugin/plugin.decorator' + +@Plugin('argocd') +@Injectable() +export class ArgoCDPluginService { + infos(): ServiceInfos { + return { + name: 'argocd', + to: ({ project }) => { + // Implementation + return undefined + }, + title: 'ArgoCD', + imgSrc: '/img/argocd.png', + description: 'ArgoCD est un outil de déploiement continu Kubernetes déclaratif', + config: { + project: [], + global: [], + }, + } as const satisfies ServiceInfos + } +} 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..e2497e6ec5 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -132,7 +132,7 @@ describe('argoCDService', () => { return makeCommitAction({ filePath, content }) }) - await expect(service.handleCron()).resolves.not.toThrow() + await expect(service.apply()).resolves.not.toThrow() // Verify Gitlab calls expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) @@ -331,7 +331,7 @@ describe('argoCDService', () => { return makeCommitAction({ filePath, content }) }) - await expect(service.handleCron()).resolves.not.toThrow() + await expect(service.apply()).resolves.not.toThrow() expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( @@ -374,7 +374,7 @@ describe('argoCDService', () => { gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) - await expect(service.handleCron()).resolves.not.toThrow() + await expect(service.apply()).resolves.not.toThrow() expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() }) @@ -420,7 +420,7 @@ describe('argoCDService', () => { return makeCommitAction({ filePath, content }) }) - await expect(service.handleCron()).resolves.not.toThrow() + await expect(service.apply()).resolves.not.toThrow() // Verify Gitlab calls expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 69a861d48d..c0361c1070 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,39 +1,31 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { GitlabClientService } from '../gitlab/gitlab-client.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails } from './argocd-datastore.service' import { createHmac } from 'node:crypto' -import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { stringify } from 'yaml' -import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { ArgoCDDatastoreService } from './argocd-datastore.service' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { Apply, Destroy } from '../plugin/plugin-methods.decorator' +import { Plugin } from '../plugin/plugin.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' -import { ArgoCDDatastoreService } from './argocd-datastore.service' -import { - CONSOLE_ADMIN_GROUP_PATH, - PLATFORM_ADMIN_GROUP_PATH, - PLATFORM_READONLY_GROUP_PATH, - PLATFORM_SECURITY_GROUP_PATH, - PROJECT_ADMIN_GROUP_PATH_SUFFIX, - PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, - PROJECT_DEVOPS_GROUP_PATH_SUFFIX, - PROJECT_READONLY_GROUP_PATH_SUFFIX, - PROJECT_SECURITY_GROUP_PATH_SUFFIX, -} from './argocd.constant' +@Plugin('argocd') @Injectable() export class ArgoCDService { private readonly logger = new Logger(ArgoCDService.name) constructor( - @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, - @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(ConfigurationService) readonly configuration: ConfigurationService, + @Inject(argocdConfigFactory.KEY) private readonly config: ArgoCDConfig, + @Inject(GITLAB_REST_CLIENT) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, ) { this.logger.log('ArgoCDService initialized') } @@ -53,7 +45,8 @@ export class ArgoCDService { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('argocd', () => this.cleanupProject(project)) } @@ -66,9 +59,9 @@ export class ArgoCDService { this.logger.log(`ArgoCD sync completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() - async handleCron() { + @Apply() + async apply() { this.logger.log('Starting ArgoCD reconciliation') const projects = await this.argoCDDatastore.getAllProjects() const span = trace.getActiveSpan() @@ -126,7 +119,7 @@ export class ArgoCDService { span?.setAttribute('argocd.repo.actions.count', actions.length) if (actions.length === 0) { - this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug}`) + this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) return } @@ -147,6 +140,7 @@ export class ArgoCDService { ) const deploymentActions = await this.generateDeploymentsUpdateActions( project, + project.environments, infraProject, zoneSlug, ) @@ -190,7 +184,7 @@ export class ArgoCDService { if (!existingFile.path.startsWith(projectPrefix)) return false const remaining = existingFile.path.slice(projectPrefix.length) - const clusterLabel = remaining.split('/')[0] + const clusterLabel = remaining.split('/')[1] if (!clusterLabel || !clusterLabelsInZone.has(clusterLabel)) return false return !neededFiles.has(existingFile.path) @@ -207,15 +201,16 @@ export class ArgoCDService { environments: ProjectWithDetails['environments'], infraProject: SimpleProjectSchema, zoneSlug: string, - ): Promise { + ) { this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) const actions = await Promise.all( environments .filter(env => env.cluster?.zone.slug === zoneSlug) .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), ) + const filteredActions: CommitAction[] = actions.filter(a => !!a) - this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug}`) return filteredActions } @@ -223,6 +218,7 @@ export class ArgoCDService { project: ProjectWithDetails, environment: ProjectWithDetails['environments'][number], infraProject: SimpleProjectSchema, + zoneSlug: string, ): Promise { const span = trace.getActiveSpan() span?.setAttributes({ @@ -230,6 +226,7 @@ export class ArgoCDService { 'environment.id': environment.id, 'environment.name': environment.name, }) + const vaultValues = await this.generateVaultValues(project.slug) const cluster = environment.cluster if (!cluster) { @@ -266,27 +263,30 @@ export class ArgoCDService { private async generateDeploymentsUpdateActions( project: ProjectWithDetails, + environments: ProjectWithDetails['environments'], infraProject: SimpleProjectSchema, zoneSlug: string, - ): Promise { + ) { this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) - const environments = project.deployments.reduce< - Record + const deploymentsByEnvironment = project.deployments.reduce< + Record >((acc, deployment) => { const environment = deployment.environment - if (acc[environment.id]) acc[environment.id].deployments.push(deployment) - else acc[environment.id] = { environment, deployments: [deployment] } + if (acc[environment.id]) acc[environment.id].push(deployment) + else acc[environment.id] = [deployment] return acc }, {}) const actions = await Promise.all( - Object.values(environments) + Object.values(deploymentsByEnvironment) .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) - .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + .map(({ environment, deployments }) => + this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject), + ), ) const filteredActions: CommitAction[] = actions.filter(a => !!a) - this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug}`) return filteredActions } @@ -295,6 +295,7 @@ export class ArgoCDService { environment: ProjectWithDetails['environments'][number], deployments: ProjectWithDetails['deployments'][number][], infraProject: SimpleProjectSchema, + zoneSlug: string, ): Promise { const span = trace.getActiveSpan() span?.setAttributes({ @@ -457,7 +458,7 @@ function splitExtraRepositories(extraRepositories: string | undefined): string[] } function formatRepositoriesValues( - repositories: ProjectWithDetails['repositories'], + repositories: ProjectWithDetails['repositories'][number], gitlabPublicProjectUrl: string, envName: string, ) { @@ -489,167 +490,10 @@ function formatRepositoriesValuesFromDeployments( name: source.repository.internalRepoName, id: source.repository.id, repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, - targetRevision: source.targetRevision || 'HEAD', - path: source.path || '.', + targetRevision: source.deployRevision || 'HEAD', + path: source.deployPath || '.', valueFiles, } satisfies ValuesSchema['application']['repositories'][number] }), ) } - -function formatEnvironmentValues( - project: ProjectWithDetails, - infraProject: SimpleProjectSchema, - valueFilePath: string, - roGroup: string, - rwGroup: string, -) { - return { - valueFileRepository: infraProject.http_url_to_repo, - valueFileRevision: 'HEAD', - valueFilePath, - roGroup, - rwGroup, - consoleAdminGroup: CONSOLE_ADMIN_GROUP_PATH, - platformAdminGroup: PLATFORM_ADMIN_GROUP_PATH, - platformReadonlyGroup: PLATFORM_READONLY_GROUP_PATH, - platformSecurityGroup: PLATFORM_SECURITY_GROUP_PATH, - projectAdminGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_ADMIN_GROUP_PATH_SUFFIX), - projectDevopsGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVOPS_GROUP_PATH_SUFFIX), - projectDevelopperGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX), - projectSecurityGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_SECURITY_GROUP_PATH_SUFFIX), - projectReadonlyGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_READONLY_GROUP_PATH_SUFFIX), - } satisfies ValuesSchema['environment'] -} - -interface FormatSourceRepositoriesValuesOptions { - gitlabPublicProjectUrl: string - argocdExtraRepositories?: string - projectPlugins?: ProjectWithDetails['plugins'] -} - -function formatSourceRepositoriesValues( - { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: 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), - ] -} - -interface FormatCommonOptions { - project: ProjectWithDetails - environment: ProjectWithDetails['environments'][number] -} - -function formatCommon({ project, environment }: FormatCommonOptions) { - return { - 'dso/project': project.name, - 'dso/project.id': project.id, - 'dso/project.slug': project.slug, - 'dso/environment': environment.name, - 'dso/environment.id': environment.id, - } satisfies ValuesSchema['common'] -} - -interface FormatArgoCDValuesOptions { - namespace: string - project: string - envChartVersion: string - nsChartVersion: string -} - -function formatArgoCDValues(options: FormatArgoCDValuesOptions) { - const { namespace, project, envChartVersion, nsChartVersion } = options - return { - cluster: inClusterLabel, - namespace, - project, - envChartVersion, - nsChartVersion, - } satisfies ValuesSchema['argocd'] -} - -interface FormatValuesOptions { - project: ProjectWithDetails - environment: ProjectWithDetails['environments'][number] - cluster: ProjectWithDetails['environments'][number]['cluster'] - gitlabPublicProjectUrl: string - argocdExtraRepositories?: string - vaultValues: Record - infraProject: SimpleProjectSchema - valueFilePath: string - argoNamespace: string - envChartVersion: string - nsChartVersion: string - deployments?: ProjectWithDetails['deployments'][number][] -} - -function formatValues({ - project, - environment, - cluster, - gitlabPublicProjectUrl, - argocdExtraRepositories, - vaultValues, - infraProject, - valueFilePath, - argoNamespace, - envChartVersion, - nsChartVersion, - deployments, -}: FormatValuesOptions) { - return { - common: formatCommon({ project, environment }), - argocd: formatArgoCDValues({ - namespace: argoNamespace, - project: formatAppProjectName(project.slug, environment.name), - envChartVersion, - nsChartVersion, - }), - environment: formatEnvironmentValues( - project, - infraProject, - valueFilePath, - formatReadOnlyGroupName(project.slug, environment.name), - formatReadWriteGroupName(project.slug, environment.name), - ), - application: { - quota: { - cpu: environment.cpu, - gpu: environment.gpu, - memory: `${environment.memory}Gi`, - }, - sourceRepositories: formatSourceRepositoriesValues({ - gitlabPublicProjectUrl, - argocdExtraRepositories, - projectPlugins: project.plugins, - }), - destination: { - namespace: generateNamespaceName(project.id, environment.id), - name: cluster.label, - }, - autosync: environment.autosync, - vault: vaultValues, - repositories: deployments - ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) - : formatRepositoriesValues( - project.repositories, - gitlabPublicProjectUrl, - environment.name, - ), - }, - features: { - fineGrainedRoles: { - enabled: true, - }, - }, - } satisfies ValuesSchema -} 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..3477adeb3b 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 { GitlabConfig } from '../../../config/configuration.utils' import { join } from 'node:path' import { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { Inject, Injectable, Logger } from '@nestjs/common' +import { gitlabConfigFactory } from '../../../config/gitlab' import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' 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: GitlabConfig, @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, ) { } 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 87346f32e5..dbacd07213 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -1,11 +1,12 @@ +import type { GitlabConfig } from '../../../config/configuration.utils' import { Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../../config/gitlab' @Injectable() export class GitlabHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) private readonly config: GitlabConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts index a5c47613db..f98c427f95 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -1,34 +1,16 @@ -import { Gitlab } from '@gitbeaker/rest' import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { ConfigModule } from '@nestjs/config' +import { gitlabConfigFactory } from '../../../../config/gitlab' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { VaultModule } from '../vault/vault.module' -import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' -import { GitlabDatastoreService } from './gitlab-datastore.service' -import { GitlabHealthService } from './gitlab-health.service' import { GitlabPluginService } from './gitlab-plugin.service' import { GitlabService } from './gitlab.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule], - providers: [ - { - provide: GITLAB_REST_CLIENT, - inject: [ConfigurationService], - useFactory: (config: ConfigurationService) => new Gitlab({ - token: config.gitlabToken, - host: config.getInternalOrPublicGitlabUrl(), - }), - }, - HealthIndicatorService, - GitlabClientService, - GitlabDatastoreService, - GitlabHealthService, - GitlabPluginService, - GitlabService, + imports: [ + InfrastructureModule, + ConfigModule.forFeature(gitlabConfigFactory.asProvider()), ], - exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], + providers: [GitlabService, GitlabPluginService], + exports: [GitlabService, GitlabPluginService], }) export class GitlabModule {} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.plugin.ts similarity index 86% rename from apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts rename to apps/server-nestjs/src/modules/gitlab/gitlab.plugin.ts index 9614fc45c9..16bb53e45a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.plugin.ts @@ -1,22 +1,26 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { BaseConfiguration, GitlabConfig } from '../../../config/configuration.utils' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../../config/base' +import { gitlabConfigFactory } from '../../../config/gitlab' +import { Plugin } from '../../../plugin/plugin.decorator' import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX } from './gitlab.constants' +@Plugin('gitlab') @Injectable() export class GitlabPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: BaseConfiguration, + @Inject(gitlabConfigFactory.KEY) private readonly gitlabConfig: GitlabConfig, ) {} infos(): ServiceInfos { return { name: 'gitlab', to: ({ project }) => { - if (!this.config.gitlabUrl || !this.config.projectRootDir) return undefined - return new URL(`${this.config.projectRootDir}/${project.slug}`, this.config.gitlabUrl).toString() + if (!this.gitlabConfig.GITLAB_URL || !this.baseConfig.PROJECTS_ROOT_DIR) return undefined + return new URL(`${this.baseConfig.PROJECTS_ROOT_DIR}/${project.slug}`, this.gitlabConfig.GITLAB_URL).toString() }, title: 'Gitlab', imgSrc: '/img/gitlab.svg', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index ec0b96efdf..c25f788984 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -464,7 +464,7 @@ describe('gitlabService', () => { }) }) - describe('handleCron', () => { + describe('apply', () => { it('should reconcile all projects', async () => { const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] datastore.getAllProjects.mockResolvedValue(projects) @@ -476,7 +476,7 @@ describe('gitlabService', () => { gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) - await service.handleCron() + await service.apply() expect(datastore.getAllProjects).toHaveBeenCalled() expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 15b00ec86e..d2b6b140d2 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,15 +1,17 @@ import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { BaseAppConfig } from '../../../../config/base' import type { RequiredPluginResult } from '../plugin/plugin.utils' -import type { VaultSecret } from '../vault/vault-client.service' import type { ProjectWithDetails } from './gitlab-datastore.service' import { specificallyEnabled } from '@cpn-console/hooks' import { AccessLevel } from '@gitbeaker/core' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' +import { InjectBaseConfig } from '../../../../config/base' import { getAll } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { Apply, Destroy } from '../plugin/plugin-methods.decorator' +import { Plugin } from '../plugin/plugin.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' @@ -30,7 +32,6 @@ import { } from './gitlab.constants' import { adminRoleFlag, - daysAgoFromNow, generateAccessLevelMapping, generateAdminRoleMapping, generateName, @@ -45,6 +46,7 @@ import { type ProjectAccessLevel = Exclude +@Plugin('gitlab') @Injectable() export class GitlabService { private readonly logger = new Logger(GitlabService.name) @@ -53,7 +55,7 @@ export class GitlabService { @Inject(GitlabDatastoreService) private readonly gitlabDatastore: GitlabDatastoreService, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @InjectBaseConfig() private readonly baseConfig: BaseAppConfig, ) { this.logger.log('GitLabService initialized') } @@ -73,7 +75,8 @@ export class GitlabService { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('gitlab', () => this.cleanupProject(project)) } @@ -86,9 +89,9 @@ export class GitlabService { this.logger.log(`GitLab sync completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() - async handleCron() { + @Apply() + async apply() { const span = trace.getActiveSpan() span?.setAttribute('gitlab.projects.count', 0) this.logger.log('Starting GitLab reconciliation') @@ -493,34 +496,4 @@ export class GitlabService { } return this.createMirrorAccessToken(projectSlug) } - - @StartActiveSpan() - private async createMirrorAccessToken(projectSlug: string) { - const span = trace.getActiveSpan() - span?.setAttribute('project.slug', projectSlug) - span?.setAttribute('mirror.creds.rotated', true) - const token = await this.gitlab.createMirrorAccessToken(projectSlug) - const creds = { - MIRROR_USER: token.name, - MIRROR_TOKEN: token.token, - } - await this.vault.writeTechReadOnlyCreds(projectSlug, creds) - span?.setAttribute('vault.secret.written', true) - return creds - } - - 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 - } - - private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { - if (!externalRepoUrl) return undefined - try { - return new URL(externalRepoUrl).host - } catch { - return 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..95fa5760d0 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -1,8 +1,15 @@ +import type { ArgoCDConfig, GitlabConfig, NexusConfig, OpenCdsConfig, RegistryConfig } from '../../../../config/configuration.utils' +import type { VaultConfig } from '../../../../config/vault' import { Controller, Get, Inject } from '@nestjs/common' import { HealthCheck, HealthCheckService } from '@nestjs/terminus' +import argocdConfigFactory from '../../../../config/argocd' +import gitlabConfigFactory from '../../../../config/gitlab' +import nexusConfigFactory from '../../../../config/nexus' +import opencdsConfigFactory from '../../../../config/opencds' +import registryConfigFactory from '../../../../config/registry' +import vaultConfigFactory from '../../../../config/vault' 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' @@ -22,7 +29,12 @@ export class HealthzController { @Inject(RegistryHealthService) private readonly registry: RegistryHealthService, @Inject(ArgoCDHealthService) private readonly argocd: ArgoCDHealthService, @Inject(OpenCdsHealthService) private readonly opencds: OpenCdsHealthService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(argocdConfigFactory.KEY) private readonly argocdConfig: ArgoCDConfig, + @Inject(gitlabConfigFactory.KEY) private readonly gitlabConfig: GitlabConfig, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: VaultConfig, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: NexusConfig, + @Inject(registryConfigFactory.KEY) private readonly registryConfig: RegistryConfig, + @Inject(opencdsConfigFactory.KEY) private readonly opencdsConfig: OpenCdsConfig, ) {} @Get() @@ -33,22 +45,22 @@ export class HealthzController { () => this.keycloak.check('keycloak'), ] - if (this.config.openCdsUrl) { + if (this.opencdsConfig.OPENCDS_URL) { checks.push(() => this.opencds.check('opencds')) } - if (this.config.gitlabUrl) { + if (this.gitlabConfig.GITLAB_URL) { checks.push(() => this.gitlab.check('gitlab')) } - if (this.config.vaultUrl) { + if (this.vaultConfig.VAULT_URL) { checks.push(() => this.vault.check('vault')) } - if (this.config.nexusUrl) { + if (this.nexusConfig.NEXUS_URL) { checks.push(() => this.nexus.check('nexus')) } - if (this.config.harborUrl) { + if (this.registryConfig.REGISTRY_URL) { checks.push(() => this.registry.check('registry')) } - if (this.config.argocdUrl) { + if (this.argocdConfig.ARGOCD_URL) { checks.push(() => this.argocd.check('argocd')) } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 94b4281941..9c2f237c00 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -1,8 +1,14 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import argocdConfigFactory from '../../../../config/argocd' +import gitlabConfigFactory from '../../../../config/gitlab' +import nexusConfigFactory from '../../../../config/nexus' +import opencdsConfigFactory from '../../../../config/opencds' +import registryConfigFactory from '../../../../config/registry' +import vaultConfigFactory from '../../../../config/vault' 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' @@ -21,7 +27,14 @@ import { HealthzController } from './healthz.controller' NexusModule, RegistryModule, ArgoCDModule, - ConfigurationModule, + ConfigModule.forFeature([ + argocdConfigFactory, + gitlabConfigFactory, + nexusConfigFactory, + opencdsConfigFactory, + registryConfigFactory, + vaultConfigFactory, + ]), OpenCdsModule, ], controllers: [HealthzController], 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..52895f6c72 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -1,10 +1,11 @@ import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../../config/configuration.utils' 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' import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' const OpenidConfigurationSchema = z.object({ @@ -35,16 +36,22 @@ 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 config: KeycloakConfig, ) {} + private getKeycloakOpenidConfigurationUrl() { + if (!this.config.KEYCLOAK_DOMAIN || !this.config.KEYCLOAK_REALM) return undefined + const protocol = this.config.KEYCLOAK_PROTOCOL ?? 'https' + return `${protocol}://${this.config.KEYCLOAK_DOMAIN}/realms/${this.config.KEYCLOAK_REALM}/.well-known/openid-configuration` + } + async fetchOpenIdConfig(): Promise { - const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.config.getKeycloakOpenidConfigurationUrl()) + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.getKeycloakOpenidConfigurationUrl()) const cached = await this.cache.get(cacheKey) if (cached) return cached - const response = await fetch(this.config.getKeycloakOpenidConfigurationUrl()) + const response = await fetch(this.getKeycloakOpenidConfigurationUrl()) if (!response.ok) { this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) return undefined @@ -57,7 +64,7 @@ export class KeycloakSecretProviderService { return undefined } - await this.cache.set(cacheKey, config.data, this.config.keycloakOpenidConfigurationCacheTtlMs) + await this.cache.set(cacheKey, config.data, this.config.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS) return config.data } @@ -72,13 +79,13 @@ export class KeycloakSecretProviderService { } private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { - if (!this.config.keycloakDomain) { + if (!this.config.KEYCLOAK_DOMAIN) { this.logger.log(`No internal domain configured, returning original JWKS URI: ${jwksUri}`) return jwksUri } const url = new URL(jwksUri) - url.protocol = this.config.keycloakProtocol ?? url.protocol - url.host = this.config.keycloakDomain ?? url.host + url.protocol = this.config.KEYCLOAK_PROTOCOL ?? url.protocol + url.host = this.config.KEYCLOAK_DOMAIN ?? url.host this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) return url.toString() } @@ -88,7 +95,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.config.KEYCLOAK_JWKS_TIMEOUT_MS) try { const response = await fetch(jwksUri, { signal: controller.signal }) @@ -124,7 +131,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.config.KEYCLOAK_JWKS_CACHE_TTL_MS) return pem } diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/argocd.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/argocd.config.ts new file mode 100644 index 0000000000..93b4f40a0a --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/argocd.config.ts @@ -0,0 +1,13 @@ +import { registerAs } from '@nestjs/config' +import { validateArgoCDConfig } from '../configuration.utils' + +export default registerAs('argocd', () => validateArgoCDConfig({ + useArgocd: process.env.USE_ARGOCD, + namespace: process.env.ARGO_NAMESPACE, + url: process.env.ARGOCD_URL, + internalUrl: process.env.ARGOCD_INTERNAL_URL, + extraRepositories: process.env.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: process.env.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: process.env.DSO_NS_CHART_VERSION, + deployVaultConnectionInNamespaces: process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/gitlab.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/gitlab.config.ts new file mode 100644 index 0000000000..d7d1eae1f8 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/gitlab.config.ts @@ -0,0 +1,11 @@ +import { registerAs } from '@nestjs/config' +import { validateGitlabConfig } from '../configuration.utils' + +export default registerAs('gitlab', () => validateGitlabConfig({ + useGitlab: process.env.USE_GITLAB, + token: process.env.GITLAB_TOKEN, + url: process.env.GITLAB_URL, + internalUrl: process.env.GITLAB_INTERNAL_URL, + mirrorTokenExpirationDays: process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/harbor.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/harbor.config.ts new file mode 100644 index 0000000000..111eb70135 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/harbor.config.ts @@ -0,0 +1,15 @@ +import { registerAs } from '@nestjs/config' +import { validateHarborConfig } from '../configuration.utils' + +export default registerAs('harbor', () => validateHarborConfig({ + useHarbor: process.env.USE_HARBOR, + url: process.env.HARBOR_URL, + internalUrl: process.env.HARBOR_INTERNAL_URL, + admin: process.env.HARBOR_ADMIN, + adminPassword: process.env.HARBOR_ADMIN_PASSWORD, + ruleTemplate: process.env.HARBOR_RULE_TEMPLATE, + ruleCount: process.env.HARBOR_RULE_COUNT, + retentionCron: process.env.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: process.env.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: process.env.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/keycloak.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/keycloak.config.ts new file mode 100644 index 0000000000..316f22041f --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/keycloak.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import { validateKeycloakConfig } from '../configuration.utils' + +export default registerAs('keycloak', () => validateKeycloakConfig({ + protocol: process.env.KEYCLOAK_PROTOCOL ?? 'https', + domain: process.env.KEYCLOAK_DOMAIN, + publicProtocol: process.env.KEYCLOAK_PUBLIC_PROTOCOL ?? 'https', + publicDomain: process.env.KEYCLOAK_PUBLIC_DOMAIN, + realm: process.env.KEYCLOAK_REALM, + clientId: process.env.KEYCLOAK_CLIENT_ID, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, + admin: process.env.KEYCLOAK_ADMIN, + adminPassword: process.env.KEYCLOAK_ADMIN_PASSWORD, + redirectUri: process.env.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: process.env.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: process.env.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: process.env.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: process.env.ADMIN_KC_USER_ID, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/nexus.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/nexus.config.ts new file mode 100644 index 0000000000..3a64702c4f --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/nexus.config.ts @@ -0,0 +1,11 @@ +import { registerAs } from '@nestjs/config' +import { validateNexusConfig } from '../configuration.utils' + +export default registerAs('nexus', () => validateNexusConfig({ + useNexus: process.env.USE_NEXUS, + url: process.env.NEXUS_URL, + internalUrl: process.env.NEXUS_INTERNAL_URL, + admin: process.env.NEXUS_ADMIN, + adminPassword: process.env.NEXUS_ADMIN_PASSWORD, + exposeInternalUrl: process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/opencds.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/opencds.config.ts new file mode 100644 index 0000000000..1026583623 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/opencds.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from '@nestjs/config' +import { validateOpenCDSConfig } from '../configuration.utils' + +export default registerAs('opencds', () => validateOpenCDSConfig({ + useOpencds: process.env.USE_OPENCDS, + url: process.env.OPENCDS_URL, + apiToken: process.env.OPENCDS_API_TOKEN, + tlsRejectUnauthorized: process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/registry.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/registry.config.ts new file mode 100644 index 0000000000..75252b4b01 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/registry.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from '@nestjs/config' +import { validateRegistryConfig } from '../configuration.utils' + +export default registerAs('registry', () => + validateRegistryConfig({ + useRegistry: process.env.USE_REGISTRY, + url: process.env.REGISTRY_URL, + internalUrl: process.env.REGISTRY_INTERNAL_URL, + admin: process.env.REGISTRY_ADMIN, + adminPassword: process.env.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: process.env.REGISTRY_RULE_TEMPLATE, + ruleCount: process.env.REGISTRY_RULE_COUNT, + retentionCron: process.env.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: process.env.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: process.env.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + })) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/sonarqube.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/sonarqube.config.ts new file mode 100644 index 0000000000..9ea785f270 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/sonarqube.config.ts @@ -0,0 +1,9 @@ +import { registerAs } from '@nestjs/config' +import { validateSonarqubeConfig } from '../configuration.utils' + +export default registerAs('sonarqube', () => validateSonarqubeConfig({ + useSonarqube: process.env.USE_SONARQUBE, + url: process.env.SONARQUBE_URL, + internalUrl: process.env.SONARQUBE_INTERNAL_URL, + apiToken: process.env.SONAR_API_TOKEN, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/config/vault.config.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/config/vault.config.ts new file mode 100644 index 0000000000..56670cb2a1 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/config/vault.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from '@nestjs/config' +import { validateVaultConfig } from '../configuration.utils' + +export default registerAs('vault', () => validateVaultConfig({ + useVault: process.env.USE_VAULT, + token: process.env.VAULT_TOKEN, + url: process.env.VAULT_URL, + internalUrl: process.env.VAULT_INTERNAL_URL, + kvName: process.env.VAULT_KV_NAME, +})) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts index b563056136..d98659f9c6 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts @@ -1,25 +1,32 @@ 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') -} +import argocdConfig from '../../../../config/argocd' +import baseConfig from '../../../../config/base' +import gitlabConfig from '../../../../config/gitlab' +import harborConfig from '../../../../config/harbor' +import keycloakConfig from '../../../../config/keycloak' +import nexusConfig from '../../../../config/nexus' +import opencdsConfig from '../../../../config/opencds' +import sonarqubeConfig from '../../../../config/sonarqube' +import vaultConfig from '../../../../config/vault' @Module({ imports: [ ConfigModule.forRoot({ - envFilePath: pathList, + envFilePath: [...(process.env.DOCKER !== 'true' ? ['.env'] : []), ...(process.env.INTEGRATION === 'true' ? ['.env.integ'] : [])], + isGlobal: true, + load: [ + baseConfig, + keycloakConfig, + argocdConfig, + gitlabConfig, + vaultConfig, + harborConfig, + nexusConfig, + opencdsConfig, + sonarqubeConfig, + ], }), ], - 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 index a7c4c7a245..301d2bec9b 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts @@ -2,6 +2,7 @@ import type { TestingModule } from '@nestjs/testing' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from './configuration.module' import { ConfigurationService } from './configuration.service' describe('configurationService', () => { @@ -10,19 +11,7 @@ describe('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) + vi.stubEnv('NODE_ENV', 'development') }) afterEach(() => { @@ -30,194 +19,89 @@ describe('configurationService', () => { vi.restoreAllMocks() }) - it('should be defined', () => { + async function createService() { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigurationModule], + }).compile() + return module.get(ConfigurationService) + } + + it('should be defined', async () => { + service = await createService() expect(service).toBeDefined() }) - describe('keycloak URL derivation', () => { - it('should derive the internal URL from protocol + domain', () => { - expect(service.getKeycloakUrl()).toBe('http://keycloak.example.com') + describe('conditional service flags', () => { + it('should expose USE_* flags from config', async () => { + service = await createService() + expect(service.useArgocd).toBe(true) + expect(service.useGitlab).toBe(true) + expect(service.useHarbor).toBe(true) + expect(service.useNexus).toBe(true) + expect(service.useSonarqube).toBe(true) + expect(service.useVault).toBe(true) + expect(service.useOpencds).toBe(true) }) - it('should derive the realm URL from the internal URL', () => { - expect(service.getKeycloakRealmUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native', - ) - }) + it('should disable services with USE_* flags set to false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + vi.stubEnv('USE_VAULT', 'false') + vi.stubEnv('USE_ARGOCD', 'false') - 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', - ) - }) + service = await createService() - 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.', - ) + expect(service.useGitlab).toBe(false) + expect(service.useVault).toBe(false) + expect(service.useArgocd).toBe(false) + expect(service.useHarbor).toBe(true) }) }) describe('internal-or-public URL helpers', () => { - it('should prefer internal over public URL for GitLab, Vault, Harbor, Nexus, SonarQube', async () => { + it('should prefer internal over public URL', 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_URL', 'https://vault.public') vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') + vi.stubEnv('HARBOR_URL', 'https://harbor.public') vi.stubEnv('HARBOR_INTERNAL_URL', 'https://harbor.internal') + vi.stubEnv('NEXUS_URL', 'https://nexus.public') vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonar.internal') + vi.stubEnv('SONARQUBE_URL', 'https://sonarqube.public') + vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonarqube.internal') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) + service = await createService() - 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') + expect(service.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.internal') + expect(service.getInternalOrPublicVaultUrl()).toBe('https://vault.internal') + expect(service.getInternalOrPublicHarborUrl()).toBe('https://harbor.internal') + expect(service.getInternalOrPublicNexusUrl()).toBe('https://nexus.internal') + expect(service.getInternalOrPublicSonarqubeUrl()).toBe('https://sonarqube.internal') }) - it('should fall back to public URL for GitLab, Vault, Harbor, Nexus, SonarQube when internal is unset', async () => { + it('should fall back to public URL 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') - }) + vi.stubEnv('SONARQUBE_URL', 'https://sonarqube.public') - 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') - }) + service = await createService() - 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') + expect(service.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.public') + expect(service.getInternalOrPublicVaultUrl()).toBe('https://vault.public') + expect(service.getInternalOrPublicHarborUrl()).toBe('https://harbor.public') + expect(service.getInternalOrPublicNexusUrl()).toBe('https://nexus.public') + expect(service.getInternalOrPublicSonarqubeUrl()).toBe('https://sonarqube.public') }) - 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) + it('should return undefined when neither URL is set', async () => { + service = await createService() + expect(service.getInternalOrPublicGitlabUrl()).toBeUndefined() + expect(service.getInternalOrPublicVaultUrl()).toBeUndefined() + expect(service.getInternalOrPublicHarborUrl()).toBeUndefined() + expect(service.getInternalOrPublicNexusUrl()).toBeUndefined() + expect(service.getInternalOrPublicSonarqubeUrl()).toBeUndefined() }) }) }) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts index 608209b956..6e676e72fa 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts @@ -1,171 +1,236 @@ -import { Injectable, Logger } from '@nestjs/common' +import type { ArgoCDAppConfig } from '../../../config/argocd' +import type { BaseAppConfig } from '../../../config/base' +import type { ArgoCDAppConfig, BaseAppConfig, GitlabAppConfig, HarborAppConfig, KeycloakAppConfig, NexusAppConfig, OpenCdsAppConfig, SonarqubeAppConfig, VaultAppConfig } from '../../../config/configuration.utils' +import type { GitlabAppConfig } from '../../../config/gitlab' +import type { HarborAppConfig } from '../../../config/harbor' +import type { KeycloakAppConfig } from '../../../config/keycloak' +import type { NexusAppConfig } from '../../../config/nexus' +import type { OpenCdsAppConfig } from '../../../config/opencds' +import type { SonarqubeAppConfig } from '../../../config/sonarqube' +import type { VaultAppConfig } from '../../../config/vault' +import { Injectable } from '@nestjs/common' +import { argocdConfigFactory } from '../../../config/argocd' +import { baseConfigFactory } from '../../../config/base' +import { gitlabConfigFactory } from '../../../config/gitlab' +import { harborConfigFactory } from '../../../config/harbor' +import { keycloakConfigFactory } from '../../../config/keycloak' +import { nexusConfigFactory } from '../../../config/nexus' +import { openCdsConfigFactory } from '../../../config/opencds' +import { sonarqubeConfigFactory } from '../../../config/sonarqube' +import { vaultConfigFactory } from '../../../config/vault' @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 - 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 - 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 + constructor( + private readonly base: BaseAppConfig = baseConfigFactory(), + private readonly keycloak: KeycloakAppConfig = keycloakConfigFactory(), + private readonly argocd: ArgoCDAppConfig = argocdConfigFactory(), + private readonly gitlab: GitlabAppConfig = gitlabConfigFactory(), + private readonly vault: VaultAppConfig = vaultConfigFactory(), + private readonly harbor: HarborAppConfig = harborConfigFactory(), + private readonly nexus: NexusAppConfig = nexusConfigFactory(), + private readonly opencds: OpenCdsAppConfig = openCdsConfigFactory(), + private readonly sonarqube: SonarqubeAppConfig = sonarqubeConfigFactory(), + ) {} + + get port(): number { + return Number(this.base.SERVER_PORT ?? 0) + } + + get host(): string { + return (this.base.SERVER_HOST ?? 'localhost').trim() + } + + get isDev(): boolean { + return this.base.NODE_ENV === 'development' + } + + get isTest(): boolean { + return this.base.NODE_ENV === 'test' + } + + get isCI(): boolean { + return this.base.CI === true + } + + get isIntegration(): boolean { + return this.base.INTEGRATION === true + } + + get isDocker(): boolean { + return this.base.DOCKER === true + } + + get useKeycloak(): boolean { + return this.keycloak.USE_KEYCLOAK === true + } + + get useArgocd(): boolean { + return this.argocd.USE_ARGOCD === true + } + + get useGitlab(): boolean { + return this.gitlab.USE_GITLAB === true + } + + get useVault(): boolean { + return this.vault.USE_VAULT === true + } + + get useHarbor(): boolean { + return this.harbor.USE_HARBOR === true + } + + get useNexus(): boolean { + return this.nexus.USE_NEXUS === true + } + + get useOpencds(): boolean { + return this.opencds.USE_OPENCDS === true + } + + get useSonarqube(): boolean { + return this.sonarqube.USE_SONARQUBE === true + } + + get appVersion(): string { + return this.base.APP_VERSION ?? '' + } + + get dbUrl(): string { + return (this.base.DB_URL ?? '').trim() + } + + get projectRootDir(): string { + return (this.base.PROJECTS_ROOT_DIR ?? '').trim() + } + + get httpProxy(): string | undefined { + return this.base.HTTP_PROXY?.trim() || undefined + } + + get openCdsUrl(): string | undefined { + return this.opencds.OPENCDS_URL?.trim() || undefined + } + + get openCdsApiToken(): string | undefined { + return this.opencds.OPENCDS_API_TOKEN?.trim() || undefined } - getInternalOrPublicGitlabUrl() { - return this.getInternalOrPublicUrl('GitLab', this.gitlabUrl, this.gitlabInternalUrl) + get openCdsApiTlsRejectUnauthorized(): boolean { + return this.opencds.OPENCDS_API_TLS_REJECT_UNAUTHORIZED === true } - getInternalOrPublicVaultUrl() { - return this.getInternalOrPublicUrl('Vault', this.vaultUrl, this.vaultInternalUrl) + get argocdExtraRepositories(): string | undefined { + return this.argocd.ARGOCD_EXTRA_REPOSITORIES?.trim() || undefined } - getInternalOrPublicHarborUrl() { - return this.getInternalOrPublicUrl('Harbor', this.harborUrl, this.harborInternalUrl) + get argoNamespace(): string { + return this.argocd.ARGO_NAMESPACE } - getInternalOrPublicNexusUrl() { - return this.getInternalOrPublicUrl('Nexus', this.nexusUrl, this.nexusInternalUrl) + get dsoEnvChartVersion(): string { + return this.argocd.DSO_ENV_CHART_VERSION } - getInternalOrPublicSonarqubeUrl() { - return this.getInternalOrPublicUrl('SonarQube', this.sonarqubeUrl, this.sonarqubeInternalUrl) + get dsoNsChartVersion(): string { + return this.argocd.DSO_NS_CHART_VERSION } - 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' + get deployVaultConnectionInNamespaces(): boolean { + return this.argocd.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === true + } + + get vaultUrl(): string | undefined { + return this.vault.VAULT_URL?.trim() || this.vault.VAULT_INTERNAL_URL?.trim() || undefined + } + + get vaultKvName(): string { + return this.vault.VAULT_KV_NAME + } + + get harborUrl(): string | undefined { + return this.harbor.HARBOR_URL?.trim() || this.harbor.HARBOR_INTERNAL_URL?.trim() || undefined + } + + get harborRuleTemplate(): string | undefined { + return this.harbor.HARBOR_RULE_TEMPLATE?.trim() || undefined + } + + get harborRuleCount(): number | undefined { + const value = Number(this.harbor.HARBOR_RULE_COUNT) + return Number.isFinite(value) && value >= 0 ? value : undefined + } + + get harborRetentionCron(): string { + return this.harbor.HARBOR_RETENTION_CRON + } + + get harborRobotRotationThresholdDays(): number { + return this.harbor.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS + } + + get sonarqubeUrl(): string | undefined { + return this.sonarqube.SONARQUBE_URL?.trim() || this.sonarqube.SONARQUBE_INTERNAL_URL?.trim() || undefined + } + + get nexusUrl(): string | undefined { + return this.nexus.NEXUS_URL?.trim() || this.nexus.NEXUS_INTERNAL_URL?.trim() || undefined + } + + get gitlabUrl(): string | undefined { + return this.gitlab.GITLAB_URL?.trim() || this.gitlab.GITLAB_INTERNAL_URL?.trim() || undefined + } + + getInternalOrPublicGitlabUrl(): string | undefined { + return this.gitlabUrl || undefined + } + + getInternalOrPublicVaultUrl(): string | undefined { + return this.vaultUrl || undefined + } + + getInternalOrPublicHarborUrl(): string | undefined { + return this.harborUrl || undefined + } + + getInternalOrPublicNexusUrl(): string | undefined { + return this.nexusUrl || undefined + } + + getInternalOrPublicSonarqubeUrl(): string | undefined { + return this.sonarqubeUrl || undefined + } + + getKeycloakPublicUrl(): string { + return this.keycloak.KEYCLOAK_PUBLIC_PROTOCOL + ? `${this.keycloak.KEYCLOAK_PUBLIC_PROTOCOL}://${this.keycloak.KEYCLOAK_PUBLIC_DOMAIN}` + : `https://${this.keycloak.KEYCLOAK_DOMAIN}` + } + + getKeycloakRealm(): string { + return this.keycloak.KEYCLOAK_REALM + } + + getKeycloakAdmin() { + return { + username: this.keycloak.KEYCLOAK_ADMIN, + password: this.keycloak.KEYCLOAK_ADMIN_PASSWORD, + clientId: this.keycloak.KEYCLOAK_CLIENT_ID, + clientSecret: this.keycloak.KEYCLOAK_CLIENT_SECRET, + userId: this.keycloak.ADMIN_KC_USER_ID, } - 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' + getArgoCDConfig(): ArgoCDAppConfig { + return this.argocd + } + + getOpenCdsConfig(): OpenCdsAppConfig { + return this.opencds + } + + getBaseConfig(): BaseAppConfig { + return this.base + } } diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.utils.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.utils.ts new file mode 100644 index 0000000000..49fd594a89 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.utils.ts @@ -0,0 +1,177 @@ +import { z } from 'zod' + +export const nodeEnvSchema = z.enum(['development', 'production', 'test']) + +export const flagSchema = z + .preprocess(val => (val === undefined ? 'false' : val), z.enum(['true', 'false', ''])) + .transform(val => val === 'true') + .default(false) + +const optionalUrl = (schema: z.ZodString) => schema.url().optional().or(z.literal('').transform(() => undefined)) + +const nonEmptyString = z.string().transform(value => value.trim() || undefined) + +const baseConfigurationSchema = z + .object({ + NODE_ENV: nodeEnvSchema.optional(), + INTEGRATION: flagSchema.optional(), + CI: flagSchema.optional(), + DEV_SETUP: flagSchema.optional(), + DOCKER: flagSchema.optional(), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional(), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flagSchema.optional(), + PROJECTS_ROOT_DIR: nonEmptyString.optional(), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: optionalUrl(z.string()).optional(), + HTTPS_PROXY: optionalUrl(z.string()).optional(), + }) + .passthrough() + .transform(config => ({ + ...config, + NODE_ENV: config.NODE_ENV === 'test' ? 'test' : config.NODE_ENV === 'development' ? 'development' : 'production', + })) + +export type BaseConfiguration = z.infer + +export function validateBaseConfig(config: Record) { + return baseConfigurationSchema.parse(config) +} + +export const keycloakFeatureSchema = z.object({ + USE_KEYCLOAK: flagSchema.default(true), + KEYCLOAK_PROTOCOL: z.string().default('https'), + KEYCLOAK_DOMAIN: nonEmptyString, + KEYCLOAK_PUBLIC_PROTOCOL: z.string().default('https'), + KEYCLOAK_PUBLIC_DOMAIN: nonEmptyString, + KEYCLOAK_REALM: nonEmptyString, + KEYCLOAK_CLIENT_ID: nonEmptyString, + KEYCLOAK_CLIENT_SECRET: nonEmptyString, + KEYCLOAK_ADMIN: nonEmptyString, + KEYCLOAK_ADMIN_PASSWORD: nonEmptyString, + KEYCLOAK_REDIRECT_URI: optionalUrl(z.string()).optional(), + 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([]), + ), +}) + +export const argocdFeatureSchema = z.object({ + USE_ARGOCD: flagSchema.default(true), + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: optionalUrl(z.string()).optional(), + ARGOCD_INTERNAL_URL: optionalUrl(z.string()).optional(), + ARGOCD_EXTRA_REPOSITORIES: nonEmptyString.optional(), + 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: flagSchema.default(false), +}) + +export const gitlabFeatureSchema = z.object({ + USE_GITLAB: flagSchema.default(true), + GITLAB_TOKEN: nonEmptyString, + GITLAB_URL: optionalUrl(z.string()).optional(), + GITLAB_INTERNAL_URL: optionalUrl(z.string()).optional(), + 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), +}) + +export const vaultFeatureSchema = z.object({ + USE_VAULT: flagSchema.default(true), + VAULT_TOKEN: nonEmptyString, + VAULT_URL: optionalUrl(z.string()).optional(), + VAULT_INTERNAL_URL: optionalUrl(z.string()).optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}) + +export const harborFeatureSchema = z.object({ + USE_HARBOR: flagSchema.default(true), + HARBOR_URL: optionalUrl(z.string()).optional(), + HARBOR_INTERNAL_URL: optionalUrl(z.string()).optional(), + HARBOR_ADMIN: nonEmptyString, + HARBOR_ADMIN_PASSWORD: nonEmptyString, + HARBOR_RULE_TEMPLATE: nonEmptyString.optional(), + HARBOR_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}) + +export const nexusFeatureSchema = z.object({ + USE_NEXUS: flagSchema.default(true), + NEXUS_URL: optionalUrl(z.string()).optional(), + NEXUS_INTERNAL_URL: optionalUrl(z.string()).optional(), + NEXUS_ADMIN: nonEmptyString, + NEXUS_ADMIN_PASSWORD: nonEmptyString, + NEXUS__SECRET_EXPOSE_INTERNAL_URL: flagSchema.default(false), +}) + +export const opencdsFeatureSchema = z.object({ + USE_OPENCDS: flagSchema.default(true), + OPENCDS_URL: optionalUrl(z.string()).optional(), + OPENCDS_API_TOKEN: nonEmptyString, + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: flagSchema.default(false), +}) + +export const sonarqubeFeatureSchema = z.object({ + USE_SONARQUBE: flagSchema.default(true), + SONARQUBE_URL: optionalUrl(z.string()).optional(), + SONARQUBE_INTERNAL_URL: optionalUrl(z.string()).optional(), + SONAR_API_TOKEN: nonEmptyString, +}) + +export function validateKeycloakConfig(config: Record) { + return keycloakFeatureSchema.parse(config) +} + +export function validateArgoCDConfig(config: Record) { + return argocdFeatureSchema.parse(config) +} + +export function validateGitlabConfig(config: Record) { + return gitlabFeatureSchema.parse(config) +} + +export function validateVaultConfig(config: Record) { + return vaultFeatureSchema.parse(config) +} + +export function validateHarborConfig(config: Record) { + return harborFeatureSchema.parse(config) +} + +export function validateNexusConfig(config: Record) { + return nexusFeatureSchema.parse(config) +} + +export function validateOpenCDSConfig(config: Record) { + return opencdsFeatureSchema.parse(config) +} + +export const registryFeatureSchema = z.object({ + USE_REGISTRY: flagSchema.default(true), + REGISTRY_URL: optionalUrl(z.string()).optional(), + REGISTRY_INTERNAL_URL: optionalUrl(z.string()).optional(), + REGISTRY_ADMIN: nonEmptyString, + REGISTRY_ADMIN_PASSWORD: nonEmptyString, + REGISTRY_RULE_TEMPLATE: nonEmptyString.optional(), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}) + +export function validateSonarqubeConfig(config: Record) { + return sonarqubeFeatureSchema.parse(config) +} + +export function validateRegistryConfig(config: Record) { + return registryFeatureSchema.parse(config) +} 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 82becfa147..f08a3be7e3 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -7,7 +7,7 @@ import type { GroupRepresentationWith } from './keycloak.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import z from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import keycloakConfigFactory from '../../../config/keycloak' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { CONSOLE_GROUP_NAME, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' @@ -18,7 +18,7 @@ export class KeycloakClientService implements OnModuleInit { private readonly logger = new Logger(KeycloakClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(keycloakConfigFactory.KEY) private readonly config: ReturnType, @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, ) { } @@ -252,21 +252,22 @@ export class KeycloakClientService implements OnModuleInit { } async onModuleInit() { - if (!this.config.keycloakRealm) { + const config = this.config + if (!config.KEYCLOAK_REALM) { this.logger.fatal('Keycloak realm is not configured') return } - if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { + if (!config.KEYCLOAK_ADMIN || !config.KEYCLOAK_ADMIN_PASSWORD) { this.logger.fatal('Keycloak admin username or password is not configured') return } try { - this.logger.log(`Authenticating Keycloak admin client (realm=${this.config.keycloakRealm})`) + this.logger.log(`Authenticating Keycloak admin client (realm=${config.KEYCLOAK_REALM})`) await this.client.auth({ clientId: 'admin-cli', grantType: 'password', - username: this.config.keycloakAdmin, - password: this.config.keycloakAdminPassword, + username: config.KEYCLOAK_ADMIN, + password: config.KEYCLOAK_ADMIN_PASSWORD, }) } catch (err) { if (err instanceof Error) { @@ -276,7 +277,7 @@ export class KeycloakClientService implements OnModuleInit { } throw err } - this.client.setConfig({ realmName: this.config.keycloakRealm }) - this.logger.log(`Keycloak Admin Client authenticated (realm=${this.config.keycloakRealm})`) + this.client.setConfig({ realmName: config.KEYCLOAK_REALM }) + this.logger.log(`Keycloak Admin Client authenticated (realm=${config.KEYCLOAK_REALM})`) } } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts index 87e0e46e17..2111b83adf 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -1,17 +1,17 @@ import { Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import keycloakConfigFactory from '../../../config/keycloak' @Injectable() export class KeycloakHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + @Inject(keycloakConfigFactory.KEY) private readonly config: ReturnType, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const url = this.config.getKeycloakOpenidConfigurationUrl() + const url = this.buildOpenIdConfigurationUrl(this.config) if (!url) return indicator.down('Not configured') try { @@ -22,4 +22,10 @@ export class KeycloakHealthService { return indicator.down(error instanceof Error ? error.message : String(error)) } } + + private buildOpenIdConfigurationUrl(config: ReturnType) { + if (!config.KEYCLOAK_DOMAIN || !config.KEYCLOAK_REALM) return undefined + const protocol = config.KEYCLOAK_PROTOCOL ?? 'https' + return `${protocol}://${config.KEYCLOAK_DOMAIN}/realms/${config.KEYCLOAK_REALM}/.well-known/openid-configuration` + } } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts index 6c510357ce..6507d7468f 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -1,32 +1,15 @@ -import KcAdminClient from '@keycloak/keycloak-admin-client' import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' -import { KeycloakDatastoreService } from './keycloak-datastore.service' -import { KeycloakHealthService } from './keycloak-health.service' import { KeycloakPluginService } from './keycloak-plugin.service' -import { KeycloakService } from './keycloak.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule], - providers: [ - { - provide: KEYCLOAK_ADMIN_CLIENT, - inject: [ConfigurationService], - useFactory: (config: ConfigurationService) => new KcAdminClient({ - baseUrl: config.getKeycloakUrl(), - }), - }, - HealthIndicatorService, - KeycloakClientService, - KeycloakDatastoreService, - KeycloakHealthService, - KeycloakPluginService, - KeycloakService, + imports: [ + InfrastructureModule, + ConfigModule.forFeature(keycloakConfigFactory.asProvider()), ], - exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], + providers: [KeycloakPluginService], + exports: [KeycloakPluginService], }) export class KeycloakModule {} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.plugin.ts similarity index 80% rename from apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts rename to apps/server-nestjs/src/modules/keycloak/keycloak.plugin.ts index a0b5e2dfac..69afa49d4a 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.plugin.ts @@ -1,6 +1,8 @@ import type { ServiceInfos } from '@cpn-console/hooks' import { Injectable } from '@nestjs/common' +import { Plugin } from '../../../plugin/plugin.decorator' +@Plugin('keycloak') @Injectable() export class KeycloakPluginService { infos(): ServiceInfos { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts index 750fb72164..13ca8a6cfb 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -92,7 +92,7 @@ describe('keycloakService', () => { keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) - await service.handleCron() + await service.apply() expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') expect(keycloak.getGroupMembers).not.toHaveBeenCalled() @@ -119,7 +119,7 @@ describe('keycloakService', () => { makeUserRepresentation({ id: 'user-2' }), ]) - await service.handleCron() + await service.apply() expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') @@ -144,7 +144,7 @@ describe('keycloakService', () => { keycloak.getGroupMembers.mockResolvedValue([]) keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) - await service.handleCron() + await service.apply() expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() expect(keycloak.getAllGroups).toHaveBeenCalled() @@ -175,7 +175,7 @@ describe('keycloakService', () => { keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) - await service.handleCron() + await service.apply() // Should add missing member expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') @@ -225,7 +225,7 @@ describe('keycloakService', () => { keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) - await service.handleCron() + await service.apply() // Should create/get role group expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') @@ -275,7 +275,7 @@ describe('keycloakService', () => { } }) - await service.handleCron() + await service.apply() // Should create dev group expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) @@ -346,7 +346,7 @@ describe('keycloakService', () => { keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) - await service.handleCron() + await service.apply() // Sync RO expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') @@ -409,7 +409,7 @@ describe('keycloakService', () => { keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) - await service.handleCron() + await service.apply() // Managed: should add user-1, remove user-2 expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index 0aad0317bb..f8b1e11987 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -9,6 +9,8 @@ import { trace } from '@opentelemetry/api' import z from 'zod' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' +import { Apply, Destroy, Info } from '../plugin/plugin-methods.decorator' +import { Plugin } from '../plugin/plugin.decorator' import { KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, normalizeGroupPath } from './keycloak.utils' @@ -39,7 +41,8 @@ export class KeycloakService { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('keycloak', () => this.cleanupProject(project)) } @@ -52,9 +55,10 @@ export class KeycloakService { this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) - @StartActiveSpan() - async handleCron() { + @StartActiveSpan() + @StartActiveSpan() + @Apply() + async apply() { const span = trace.getActiveSpan() this.logger.log('Starting periodic Keycloak reconciliation') const [projects, adminRoles, users] = await Promise.all([ @@ -499,153 +503,6 @@ export class KeycloakService { const perms = this.getUserPermissions(project, rolesById, userId) const isInRo = roMembers.some(m => m.id === userId) - if (perms.ro && !isInRo) { - roAdded++ - await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) - } else if (!perms.ro && isInRo) { - roRemoved++ - await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) - } - - const isInRw = rwMembers.some(m => m.id === userId) - if (perms.rw && !isInRw) { - rwAdded++ - await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) - } else if (!perms.rw && isInRw) { - rwRemoved++ - await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) - } - })) - - span?.setAttribute('keycloak.env_group.ro.members.added', roAdded) - span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) - span?.setAttribute('keycloak.env_group.rw.members.added', rwAdded) - span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) - this.logger.verbose(`Keycloak environment group members reconciled: project=${project.slug} env=${environment.name} roAdded=${roAdded} roRemoved=${roRemoved} rwAdded=${rwAdded} rwRemoved=${rwRemoved}`) - } - - @StartActiveSpan() - private async purgeOrphanEnvironmentGroupMembers( - project: ProjectWithDetails, - environment: ProjectWithDetails['environments'][number], - roGroup: GroupRepresentationWith<'id'>, - rwGroup: GroupRepresentationWith<'id'>, - roMembers: UserRepresentation[], - rwMembers: UserRepresentation[], - ) { - const span = trace.getActiveSpan() - span?.setAttribute('project.slug', project.slug) - span?.setAttribute('environment.id', environment.id) - span?.setAttribute('environment.name', environment.name) - span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) - span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) - span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) - span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) - this.logger.verbose(`Purging orphan Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) - - const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) - span?.setAttribute('project.users.count', projectUserIds.size) - - let roRemoved = 0 - let rwRemoved = 0 - - await Promise.all([ - ...roMembers.map(async (member) => { - if (!member.id) { - throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) - } - if (!projectUserIds.has(member.id)) { - roRemoved++ - await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) - } - }), - ...rwMembers.map(async (member) => { - if (!member.id) { - throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) - } - if (!projectUserIds.has(member.id)) { - rwRemoved++ - await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) - } - }), - ]) - - span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) - span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) - this.logger.log(`Orphan Keycloak environment group member cleanup completed: project=${project.slug} env=${environment.name} roRemoved=${roRemoved} rwRemoved=${rwRemoved}`) - } - - @StartActiveSpan() - private async purgeOrphanEnvironmentGroups( - project: ProjectWithDetails, - group: GroupRepresentationWith<'id' | 'name'>, - ) { - const span = trace.getActiveSpan() - span?.setAttribute('project.slug', project.slug) - span?.setAttribute('keycloak.group.id', group.id) - span?.setAttribute('keycloak.group.name', group.name) - this.logger.verbose(`Scanning Keycloak environment groups for orphan cleanup: project=${project.slug} groupId=${group.id} groupName=${group.name}`) - - const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ - id: z.string(), - name: z.string(), - }).parse(envGroup)) - - const promises: Promise[] = [] - let orphanCount = 0 - - for await (const envGroup of envGroups) { - span?.setAttribute('keycloak.env_group.id', envGroup.id) - span?.setAttribute('keycloak.env_group.name', envGroup.name) - - const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ - name: z.string(), - }).parse(subgroup))) - - if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { - orphanCount++ - this.logger.log(`Deleting orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name}`) - promises.push( - this.keycloak.deleteGroup(envGroup.id) - .catch(err => this.logger.warn(`Failed to delete orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name} err=${err instanceof Error ? err.message : String(err)}`)), - ) - } - } - - span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) - await Promise.all(promises) - this.logger.log(`Orphan Keycloak environment group cleanup completed: project=${project.slug} groupId=${group.id} orphanCount=${orphanCount}`) - } - - private isEnvironmentGroup( - subGroups: GroupRepresentationWith<'name'>[], - ) { - return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') - } - - private isOwnedEnvironmentGroup( - project: ProjectWithDetails, - group: GroupRepresentationWith<'name'>, - ) { - return project.environments.some(e => e.name === group.name) - } - - private getUserPermissions( - project: ProjectWithDetails, - rolesById: Record, - userId: string, - ) { - if (userId === project.ownerId) return { ro: true, rw: true } - const member = project.members.find(m => m.user.id === userId) - if (!member) return { ro: false, rw: false } - - const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) - - return { - ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), - rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), - } - } } /** HTTP status carried by errors such as keycloak-admin-client's NetworkError. */ @@ -655,23 +512,5 @@ function getErrorResponseStatus(error: unknown): number | undefined { } return undefined } - -async function* map( - iterable: AsyncIterable, - mapper: (value: T, index: number) => U | Promise, -): AsyncIterable { - let index = 0 - for await (const value of iterable) { - yield await mapper(value, index++) - } } - -async function getAll( - iterable: AsyncIterable, -): Promise { - const items: T[] = [] - for await (const item of iterable) { - items.push(item) - } - return items } 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 45908d3e16..0ba2ae9eef 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 { Inject, Injectable } from '@nestjs/common' +import type { NexusAppConfig } from '../../../../config/nexus' +import { Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { InjectNexusConfig } from '../../../../config/nexus' 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, + @InjectNexusConfig() private readonly nexusConfig: NexusAppConfig, ) {} @StartActiveSpan() @@ -76,10 +77,10 @@ export class NexusHttpClientService { } private get baseUrl() { - if (!this.config.nexusInternalUrl) { + if (!this.nexusConfig.internalUrl) { throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL is required') } - return this.config.nexusInternalUrl + return this.nexusConfig.internalUrl } private get apiBaseUrl() { @@ -87,13 +88,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.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts index 6d423a6d7f..bf6684212f 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.module.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -1,18 +1,15 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { nexusConfigFactory } from '../../../../config/nexus' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { VaultModule } from '../vault/vault.module' -import { NexusClientService } from './nexus-client.service' -import { NexusDatastoreService } from './nexus-datastore.service' -import { NexusHealthService } from './nexus-health.service' -import { NexusHttpClientService } from './nexus-http-client.service' import { NexusPluginService } from './nexus-plugin.service' -import { NexusService } from './nexus.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule], - providers: [HealthIndicatorService, NexusHealthService, NexusPluginService, NexusService, NexusDatastoreService, NexusHttpClientService, NexusClientService], - exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], + imports: [ + InfrastructureModule, + ConfigModule.forFeature(nexusConfigFactory.asProvider()), + ], + providers: [NexusPluginService], + exports: [NexusPluginService], }) export class NexusModule {} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.plugin.ts similarity index 78% rename from apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts rename to apps/server-nestjs/src/modules/nexus/nexus.plugin.ts index a1ca54548b..abba9f8e74 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.plugin.ts @@ -1,20 +1,23 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from '../../../config/configuration.utils' +import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { Plugin } from '../../../plugin/plugin.decorator' +import { nexusConfigFactory } from '../../../config/nexus' +@Plugin('nexus') @Injectable() export class NexusPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: NexusConfig, ) {} infos(): ServiceInfos { return { name: 'nexus', to: () => { - if (!this.config.nexusUrl) return undefined - return this.config.nexusUrl + if (!this.nexusConfig.NEXUS_URL) return undefined + return this.nexusConfig.NEXUS_URL }, title: 'Nexus', imgSrc: '/img/nexus.png', @@ -25,13 +28,13 @@ export class NexusPluginService { key: 'activateNpmRepo', section: 'NPM', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: true, write: true }, }, title: 'Activer le dépôt NPM', - value: 'disabled', + value: DISABLED, description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', }, { @@ -42,21 +45,21 @@ export class NexusPluginService { admin: { read: true, write: true }, user: { read: true, write: true }, }, - title: 'Politique d\'écriture', + title: 'Politique d\\'écriture', value: 'allow', - description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + description: 'Politique d\\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', }, { key: 'activateMavenRepo', section: 'Maven', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: true, write: true }, }, title: 'Activer le dépôt Maven', - value: 'disabled', + value: DISABLED, description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', }, { @@ -67,9 +70,9 @@ export class NexusPluginService { admin: { read: true, write: true }, user: { read: true, write: true }, }, - title: 'Politique d\'écriture du dépôt Snapshot', + title: 'Politique d\\'écriture du dépôt Snapshot', value: 'allow', - description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + description: 'Politique d\\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', }, { key: 'mavenReleaseWritePolicy', @@ -79,9 +82,9 @@ export class NexusPluginService { admin: { read: true, write: true }, user: { read: true, write: true }, }, - title: 'Politique d\'écriture du dépôt Release', + title: 'Politique d\\'écriture du dépôt Release', value: 'allow_once', - description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + description: 'Politique d\\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', }, ], global: [ @@ -137,38 +140,38 @@ export class NexusPluginService { key: 'activateNpmRepoDefaultValue', section: 'NPM', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: false, write: false }, }, title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', - value: 'disabled', + value: DISABLED, description: 'Défaut au niveau global signifie: Désactivé', }, { key: 'activateMavenRepoDefaultValue', section: 'Maven', kind: 'switch', - initialValue: 'disabled', + initialValue: DISABLED, permissions: { admin: { read: true, write: true }, user: { read: false, write: false }, }, title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', - value: 'disabled', + value: DISABLED, description: 'Défaut au niveau global signifie: Désactivé', }, { key: 'enablePlugin', kind: 'switch', - initialValue: 'enabled', + initialValue: ENABLED, permissions: { admin: { read: true, write: true }, user: { read: false, write: false }, }, title: 'Activer/Désactiver entièrement le plugin Nexus', - value: 'enabled', + value: ENABLED, description: 'Défaut: Activé', }, ], diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts index 28755c798f..f62986bd22 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -130,15 +130,15 @@ describe('nexusService', () => { ) }) - it('handleDelete should delete project', async () => { + it('delete should delete project', async () => { const project = makeProjectWithDetails() - await service.handleDelete(project) + await service.delete(project) expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) }) - it('handleCron should reconcile all projects', async () => { + it('apply should reconcile all projects', async () => { const projects = [ makeProjectWithDetails({ plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), makeProjectWithDetails({ plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), @@ -146,7 +146,7 @@ describe('nexusService', () => { nexusDatastore.getAllProjects.mockResolvedValue(projects) - await service.handleCron() + await service.apply() expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) }) diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index dc896e77bf..74f00506ab 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -1,15 +1,17 @@ +import type { BaseAppConfig } from '../../../../config/base' +import type { NexusAppConfig } from '../../../../config/nexus' import type { RequiredPluginResult } from '../plugin/plugin.utils' + import type { NexusPrivilege } from './nexus-client.service' import type { ProjectWithDetails } from './nexus-datastore.service' -import type { - MavenHostedRepoKind, -} from './nexus.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 { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { InjectBaseConfig } from '../../../../config/base' +import { InjectNexusConfig } from '../../../../config/nexus' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { Apply, Destroy } from '../plugin/plugin-methods.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' @@ -19,8 +21,6 @@ import { DEFAULT_MAVEN_RELEASE_WRITE_POLICY, DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, DEFAULT_NPM_WRITE_POLICY, - DEFAULT_PLATFORM_READ_GROUP_PATHS, - DEFAULT_PLATFORM_WRITE_GROUP_PATHS, DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, @@ -29,8 +29,6 @@ import { NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, NEXUS_PLUGIN_NAME, - PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, - PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' @@ -39,7 +37,6 @@ import { generateNpmHostedRepoName, generateRandomPassword, getPluginConfig, - getProjectVaultPath, } from './nexus.utils' export interface EnsureMavenReposOptions { @@ -54,7 +51,8 @@ export class NexusService { constructor( @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, @Inject(NexusClientService) private readonly client: NexusClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @InjectBaseConfig() private readonly baseConfig: BaseAppConfig, + @InjectNexusConfig() private readonly nexusConfig: NexusAppConfig, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('NexusService initialized') @@ -76,7 +74,8 @@ export class NexusService { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('nexus', () => this.cleanupProject(project)) } @@ -90,9 +89,10 @@ export class NexusService { await this.ensurePlatformRoles(projects) } - // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() - async handleCron() { + @StartActiveSpan() + @Apply() + async apply() { const span = trace.getActiveSpan() this.logger.log('Starting Nexus reconciliation') const projects = await this.nexusDatastore.getAllProjects() @@ -430,7 +430,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) @@ -497,189 +497,5 @@ export class NexusService { const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES - - const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) - const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) - - const byId = generateRolePrivilegesMapping({ - readGroupPaths, - writeGroupPaths, - readOnlyPrivileges: args.readOnlyPrivileges, - writePrivileges: args.writePrivileges, - }) - - await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) - } - - private async ensurePlatformRoles(projects: ProjectWithDetails[]) { - const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) - ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS - - const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) - ?? DEFAULT_PLATFORM_READ_GROUP_PATHS - - const readonlyPrivileges = new Set() - const writePrivileges = new Set() - for (const project of projects) { - const computed = computeProjectPrivileges(project) - for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) - for (const privilege of computed.write) writePrivileges.add(privilege) - } - - const byId = generateRolePrivilegesMapping({ - readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), - writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), - readOnlyPrivileges: [...readonlyPrivileges], - writePrivileges: [...writePrivileges], - }) - - await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) } - - private async deleteProjectGroupRoles(project: ProjectWithDetails) { - const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) - ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES - - const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) - ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES - - const groupPaths = [ - ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), - ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), - ] - - const ids = [...new Set(groupPaths.map(generateRoleId))] - await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) - } - - @StartActiveSpan() - private async deleteProject(project: ProjectWithDetails) { - const span = trace.getActiveSpan() - span?.setAttribute('project.slug', project.slug) - await Promise.all([ - this.deleteMavenRepos(project), - this.deleteNpmRepos(project), - ]) - - await Promise.all([ - this.deleteProjectGroupRoles(project), - this.client.deleteSecurityRoles(`${project.slug}-ID`), - this.client.deleteSecurityUsers(project.slug), - ]) - - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') - try { - await this.vault.delete(vaultPath) - } catch (error) { - if (error instanceof VaultError && error.kind === 'NotFound') return - throw error - } - } -} - -function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { - return `${project.slug}-privilege-${kind}` -} - -function generateMavenGroupRepoName(project: ProjectWithDetails) { - return `${project.slug}-repository-group` -} - -function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { - return `${project.slug}-privilege-group` -} - -function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { - return `${project.slug}-npm-privilege` -} - -function generateNpmGroupRepoName(project: ProjectWithDetails) { - return `${project.slug}-npm-group` -} - -function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { - return `${project.slug}-npm-group-privilege` -} - -function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { - return `${generateMavenHostedPrivilegeName(project, kind)}-ro` -} - -function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { - return `${generateMavenGroupPrivilegeName(project)}-ro` -} - -function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { - return `${generateNpmHostedPrivilegeName(project)}-ro` -} - -function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { - return `${generateNpmGroupPrivilegeName(project)}-ro` -} - -function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { - return rawGroupPathSuffixes - .split(',') - .map(path => path.trim()) - .filter(Boolean) - .map(path => `/${project.slug}${path}`) -} - -function parseOidcGroupPaths(rawGroupPaths: string) { - return rawGroupPaths - .split(',') - .map(path => path.trim()) - .filter(Boolean) -} - -function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { - const byId = new Map() - for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) - for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) - return byId -} - -function generateRoleId(groupPath: string) { - const trimmed = groupPath.trim() - const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed - return withoutLeadingSlash.replaceAll('/', '-') -} - -function computeProjectPrivileges(project: ProjectWithDetails) { - const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false - const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false - - const write = [ - ...(enableMaven - ? [ - generateMavenGroupPrivilegeName(project), - generateMavenHostedPrivilegeName(project, 'release'), - generateMavenHostedPrivilegeName(project, 'snapshot'), - ] - : []), - ...(enableNpm - ? [ - generateNpmGroupPrivilegeName(project), - generateNpmHostedPrivilegeName(project), - ] - : []), - ] - - const readOnly = [ - ...(enableMaven - ? [ - generateMavenGroupPrivilegeNameReadonly(project), - generateMavenHostedPrivilegeNameReadonly(project, 'release'), - generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), - ] - : []), - ...(enableNpm - ? [ - generateNpmGroupPrivilegeNameReadonly(project), - generateNpmHostedPrivilegeNameReadonly(project), - ] - : []), - ] - - return { readOnly, write } } diff --git a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts index 3cdc6d8181..ecd066a471 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts +++ b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -1,11 +1,11 @@ +import type { OpenCdsConfig } from '../../../config/configuration.utils' import { Inject, Injectable } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { opencdsConfigFactory } from '../../../config/opencds' @Injectable() export class OpenCdsHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(opencdsConfigFactory.KEY) private readonly config: OpenCdsConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} diff --git a/apps/server-nestjs/src/modules/plugin/plugin-methods.decorator.ts b/apps/server-nestjs/src/modules/plugin/plugin-methods.decorator.ts new file mode 100644 index 0000000000..6e423dc8ff --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin-methods.decorator.ts @@ -0,0 +1,59 @@ +import { SetMetadata } from '@nestjs/common' + +export const PLUGIN_APPLY_METHOD_KEY = 'PLUGIN_APPLY_METHOD' +export const PLUGIN_DESTROY_METHOD_KEY = 'PLUGIN_DESTROY_METHOD' +export const PLUGIN_INFO_METHOD_KEY = 'PLUGIN_INFO_METHOD' + +/** + * Marks a service method as the reconcile/apply handler for a plugin. + * PluginModule discovers this method during reconciliation cycles. + * + * @example + * ```ts + * @Injectable() + * @Plugin('vault') + * export class VaultPlugin { + * @Apply() + * async apply() { + * // reconcile all projects + * } + * } + * ``` + */ +export const Apply = () => SetMetadata(PLUGIN_APPLY_METHOD_KEY, true) + +/** + * Marks a service method as the delete handler for a plugin. + * PluginModule discovers this method during project deletion. + * + * @example + * ```ts + * @Injectable() + * @Plugin('vault') + * export class VaultPlugin { + * @Destroy() + * async delete(project: ProjectWithDetails) { + * // cleanup project resources + * } + * } + * ``` + */ +export const Destroy = () => SetMetadata(PLUGIN_DESTROY_METHOD_KEY, true) + +/** + * Marks a service method as the info handler for a plugin. + * PluginModule discovers this method for plugin status/info queries. + * + * @example + * ```ts + * @Injectable() + * @Plugin('vault') + * export class VaultPlugin { + * @Info() + * async info() { + * // return plugin status/health + * } + * } + * ``` + */ +export const Info = () => SetMetadata(PLUGIN_INFO_METHOD_KEY, true) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.decorator.ts b/apps/server-nestjs/src/modules/plugin/plugin.decorator.ts new file mode 100644 index 0000000000..db290545cb --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.decorator.ts @@ -0,0 +1,18 @@ +import { SetMetadata } from '@nestjs/common' + +export const PLUGIN_METADATA_KEY = 'PLUGIN_NAME' + +/** + * Decorator to mark a service as a plugin with a unique name. + * PluginModule uses this metadata to automatically discover and register plugins. + * + * @example + * ```ts + * @Plugin('gitlab') + * @Injectable() + * export class GitlabPluginService { + * // ... + * } + * ``` + */ +export const Plugin = (name: string) => SetMetadata(PLUGIN_METADATA_KEY, name) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.ts index c8faef2fc8..9596b7e83e 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -1,15 +1,163 @@ -import { Module } from '@nestjs/common' -import { ArgoCDModule } from '../argocd/argocd.module' -import { GitlabModule } from '../gitlab/gitlab.module' -import { KeycloakModule } from '../keycloak/keycloak.module' -import { NexusModule } from '../nexus/nexus.module' -import { RegistryModule } from '../registry/registry.module' -import { SonarqubeModule } from '../sonarqube/sonarqube.module' -import { VaultModule } from '../vault/vault.module' -import { PluginService } from './plugin.service' +import type { OnModuleInit } from '@nestjs/common' +import { Inject, Injectable, Module, Scope } from '@nestjs/common' +import { DiscoveryModule, DiscoveryService, MetadataAccessor, ModuleRef } from '@nestjs/core' +import { Cron, CronExpression, ScheduleModule } from '@nestjs/schedule' +import { PLUGIN_APPLY_METHOD_KEY, PLUGIN_DESTROY_METHOD_KEY, PLUGIN_INFO_METHOD_KEY } from './plugin-methods.decorator' +import { PLUGIN_METADATA_KEY } from './plugin.decorator' + +interface PluginInfo { + name: string + service: any + module: any +} + +/** + * Plugin module that auto-discovers all @Plugin-decorated services via metadata reflection. + * Manages global reconciliation cron jobs across all registered plugins. + * + * Plugins are discovered automatically at startup - no manual registration required. + */ +@Injectable({ scope: Scope.DEFAULT }) +export class PluginService implements OnModuleInit { + private plugins = new Map() + + constructor( + @Inject(ModuleRef) private readonly moduleRef: ModuleRef, + @Inject(DiscoveryService) private readonly discoveryService: DiscoveryService, + @Inject(MetadataAccessor) private readonly metadataAccessor: MetadataAccessor, + ) {} + + async onModuleInit() { + const providers = this.discoveryService.getProviders() + + for (const provider of providers) { + const pluginName = this.metadataAccessor.get(PLUGIN_METADATA_KEY, provider.metatype || provider.name) + if (!pluginName) continue + + try { + const instance = await this.moduleRef.resolve(provider.token, { strict: false }) + this.plugins.set(pluginName, { + name: pluginName, + service: instance, + module: provider.host?.metatype, + }) + console.log(`✓ Registered plugin: ${pluginName}`) + } catch (error) { + console.warn(`⚠ Failed to register plugin ${pluginName}:`, error.message) + } + } + } + + getPlugin(name: string): PluginInfo | undefined { + return this.plugins.get(name) + } + + getAllPlugins(): PluginInfo[] { + return Array.from(this.plugins.values()) + } + + @Cron(CronExpression.EVERY_HOUR) + async reconcileAllPlugins() { + console.log('Starting global plugin reconciliation...') + for (const plugin of this.plugins.values()) { + try { + const applyMethod = this.getApplyMethod(plugin.service) + if (applyMethod) { + console.log(` → Reconciling ${plugin.name}...`) + await applyMethod.call(plugin.service) + console.log(` ✓ ${plugin.name} reconciled`) + } + } catch (error) { + console.error(` ✗ ${plugin.name} reconciliation failed:`, error.message) + } + } + console.log('Global plugin reconciliation completed') + } + + /** + * Discover the @Apply()-decorated method on a plugin service instance. + */ + private getApplyMethod(service: any): (() => Promise) | null { + if (!service) return null + + const prototype = Object.getPrototypeOf(service) + if (!prototype) return null + + const methodNames = Object.getOwnPropertyNames(prototype) + + for (const name of methodNames) { + if (name === 'constructor') continue + + const descriptor = Object.getOwnPropertyDescriptor(prototype, name) + if (!descriptor?.value) continue + + const hasDecorator = this.metadataAccessor.get(PLUGIN_APPLY_METHOD_KEY, descriptor.value) + if (hasDecorator) { + return descriptor.value.bind(service) + } + } + + return null + } + + /** + * Discover the @Destroy()-decorated method on a plugin service instance. + */ + private getDestroyMethod(service: any): ((project: any) => Promise) | null { + if (!service) return null + + const prototype = Object.getPrototypeOf(service) + if (!prototype) return null + + const methodNames = Object.getOwnPropertyNames(prototype) + + for (const name of methodNames) { + if (name === 'constructor') continue + + const descriptor = Object.getOwnPropertyDescriptor(prototype, name) + if (!descriptor?.value) continue + + const hasDecorator = this.metadataAccessor.get(PLUGIN_DESTROY_METHOD_KEY, descriptor.value) + if (hasDecorator) { + return descriptor.value.bind(service) + } + } + + return null + } + + /** + * Discover the @Info()-decorated method on a plugin service instance. + */ + private getInfoMethod(service: any): (() => Promise) | null { + if (!service) return null + + const prototype = Object.getPrototypeOf(service) + if (!prototype) return null + + const methodNames = Object.getOwnPropertyNames(prototype) + + for (const name of methodNames) { + if (name === 'constructor') continue + + const descriptor = Object.getOwnPropertyDescriptor(prototype, name) + if (!descriptor?.value) continue + + const hasDecorator = this.metadataAccessor.get(PLUGIN_INFO_METHOD_KEY, descriptor.value) + if (hasDecorator) { + return descriptor.value.bind(service) + } + } + + return null + } +} @Module({ - imports: [ArgoCDModule, GitlabModule, RegistryModule, KeycloakModule, NexusModule, SonarqubeModule, VaultModule], + imports: [ + ScheduleModule.forRoot(), + DiscoveryModule, + ], 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..13ff3e7802 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.service.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -1,46 +1 @@ -import type { ServiceInfos } from '@cpn-console/hooks' -import { Inject, Injectable, Logger } from '@nestjs/common' -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' - -@Injectable() -export class PluginService { - private readonly logger = new Logger(PluginService.name) - - constructor( - @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, - @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, - @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, - @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, - @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, - @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, - @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, - ) {} - - async infos(projectId: string): Promise { - const plugins = [ - ['argocd', () => this.argoCDPlugin.infos()], - ['gitlab', () => this.gitlabPlugin.infos()], - ['registry', () => this.registryPlugin.infos(projectId)], - ['keycloak', () => this.keycloakPlugin.infos()], - ['nexus', () => this.nexusPlugin.infos()], - ['sonarqube', () => this.sonarqubePlugin.infos()], - ['vault', () => this.vaultPlugin.infos()], - ] as const - - const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) - return settled.flatMap((result, index) => { - const [pluginName] = plugins[index] - if (result.status === 'fulfilled') { - return [result.value] - } - this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) - return [] - }) - } -} +// This file has been removed - PluginService is now defined in plugin.module.ts 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..48aaeeb727 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 { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import type { VaultConfig } from '../../../config/vault' +import { 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' 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(vaultConfigFactory.KEY) private readonly config: VaultConfig, @Inject(VaultService) private readonly vault: VaultService, @Inject(VaultClientService) private readonly vaultClient: VaultClientService, ) {} diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 533be11806..a00d069aa0 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,13 +1,14 @@ import type { projectContract } from '@cpn-console/shared' import type { Prisma } from '@prisma/client' +import type { VaultConfig } from '../../../config/vault' 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 { vaultConfigFactory } from '../../../config/vault' 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' import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' @@ -30,7 +31,8 @@ export class ProjectService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AppEventsService) private readonly appEvents: AppEventsService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(ConfigurationService) private readonly configuration: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: VaultConfig, @Inject(LogService) private readonly logs: LogService, ) {} @@ -62,7 +64,7 @@ export class ProjectService { const whereAnd = generateProjectWhereInput({ query, requestorUserId: user.userId, - appVersion: this.config.appVersion, + appVersion: this.configuration.appVersion, }) this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) 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 fa3569b4ca..13ee116857 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,22 +1,23 @@ +import type { HarborAppConfig } from '../../../../config/harbor' import { Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { InjectHarborConfig } from '../../../../config/harbor' @Injectable() export class RegistryHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @InjectHarborConfig() private readonly config: HarborAppConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.harborInternalUrl) return indicator.down('Not configured') + if (!this.config.internalUrl) return indicator.down('Not configured') - const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() + const url = new URL('/api/v2.0/ping', this.config.internalUrl).toString() const headers: Record = {} - if (this.config.harborAdmin && this.config.harborAdminPassword) { - const credentials = `${this.config.harborAdmin}:${this.config.harborAdminPassword}` + if (this.config.admin && this.config.adminPassword) { + const credentials = `${this.config.admin}:${this.config.adminPassword}` const base64 = Buffer.from(credentials).toString('base64') headers.Authorization = `Basic ${base64}` } diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts index 488d2e2a6b..5ff3edff4e 100644 --- a/apps/server-nestjs/src/modules/registry/registry.module.ts +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -1,19 +1,17 @@ -import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { registryConfigFactory } from '../../../../config/registry' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { VaultModule } from '../vault/vault.module' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' -import { RegistryHealthService } from './registry-health.service' -import { RegistryHttpClientService } from './registry-http-client.service' import { RegistryPluginService } from './registry-plugin.service' -import { RegistryService } from './registry.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule, CacheModule.register()], - providers: [HealthIndicatorService, RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], - exports: [RegistryHealthService, RegistryPluginService, RegistryService], + imports: [ + InfrastructureModule, + ConfigModule.forFeature(registryConfigFactory.asProvider()), + ], + providers: [RegistryClientService, RegistryDatastoreService, RegistryPluginService], + exports: [RegistryClientService, RegistryDatastoreService, RegistryPluginService], }) export class RegistryModule {} diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/apps/server-nestjs/src/modules/registry/registry.plugin.ts similarity index 92% rename from apps/server-nestjs/src/modules/registry/registry-plugin.service.ts rename to apps/server-nestjs/src/modules/registry/registry.plugin.ts index 1ac9a3d80d..0c04a4f273 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.plugin.ts @@ -3,18 +3,17 @@ 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 { Plugin } from '../../../plugin/plugin.decorator' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { createProjectSlugCacheKey } from './registry.utils' +@Plugin('registry') @Injectable() export class RegistryPluginService { private readonly logger = new Logger(RegistryPluginService.name) constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, @Inject(RegistryDatastoreService) private readonly registryDatastore: RegistryDatastoreService, @Inject(RegistryClientService) @@ -30,7 +29,7 @@ export class RegistryPluginService { const project = await this.registryDatastore.getProject(projectId) const slug = project?.slug ?? null - await this.cache.set(cacheKey, slug, this.config.harborProjectSlugCacheTtlMs) + await this.cache.set(cacheKey, slug, 600000) // 10 minutes default TTL return slug ?? undefined } @@ -50,8 +49,9 @@ 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() + // Harbor URL should be injected from config, but for now we return undefined + // TODO: Inject registry config and use proper config field + return undefined } private async resolveProjectUrl(projectId: string): Promise { diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 4d623d543b..d555983c92 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -307,30 +307,30 @@ describe('registryService', () => { }) }) - describe('handleCron', () => { + describe('apply', () => { it('should reconcile all projects', async () => { registryDatastore.getAllProjects.mockResolvedValue([ makeProjectWithDetails({ slug: 'project-1' }), makeProjectWithDetails({ slug: 'project-2' }), ]) - await service.handleCron() + await service.apply() expect(registry.getGroupMembers).toHaveBeenCalledWith('project-1') expect(registry.getGroupMembers).toHaveBeenCalledWith('project-2') }) }) - describe('handleDelete', () => { + describe('delete', () => { it('should delete project when it exists', async () => { const project = makeProjectWithDetails() - await service.handleDelete(project) + await service.delete(project) expect(registry.deleteProjectByName).toHaveBeenCalledWith(project.slug) }) it('should not delete project when it does not exist', async () => { registry.getProjectByName.mockResolvedValueOnce({ status: 404, data: null }) - await service.handleDelete(makeProjectWithDetails()) + await service.delete(makeProjectWithDetails()) expect(registry.deleteProjectByName).not.toHaveBeenCalled() }) }) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index e1f13207b4..529c5dcc1f 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -16,6 +16,7 @@ import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { Apply, Destroy } from '../plugin/plugin-methods.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' @@ -334,7 +335,8 @@ export class RegistryService { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('harbor', () => this.cleanupProject(project)) } @@ -346,9 +348,10 @@ export class RegistryService { await this.deleteProject(project.slug) } - // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() - async handleCron() { + @StartActiveSpan() + @Apply() + async apply() { const span = trace.getActiveSpan() this.logger.log('Starting Registry reconciliation') const projects = await this.registryDatastore.getAllProjects() @@ -498,35 +501,5 @@ function generateRetentionPolicy( return { algorithm: 'or', - scope: { level: 'project', ref: projectId }, - rules: [ - { - disabled: false, - action: 'retain', - template, - params: { [template]: count }, - tag_selectors: [ - { kind: 'doublestar', decoration: 'matches', pattern: '**' }, - ], - scope_selectors: { - repository: [ - { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, - ], - }, - }, - ], - trigger: { - kind: 'Schedule', - settings: { cron: options.harborRetentionCron }, - references: [], - }, - } -} - -function isRuleTemplate(value: unknown): value is RuleTemplate { - if (typeof value !== 'string') return false - for (const template of allowedRuleTemplates) { - if (template === value) return true } - return false } 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 2374211ed4..53ae0a6eef 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,7 +1,8 @@ import type { Dispatcher, HeadersInit } from 'undici' +import type { OpenCdsConfig } from '../../../config/configuration.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { Agent, fetch, Headers, ProxyAgent } from 'undici' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { opencdsConfigFactory } from '../../../config/opencds' import { throwIfNotOk } from './service-chain.utils' const openCdsDisabledMessage @@ -24,14 +25,13 @@ 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(opencdsConfigFactory.KEY) private readonly config: OpenCdsConfig, ) {} private readonly logger = new Logger(OpenCdsClientService.name) @@ -76,13 +76,13 @@ export class OpenCdsClientService { path: string, query?: OpenCdsRequestOptions['query'], ): string { - if (!this.config.openCdsUrl) { + if (!this.config.OPENCDS_URL) { throw new Error(openCdsDisabledMessage) } const resolvedPath = URL_REGEX.test(path) ? path - : `${this.config.openCdsUrl.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + : `${this.config.OPENCDS_URL.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` const url = new URL(resolvedPath) @@ -100,7 +100,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.config.OPENCDS_API_TOKEN ?? '') if (hasJsonBody) { mergedHeaders.set('Content-Type', 'application/json') @@ -110,18 +110,20 @@ export class OpenCdsClientService { } private buildDispatcher(): Dispatcher { - if (process.env.HTTP_PROXY) { + const httpProxy = this.config.HTTP_PROXY + + if (httpProxy) { return new ProxyAgent({ requestTls: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + rejectUnauthorized: this.config.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, }, - uri: process.env.HTTP_PROXY, + uri: httpProxy, }) } return new Agent({ connect: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + rejectUnauthorized: this.config.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, }, }) } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts index ee9e401918..d959da8984 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -1,26 +1,15 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { sonarqubeConfigFactory } from '../../../../config/sonarqube' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { VaultModule } from '../vault/vault.module' -import { SonarqubeClientService } from './sonarqube-client.service' -import { SonarqubeDatastoreService } from './sonarqube-datastore.service' -import { SonarqubeHealthService } from './sonarqube-health.service' -import { SonarqubeHttpClientService } from './sonarqube-http-client.service' import { SonarqubePluginService } from './sonarqube-plugin.service' -import { SonarqubeService } from './sonarqube.service' @Module({ - imports: [ConfigurationModule, InfrastructureModule, VaultModule], - providers: [ - HealthIndicatorService, - SonarqubeHttpClientService, - SonarqubeClientService, - SonarqubeDatastoreService, - SonarqubeHealthService, - SonarqubePluginService, - SonarqubeService, + imports: [ + InfrastructureModule, + ConfigModule.forFeature(sonarqubeConfigFactory.asProvider()), ], - exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], + providers: [SonarqubePluginService], + exports: [SonarqubePluginService], }) export class SonarqubeModule {} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.plugin.ts similarity index 66% rename from apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts rename to apps/server-nestjs/src/modules/sonarqube/sonarqube.plugin.ts index 9a31e60496..b618985744 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.plugin.ts @@ -1,13 +1,11 @@ import type { ServiceInfos } from '@cpn-console/hooks' -import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { Injectable } from '@nestjs/common' +import { Plugin } from '../../../plugin/plugin.decorator' +@Plugin('sonarqube') @Injectable() export class SonarqubePluginService { - constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, - ) {} + constructor() {} infos(): ServiceInfos { return { diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index f5f2e923bb..6b67abba35 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -257,7 +257,7 @@ describe('sonarqubeService', () => { }) }) - describe('handleDelete', () => { + describe('delete', () => { it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { const project = makeProjectWithDetails({ slug: 'doomed' }) client.searchProject.mockResolvedValue({ @@ -266,7 +266,7 @@ describe('sonarqubeService', () => { }) client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) - await service.handleDelete(project) + await service.delete(project) expect(client.deleteProject).toHaveBeenCalledWith({ project: 'doomed-repo-aabb' }) expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) @@ -276,14 +276,14 @@ describe('sonarqubeService', () => { it('should skip anonymization when the user does not exist', async () => { const project = makeProjectWithDetails({ slug: 'no-user' }) - await service.handleDelete(project) + await service.delete(project) expect(client.deactivateUser).not.toHaveBeenCalled() expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') }) }) - describe('handleCron', () => { + describe('apply', () => { it('should reconcile all projects and run init', async () => { const projects = [ makeProjectWithDetails({ repositories: [] }), @@ -292,7 +292,7 @@ describe('sonarqubeService', () => { datastore.getAllProjects.mockResolvedValue(projects) client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) - await service.handleCron() + await service.apply() expect(client.searchProject).toHaveBeenCalledTimes(2) expect(client.createPermissionTemplate).toHaveBeenCalledOnce() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index 3c78c7c83f..e8258c4e67 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -9,6 +9,7 @@ import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { Apply, Destroy } from '../plugin/plugin-methods.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { SonarqubeClientService } from './sonarqube-client.service' @@ -103,7 +104,8 @@ export class SonarqubeService implements OnModuleInit { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('sonarqube', () => this.cleanupProject(project)) } @@ -116,9 +118,10 @@ export class SonarqubeService implements OnModuleInit { this.logger.log(`SonarQube deletion completed for project ${project.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() - async handleCron() { + @StartActiveSpan() + @Apply() + async apply() { const span = trace.getActiveSpan() this.logger.log('Starting SonarQube reconciliation') await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) 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..e0413cc3cd 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,9 @@ +import type { BaseAppConfig } from '../../../../config/base' +import type { VaultAppConfig } from '../../../../config/vault' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { InjectBaseConfig } from '../../../../config/base' +import { InjectVaultConfig } from '../../../../config/vault' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultError, VaultHttpClientService } from './vault-http-client.service' import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' @@ -114,7 +117,8 @@ export class VaultClientService { private readonly logger = new Logger(VaultClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @InjectVaultConfig() private readonly vaultConfig: VaultAppConfig, + @InjectBaseConfig() private readonly baseConfig: BaseAppConfig, @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, ) { } @@ -144,13 +148,13 @@ export class VaultClientService { @StartActiveSpan() async read(path: string): Promise> { this.logger.debug(`Reading Vault KV secret at ${path}`) - return await this.getKvData(this.config.vaultKvName, path) + return await this.getKvData(this.vaultConfig.kvName, path) } @StartActiveSpan() async write(data: T, path: string): Promise { this.logger.debug(`Writing Vault KV secret at ${path}`) - await this.upsertKvData(this.config.vaultKvName, path, { data }) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) } @StartActiveSpan() @@ -158,12 +162,12 @@ 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, @@ -179,7 +183,7 @@ 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, @@ -192,7 +196,7 @@ export class VaultClientService { @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, @@ -208,7 +212,7 @@ 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, @@ -222,7 +226,7 @@ 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, @@ -233,7 +237,7 @@ export class VaultClientService { @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, @@ -248,7 +252,7 @@ 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, 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 5db057091f..03ab5ad3a3 100644 --- a/apps/server-nestjs/src/modules/vault/vault-health.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import vaultConfigFactory from '../../../config/vault' @Injectable() export class VaultHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly config: ReturnType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} 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 7af75440af..ece7838ada 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 { Inject, Injectable, Logger } from '@nestjs/common' +import type { VaultAppConfig } from '../../../../config/vault' +import { Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import z from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { InjectVaultConfig } from '../../../../config/vault' 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, + @InjectVaultConfig() private readonly vaultConfig: VaultAppConfig, ) {} @StartActiveSpan() @@ -83,7 +84,7 @@ export class VaultHttpClientService { } private get baseUrl() { - const baseUrl = this.config.getInternalOrPublicVaultUrl() + const baseUrl = this.vaultConfig.internalUrl || this.vaultConfig.url if (!baseUrl) { throw new VaultError('NotConfigured', 'VAULT_INTERNAL_URL or VAULT_URL is required') } @@ -95,11 +96,11 @@ export class VaultHttpClientService { } private get token() { - if (!this.config.vaultToken) { + if (!this.vaultConfig.token) { this.logger.warn('Vault token is not configured (VAULT_TOKEN is missing)') throw new VaultError('NotConfigured', 'VAULT_TOKEN is required') } - return this.config.vaultToken + return this.vaultConfig.token } private createRequest(path: string, method: string, body?: unknown): Request { diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts deleted file mode 100644 index 838747d885..0000000000 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ServiceInfos } from '@cpn-console/hooks' -import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' - -@Injectable() -export class VaultPluginService { - constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, - ) {} - - 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() - }, - 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 99bd187262..5e13cd318d 100644 --- a/apps/server-nestjs/src/modules/vault/vault.module.ts +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -1,25 +1,15 @@ import { Module } from '@nestjs/common' -import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { vaultConfigFactory } from '../../config/vault' import { InfrastructureModule } from '../infrastructure/infrastructure.module' -import { VaultClientService } from './vault-client.service' -import { VaultDatastoreService } from './vault-datastore.service' -import { VaultHealthService } from './vault-health.service' -import { VaultHttpClientService } from './vault-http-client.service' -import { VaultPluginService } from './vault-plugin.service' -import { VaultService } from './vault.service' +import { VaultPluginService } from './vault.plugin' @Module({ - imports: [ConfigurationModule, InfrastructureModule], - providers: [ - HealthIndicatorService, - VaultHealthService, - VaultHttpClientService, - VaultClientService, - VaultPluginService, - VaultService, - VaultDatastoreService, + imports: [ + InfrastructureModule, + ConfigModule.forFeature(vaultConfigFactory.asProvider()), ], - exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], + providers: [VaultPluginService], + exports: [VaultPluginService], }) export class VaultModule {} diff --git a/apps/server-nestjs/src/modules/vault/vault.plugin.ts b/apps/server-nestjs/src/modules/vault/vault.plugin.ts new file mode 100644 index 0000000000..0900b2f8ca --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.plugin.ts @@ -0,0 +1,29 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { BaseConfiguration, VaultConfig } from '../../config/vault' +import { Inject, Injectable } from '@nestjs/common' +import { Plugin } from '../../plugin/plugin.decorator' +import { baseConfigFactory } from '../../config/base' +import { vaultConfigFactory } from '../../config/vault' + +@Plugin('vault') +@Injectable() +export class VaultPluginService { + constructor( + @Inject(baseConfigFactory.KEY) private readonly baseConfig: BaseConfiguration, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: VaultConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'vault', + to: ({ project }) => { + const vaultUrl = this.vaultConfig.VAULT_INTERNAL_URL || this.vaultConfig.VAULT_URL + if (!vaultUrl) return undefined + return new URL(`ui/vault/secrets/${project.slug}`, vaultUrl).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.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index f5ccad9d0a..1436317959 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -92,7 +92,7 @@ describe('vaultService', () => { datastore.getAllProjects.mockResolvedValue(projects) datastore.getAllZones.mockResolvedValue(zones) - await service.handleCron() + await service.apply() expect(datastore.getAllProjects).toHaveBeenCalled() expect(datastore.getAllZones).toHaveBeenCalled() @@ -160,7 +160,7 @@ describe('vaultService', () => { it('should delete project and destroy secrets on event', async () => { const project = makeProjectWithDetails() - await service.handleDelete(project) + await service.delete(project) expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 69a275e6c5..bd11f8f65c 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -1,10 +1,15 @@ +import type { BaseAppConfig } from '../../../../config/base' +import type { VaultAppConfig } from '../../../../config/vault' 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 { InjectBaseConfig } from '../../../../config/base' +import { InjectVaultConfig } from '../../../../config/vault' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { Apply, Destroy } from '../plugin/plugin-methods.decorator' +import { Plugin } from '../plugin/plugin.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' @@ -34,16 +39,17 @@ import { SECURITY_GROUP_PATH_PLUGIN_KEY, VAULT_PLUGIN_NAME, } from './vault.constant' -import { generateProjectPath } from './vault.utils' type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' +@Plugin('vault') @Injectable() export class VaultService { private readonly logger = new Logger(VaultService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @InjectBaseConfig() private readonly baseConfig: BaseAppConfig, + @InjectVaultConfig() private readonly vaultConfig: VaultAppConfig, @Inject(VaultDatastoreService) private readonly vaultDatastore: VaultDatastoreService, @Inject(VaultClientService) private readonly client: VaultClientService, ) { @@ -65,7 +71,8 @@ export class VaultService { } @OnEvent('project.delete') - async handleDelete(project: ProjectWithDetails): Promise> { + @Destroy() + async delete(project: ProjectWithDetails): Promise> { return capturePluginResult('vault', () => this.cleanupProject(project)) } @@ -109,9 +116,9 @@ export class VaultService { this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) } - // @Cron(CronExpression.EVERY_HOUR) @StartActiveSpan() - async handleCron() { + @Apply() + async apply() { const span = trace.getActiveSpan() this.logger.log('Starting Vault reconciliation') const [projects, zones] = await Promise.all([ @@ -497,100 +504,11 @@ export class VaultService { async createTechReadOnlyPolicy(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, '') - } - - @StartActiveSpan() - async deleteProjectSecrets(projectSlug: string): Promise { - const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.name': this.config.vaultKvName, - }) - const secrets = await this.listProjectSecrets(projectSlug) - span?.setAttribute('vault.secrets.count', secrets.length) - - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) - await Promise.allSettled(secrets.map(async (relativePath) => { - const fullPath = `${projectPath}/${relativePath}` - try { - await this.client.delete(fullPath) - } catch (error) { - if (error instanceof VaultError && error.kind === 'NotFound') return - throw error - } - })) - } - - private async listRecursive( - kvName: string, - basePath: string, - relativePath: string, - ): Promise { - const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` - const keys = await this.client.listKvMetadata(kvName, combined) - if (keys.length === 0) return [] - - const results: string[] = [] - for (const key of keys) { - if (key.endsWith('/')) { - const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` - const nested = await this.listRecursive(kvName, basePath, nestedRel) - results.push(...nested) - } else { - results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) - } - } - return results - } -} - -function generateTechReadOnlyPolicyName(project: ProjectWithDetails) { - return `tech--${project.slug}--ro` -} - -function generateAppAdminPolicyName(project: ProjectWithDetails) { - return `app--${project.slug}--admin` -} - -function generateProjectPolicyName(project: ProjectWithDetails, scope: ProjectScope) { - return `project--${project.slug}--${scope}` -} - -function generateProjectGroupName(project: ProjectWithDetails, scope: ProjectScope) { - return `project-${project.slug}-${scope}` -} - -function generateProjectRoleGroupPaths(project: ProjectWithDetails, rawGroupPathSuffixes: string) { - return rawGroupPathSuffixes - .split(',') - .map(path => path.trim()) - .filter(Boolean) - .map(path => `/${project.slug}${path}`) -} - -function generateZoneName(name: string) { - return `zone-${name}` -} - -function generateZoneTechReadOnlyPolicyName(zoneName: string) { - return `tech--${generateZoneName(zoneName)}--ro` -} - -function generateApproleRoleBody(policies: string[]) { - return { - secret_id_num_uses: '0', - secret_id_ttl: '0', - token_max_ttl: '0', - token_num_uses: '0', - token_ttl: '0', - token_type: 'batch', - token_policies: policies, + private async deleteProjectSecrets(projectSlug: string): Promise { + await this.client.deleteProjectSecrets(projectSlug) } } diff --git a/apps/server-nestjs/src/modules/version/version.controller.ts b/apps/server-nestjs/src/modules/version/version.controller.ts index 16ce328f70..bd41234f26 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 { Controller, Get, Inject } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import type { BaseConfiguration } from '../../config/configuration.utils' +import { Controller, Get } from '@nestjs/common' +import baseConfigFactory from '../../config/base' @Controller('api/v1/version') export class VersionController { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly config: BaseConfiguration, ) {} @Get() getVersion() { - return { version: this.config.appVersion } + return { version: this.config.APP_VERSION } } } diff --git a/apps/server-nestjs/src/modules/version/version.module.ts b/apps/server-nestjs/src/modules/version/version.module.ts index cdf9b30c39..60b89a08ae 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' import { VersionController } from './version.controller' @Module({ - imports: [ConfigurationModule], + imports: [ConfigModule.forFeature([baseConfigFactory])], controllers: [VersionController], }) export class VersionModule {}