Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0bdbb5a
refactor(cluster): migrate module from server
shikanime Oct 21, 2026
70ec0ce
fix(server-nestjs): register AdminTokenModule for downstream stacks
shikanime Oct 21, 2026
0261a15
refactor(cluster): reuse shared cluster body type aliases in controller
shikanime Oct 21, 2026
c57102b
fix(server-nestjs): drop AdminTokenModule registration, owned by stac…
shikanime Oct 21, 2026
8c56224
refactor(cluster): upstream named body schemas and apply review fixes
shikanime Oct 21, 2026
c262f5b
refactor(cluster): use shared ClusterUsage type and plain string ids
shikanime Oct 21, 2026
6377b16
fix(cluster): wire missing module imports
shikanime Oct 21, 2026
7bfc3c8
fix(server-nestjs): provide EventEmitter2 to ClusterModule
shikanime Oct 21, 2026
a9cd258
test(cluster): lock controller permission matrix
shikanime Oct 21, 2026
a520733
fix(server-nestjs): wire ClusterModule provider imports
shikanime Oct 21, 2026
7f071e1
fix(cluster): drop invalid guard metadata specs
shikanime Oct 21, 2026
f76bcb4
refactor(events): move plugin-failure throws into AppEventsService
shikanime Oct 21, 2026
796e1dd
fix(argocd): align vault secret-id helper with renamed ensure method
shikanime Oct 21, 2026
a6352f9
refactor(events): return failed-event handling to calling services
shikanime Oct 21, 2026
ebd0f99
refactor(cluster): drop Query import aliases for query utils
shikanime Oct 21, 2026
bddd915
refactor(cluster): drop Kubeconfig import alias
shikanime Oct 21, 2026
2c86e1a
refactor(cluster): move syncClusterStageLinks to queries utils
shikanime Oct 21, 2026
29e6b92
refactor(cluster): restore local plugin-failure throws per zone pattern
shikanime Oct 21, 2026
f2d463c
fix(server-nestjs): restore legacy argocd cluster config parity
shikanime Oct 21, 2026
575bbf5
refactor(server-nestjs): move cluster project-link sync to cluster-qu…
shikanime Oct 21, 2026
8bdda44
fix(cluster): keep makeCluster factory module-local
shikanime Oct 21, 2026
dc44375
fix(cluster): port argocd handlers onto the renamed client methods
shikanime Oct 21, 2026
1a244ca
fix(server-nestjs): keep AdminTokenModule registered alongside Cluste…
shikanime Oct 21, 2026
a877d9d
fix(cluster): validate the PUT clusterId and preserve dedicated proje…
shikanime Oct 21, 2026
1e06cca
fix(argocd): restore legacy cluster-sync parity on delete and secret …
shikanime Oct 21, 2026
b6a8add
fix(cluster): serve the contract 204 status on cluster deletion
shikanime Oct 21, 2026
6033fcb
fix(argocd): fail cluster sync when zone vault credentials are unavai…
shikanime Oct 21, 2026
83bdc69
style(events): drop extra blank line in app-events.service
shikanime Oct 21, 2026
d60a3b6
refactor(argocd,cluster): dedupe factory, inline privacy enum, compos…
shikanime Oct 21, 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: 3 additions & 1 deletion apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 All @@ -24,8 +25,8 @@ import { RepositoryModule } from './modules/repository/repository.module'
import { StageModule } from './modules/stage/stage.module'
import { SystemConfigModule } from './modules/system-config/system-config.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { UserModule } from './modules/user/user.module'
import { UserTokensModule } from './modules/user-tokens/user-tokens.module'
import { UserModule } from './modules/user/user.module'
import { VersionModule } from './modules/version/version.module'
import { ZoneModule } from './modules/zone/zone.module'
import { getDotenvPaths } from './utils/dotenv.utils'
Expand All @@ -41,6 +42,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
AdminRoleModule,
AdminTokenModule,
AuthModule,
ClusterModule,
Comment thread
shikanime marked this conversation as resolved.
DeploymentModule,
EnvironmentModule,
HealthzModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { PrismaService } from '../infrastructure/database/prisma.service'
import { makeZone } from '../zone/zone-testing.utils'
import { ArgoCDDatastoreService } from './argocd-datastore.service'

