Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfa
## Database (Prisma)

Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography).
Singleton PrismaClient in `apps/server/src/prisma.ts`. Queries centralized per resource, re-exported via `queries-index.ts`.
Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`.

## Environment config
Expand Down
8 changes: 8 additions & 0 deletions apps/server-nestjs/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ KEYCLOAK_PROTOCOL=http
KEYCLOAK_CLIENT_ID=dso-console-backend
# Secret du client Keycloak backend (confidentiel)
KEYCLOAK_CLIENT_SECRET=client-secret-backend
# Identifiant de l'administrateur Keycloak
KEYCLOAK_ADMIN=admin
# Mot de passe de l'administrateur Keycloak
KEYCLOAK_ADMIN_PASSWORD=admin
# Identifiant administrateur Keycloak (utilisé pour l'API admin)
KEYCLOAK_ADMIN=admin
# Mot de passe administrateur Keycloak (confidentiel)
KEYCLOAK_ADMIN_PASSWORD=admin
# URL de redirection après authentification Keycloak
KEYCLOAK_REDIRECT_URI=http://localhost:8080
# Port d'écoute du serveur backend
Expand Down
4 changes: 4 additions & 0 deletions apps/server-nestjs/.env.docker-example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ KEYCLOAK_PROTOCOL=http
KEYCLOAK_CLIENT_ID=dso-console-backend
# Secret du client Keycloak backend (confidentiel)
KEYCLOAK_CLIENT_SECRET=client-secret-backend
# Identifiant de l'administrateur Keycloak
KEYCLOAK_ADMIN=admin
# Mot de passe de l'administrateur Keycloak
KEYCLOAK_ADMIN_PASSWORD=admin
# URL de redirection après authentification Keycloak
KEYCLOAK_REDIRECT_URI=http://localhost:8080
# Port d'écoute du serveur dans le réseau Docker
Expand Down
4 changes: 4 additions & 0 deletions apps/server-nestjs/.env.integ-example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_DOMAIN=
# Royaume Keycloak d'intégration
KEYCLOAK_REALM=
# Identifiant de l'administrateur Keycloak
KEYCLOAK_ADMIN=
# Mot de passe de l'administrateur Keycloak
KEYCLOAK_ADMIN_PASSWORD=

# --- ArgoCD ---
# Namespace Kubernetes dans lequel ArgoCD est déployé
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`)

Les modules NestJS métier vivent dans `src/modules/<nom-du-module>/` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service (API publique)**, **controller service (orchestration)**, **datastore**, **utils** et **tests**.

Exemples concrets :

- Module GitLab : `src/modules/gitlab/`
- Module Keycloak : `src/modules/keycloak/`

## Structure type

```txt
src/modules/<module>/
├── <module>.module.ts
├── <module>.constants.ts
├── <module>-client.service.ts
├── <module>.service.ts
├── <module>-datastore.service.ts
├── <module>.utils.ts
├── <module>-testing.utils.ts
└── *.spec.ts
```

## Sens des dépendances (flow recommandé)

Objectif : un flux de dépendances lisible et sans cycles.

```txt
<module>.service.ts
↙ ↘
<module>-client.service.ts <module>-datastore.service.ts
```

Règles pratiques :

- Le `<module>.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`.
- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage).
- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau).
- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées.
- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest).
- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests.

## Composants

### `<module>.module.ts`

Rôle :

- Déclare les providers, imports, exports du module.
- Exporte le `service` du module (`<module>.service.ts`) qui constitue l’API publique.

### `<module>-client.service.ts`

Rôle :

- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.).
- Conserver un contrat stable pour le reste du module.
- Mapper/normaliser les erreurs externes si nécessaire.

À éviter :

- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `<module>.service.ts` ou le controller service.

### `<module>.service.ts`

Rôle :

- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”.
- Coordination entre `client` et `datastore` (sans dépendre du `service`).
- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins).

### `<module>-datastore.service.ts`

Rôle :

