Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@fastify/swagger": "^8.15.0",
"@fastify/swagger-ui": "^4.2.0",
"@gitbeaker/core": "^40.6.0",
"@gitbeaker/requester-utils": "^40.6.0",
"@gitbeaker/rest": "^40.6.0",
"@keycloak/keycloak-admin-client": "^24.0.0",
"@kubernetes-models/argo-cd": "^2.6.2",
Expand All @@ -49,11 +50,11 @@
"@ts-rest/core": "^3.52.1",
"@ts-rest/fastify": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"axios": "1.12.2",
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"fastify": "^4.29.1",
"fastify-keycloak-adapter": "2.3.2",
"js-yaml": "^4.1.1",
"json-2-csv": "^5.5.7",
"keycloak-connect": "^25.0.0",
"mustache": "^4.2.0",
Expand All @@ -64,7 +65,8 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"undici": "^7.1.0",
"vitest-mock-extended": "^2.0.2"
"vitest-mock-extended": "^2.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@cpn-console/eslint-config": "workspace:^",
Expand All @@ -78,13 +80,15 @@
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/js-yaml": "4.0.9",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^2.1.8",
"eslint": "^9.18.0",
"fastify-plugin": "^5.0.1",
"globals": "^16.0.0",
"jest": "^30.0.0",
"msw": "^2.12.10",
"nodemon": "^3.1.7",
"pino-pretty": "^13.0.0",
"rimraf": "^6.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,36 @@ export class ConfigurationService {
= process.env.CONTACT_EMAIL
?? 'cloudpinative-relations@interieur.gouv.fr'

// argocd
argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd'
argocdUrl = process.env.ARGOCD_URL
argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES

// dso
dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0'
dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5'

// plugins
mockPlugins = process.env.MOCK_PLUGINS === 'true'
projectRootDir = process.env.PROJECTS_ROOT_DIR
projectRootPath = process.env.PROJECTS_ROOT_DIR
pluginsDir = process.env.PLUGINS_DIR ?? '/plugins'

// gitlab
gitlabToken = process.env.GITLAB_TOKEN
gitlabUrl = process.env.GITLAB_URL
gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL
? process.env.GITLAB_INTERNAL_URL
: process.env.GITLAB_URL

// vault
vaultToken = process.env.VAULT_TOKEN
vaultUrl = process.env.VAULT_URL
vaultInternalUrl = process.env.VAULT_INTERNAL_URL
? process.env.VAULT_INTERNAL_URL
: process.env.VAULT_URL

vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso'

NODE_ENV
= process.env.NODE_ENV === 'test'
? 'test'
Expand Down
6 changes: 6 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { ScheduleModule } from '@nestjs/schedule'
import { CpinModule } from './cpin-module/cpin.module'
import { IamModule } from './modules/iam/iam.module'
import { KeycloakModule } from './modules/keycloak/keycloak.module'
import { ArgoCDModule } from './modules/argocd/argocd.module'
import { GitlabModule } from './modules/gitlab/gitlab.module'
import { VaultModule } from './modules/vault/vault.module'

// This module only exists to import other module.
// « One module to rule them all, and in NestJs bind them »
Expand All @@ -13,6 +16,9 @@ import { KeycloakModule } from './modules/keycloak/keycloak.module'
CpinModule,
IamModule,
KeycloakModule,
ArgoCDModule,
GitlabModule,
VaultModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Test, type TestingModule } from '@nestjs/testing'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { Mocked } from 'vitest'
import { dump } from 'js-yaml'
import { ArgoCDControllerService } from './argocd-controller.service'
import { ArgoCDDatastoreService, type ProjectWithDetails } from './argocd-datastore.service'
import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service'
import { GitlabService } from '../gitlab/gitlab.service'
import { VaultService } from '../vault/vault.service'
import type { ProjectSchema } from '@gitbeaker/core'
import { generateNamespaceName } from '@cpn-console/shared'

function createArgoCDControllerServiceTestingModule() {
return Test.createTestingModule({
providers: [
ArgoCDControllerService,
{
provide: ArgoCDDatastoreService,
useValue: {
getAllProjects: vi.fn(),
} satisfies Partial<ArgoCDDatastoreService>,
},
{
provide: ConfigurationService,
useValue: {
keycloakControllerPurgeOrphans: true,
argoNamespace: 'argocd',
argocdUrl: 'http://argocd',
argocdExtraRepositories: 'repo3',
dsoEnvChartVersion: 'dso-env-1.6.0',
dsoNsChartVersion: 'dso-ns-1.1.5',
} satisfies Partial<ConfigurationService>,
},
{
provide: GitlabService,
useValue: {
getOrCreateInfraGroupRepo: vi.fn(),
getGroupPublicUrl: vi.fn(),
getInfraGroupRepoPublicUrl: vi.fn(),
maybeCommitUpdate: vi.fn(),
maybeCommitDelete: vi.fn(),
listFiles: vi.fn(),
} satisfies Partial<GitlabService>,
},
{
provide: VaultService,
useValue: {
getProjectValues: vi.fn(),
} satisfies Partial<VaultService>,
},
],
})
}

