Skip to content

Commit e92b234

Browse files
committed
refactor(argocd): migrate ArgoCD to NestJS
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent 016e552 commit e92b234

13 files changed

Lines changed: 916 additions & 2 deletions

File tree

apps/server-nestjs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"dotenv": "^16.4.7",
5555
"fastify": "^4.29.1",
5656
"fastify-keycloak-adapter": "2.3.2",
57+
"js-yaml": "^4.1.1",
5758
"json-2-csv": "^5.5.7",
5859
"keycloak-connect": "^25.0.0",
5960
"mustache": "^4.2.0",
@@ -78,6 +79,7 @@
7879
"@nestjs/testing": "^11.0.1",
7980
"@types/express": "^5.0.0",
8081
"@types/jest": "^30.0.0",
82+
"@types/js-yaml": "4.0.9",
8183
"@types/node": "^22.10.7",
8284
"@types/supertest": "^6.0.2",
8385
"@vitest/coverage-v8": "^2.1.8",

apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export class ConfigurationService {
3434
= process.env.CONTACT_EMAIL
3535
?? 'cloudpinative-relations@interieur.gouv.fr'
3636

37+
// argocd
38+
argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd'
39+
argocdUrl = process.env.ARGOCD_URL
40+
argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES
41+
42+
// dso
43+
dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0'
44+
dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5'
45+
3746
// plugins
3847
mockPlugins = process.env.MOCK_PLUGINS === 'true'
3948
projectRootDir = process.env.PROJECTS_ROOT_DIR

apps/server-nestjs/src/main.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { ScheduleModule } from '@nestjs/schedule'
55
import { CpinModule } from './cpin-module/cpin.module'
66
import { IamModule } from './modules/iam/iam.module'
77
import { KeycloakModule } from './modules/keycloak/keycloak.module'
8+
import { ArgoCDModule } from './modules/argocd/argocd.module'
9+
import { GitlabModule } from './modules/gitlab/gitlab.module'
10+
import { VaultModule } from './modules/vault/vault.module'
811

912
// This module only exists to import other module.
1013
// « One module to rule them all, and in NestJs bind them »
@@ -13,6 +16,9 @@ import { KeycloakModule } from './modules/keycloak/keycloak.module'
1316
CpinModule,
1417
IamModule,
1518
KeycloakModule,
19+
ArgoCDModule,
20+
GitlabModule,
21+
VaultModule,
1622
EventEmitterModule.forRoot(),
1723
ScheduleModule.forRoot(),
1824
],
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest'
2+
import type { Mocked } from 'vitest'
3+
import { load } from 'js-yaml'
4+
import { ArgoCDControllerService } from './argocd-controller.service'
5+
import type { ArgoCDDatastoreService, ProjectWithDetails } from './argocd-datastore.service'
6+
import type { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service'
7+
import type { GitlabService } from '../gitlab/gitlab.service'
8+
import type { VaultService } from '../vault/vault.service'
9+
10+
const mockArgoCDDatastore = {
11+
getAllProjects: vi.fn(),
12+
} as unknown as Mocked<ArgoCDDatastoreService>
13+
14+
const mockConfigService = {
15+
keycloakControllerPurgeOrphans: true,
16+
argoNamespace: 'argocd',
17+
argocdUrl: 'http://argocd',
18+
argocdExtraRepositories: 'repo3',
19+
dsoEnvChartVersion: 'dso-env-1.6.0',
20+
dsoNsChartVersion: 'dso-ns-1.1.5',
21+
} as unknown as Mocked<ConfigurationService>
22+
23+
const mockGitlabService = {
24+
getOrCreateInfraProject: vi.fn(),
25+
getPublicGroupUrl: vi.fn(),
26+
getPublicRepoUrl: vi.fn(),
27+
commitCreateOrUpdate: vi.fn(),
28+
commitDelete: vi.fn(),
29+
listFiles: vi.fn(),
30+
} as unknown as Mocked<GitlabService>
31+
32+
const mockVaultService = {
33+
getProjectValues: vi.fn(),
34+
} as unknown as Mocked<VaultService>
35+
36+
describe('argoCDControllerService', () => {
37+
let service: ArgoCDControllerService
38+
let datastore: Mocked<ArgoCDDatastoreService>
39+
let gitlabService: Mocked<GitlabService>
40+
let vaultService: Mocked<VaultService>
41+
42+
beforeEach(() => {
43+
service = new ArgoCDControllerService(
44+
mockArgoCDDatastore,
45+
mockConfigService,
46+
mockGitlabService,
47+
mockVaultService,
48+
)
49+
datastore = mockArgoCDDatastore
50+
gitlabService = mockGitlabService
51+
vaultService = mockVaultService
52+
vi.clearAllMocks()
53+
})
54+
55+
it('should be defined', () => {
56+
expect(service).toBeDefined()
57+
})
58+
59+
describe('reconcile', () => {
60+
it('should sync project environments', async () => {
61+
const mockProject = {
62+
id: '123e4567-e89b-12d3-a456-426614174000',
63+
slug: 'project-1',
64+
name: 'Project 1',
65+
environments: [
66+
{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true },
67+
{ id: '123e4567-e89b-12d3-a456-426614174002', name: 'prod', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true },
68+
],
69+
clusters: [
70+
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
71+
],
72+
repositories: [
73+
{
74+
id: 'repo-1',
75+
internalRepoName: 'infra-repo',
76+
url: 'http://gitlab/infra-repo',
77+
isInfra: true,
78+
},
79+
],
80+
plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }],
81+
} as unknown as ProjectWithDetails
82+
83+
datastore.getAllProjects.mockResolvedValue([mockProject])
84+
gitlabService.getOrCreateInfraProject.mockResolvedValue({ id: 100, http_url_to_repo: 'http://gitlab/infra' })
85+
gitlabService.getPublicGroupUrl.mockResolvedValue('http://gitlab/group')
86+
gitlabService.getPublicRepoUrl.mockResolvedValue('http://gitlab/infra-repo')
87+
gitlabService.listFiles.mockResolvedValue([])
88+
vaultService.getProjectValues.mockResolvedValue({ secret: 'value' })
89+
90+
const results = await (service as any).reconcile()
91+
92+
expect(results).toHaveLength(3) // 2 envs + 1 cleanup (1 zone)
93+
94+
// Verify Gitlab calls
95+
expect(gitlabService.commitCreateOrUpdate).toHaveBeenCalledTimes(2)
96+
97+
const calls = gitlabService.commitCreateOrUpdate.mock.calls
98+
const devCall = calls.find(c => c[2] === 'Project 1/cluster-1/dev/values.yaml')
99+
expect(devCall).toBeDefined()
100+
101+
const content = load(devCall![1]) as any
102+
expect(content).toMatchObject({
103+
common: {
104+
'dso/project': 'Project 1',
105+
'dso/project.slug': 'project-1',
106+
'dso/environment': 'dev',
107+
},
108+
argocd: {
109+
namespace: 'argocd',
110+
project: expect.stringMatching(/^project-1-dev-[a-f0-9]{4}$/),
111+
},
112+
environment: {
113+
valueFileRepository: 'http://gitlab/infra',
114+
valueFilePath: 'Project 1/cluster-1/dev/values.yaml',
115+
roGroup: '/project-project-1/console/dev/RO',
116+
rwGroup: '/project-project-1/console/dev/RW',
117+
},
118+
application: {
119+
quota: {
120+
cpu: 1,
121+
gpu: 0,
122+
memory: '1Gi',
123+
},
124+
sourceRepositories: expect.arrayContaining([
125+
expect.stringContaining('repo3'),
126+
expect.stringContaining('repo2'),
127+
expect.stringContaining('http://gitlab/group'),
128+
]),
129+
destination: {
130+
namespace: expect.any(String),
131+
name: 'cluster-1',
132+
},
133+
autosync: true,
134+
vault: { secret: 'value' },
135+
repositories: [
136+
{
137+
repoURL: 'http://gitlab/infra-repo',
138+
targetRevision: 'HEAD',
139+
path: '.',
140+
valueFiles: [],
141+
},
142+
],
143+
},
144+
})
145+
})
146+
147+
it('should handle errors gracefully', async () => {
148+
const mockProject = {
149+
id: '123e4567-e89b-12d3-a456-426614174000',
150+
slug: 'project-1',
151+
name: 'Project 1',
152+
environments: [{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }],
153+
clusters: [
154+
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
155+
],
156+
} as unknown as ProjectWithDetails
157+
158+
datastore.getAllProjects.mockResolvedValue([mockProject])
159+
gitlabService.getOrCreateInfraProject.mockRejectedValue(new Error('Sync failed'))
160+
161+
const results = await (service as any).reconcile()
162+
163+
// 1 env (fails) + 1 cleanup (fails because getOrCreateInfraProject fails)
164+
expect(results).toHaveLength(2)
165+
const failed = results.filter((r: any) => r.status === 'rejected')
166+
expect(failed).toHaveLength(2)
167+
})
168+
})
169+
})
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import type { OnModuleInit } from '@nestjs/common'
2+
import { Injectable, Logger } from '@nestjs/common'
3+
import { OnEvent } from '@nestjs/event-emitter'
4+
import { Cron, CronExpression } from '@nestjs/schedule'
5+
import { dump } from 'js-yaml'
6+
7+
import type { ArgoCDDatastoreService, ProjectWithDetails } from './argocd-datastore.service'
8+
import type { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service'
9+
import type { GitlabService } from '../gitlab/gitlab.service'
10+
import type { VaultService } from '../vault/vault.service'
11+
import {
12+
formatEnvironmentValuesFilePath,
13+
formatValues,
14+
getDistinctZones,
15+
} from './argocd.utils'
16+
17+
@Injectable()
18+
export class ArgoCDControllerService implements OnModuleInit {
19+
private readonly logger = new Logger(ArgoCDControllerService.name)
20+
21+
constructor(
22+
private readonly argoCDDatastore: ArgoCDDatastoreService,
23+
private readonly configService: ConfigurationService,
24+
private readonly gitlabService: GitlabService,
25+
private readonly vaultService: VaultService,
26+
) {
27+
this.logger.log('ArgoCDControllerService initialized')
28+
}
29+
30+
onModuleInit() {
31+
this.handleCron()
32+
}
33+
34+
@OnEvent('project.upsert')
35+
async handleUpsert(project: ProjectWithDetails) {
36+
this.logger.log(`Handling project upsert for ${project.slug}`)
37+
return this.reconcile()
38+
}
39+
40+
@OnEvent('project.delete')
41+
async handleDelete(project: ProjectWithDetails) {
42+
this.logger.log(`Handling project delete for ${project.slug}`)
43+
return this.reconcile()
44+
}
45+
46+
@Cron(CronExpression.EVERY_HOUR)
47+
async handleCron() {
48+
this.logger.log('Starting ArgoCD reconciliation')
49+
await this.reconcile()
50+
}
51+
52+
private async reconcile() {
53+
const projects = await this.argoCDDatastore.getAllProjects()
54+
const results: PromiseSettledResult<void>[] = []
55+
56+
const projectResults = await Promise.all(projects.map(async (project) => {
57+
const pResults: PromiseSettledResult<void>[] = []
58+
59+
const ensureResults = await Promise.allSettled(
60+
project.environments.map(env => this.ensureValues(project, env)),
61+
)
62+
pResults.push(...ensureResults)
63+
64+
const cleanupResults = await this.cleanupStaleValues(project)
65+
pResults.push(...cleanupResults)
66+
67+
return pResults
68+
}))
69+
70+
results.push(...projectResults.flat())
71+
72+
results.forEach((result) => {
73+
if (result.status === 'rejected') {
74+
this.logger.error(`Reconciliation failed: ${result.reason}`)
75+
}
76+
})
77+
78+
return results
79+
}
80+
81+
private async cleanupStaleValues(project: ProjectWithDetails) {
82+
const zones = getDistinctZones(project)
83+
return Promise.allSettled(zones.map(async (zoneSlug) => {
84+
const infraProject = await this.gitlabService.getOrCreateInfraProject(zoneSlug)
85+
const existingFiles = await this.gitlabService.listFiles(infraProject.id, {
86+
path: `${project.name}/`,
87+
recursive: true,
88+
})
89+
90+
const neededFiles = project.environments
91+
.filter((env) => {
92+
const cluster = project.clusters.find(c => c.id === env.clusterId)
93+
return cluster?.zone.slug === zoneSlug
94+
})
95+
.map((env) => {
96+
const cluster = project.clusters.find(c => c.id === env.clusterId)!
97+
return formatEnvironmentValuesFilePath(project, cluster, env)
98+
})
99+
100+
const filesToDelete: string[] = []
101+
for (const existingFile of existingFiles) {
102+
if (
103+
existingFile.name === 'values.yaml'
104+
&& !neededFiles.includes(existingFile.path)
105+
) {
106+
filesToDelete.push(existingFile.path)
107+
}
108+
}
109+
110+
if (filesToDelete.length > 0) {
111+
await this.gitlabService.commitDelete(infraProject.id, filesToDelete)
112+
}
113+
}))
114+
}
115+
116+
async ensureValues(
117+
project: ProjectWithDetails,
118+
environment: ProjectWithDetails['environments'][number],
119+
) {
120+
const vaultValues = await this.vaultService.getProjectValues(project.id)
121+
const cluster = project.clusters.find(c => c.id === environment.clusterId)
122+
if (!cluster) throw new Error(`Cluster not found for environment ${environment.id}`)
123+
124+
const infraProject = await this.gitlabService.getOrCreateInfraProject(cluster.zone.slug)
125+
const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment)
126+
127+
const repo = project.repositories.find(r => r.isInfra)
128+
if (!repo) throw new Error(`Infra repository not found for project ${project.id}`)
129+
const repoUrl = await this.gitlabService.getPublicRepoUrl(repo.internalRepoName)
130+
131+
const values = formatValues({
132+
project,
133+
environment,
134+
cluster,
135+
gitlabPublicGroupUrl: await this.gitlabService.getPublicGroupUrl(),
136+
argocdExtraRepositories: this.configService.argocdExtraRepositories,
137+
infraProject,
138+
valueFilePath,
139+
repoUrl,
140+
vaultValues,
141+
argoNamespace: this.configService.argoNamespace,
142+
envChartVersion: this.configService.dsoEnvChartVersion,
143+
nsChartVersion: this.configService.dsoNsChartVersion,
144+
})
145+
146+
await this.gitlabService.commitCreateOrUpdate(infraProject.id, dump(values), valueFilePath)
147+
}
148+
}

0 commit comments

Comments
 (0)