You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
🐛 [BUG] - Un projet dont la suppression externe échoue reste verrouillé en <slug>_<horodatage>_archived sans voie de récupération #2813
L'archivage d'un projet dont la suppression échoue sur un service externe laisse le projet dans un état irrécupérable : renommé <nom>_<horodatage>_archived, locked: true, sans aucune voie API pour relancer la suppression ou le déverrouiller.
La transaction en base et la suppression des ressources externes ne sont pas atomiques. La transaction — renommage, status: archived, locked: true — est committée avant l'émission de l'événement project.delete vers les listeners externes (Keycloak, GitLab, Nexus, Vault, Sonarqube, Registry, ArgoCD, observabilité). Un échec du listener ne peut plus annuler le verrouillage.
flowchart TD
A[DELETE /projects/:id] --> B[Transaction: renommage _archived, locked=true]
B --> C[commit]
C --> D[emit project.delete vers les listeners externes]
D -- OK --> E[statut archived]
D -- KO --> F[statut failed ou erreur 500]
F --> G[projet verrouillé, aucune voie de récupération]
Loading
Étapes de reproduction
Disposer d'un projet dont une ressource existe sur un service externe en erreur — par exemple Keycloak injoignable.
Archiver le projet via DELETE /projects/{projectId}.
Constater que le projet est renommé <nom>_<horodatage>_archived, locked: true, statut archived ou failed selon la nature de l'échec.
Tenter une récupération : la mise à jour du projet renvoie 404 — Projet introuvable, le rejeu des webhooks est refusé — statut bloqué par le garde si le projet reste archived, verrouillage bloqué par le garde s'il est passé à failed — un second archivage renvoie 400, et aucun endpoint ne réémet project.delete.
Comportement attendu
Un archivage dont la suppression externe a échoué doit rester récupérable : un administrateur doit pouvoir relancer la suppression des ressources externes ou déverrouiller le projet pour reprendre le nettoyage, sans intervention manuelle en base de données.
Logs ou journal
Aucune pièce jointe au signalement ; l'analyse de cause racine est postée en commentaire.
Environnement
server-nestjs, main courant.
Définition du fini
Le correctif est terminé
Un test simule l'échec d'un listener project.delete pendant l'archivage et vérifie que le projet est récupérable sans intervention manuelle en base
pnpm --filter server-nestjs test project passe avec ce test
constproject=awaitthis.prisma.$transaction(async(tx)=>{constloaded=awaitgetProject(tx,projectId)if(!loaded)thrownewNotFoundException('Projet introuvable')if(loaded.status==='archived')thrownewBadRequestException('Le projet est archivé')awaitdeleteProjectDependencies(tx,projectId)constarchivedSuffix=`${Date.now()}_archived`awaitupdateProject(tx,projectId,{name: `${loaded.name}_${archivedSuffix}`,slug: `${loaded.slug}_${archivedSuffix}`,status: 'archived',locked: true,clusters: {set: []},})returnloaded})
L'émission project.delete vers les huit listeners externes — keycloak, gitlab, nexus, vault, sonarqube, registry, argocd, observabilité — se fait après le commit (project.service.ts:212). Deux issues en découlent :
Quand un listener échoue via capturePluginResult, AppEventsService.updateProjectStatus (apps/server-nestjs/src/modules/events/app-events.service.ts:149) bascule le statut à failed mais ne touche ni à locked ni au suffixe _archived déjà posé.
Quand un listener lève hors capturePluginResult, emitProjectEvent propage l'exception, archive la re-jette, la requête finit en 500 — et la ligne reste archived + locked: true, le renommage étant déjà committé. C'est l'état décrit dans le signalement.
Chaque route API est fermée par un garde pour un projet en {slug}_{timestamp}_archived + locked: true :
PUT /api/v1/projects/:id : getNotArchivedProjectForUpdate exclut les projets archived → 404 Projet introuvable.
PUT /api/v1/projects/:id/hooks (rejeu) : @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) (apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts:22-23), et ProjectHooksService.replay refuse en plus archived et locked (project-hooks.service.ts:29-37). Le rejeu réémet de toute façon project.upsert, pas project.delete — il re-provisionnerait au lieu de nettoyer.
Second DELETE : loaded.status === 'archived' → 400 Le projet est archivé.
Action en lot lock/unlock : passe par updateProjectLocked sans garde de statut, mais après déverrouillage rien ne réémet project.delete.
Cas intermédiaire : quand le statut a basculé à failed, un administrateur peut théoriquement déverrouiller via PUT avec locked: false, mais le projet reste renommé _archived, clusters vidé, sans aucune route pour relancer la suppression externe.
Conclusion : la seule récupération actuelle est un UPDATE manuel en base de données. Le correctif doit offrir une voie supportée — par exemple réémettre project.delete pour un projet archived en erreur, ou ne pas verrouiller avant le succès des listeners — la décision de conception appartient à la revue.
Activity
shikanime commentedon Oct 5, 2026
Cause racine
L'archivage commute la transaction base avant d'appeler les services externes, et l'échec externe ne peut plus défaire le verrouillage.
ProjectService.archive(apps/server-nestjs/src/modules/project/project.service.ts:187) :L'émission
project.deletevers les huit listeners externes — keycloak, gitlab, nexus, vault, sonarqube, registry, argocd, observabilité — se fait après le commit (project.service.ts:212). Deux issues en découlent :capturePluginResult,AppEventsService.updateProjectStatus(apps/server-nestjs/src/modules/events/app-events.service.ts:149) bascule le statut àfailedmais ne touche ni àlockedni au suffixe_archiveddéjà posé.capturePluginResult,emitProjectEventpropage l'exception,archivela re-jette, la requête finit en 500 — et la ligne restearchived+locked: true, le renommage étant déjà committé. C'est l'état décrit dans le signalement.shikanime commentedon Oct 5, 2026
Voies de récupération épuisées
Chaque route API est fermée par un garde pour un projet en
{slug}_{timestamp}_archived+locked: true:PUT /api/v1/projects/:id:getNotArchivedProjectForUpdateexclut les projetsarchived→ 404Projet introuvable.PUT /api/v1/projects/:id/hooks(rejeu) :@RequireProjectStatus('initializing', 'created', 'failed', 'warning')+@RequireProjectLocked(false)(apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts:22-23), etProjectHooksService.replayrefuse en plusarchivedetlocked(project-hooks.service.ts:29-37). Le rejeu réémet de toute façonproject.upsert, pasproject.delete— il re-provisionnerait au lieu de nettoyer.DELETE:loaded.status === 'archived'→ 400Le projet est archivé.lock/unlock: passe parupdateProjectLockedsans garde de statut, mais après déverrouillage rien ne réémetproject.delete.Cas intermédiaire : quand le statut a basculé à
failed, un administrateur peut théoriquement déverrouiller viaPUTaveclocked: false, mais le projet reste renommé_archived,clustersvidé, sans aucune route pour relancer la suppression externe.Conclusion : la seule récupération actuelle est un UPDATE manuel en base de données. Le correctif doit offrir une voie supportée — par exemple réémettre
project.deletepour un projetarchiveden erreur, ou ne pas verrouiller avant le succès des listeners — la décision de conception appartient à la revue.fix(server-nestjs): fiabiliser la suppression concurrente et le statu…
fix(server-nestjs): fiabiliser la suppression concurrente et le statu…
fix(project): piloter la suppression externe avant d'archiver la ligne
fix(server-nestjs): fiabiliser la suppression concurrente et le statu…
fix(project): piloter la suppression externe avant d'archiver la ligne
fix(server-nestjs): fiabiliser la suppression concurrente et le statu…
fix(project): piloter la suppression externe avant d'archiver la ligne
fix(server-nestjs): fiabiliser la suppression concurrente et le statu…
fix(project): piloter la suppression externe avant d'archiver la ligne
fix(server-nestjs): fiabiliser la suppression concurrente et le statu…
fix(project): piloter la suppression externe avant d'archiver la ligne
fix(server-nestjs): fiabiliser la suppression concurrente et le statu…