function makeZoneWithClusters(labels: string[]) {
return { ...makeZone(), clusters: labels.map(label => ({ label })) }
}

describe('argoCDDatastoreService', () => {
let service: ArgoCDDatastoreService
let prisma: DeepMockProxy<PrismaService>

beforeEach(async () => {
prisma = mockDeep<PrismaService>()

const module: TestingModule = await Test.createTestingModule({
providers: [
ArgoCDDatastoreService,
{ provide: PrismaService, useValue: prisma },
],
}).compile()

service = module.get(ArgoCDDatastoreService)
})

describe('getZoneClusterNames', () => {
it('queries the zone by slug', async () => {
prisma.zone.findUnique.mockResolvedValue(makeZoneWithClusters(['review-cluster']))

await service.getZoneClusterNames('zone-review')

expect(prisma.zone.findUnique).toHaveBeenCalledWith({
where: { slug: 'zone-review' },
select: { clusters: { select: { label: true } } },
})
})

it('excludes the deleted cluster label', async () => {
prisma.zone.findUnique.mockResolvedValue(makeZoneWithClusters(['review-cluster', 'other-cluster']))

await expect(service.getZoneClusterNames('zone-review', 'review-cluster')).resolves.toEqual(['other-cluster'])
})

it('returns no names when the zone is unknown', async () => {
prisma.zone.findUnique.mockResolvedValue(null)

await expect(service.getZoneClusterNames('missing-zone')).resolves.toEqual([])
})
})
})
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,29 @@ 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,
})
}

// Runs while the cluster row still exists (the service deletes it only after
// this listener succeeds), so it must be excluded explicitly.
async getZoneClusterNames(zoneSlug: string, excludedClusterLabel?: string): Promise<string[]> {
const zones = await this.prisma.zone.findUnique({
where: { slug: zoneSlug },
select: { clusters: { select: { label: true } } },
})
return zones?.clusters.map(({ label }) => label).filter(label => label !== excludedClusterLabel) ?? []
}

async getZoneSlug(zoneId: string): Promise<string | null> {
const zone = await this.prisma.zone.findUnique({
where: { id: zoneId },
select: { slug: true },
})
return zone?.slug ?? null
}
}
14 changes: 12 additions & 2 deletions apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import type { ProjectWithDetails } from './argocd-datastore.service'

import type { ClusterWithZone, ProjectWithDetails } from './argocd-datastore.service'
import { faker } from '@faker-js/faker'
import { makeKubeconfig } from '../cluster/cluster-testing.utils'

export function makeClusterWithZone(overrides: Partial<ClusterWithZone> = {}): ClusterWithZone {
return {
label: 'review-cluster',
clusterResources: true,
kubeconfig: makeKubeconfig(),
zone: { id: faker.string.uuid(), slug: 'zone-review' },
...overrides,
}
}

