Skip to content

Commit f2a2d43

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

22 files changed

Lines changed: 2580 additions & 94 deletions

apps/server-nestjs/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@fastify/swagger": "^8.15.0",
3737
"@fastify/swagger-ui": "^4.2.0",
3838
"@gitbeaker/core": "^40.6.0",
39+
"@gitbeaker/requester-utils": "^40.6.0",
3940
"@gitbeaker/rest": "^40.6.0",
4041
"@keycloak/keycloak-admin-client": "^24.0.0",
4142
"@kubernetes-models/argo-cd": "^2.6.2",
@@ -49,11 +50,11 @@
4950
"@ts-rest/core": "^3.52.1",
5051
"@ts-rest/fastify": "^3.52.1",
5152
"@ts-rest/open-api": "^3.52.1",
52-
"axios": "1.12.2",
5353
"date-fns": "^4.1.0",
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",
@@ -64,7 +65,8 @@
6465
"reflect-metadata": "^0.2.2",
6566
"rxjs": "^7.8.1",
6667
"undici": "^7.1.0",
67-
"vitest-mock-extended": "^2.0.2"
68+
"vitest-mock-extended": "^2.0.2",
69+
"zod": "^3.25.76"
6870
},
6971
"devDependencies": {
7072
"@cpn-console/eslint-config": "workspace:^",
@@ -78,13 +80,15 @@
7880
"@nestjs/testing": "^11.0.1",
7981
"@types/express": "^5.0.0",
8082
"@types/jest": "^30.0.0",
83+
"@types/js-yaml": "4.0.9",
8184
"@types/node": "^22.10.7",
8285
"@types/supertest": "^6.0.2",
8386
"@vitest/coverage-v8": "^2.1.8",
8487
"eslint": "^9.18.0",
8588
"fastify-plugin": "^5.0.1",
8689
"globals": "^16.0.0",
8790
"jest": "^30.0.0",
91+
"msw": "^2.12.10",
8892
"nodemon": "^3.1.7",
8993
"pino-pretty": "^13.0.0",
9094
"rimraf": "^6.0.1",

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,36 @@ 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
4049
pluginsDir = process.env.PLUGINS_DIR ?? '/plugins'
50+
51+
// gitlab
52+
gitlabToken = process.env.GITLAB_TOKEN
53+
gitlabUrl = process.env.GITLAB_URL
54+
gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL
55+
? process.env.GITLAB_INTERNAL_URL
56+
: process.env.GITLAB_URL
57+
58+
// vault
59+
vaultToken = process.env.VAULT_TOKEN
60+
vaultUrl = process.env.VAULT_URL
61+
vaultInternalUrl = process.env.VAULT_INTERNAL_URL
62+
? process.env.VAULT_INTERNAL_URL
63+
: process.env.VAULT_URL
64+
65+
vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso'
66+
4167
NODE_ENV
4268
= process.env.NODE_ENV === 'test'
4369
? 'test'

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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { Test, type TestingModule } from '@nestjs/testing'
2+
import { describe, it, expect, beforeEach, vi } from 'vitest'
3+
import type { Mocked } from 'vitest'
4+
import { load } from 'js-yaml'
5+
import { ArgoCDControllerService } from './argocd-controller.service'
6+
import { ArgoCDDatastoreService, type ProjectWithDetails } from './argocd-datastore.service'
7+
import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service'
8+
import { GitlabService } from '../gitlab/gitlab.service'
9+
import { VaultService } from '../vault/vault.service'
10+
import { valuesSchema } from './argocd.utils'
11+
12+
function createArgoCDControllerServiceTestingModule() {
13+
return Test.createTestingModule({
14+
providers: [
15+
ArgoCDControllerService,
16+
{
17+
provide: ArgoCDDatastoreService,
18+
useValue: {
19+
getAllProjects: vi.fn(),
20+
} satisfies Partial<ArgoCDDatastoreService>,
21+
},
22+
{
23+
provide: ConfigurationService,
24+
useValue: {
25+
keycloakControllerPurgeOrphans: true,
26+
argoNamespace: 'argocd',
27+
argocdUrl: 'http://argocd',
28+
argocdExtraRepositories: 'repo3',
29+
dsoEnvChartVersion: 'dso-env-1.6.0',
30+
dsoNsChartVersion: 'dso-ns-1.1.5',
31+
} satisfies Partial<ConfigurationService>,
32+
},
33+
{
34+
provide: GitlabService,
35+
useValue: {
36+
getOrCreateInfraProject: vi.fn(),
37+
getPublicGroupUrl: vi.fn(),
38+
getPublicRepoUrl: vi.fn(),
39+
commitCreateOrUpdate: vi.fn(),
40+
commitDelete: vi.fn(),
41+
listFiles: vi.fn(),
42+
} satisfies Partial<GitlabService>,
43+
},
44+
{
45+
provide: VaultService,
46+
useValue: {
47+
getProjectValues: vi.fn(),
48+
} satisfies Partial<VaultService>,
49+
},
50+
],
51+
})
52+
}
53+
54+
describe('argoCDControllerService', () => {
55+
let service: ArgoCDControllerService
56+
let datastore: Mocked<ArgoCDDatastoreService>
57+
let gitlabService: Mocked<GitlabService>
58+
let vaultService: Mocked<VaultService>
59+
60+
beforeEach(async () => {
61+
vi.clearAllMocks()
62+
const module: TestingModule = await createArgoCDControllerServiceTestingModule().compile()
63+
service = module.get(ArgoCDControllerService)
64+
datastore = module.get(ArgoCDDatastoreService)
65+
gitlabService = module.get(GitlabService)
66+
vaultService = module.get(VaultService)
67+
})
68+
69+
it('should be defined', () => {
70+
expect(service).toBeDefined()
71+
})
72+
73+
describe('reconcile', () => {
74+
it('should sync project environments', async () => {
75+
const mockProject = {
76+
id: '123e4567-e89b-12d3-a456-426614174000',
77+
slug: 'project-1',
78+
name: 'Project 1',
79+
environments: [
80+
{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true },
81+
{ id: '123e4567-e89b-12d3-a456-426614174002', name: 'prod', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true },
82+
],
83+
clusters: [
84+
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
85+
],
86+
repositories: [
87+
{
88+
id: 'repo-1',
89+
internalRepoName: 'infra-repo',
90+
url: 'http://gitlab/infra-repo',
91+
isInfra: true,
92+
},
93+
],
94+
plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }],
95+
} as unknown as ProjectWithDetails
96+
97+
datastore.getAllProjects.mockResolvedValue([mockProject])
98+
gitlabService.getOrCreateInfraProject.mockResolvedValue({ id: 100, http_url_to_repo: 'http://gitlab/infra' })
99+
gitlabService.getPublicGroupUrl.mockResolvedValue('http://gitlab/group')
100+
gitlabService.getPublicRepoUrl.mockResolvedValue('http://gitlab/infra-repo')
101+
gitlabService.listFiles.mockResolvedValue([])
102+
vaultService.getProjectValues.mockResolvedValue({ secret: 'value' })
103+
104+
const results = await service.reconcile()
105+
106+
expect(results).toHaveLength(3) // 2 envs + 1 cleanup (1 zone)
107+
108+
// Verify Gitlab calls
109+
expect(gitlabService.commitCreateOrUpdate).toHaveBeenCalledTimes(2)
110+
111+
const calls = gitlabService.commitCreateOrUpdate.mock.calls
112+
const devCall = calls.find(c => c[2] === 'Project 1/cluster-1/dev/values.yaml')
113+
expect(devCall).toBeDefined()
114+
115+
const content = valuesSchema.parse(load(devCall![1]))
116+
expect(content).toMatchObject({
117+
common: {
118+
'dso/project': 'Project 1',
119+
'dso/project.slug': 'project-1',
120+
'dso/environment': 'dev',
121+
},
122+
argocd: {
123+
namespace: 'argocd',
124+
project: expect.stringMatching(/^project-1-dev-[a-f0-9]{4}$/),
125+
},
126+
environment: {
127+
valueFileRepository: 'http://gitlab/infra',
128+
valueFilePath: 'Project 1/cluster-1/dev/values.yaml',
129+
roGroup: '/project-project-1/console/dev/RO',
130+
rwGroup: '/project-project-1/console/dev/RW',
131+
},
132+
application: {
133+
quota: {
134+
cpu: 1,
135+
gpu: 0,
136+
memory: '1Gi',
137+
},
138+
sourceRepositories: expect.arrayContaining([
139+
expect.stringContaining('repo3'),
140+
expect.stringContaining('repo2'),
141+
expect.stringContaining('http://gitlab/group'),
142+
]),
143+
destination: {
144+
namespace: expect.any(String),
145+
name: 'cluster-1',
146+
},
147+
autosync: true,
148+
vault: { secret: 'value' },
149+
repositories: [
150+
{
151+
repoURL: 'http://gitlab/infra-repo',
152+
targetRevision: 'HEAD',
153+
path: '.',
154+
valueFiles: [],
155+
},
156+
],
157+
},
158+
})
159+
})
160+
161+
it('should handle errors gracefully', async () => {
162+
const mockProject = {
163+
id: '123e4567-e89b-12d3-a456-426614174000',
164+
slug: 'project-1',
165+
name: 'Project 1',
166+
environments: [{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }],
167+
clusters: [
168+
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
169+
],
170+
} as unknown as ProjectWithDetails
171+
172+
datastore.getAllProjects.mockResolvedValue([mockProject])
173+
gitlabService.getOrCreateInfraProject.mockRejectedValue(new Error('Sync failed'))
174+
175+
const results = await service.reconcile()
176+
177+
// 1 env (fails) + 1 cleanup (fails because getOrCreateInfraProject fails)
178+
expect(results).toHaveLength(2)
179+
const failed = results.filter((r: any) => r.status === 'rejected')
180+
expect(failed).toHaveLength(2)
181+
})
182+
})
183+
})

0 commit comments

Comments
 (0)