Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bb42249
refactor(cluster): migrate module from server
shikanime Aug 13, 2026
29e1757
fix(server-nestjs): register AdminTokenModule for downstream stacks
shikanime Sep 18, 2026
8099274
refactor(cluster): reuse shared cluster body type aliases in controller
shikanime Sep 18, 2026
381e4df
fix(server-nestjs): drop AdminTokenModule registration, owned by stac…
shikanime Sep 18, 2026
2287cf5
refactor(cluster): upstream named body schemas and apply review fixes
shikanime Sep 18, 2026
263f912
refactor(cluster): use shared ClusterUsage type and plain string ids
shikanime Sep 22, 2026
047b15b
fix(cluster): wire missing module imports
shikanime Sep 24, 2026
807cb65
fix(server-nestjs): provide EventEmitter2 to ClusterModule
shikanime Sep 24, 2026
ff92ce1
test(cluster): lock controller permission matrix
shikanime Sep 25, 2026
cae9d48
fix(server-nestjs): wire ClusterModule provider imports
shikanime Sep 24, 2026
18d7d05
fix(cluster): drop invalid guard metadata specs
shikanime Sep 29, 2026
eea46ba
refactor(events): move plugin-failure throws into AppEventsService
shikanime Sep 30, 2026
f37e5de
fix(argocd): align vault secret-id helper with renamed ensure method
shikanime Oct 1, 2026
662c7ef
refactor(events): return failed-event handling to calling services
shikanime Oct 1, 2026
b4c16d4
refactor(cluster): drop Query import aliases for query utils
shikanime Oct 1, 2026
265afea
refactor(cluster): drop Kubeconfig import alias
shikanime Oct 2, 2026
3fda1b4
refactor(cluster): move syncClusterStageLinks to queries utils
shikanime Oct 5, 2026
c0ed827
refactor(cluster): restore local plugin-failure throws per zone pattern
shikanime Oct 7, 2026
0b4cb4d
fix(server-nestjs): restore legacy argocd cluster config parity
shikanime Oct 7, 2026
bdce543
refactor(server-nestjs): move cluster project-link sync to cluster-qu…
shikanime Oct 8, 2026
fc9f44f
refactor(cluster): align cluster query names with authoring vocabulary
shikanime Oct 8, 2026
60e743c
fix(cluster): validate cluster route ids and dedupe makeCluster factory
shikanime Oct 8, 2026
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
4 changes: 2 additions & 2 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { ScheduleModule } from '@nestjs/schedule'
import { TerminusModule } from '@nestjs/terminus'
import { baseConfigFactory } from './config/base.config'
import { AdminRoleModule } from './modules/admin-role/admin-role.module'
import { AdminTokenModule } from './modules/admin-token/admin-token.module'
import { AuthModule } from './modules/auth/auth.module'
import { ClusterModule } from './modules/cluster/cluster.module'
import { DeploymentModule } from './modules/deployment/deployment.module'
import { EnvironmentModule } from './modules/environment/environment.module'
import { HealthzModule } from './modules/healthz/healthz.module'
Expand Down Expand Up @@ -38,8 +38,8 @@ import { getDotenvPaths } from './utils/dotenv.utils'
}),
TerminusModule.forRoot(),
AdminRoleModule,
AdminTokenModule,
AuthModule,
ClusterModule,
DeploymentModule,
EnvironmentModule,
HealthzModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,27 @@ export type ProjectWithDetails = Prisma.ProjectGetPayload<{
select: typeof projectSelect
}>

export const clusterSelect = {
label: true,
clusterResources: true,
kubeconfig: {
select: {
cluster: true,
user: true,
},
},
zone: {
select: {
id: true,
slug: true,
},
},
} satisfies Prisma.ClusterSelect

export type ClusterWithZone = Prisma.ClusterGetPayload<{
select: typeof clusterSelect
}>

@Injectable()
export class ArgoCDDatastoreService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
Expand All @@ -128,4 +149,27 @@ export class ArgoCDDatastoreService {
})
return zones.map(zone => zone.slug)
}

async getCluster(clusterId: string): Promise<ClusterWithZone | null> {
return this.prisma.cluster.findUnique({
where: { id: clusterId },
select: clusterSelect,
})
}

