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
19 changes: 16 additions & 3 deletions apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "server-nestjs",
"version": "9.13.2",
"private": true,
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
Expand All @@ -12,9 +12,15 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect"
},
"dependencies": {
"@casl/ability": "^6.7.1",
"@casl/prisma": "^1.5.0",
"@cpn-console/argocd-plugin": "workspace:^",
"@cpn-console/gitlab-plugin": "workspace:^",
"@cpn-console/harbor-plugin": "workspace:^",
Expand All @@ -30,23 +36,29 @@
"@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",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^5.0.1",
"@prisma/client": "^6.0.1",
"@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",
"nest-keycloak-connect": "^1.10.1",
"nestjs-pino": "^4.5.0",
"pino-http": "^11.0.0",
"prisma": "^6.0.1",
Expand All @@ -67,6 +79,7 @@
"@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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class ConfigurationService {
keycloakClientId = process.env.KEYCLOAK_CLIENT_ID
keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET
keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI
keycloakControllerPurgeOrphans = Boolean(process.env.KEYCLOAK_RECONCILER_PURGE_ORPHANS)
adminsUserId = process.env.ADMIN_KC_USER_ID
? process.env.ADMIN_KC_USER_ID.split(',')
: []
Expand All @@ -33,10 +34,35 @@ 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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { Injectable } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect()
}

async onModuleDestroy() {
await this.$disconnect()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'

import { ConfigurationModule } from './configuration/configuration.module'
import { DatabaseService } from './database/database.service'
import { PrismaService } from './database/prisma.service'
import { HttpClientService } from './http-client/http-client.service'
import { LoggerModule } from './logger/logger.module'
import { ServerService } from './server/server.service'

@Module({
providers: [DatabaseService, HttpClientService, ServerService],
providers: [DatabaseService, PrismaService, HttpClientService, ServerService],
imports: [LoggerModule, ConfigurationModule],
exports: [DatabaseService, HttpClientService, ServerService],
exports: [DatabaseService, PrismaService, HttpClientService, ServerService],
})
export class InfrastructureModule {}
18 changes: 17 additions & 1 deletion apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { Module } from '@nestjs/common'
import { EventEmitterModule } from '@nestjs/event-emitter'
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 »
@Module({
imports: [CpinModule],
imports: [
CpinModule,
IamModule,
KeycloakModule,
ArgoCDModule,
GitlabModule,
VaultModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
],
controllers: [],
providers: [],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import type { Mocked } from 'vitest'
import { load } from 'js-yaml'
import { ArgoCDControllerService } from './argocd-controller.service'
import type { ArgoCDDatastoreService, ProjectWithDetails } from './argocd-datastore.service'
import type { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service'
import type { GitlabService } from '../gitlab/gitlab.service'
import type { VaultService } from '../vault/vault.service'

const mockArgoCDDatastore = {
getAllProjects: vi.fn(),
} as unknown as Mocked<ArgoCDDatastoreService>

const mockConfigService = {
keycloakControllerPurgeOrphans: true,
argoNamespace: 'argocd',
argocdUrl: 'http://argocd',
argocdExtraRepositories: 'repo3',
dsoEnvChartVersion: 'dso-env-1.6.0',
dsoNsChartVersion: 'dso-ns-1.1.5',
} as unknown as Mocked<ConfigurationService>

const mockGitlabService = {
getOrCreateInfraProject: vi.fn(),
getPublicGroupUrl: vi.fn(),
getPublicRepoUrl: vi.fn(),
commitCreateOrUpdate: vi.fn(),
commitDelete: vi.fn(),
listFiles: vi.fn(),
} as unknown as Mocked<GitlabService>

const mockVaultService = {
getProjectValues: vi.fn(),
} as unknown as Mocked<VaultService>

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

beforeEach(() => {
service = new ArgoCDControllerService(
mockArgoCDDatastore,
mockConfigService,
mockGitlabService,
mockVaultService,
)
datastore = mockArgoCDDatastore
gitlabService = mockGitlabService
vaultService = mockVaultService
vi.clearAllMocks()
})

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.getOrCreateInfraProject.mockResolvedValue({ id: 100, http_url_to_repo: 'http://gitlab/infra' })
gitlabService.getPublicGroupUrl.mockResolvedValue('http://gitlab/group')
gitlabService.getPublicRepoUrl.mockResolvedValue('http://gitlab/infra-repo')
gitlabService.listFiles.mockResolvedValue([])
vaultService.getProjectValues.mockResolvedValue({ secret: 'value' })

const results = await (service as any).reconcile()

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

// Verify Gitlab calls
expect(gitlabService.commitCreateOrUpdate).toHaveBeenCalledTimes(2)

const calls = gitlabService.commitCreateOrUpdate.mock.calls
const devCall = calls.find(c => c[2] === 'Project 1/cluster-1/dev/values.yaml')
expect(devCall).toBeDefined()

const content = load(devCall![1]) as any
expect(content).toMatchObject({
common: {
'dso/project': 'Project 1',
'dso/project.slug': 'project-1',
'dso/environment': 'dev',
},
argocd: {
namespace: 'argocd',
project: expect.stringMatching(/^project-1-dev-[a-f0-9]{4}$/),
},
environment: {
valueFileRepository: 'http://gitlab/infra',
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: expect.arrayContaining([
expect.stringContaining('repo3'),
expect.stringContaining('repo2'),
expect.stringContaining('http://gitlab/group'),
]),
destination: {
namespace: expect.any(String),
name: 'cluster-1',
},
autosync: true,
vault: { secret: 'value' },
repositories: [
{
repoURL: 'http://gitlab/infra-repo',
targetRevision: 'HEAD',
path: '.',
valueFiles: [],
},
],
},
})
})

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.getOrCreateInfraProject.mockRejectedValue(new Error('Sync failed'))

const results = await (service as any).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