describe('argoCDControllerService', () => {
let service: ArgoCDControllerService
let datastore: Mocked<ArgoCDDatastoreService>
let gitlabService: Mocked<GitlabService>
let vaultService: Mocked<VaultService>

beforeEach(async () => {
vi.clearAllMocks()
const module: TestingModule = await createArgoCDControllerServiceTestingModule().compile()
service = module.get(ArgoCDControllerService)
datastore = module.get(ArgoCDDatastoreService)
gitlabService = module.get(GitlabService)
vaultService = module.get(VaultService)
})

it('should be defined', () => {
expect(service).toBeDefined()
})

describe('reconcile', () => {
it('should sync project environments', async () => {
const mockProject = {
id: '123e4567-e89b-12d3-a456-426614174000',
slug: 'project-1',
name: 'Project 1',
environments: [
{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true },
{ id: '123e4567-e89b-12d3-a456-426614174002', name: 'prod', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true },
],
clusters: [
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
],
repositories: [
{
id: 'repo-1',
internalRepoName: 'infra-repo',
url: 'http://gitlab/infra-repo',
isInfra: true,
},
],
plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }],
} as unknown as ProjectWithDetails

datastore.getAllProjects.mockResolvedValue([mockProject])
gitlabService.getOrCreateInfraGroupRepo.mockResolvedValue({ id: 100, http_url_to_repo: 'http://gitlab/infra' } as ProjectSchema)
gitlabService.getGroupPublicUrl.mockResolvedValue('http://gitlab/group')
gitlabService.getInfraGroupRepoPublicUrl.mockResolvedValue('http://gitlab/infra-repo')
gitlabService.listFiles.mockResolvedValue([])
vaultService.getProjectValues.mockResolvedValue({ secret: 'value' })

const results = await service.reconcile()

expect(results).toHaveLength(3) // 2 envs + 1 cleanup (1 zone)

// Verify Gitlab calls
expect(gitlabService.maybeCommitUpdate).toHaveBeenCalledTimes(2)
expect(gitlabService.maybeCommitUpdate).toHaveBeenCalledWith(
100,
[
{
content: dump({
common: {
'dso/project': 'Project 1',
'dso/project.id': '123e4567-e89b-12d3-a456-426614174000',
'dso/project.slug': 'project-1',
'dso/environment': 'dev',
'dso/environment.id': '123e4567-e89b-12d3-a456-426614174001',
},
argocd: {
cluster: 'in-cluster',
namespace: 'argocd',
project: 'project-1-dev-6293',
envChartVersion: 'dso-env-1.6.0',
nsChartVersion: 'dso-ns-1.1.5',
},
environment: {
valueFileRepository: 'http://gitlab/infra',
valueFileRevision: 'HEAD',
valueFilePath: 'Project 1/cluster-1/dev/values.yaml',
roGroup: '/project-project-1/console/dev/RO',
rwGroup: '/project-project-1/console/dev/RW',
},
application: {
quota: {
cpu: 1,
gpu: 0,
memory: '1Gi',
},
sourceRepositories: [
'http://gitlab/group/**',
'repo3',
'repo2',
],
destination: {
namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id),
name: 'cluster-1',
},
autosync: true,
vault: { secret: 'value' },
repositories: [
{
repoURL: 'http://gitlab/infra-repo',
targetRevision: 'HEAD',
path: '.',
valueFiles: [],
},
],
},
}),
filePath: 'Project 1/cluster-1/dev/values.yaml',
},
],
'ci: :robot_face: Update Project 1/cluster-1/dev/values.yaml',
)
})

it('should handle errors gracefully', async () => {
const mockProject = {
id: '123e4567-e89b-12d3-a456-426614174000',
slug: 'project-1',
name: 'Project 1',
environments: [{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }],
clusters: [
{ id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } },
],
} as unknown as ProjectWithDetails

datastore.getAllProjects.mockResolvedValue([mockProject])
gitlabService.getOrCreateInfraGroupRepo.mockRejectedValue(new Error('Sync failed'))

const results = await service.reconcile()

// 1 env (fails) + 1 cleanup (fails because getOrCreateInfraProject fails)
expect(results).toHaveLength(2)
const failed = results.filter((r: any) => r.status === 'rejected')
expect(failed).toHaveLength(2)
})
})
})
Loading
Loading