async getZoneClusterNames(zoneId: string): Promise<string[]> {
const zones = await this.prisma.zone.findUnique({
where: { id: zoneId },
select: { clusters: { select: { label: true } } },
})
return zones?.clusters.map(({ label }) => label) ?? []
}

async getZoneSlug(zoneId: string): Promise<string | null> {
const zone = await this.prisma.zone.findUnique({
where: { id: zoneId },
select: { slug: true },
})
return zone?.slug ?? null
}
}
81 changes: 80 additions & 1 deletion apps/server-nestjs/src/modules/argocd/argocd.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core'
import type { ConfigType } from '@nestjs/config'
import type { ClusterEventPayload } from '../events/app-events.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 { generateNamespaceName, inClusterLabel, KubeconfigSchema } from '@cpn-console/shared'
import { Inject, Injectable, Logger } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'
import { trace } from '@opentelemetry/api'
Expand All @@ -27,6 +28,7 @@ import {
PROJECT_READONLY_GROUP_PATH_SUFFIX,
PROJECT_SECURITY_GROUP_PATH_SUFFIX,
} from './argocd.constants'
import { generateClusterSecretData, generateZoneVaultValues } from './argocd.utils'

@Injectable()
export class ArgoCDService {
Expand Down Expand Up @@ -68,6 +70,83 @@ export class ArgoCDService {
return capturePluginResult('argocd', () => this.cleanupProject(project))
}

@OnEvent('cluster.upsert')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — Le corps de la PR (section « État lors de la bascule ») affirme encore que « server-nestjs n'a aucun @onevent('cluster.upsert') ni @onevent('cluster.delete') » et que l'émission sera un no-op à la bascule. C'est faux sur ce head : les listeners existent dans argocd.service.ts (lignes 73 et 100). Mettre à jour le corps — c'est la note qui décide si #2756 peut fusionner sans risque de no-op. Profiter pour corriger la route listée GET /:clusterId/usage → le contrat est /usage/:clusterId.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Le corps de la PR a été corrigé entre-temps : la section « État lors de la bascule » documente désormais les listeners @OnEvent('cluster.upsert'/'cluster.delete') d'argocd.service.ts et conclut à l'absence de no-op. Marqué à jour.

async handleClusterUpsert(payload: ClusterEventPayload): Promise<RequiredPluginResult<'argocd'>> {
return capturePluginResult('argocd', () => this.syncCluster(payload))
}

@StartActiveSpan()
private async syncCluster(payload: ClusterEventPayload) {
const cluster = await this.datastore.getCluster(payload.clusterId)
if (!cluster) throw new Error(`Cluster not found for event (clusterId=${payload.clusterId})`)

@shikanime shikanime Oct 7, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 throw new Error dans un handler d'événement : legacy renvoyait un KO plugin (« Cluster not found »), la nouvelle chaîne n'attrape que UnprocessableEntityException (via throwOnPluginFailure). Ici l'erreur traverse capturePluginResult et devient une exception brute côté handler — code divergent des autres chemins d'erreur.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constat retiré après relecture : ces handlers renvoient déjà capturePluginResult('argocd', …), qui capture le throw en résultat KO avec le message d'erreur ; emitClusterEventAndThrowOnFailure traduit ensuite le KO en 422. Le throw new Error interne est donc bien centralisé, aucune fuite hors pipeline.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vérifié : capturePluginResult attrape toute erreur (catch (error: unknown), plugin.utils.ts:78) et la replie en résultat KO — pas seulement UnprocessableEntityException. Le throw new Error('Cluster not found') produit donc bien un KO plugin avec message, comme le legacy. Pas de changement nécessaire.

const span = trace.getActiveSpan()
span?.setAttribute('cluster.label', cluster.label)
span?.setAttribute('zone.slug', cluster.zone.slug)
this.logger.log(`Handling a cluster upsert event for ${cluster.label}`)
const kubeconfig = KubeconfigSchema.parse(cluster.kubeconfig)
await this.vault.upsertKvData(
`zone-${cluster.zone.slug}`,
`clusters/cluster-${cluster.label}/argocd-cluster-secret`,
{ data: generateClusterSecretData(cluster, kubeconfig) },
)
await this.commitZoneValues(cluster.zone.slug)
if (payload.zoneId && payload.zoneId !== cluster.zone.id) {
const previousZoneSlug = await this.datastore.getZoneSlug(payload.zoneId)
if (previousZoneSlug) await this.commitZoneValues(previousZoneSlug)
}
this.logger.log(`ArgoCD cluster sync completed for ${cluster.label}`)
}

@OnEvent('cluster.delete')
async handleClusterDelete(payload: ClusterEventPayload): Promise<RequiredPluginResult<'argocd'>> {
return capturePluginResult('argocd', () => this.cleanupCluster(payload))
}

@StartActiveSpan()
private async cleanupCluster(payload: ClusterEventPayload) {
const cluster = await this.datastore.getCluster(payload.clusterId)
if (!cluster) throw new Error(`Cluster not found for event (clusterId=${payload.clusterId})`)
const span = trace.getActiveSpan()
span?.setAttribute('cluster.label', cluster.label)
span?.setAttribute('zone.slug', cluster.zone.slug)
this.logger.log(`Handling a cluster delete event for ${cluster.label}`)
await this.vault.deleteKvMetadata(
`zone-${cluster.zone.slug}`,
`clusters/cluster-${cluster.label}/argocd-cluster-secret`,
)
await this.commitZoneValues(cluster.zone.slug)
this.logger.log(`ArgoCD cluster cleanup completed for ${cluster.label}`)
}

private async commitZoneValues(zoneSlug: string) {
const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug)
const clusters = await this.datastore.getZoneClusterNames(zoneSlug)
const vaultValues = await this.generateZoneVaultValues(zoneSlug)
const action = await this.gitlab.generateCreateOrUpdateAction(
infraProject,
'main',
'argocd-values.yaml',
stringify({ vault: vaultValues, clusters }),
)
if (!action) {
this.logger.verbose(`Zone argocd-values.yaml is up to date (zone=${zoneSlug})`)
return
}
await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Update zone ${zoneSlug}`, [action])
}

private async generateZoneVaultValues(zoneSlug: string) {
const roleId = await this.vault.getAuthApproleRoleRoleId(`zone-${zoneSlug}`).catch(() => {
this.logger.warn(`Couldn't find zone app role (zone=${zoneSlug})`)
return undefined
})
const secretId = await this.vault.ensureAuthApproleRoleSecretId(`zone-${zoneSlug}`).catch(() => {
this.logger.warn(`Couldn't generate zone app role secret (zone=${zoneSlug})`)
return undefined
})
return generateZoneVaultValues(this.vaultConfig.url, zoneSlug, roleId, secretId)
}