- Accès DB via Prisma (select/include, transactions, pagination).
- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`).

À éviter :

- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence.

### `<module>.utils.ts`

Rôle :

- Fonctions utilitaires pures : mapping, helpers de collections, types partagés.
- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau.

### `<module>-testing.utils.ts`

Rôle :

- Factories typées pour les structures fréquemment utilisées en tests.
- Support d’`overrides` pour construire rapidement des variantes.
- Centralisation des erreurs/fake responses spécifiques au module (quand utile).

## Tests (Vitest)

### `<module>.service.spec.ts`

Cible :

- Orchestration : séquences d’appels, side-effects attendus, reconcile.

Approche :

- Mock du `service`, du `datastore`, et des appels externes.
- Vérification des appels effectués et des paramètres attendus.

### `<module>-datastore.service.spec.ts` (si présent)

Cible :

- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé.

Approche :

- Mock de Prisma/DatabaseService, pas de logique métier.

9 changes: 8 additions & 1 deletion apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,16 @@
"@fastify/swagger": "^8.15.0",
"@fastify/swagger-ui": "^4.2.0",
"@gitbeaker/core": "^40.6.0",
"@gitbeaker/requester-utils": "^40.6.0",
"@gitbeaker/rest": "^40.6.0",
"@keycloak/keycloak-admin-client": "^24.0.0",
"@kubernetes-models/argo-cd": "^2.7.2",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.16",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.1.16",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.70.1",
Expand Down Expand Up @@ -76,7 +80,9 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"undici": "^7.24.0",
"vitest-mock-extended": "^2.0.2"
"vitest-mock-extended": "^2.0.2",
"yaml": "^2.8.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@cpn-console/eslint-config": "workspace:^",
Expand All @@ -95,6 +101,7 @@
"eslint": "^9.39.4",
"fastify-plugin": "^5.1.0",
"globals": "^16.5.0",
"msw": "^2.12.10",
"nodemon": "^3.1.14",
"pino-pretty": "^13.1.3",
"rimraf": "^6.1.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export class ConfigurationService {
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

adminsUserId = process.env.ADMIN_KC_USER_ID
? process.env.ADMIN_KC_USER_ID.split(',')
: []
Expand All @@ -33,10 +36,58 @@ export class ConfigurationService {
= 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'

// plugins
mockPlugins = process.env.MOCK_PLUGINS === 'true'
projectRootDir = process.env.PROJECTS_ROOT_DIR
projectRootPath = 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
? process.env.GITLAB_INTERNAL_URL
: process.env.GITLAB_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
? process.env.VAULT_INTERNAL_URL
: process.env.VAULT_URL

vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso'

// registry (harbor)
harborUrl = process.env.HARBOR_URL
harborInternalUrl = process.env.HARBOR_INTERNAL_URL ?? process.env.HARBOR_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

// nexus
nexusUrl = process.env.NEXUS_URL
nexusInternalUrl = process.env.NEXUS_INTERNAL_URL ?? process.env.NEXUS_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)
: process.env.NEXUS_URL

NODE_ENV
= process.env.NODE_ENV === 'test'
? 'test'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const DEFAULT_RECONCILE_MAX_RETRIES = 5
export const DEFAULT_RECONCILE_REQUEUE_AFTER_MS = 0
export const DEFAULT_RECONCILE_ERROR_REQUEUE_AFTER_MS = 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { setTimeout } from 'node:timers/promises'
import { Logger } from '@nestjs/common'
import {
DEFAULT_RECONCILE_ERROR_REQUEUE_AFTER_MS,
DEFAULT_RECONCILE_MAX_RETRIES,
DEFAULT_RECONCILE_REQUEUE_AFTER_MS,
} from './reconcile.constants'

export interface RequeueResult {
requeueAfterMs?: number
}

export type ReconcileResult = undefined | RequeueResult

export function requeue(options: RequeueResult = {}): RequeueResult {
return options
}

export interface ReconcileOptions {
maxRetries?: number
defaultRequeueAfterMs?: number
defaultErrorRequeueAfterMs?: number
shouldRetry?: (error: unknown) => boolean
onError?: (error: unknown) => void
}

async function reconcile<T>(handler: () => Promise<T> | T, options: ReconcileOptions = {}): Promise<T> {
const {
maxRetries = DEFAULT_RECONCILE_MAX_RETRIES,
defaultRequeueAfterMs = DEFAULT_RECONCILE_REQUEUE_AFTER_MS,
defaultErrorRequeueAfterMs = DEFAULT_RECONCILE_ERROR_REQUEUE_AFTER_MS,
shouldRetry,
onError,
} = options

const run = async (attempt: number): Promise<T> => {
try {
const result = await handler()
const requeueResult = toRequeueResult(result)

if (requeueResult) {
if (attempt >= maxRetries) return result
const delayMs = Math.max(0, requeueResult.requeueAfterMs ?? defaultRequeueAfterMs)
await setTimeout(delayMs)
return await run(attempt + 1)
}

return result
} catch (error) {
onError?.(error)
const canRetry = attempt < maxRetries && (shouldRetry?.(error) ?? true)
if (!canRetry) throw error

await setTimeout(Math.max(0, defaultErrorRequeueAfterMs))
return await run(attempt + 1)
}
}

return await run(0)
}

export type TypedMethodDecorator = <T extends (this: any, ...args: any[]) => any>(
target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>,
) => void

export function Reconcile(options: ReconcileOptions = {}): TypedMethodDecorator {
return <T extends (this: any, ...args: any[]) => any>(
_target: object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>,
): void => {
const original = descriptor.value
if (!original) return

descriptor.value = (async function (this: ThisParameterType<T>, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
const logger: Logger = this?.logger instanceof Logger
? this.logger
: new Logger(this?.constructor?.name ?? 'Reconcile')

try {
return await reconcile(
() => original.apply(this, args),
options,
) as Awaited<ReturnType<T>>
} catch (error) {
logger.error(
`Handler ${String(propertyKey)} failed permanently`,
error instanceof Error ? error.stack : undefined,
)
throw error
}
}) as T
}
}

function toRequeueResult(value: unknown): RequeueResult | undefined {
if (value && typeof value === 'object' && 'requeueAfterMs' in value) {
const ms = (value as RequeueResult).requeueAfterMs
return ms === undefined || typeof ms === 'number' ? { requeueAfterMs: ms } : undefined
}
}
Loading