export function makeProjectDeploymentSource(
overrides: Partial<ProjectWithDetails['deployments'][number]['deploymentSources'][number]> = {},
Expand Down
49 changes: 49 additions & 0 deletions apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ConfigType } from '@nestjs/config'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { generateNamespaceName } from '@cpn-console/shared'
import { faker } from '@faker-js/faker'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
Expand All @@ -13,6 +14,7 @@ import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '.
import { VaultClientService } from '../vault/vault-client.service'
import { ArgoCDDatastoreService } from './argocd-datastore.service'
import {
makeClusterWithZone,
makeProjectDeployment,
makeProjectDeploymentSource,
makeProjectEnvironment,
Expand All @@ -22,6 +24,7 @@ import {
makeProjectWithDetails,
} from './argocd-testing.utils'
import { ArgoCDService } from './argocd.service'
import { generateClusterSecretData } from './argocd.utils'

describe('argoCDService', () => {
let service: ArgoCDService
Expand Down Expand Up @@ -855,4 +858,50 @@ describe('argoCDService', () => {
'repo2',
])
})

describe('cluster events', () => {
it('serializes the argocd cluster secret config field as JSON', () => {
const secret = generateClusterSecretData(
{ label: 'review-cluster', clusterResources: true },
{ cluster: { tlsServerName: 'example.com' }, user: { token: 'tok' } },
)

const config = JSON.parse(secret.config)
expect(config.bearerToken).toBe('tok')
expect(config.tlsClientConfig.serverName).toBe('example.com')
})

it('passes the zone slug and excludes the deleted cluster label from the values', async () => {
datastore.getCluster.mockResolvedValue(makeClusterWithZone())
datastore.getZoneClusterNames.mockResolvedValue(['review-cluster'])
datastore.getZoneSlug.mockResolvedValue(null)
gitlab.ensureInfraGroupRepo.mockResolvedValue(makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }))
gitlab.generateCreateOrUpdateAction.mockResolvedValue(null)

const result = await service.handleClusterDelete({ clusterId: faker.string.uuid() })

expect(result.argocd?.status).toBe('OK')
expect(datastore.getZoneClusterNames).toHaveBeenCalledWith('zone-review', 'review-cluster')
})

it.each<[field: 'role-id' | 'secret-id']>([
['role-id'],
['secret-id'],
])('does not commit zone values when the %s cannot be fetched', async (field) => {
datastore.getCluster.mockResolvedValue(makeClusterWithZone())
if (field === 'role-id') {
vault.getAuthApproleRoleRoleId.mockRejectedValue(new Error('missing'))
vault.ensureAuthApproleRoleSecretId.mockResolvedValue('valid-secret')
} else {
vault.getAuthApproleRoleRoleId.mockResolvedValue('valid-role')
vault.ensureAuthApproleRoleSecretId.mockRejectedValue(new Error('missing'))
}

const result = await service.handleClusterUpsert({ clusterId: faker.string.uuid() })

expect(result.argocd?.status).toBe('KO')
expect(result.argocd?.message).toBe('missing')
expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled()
})
})
})
79 changes: 78 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,81 @@ export class ArgoCDService {
return capturePluginResult('argocd', () => this.cleanupProject(project))
}

@OnEvent('cluster.upsert')
Comment thread
shikanime marked this conversation as resolved.
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})`)
Comment thread
shikanime marked this conversation as resolved.
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.ensureKvData(
`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, cluster.label)
this.logger.log(`ArgoCD cluster cleanup completed for ${cluster.label}`)
}

private async commitZoneValues(zoneSlug: string, excludedClusterLabel?: string) {
const infraProject = await this.gitlab.ensureInfraGroupRepo(zoneSlug)
const clusters = await this.datastore.getZoneClusterNames(zoneSlug, excludedClusterLabel)
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) {
// Propagate failures instead of falling back to `none`: AppRole is required
// to read cluster secrets (#1833), and committing placeholder credentials
// would overwrite the functional ones for the whole zone. Legacy parity:
// the error reaches the ArgoCD hook catch, which returns KO (→ API 422).
const roleId = await this.vault.getAuthApproleRoleRoleId(`zone-${zoneSlug}`)
const secretId = await this.vault.ensureAuthApproleRoleSecretId(`zone-${zoneSlug}`)
return generateZoneVaultValues(this.vaultConfig.url, zoneSlug, roleId, secretId)
}

@StartActiveSpan()
private async cleanupProject(project: ProjectWithDetails) {
const span = trace.getActiveSpan()
Expand Down
34 changes: 34 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,34 @@
import type { Kubeconfig } from '@cpn-console/shared'

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: JSON.stringify(generateClusterTlsClientConfig(kubeconfig)),
}
}
Loading
Loading