@StartActiveSpan()
private async cleanupProject(project: ProjectWithDetails) {
const span = trace.getActiveSpan()
Expand Down
35 changes: 35 additions & 0 deletions apps/server-nestjs/src/modules/argocd/argocd.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Kubeconfig } from '@cpn-console/shared'
import { stringify } from 'yaml'

export function generateClusterTlsClientConfig(kubeconfig: Kubeconfig) {
return {
...kubeconfig.user.username && { username: kubeconfig.user.username },
...kubeconfig.user.password && { password: kubeconfig.user.password },
...kubeconfig.user.token && { bearerToken: kubeconfig.user.token },
tlsClientConfig: {
...kubeconfig.user.keyData && { keyData: kubeconfig.user.keyData },
...kubeconfig.user.certData && { certData: kubeconfig.user.certData },
...kubeconfig.cluster.caData && !kubeconfig.cluster.skipTLSVerify && { caData: kubeconfig.cluster.caData },
...kubeconfig.cluster.skipTLSVerify && { insecure: kubeconfig.cluster.skipTLSVerify },
serverName: kubeconfig.cluster.tlsServerName,
},
}
}

export function generateZoneVaultValues(vaultUrl: string, zoneSlug: string, roleId: string | undefined, secretId: string | undefined) {
return {
url: vaultUrl,
kvName: `zone-${zoneSlug}`,
roleId: roleId ?? 'none',
secretId: secretId ?? 'none',
}
}

export function generateClusterSecretData(cluster: { label: string, clusterResources: boolean }, kubeconfig: Kubeconfig) {
return {
name: cluster.label,
clusterResources: String(cluster.clusterResources),
server: kubeconfig.cluster.server,
config: stringify(generateClusterTlsClientConfig(kubeconfig)),
}
}
Loading
Loading