Skip to content

Commit 8179b81

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

26 files changed

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

0 commit comments

Comments
 (0)