Skip to content

Commit f8047ad

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

24 files changed

+3041
-90
lines changed

apps/server-nestjs/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@fastify/swagger": "^8.15.0",
4141
"@fastify/swagger-ui": "^4.2.0",
4242
"@gitbeaker/core": "^40.6.0",
43+
"@gitbeaker/requester-utils": "^40.6.0",
4344
"@gitbeaker/rest": "^40.6.0",
4445
"@keycloak/keycloak-admin-client": "^24.0.0",
4546
"@kubernetes-models/argo-cd": "^2.6.2",
@@ -61,11 +62,11 @@
6162
"@ts-rest/core": "^3.52.1",
6263
"@ts-rest/fastify": "^3.52.1",
6364
"@ts-rest/open-api": "^3.52.1",
64-
"axios": "1.12.2",
6565
"date-fns": "^4.1.0",
6666
"dotenv": "^16.4.7",
6767
"fastify": "^4.29.1",
6868
"fastify-keycloak-adapter": "2.3.2",
69+
"js-yaml": "^4.1.1",
6970
"json-2-csv": "^5.5.7",
7071
"keycloak-connect": "^25.0.0",
7172
"mustache": "^4.2.0",
@@ -76,7 +77,8 @@
7677
"reflect-metadata": "^0.2.2",
7778
"rxjs": "^7.8.1",
7879
"undici": "^7.1.0",
79-
"vitest-mock-extended": "^2.0.2"
80+
"vitest-mock-extended": "^2.0.2",
81+
"zod": "^3.25.76"
8082
},
8183
"devDependencies": {
8284
"@cpn-console/eslint-config": "workspace:^",
@@ -90,13 +92,15 @@
9092
"@nestjs/testing": "^11.0.1",
9193
"@types/express": "^5.0.0",
9294
"@types/jest": "^30.0.0",
95+
"@types/js-yaml": "4.0.9",
9396
"@types/node": "^22.10.7",
9497
"@types/supertest": "^6.0.2",
9598
"@vitest/coverage-v8": "^2.1.8",
9699
"eslint": "^9.18.0",
97100
"fastify-plugin": "^5.0.1",
98101
"globals": "^16.0.0",
99102
"jest": "^30.0.0",
103+
"msw": "^2.12.10",
100104
"nodemon": "^3.1.7",
101105
"pino-pretty": "^13.0.0",
102106
"rimraf": "^6.0.1",

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,39 @@ export class ConfigurationService {
3636
= process.env.CONTACT_EMAIL
3737
?? 'cloudpinative-relations@interieur.gouv.fr'
3838

39+
// argocd
40+
argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd'
41+
argocdUrl = process.env.ARGOCD_URL
42+
argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES
43+
44+
// dso
45+
dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0'
46+
dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5'
47+
3948
// plugins
4049
mockPlugins = process.env.MOCK_PLUGINS === 'true'
41-
projectRootDir = process.env.PROJECTS_ROOT_DIR
50+
projectRootPath = process.env.PROJECTS_ROOT_DIR
4251
pluginsDir = process.env.PLUGINS_DIR ?? '/plugins'
52+
53+
// gitlab
54+
gitlabToken = process.env.GITLAB_TOKEN
55+
gitlabUrl = process.env.GITLAB_URL
56+
gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL
57+
? process.env.GITLAB_INTERNAL_URL
58+
: process.env.GITLAB_URL
59+
60+
gitlabMirrorTokenExpirationDays = Number(process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS) ?? 180
61+
gitlabMirrorTokenRotationThresholdDays = Number(process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS) ?? 90
62+
63+
// vault
64+
vaultToken = process.env.VAULT_TOKEN
65+
vaultUrl = process.env.VAULT_URL
66+
vaultInternalUrl = process.env.VAULT_INTERNAL_URL
67+
? process.env.VAULT_INTERNAL_URL
68+
: process.env.VAULT_URL
69+
70+
vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso'
71+
4372
NODE_ENV
4473
= process.env.NODE_ENV === 'test'
4574
? 'test'
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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+
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+
getOrCreateInfraGroupRepo: vi.fn(),
37+
getProjectGroupPublicUrl: vi.fn(),
38+
getInfraGroupRepoPublicUrl: vi.fn(),
39+
maybeCommitUpdate: vi.fn(),
40+
maybeCommitDelete: 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 gitlab: Mocked<GitlabService>
58+
let vault: 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+
gitlab = module.get(GitlabService)
66+
vault = 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+
gitlab.getOrCreateInfraGroupRepo.mockResolvedValue({ id: 100, http_url_to_repo: 'http://gitlab/infra' } as ProjectSchema)
99+
gitlab.getProjectGroupPublicUrl.mockResolvedValue('http://gitlab/group')
100+
gitlab.getInfraGroupRepoPublicUrl.mockResolvedValue('http://gitlab/infra-repo')
101+
gitlab.listFiles.mockResolvedValue([])
102+
vault.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(gitlab.maybeCommitUpdate).toHaveBeenCalledTimes(2)
110+
expect(gitlab.maybeCommitUpdate).toHaveBeenCalledWith(
111+
100,
112+
[
113+
{
114+
content: dump({
115+
common: {
116+
'dso/project': 'Project 1',
117+
'dso/project.id': '123e4567-e89b-12d3-a456-426614174000',
118+
'dso/project.slug': 'project-1',
119+
'dso/environment': 'dev',
120+
'dso/environment.id': '123e4567-e89b-12d3-a456-426614174001',
121+
},
122+
argocd: {
123+
cluster: 'in-cluster',
124+
namespace: 'argocd',
125+
project: 'project-1-dev-6293',
126+
envChartVersion: 'dso-env-1.6.0',
127+
nsChartVersion: 'dso-ns-1.1.5',
128+
},
129+
environment: {
130+
valueFileRepository: 'http://gitlab/infra',
131+
valueFileRevision: 'HEAD',
132+
valueFilePath: 'Project 1/cluster-1/dev/values.yaml',
133+
roGroup: '/project-project-1/console/dev/RO',
134+
rwGroup: '/project-project-1/console/dev/RW',
135+
},
136+
application: {
137+
quota: {
138+
cpu: 1,
139+
gpu: 0,
140+
memory: '1Gi',
141+
},
142+
sourceRepositories: [
143+
'http://gitlab/group/**',
144+
'repo3',
145+
'repo2',
146+
],
147+
destination: {
148+
namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id),
149+
name: 'cluster-1',
150+
},
151+
autosync: true,
152+
vault: { secret: 'value' },
153+
repositories: [
154+
{
155+
repoURL: 'http://gitlab/infra-repo',
156+
targetRevision: 'HEAD',
157+
path: '.',
158+
valueFiles: [],
159+
},
160+
],
161+
},
162+
}),
163+
filePath: 'Project 1/cluster-1/dev/values.yaml',
164+
},
165+
],
166+
'ci: :robot_face: Update Project 1/cluster-1/dev/values.yaml',
167+
)
168+
})
169+
170+
it('should handle errors gracefully', async () => {
171+
const mockProject = {
172+
id: '123e4567-e89b-12d3-a456-426614174000',
173+
slug: 'project-1',
174+
name: 'Project 1',
175+
environments: [{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }],
176+
clusters: [
177+
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
178+
],
179+
} as unknown as ProjectWithDetails
180+
181+
datastore.getAllProjects.mockResolvedValue([mockProject])
182+
gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(new Error('Sync failed'))
183+
184+
const results = await service.reconcile()
185+
186+
// 1 env (fails) + 1 cleanup (fails because getOrCreateInfraProject fails)
187+
expect(results).toHaveLength(2)
188+
const failed = results.filter((r: any) => r.status === 'rejected')
189+
expect(failed).toHaveLength(2)
190+
})
191+
})
192+
})

0 commit comments

Comments
 (0)