diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index b0c3d6ac4..60d961980 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -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", @@ -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", @@ -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:^", @@ -78,6 +80,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", @@ -85,6 +88,7 @@ "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", diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts index b8ee7b5c8..837a2b39b 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts @@ -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' diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 29edea89e..8a7d48ab7 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -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 » @@ -13,6 +16,9 @@ import { KeycloakModule } from './modules/keycloak/keycloak.module' CpinModule, IamModule, KeycloakModule, + ArgoCDModule, + GitlabModule, + VaultModule, EventEmitterModule.forRoot(), ScheduleModule.forRoot(), ], diff --git a/apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts new file mode 100644 index 000000000..69a06dbb6 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts @@ -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, + }, + { + 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, + }, + { + provide: GitlabService, + useValue: { + getOrCreateInfraGroupRepo: vi.fn(), + getGroupPublicUrl: vi.fn(), + getInfraGroupRepoPublicUrl: vi.fn(), + maybeCommitUpdate: vi.fn(), + maybeCommitDelete: vi.fn(), + listFiles: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultService, + useValue: { + getProjectValues: vi.fn(), + } satisfies Partial, + }, + ], + }) +} + +describe('argoCDControllerService', () => { + let service: ArgoCDControllerService + let datastore: Mocked + let gitlabService: Mocked + let vaultService: Mocked + + 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) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts new file mode 100644 index 000000000..7e37593cd --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts @@ -0,0 +1,148 @@ +import type { OnModuleInit } from '@nestjs/common' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { dump } from 'js-yaml' + +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 { + formatEnvironmentValuesFilePath, + formatValues, + getDistinctZones, +} from './argocd.utils' + +@Injectable() +export class ArgoCDControllerService implements OnModuleInit { + private readonly logger = new Logger(ArgoCDControllerService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, + @Inject(ConfigurationService) private readonly configService: ConfigurationService, + @Inject(GitlabService) private readonly gitlabService: GitlabService, + @Inject(VaultService) private readonly vaultService: VaultService, + ) { + this.logger.log('ArgoCDControllerService initialized') + } + + onModuleInit() { + this.handleCron() + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + this.logger.log(`Handling project upsert for ${project.slug}`) + return this.reconcile() + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + this.logger.log(`Handling project delete for ${project.slug}`) + return this.reconcile() + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + await this.reconcile() + } + + async reconcile() { + const projects = await this.argoCDDatastore.getAllProjects() + const results: PromiseSettledResult[] = [] + + const projectResults = await Promise.all(projects.map(async (project) => { + const pResults: PromiseSettledResult[] = [] + + const ensureResults = await Promise.allSettled( + project.environments.map(env => this.generateValues(project, env)), + ) + pResults.push(...ensureResults) + + const cleanupResults = await this.cleanupStaleValues(project) + pResults.push(...cleanupResults) + + return pResults + })) + + results.push(...projectResults.flat()) + + results.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Reconciliation failed: ${result.reason}`) + } + }) + + return results + } + + private async cleanupStaleValues(project: ProjectWithDetails) { + const zones = getDistinctZones(project) + return Promise.allSettled(zones.map(async (zoneSlug) => { + const infraProject = await this.gitlabService.getOrCreateInfraGroupRepo(zoneSlug) + const existingFiles = await this.gitlabService.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + + const neededFiles = project.environments + .filter((env) => { + const cluster = project.clusters.find(c => c.id === env.clusterId) + return cluster?.zone.slug === zoneSlug + }) + .map((env) => { + const cluster = project.clusters.find(c => c.id === env.clusterId)! + return formatEnvironmentValuesFilePath(project, cluster, env) + }) + + const filesToDelete = existingFiles + .filter((existingFile) => { + return ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) + }) + .map(existingFile => existingFile.path) + + await this.gitlabService.maybeCommitDelete(infraProject.id, filesToDelete) + })) + } + + async generateValues( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + ) { + const vaultValues = await this.vaultService.getProjectValues(project.id) + const cluster = project.clusters.find(c => c.id === environment.clusterId) + if (!cluster) throw new Error(`Cluster not found for environment ${environment.id}`) + + const infraProject = await this.gitlabService.getOrCreateInfraGroupRepo(cluster.zone.slug) + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const repo = project.repositories.find(r => r.isInfra) + if (!repo) throw new Error(`Infra repository not found for project ${project.id}`) + const repoUrl = await this.gitlabService.getInfraGroupRepoPublicUrl(repo.internalRepoName) + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicGroupUrl: await this.gitlabService.getGroupPublicUrl(), + argocdExtraRepositories: this.configService.argocdExtraRepositories, + infraProject, + valueFilePath, + repoUrl, + vaultValues, + argoNamespace: this.configService.argoNamespace, + envChartVersion: this.configService.dsoEnvChartVersion, + nsChartVersion: this.configService.dsoNsChartVersion, + }) + + await this.gitlabService.maybeCommitUpdate(infraProject.id, [{ + content: dump(values), + filePath: valueFilePath, + }], `ci: :robot_face: Update ${valueFilePath}`) + } +} diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 000000000..c07b9aed5 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,65 @@ +import { Injectable, Logger } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import type { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + clusterId: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + private readonly logger = new Logger(ArgoCDDatastoreService.name) + + constructor(private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 000000000..ee8080467 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { ArgoCDControllerService } from './argocd-controller.service' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ConfigurationModule } from '@/cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { VaultModule } from '../vault/vault.module' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, GitlabModule, VaultModule], + providers: [ArgoCDControllerService, ArgoCDDatastoreService], + exports: [], +}) +export class ArgoCDModule {} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts new file mode 100644 index 000000000..80bc0b2e1 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -0,0 +1,236 @@ +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import type { ProjectWithDetails } from './argocd-datastore.service.js' +import z from 'zod' + +export const valuesSchema = z.object({ + common: z.object({ + 'dso/project': z.string(), + 'dso/project.id': z.string(), + 'dso/project.slug': z.string(), + 'dso/environment': z.string(), + 'dso/environment.id': z.string(), + }), + argocd: z.object({ + cluster: z.string(), + namespace: z.string(), + project: z.string(), + envChartVersion: z.string(), + nsChartVersion: z.string(), + }), + environment: z.object({ + valueFileRepository: z.string(), + valueFileRevision: z.string(), + valueFilePath: z.string(), + roGroup: z.string(), + rwGroup: z.string(), + }), + application: z.object({ + quota: z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.string(), + }), + sourceRepositories: z.array(z.string()), + destination: z.object({ + namespace: z.string(), + name: z.string(), + }), + autosync: z.boolean(), + vault: z.record(z.any()), + repositories: z.array(z.object({ + repoURL: z.string(), + targetRevision: z.string(), + path: z.string(), + valueFiles: z.array(z.string()), + })), + }), +}) + +export function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return `/project-${projectSlug}/console/${environmentName}/RO` +} + +export function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return `/project-${projectSlug}/console/${environmentName}/RW` +} + +export function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +export function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return Array.from(zones) +} + +export function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +export function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + repoUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + repoURL: repoUrl, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies z.infer[number] + }) +} + +export function formatEnvironmentValues( + infraProject: { http_url_to_repo: string }, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + } satisfies z.infer +} + +export interface FormatSourceRepositoriesValuesOptions { + gitlabPublicGroupUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +export function formatSourceRepositoriesValues( + { gitlabPublicGroupUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicGroupUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +export interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +export function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies z.infer +} + +export interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +export function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies z.infer +} + +export interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['clusters'][number] + gitlabPublicGroupUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: { http_url_to_repo: string } + valueFilePath: string + repoUrl: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string +} + +export function formatValues({ + project, + environment, + cluster, + gitlabPublicGroupUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + repoUrl, + argoNamespace, + envChartVersion, + nsChartVersion, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicGroupUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: formatRepositoriesValues( + project.repositories, + repoUrl, + environment.name, + ), + }, + } satisfies z.infer +} diff --git a/apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml b/apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml new file mode 100644 index 000000000..ca9be2984 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/apps/server-nestjs/src/modules/gitlab/files/mirror.sh b/apps/server-nestjs/src/modules/gitlab/files/mirror.sh new file mode 100644 index 000000000..c50c923f8 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 000000000..6b75d728b --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,15 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Gitlab } from '@gitbeaker/core' +import { Injectable, Inject } from '@nestjs/common' + +@Injectable() +export class GitlabClientService extends Gitlab { + constructor( + @Inject(ConfigurationService) readonly configService: ConfigurationService, + ) { + super({ + token: configService.gitlabToken, + host: configService.gitlabInternalUrl, + }) + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts new file mode 100644 index 000000000..f5b790e3d --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts @@ -0,0 +1,111 @@ +import { Test } from '@nestjs/testing' +import { GitlabControllerService } from './gitlab-controller.service' +import { GitlabService } from './gitlab.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { VaultService } from '../vault/vault.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { vi, describe, beforeEach, it, expect, type Mocked } from 'vitest' +import type { AccessTokenExposedSchema, GroupSchema, ProjectSchema, SimpleUserSchema } from '@gitbeaker/core' + +function createGitlabControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + GitlabControllerService, + { + provide: GitlabService, + useValue: { + getOrCreateProjectSubGroup: vi.fn(), + getGroupMembers: vi.fn(), + addGroupMember: vi.fn(), + removeGroupMember: vi.fn(), + getUserByEmail: vi.fn(), + createUser: vi.fn(), + getRepositories: vi.fn(), + createEmptyProjectRepository: vi.fn(), + getProjectToken: vi.fn(), + getInfraGroupRepoPublicUrl: vi.fn(), + maybeCommitUpdate: vi.fn(), + deleteGroup: vi.fn(), + commitMirror: vi.fn(), + getMirrorProjectTriggerToken: vi.fn(), + createProjectToken: vi.fn(), + } satisfies Partial, + }, + { + provide: GitlabDatastoreService, + useValue: { + getAllProjects: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultService, + useValue: { + read: vi.fn(), + write: vi.fn(), + destroy: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + projectRootDir: 'forge/console', + }, + }, + ], + }) +} + +describe('gitlabControllerService', () => { + let service: GitlabControllerService + let gitlabService: Mocked + let vaultService: Mocked + + beforeEach(async () => { + const module = await createGitlabControllerServiceTestingModule().compile() + service = module.get(GitlabControllerService) + gitlabService = module.get(GitlabService) + vaultService = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + // Mock data + const project = { + id: 'p1', + slug: 'project-1', + name: 'Project 1', + description: 'Test project', + members: [], + repositories: [], + clusters: [], + } as ProjectWithDetails + const group = { + id: 123, + full_path: 'forge/console/project-1', + path: 'project-1', + } as GroupSchema + + // Mock implementations + gitlabService.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlabService.getGroupMembers.mockResolvedValue([]) + gitlabService.getRepositories.mockReturnValue((async function* () { })()) + gitlabService.getProjectToken.mockResolvedValue({ token: 'token' } as AccessTokenExposedSchema) + gitlabService.createEmptyProjectRepository.mockResolvedValue({ id: 1 } as ProjectSchema) + gitlabService.getInfraGroupRepoPublicUrl.mockResolvedValue('http://gitlab/repo') + gitlabService.getMirrorProjectTriggerToken.mockResolvedValue({ repoId: 1, token: 'trigger-token', id: 1 }) + gitlabService.getUserByEmail.mockResolvedValue({ id: 123, username: 'user' } as SimpleUserSchema) + vaultService.read.mockResolvedValue({ MIRROR_TOKEN: 'token' }) + + await service.handleUpsert(project) + + expect(gitlabService.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlabService.getGroupMembers).toHaveBeenCalledWith(group.id) + expect(gitlabService.getRepositories).toHaveBeenCalledWith(project.slug) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts new file mode 100644 index 000000000..b2cd4758d --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts @@ -0,0 +1,216 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { AccessLevel, type MemberSchema, type ProjectSchema, type SimpleUserSchema } from '@gitbeaker/core' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { GitlabService } from './gitlab.service' +import { VaultService } from '../vault/vault.service' +import { INFRA_APPS_REPO_NAME, INTERNAL_MIRROR_REPO_NAME } from './gitlab.constant' +import { getAll } from './gitlab.utils' + +@Injectable() +export class GitlabControllerService { + private readonly logger = new Logger(GitlabControllerService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly gitlabDatastore: GitlabDatastoreService, + @Inject(GitlabService) private readonly gitlabService: GitlabService, + @Inject(VaultService) private readonly vaultService: VaultService, + @Inject(ConfigurationService) private readonly configService: ConfigurationService, + ) { + this.logger.log('GitlabControllerService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + this.logger.log(`Handling project upsert for ${project.slug}`) + return this.reconcileProject(project) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + this.logger.log(`Handling project delete for ${project.slug}`) + const group = await this.gitlabService.getProjectGroup(project.slug) + if (group) { + await this.gitlabService.deleteGroup(group.id) + } + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + this.logger.log('Starting Gitlab reconciliation') + const projects = await this.gitlabDatastore.getAllProjects() + const results = await Promise.allSettled(projects.map(p => this.reconcileProject(p))) + results.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Reconciliation failed: ${result.reason}`) + } + }) + } + + private async reconcileProject(project: ProjectWithDetails) { + try { + await this.ensureMembers(project) + await this.ensureRepositories(project) + } catch (error) { + this.logger.error(`Failed to reconcile project ${project.slug}: ${error}`) + throw error + } + } + + private async ensureMembers(project: ProjectWithDetails) { + const group = await this.gitlabService.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlabService.getGroupMembers(group.id) + const projectUsers = project.members.map(m => m.user) + + const gitlabUsers = await Promise.all(projectUsers.map(async (user) => { + let gitlabUser = await this.gitlabService.getUserByEmail(user.email) + const username = user.email.split('@')[0] + + if (!gitlabUser) { + try { + gitlabUser = await this.gitlabService.createUser(user.email, username, `${user.firstName} ${user.lastName}`) + } catch (e) { + this.logger.warn(`Failed to create user ${user.email}: ${e}`) + return null + } + } + return gitlabUser + })) + + const validGitlabUsers = gitlabUsers.filter(u => u !== null) + + await this.addMissingMembers(group.id, validGitlabUsers, members) + await this.purgeOrphanMembers(group.id, validGitlabUsers, members) + } + + private async addMissingMembers(groupId: number, validGitlabUserIds: SimpleUserSchema[], members: MemberSchema[]) { + for (const user of validGitlabUserIds) { + if (!members.find(m => m.id === user.id)) { + await this.gitlabService.addGroupMember(groupId, user.id, AccessLevel.DEVELOPER) + } + } + } + + private async purgeOrphanMembers(groupId: number, validGitlabUserIds: SimpleUserSchema[], members: MemberSchema[]) { + for (const member of members) { + if (this.isManagedUser(member)) continue + if (!validGitlabUserIds.find(u => u.id === member.id)) { + await this.gitlabService.removeGroupMember(groupId, member.id) + } + } + } + + private isManagedUser(member: MemberSchema) { + return member.username.match(/group_\d+_bot/) + } + + private async ensureRepositories(project: ProjectWithDetails) { + const gitlabRepositories = await getAll(this.gitlabService.getRepositories(project.slug)) + const projectMirrorCreds = await this.getProjectMirrorCreds(project.slug) + await this.syncProjectRepositories(project, gitlabRepositories, projectMirrorCreds) + await this.ensureSpecialRepositories(project, gitlabRepositories) + } + + private async syncProjectRepositories(project: ProjectWithDetails, gitlabRepositories: ProjectSchema[], projectMirrorCreds: { MIRROR_USER: string, MIRROR_TOKEN: string }) { + for (const repo of project.repositories) { + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + await this.configureRepositoryMirroring(project, repo, projectMirrorCreds) + } else { + await this.cleanupMirrorSecrets(project, repo) + } + } + } + + private async ensureRepository(project: ProjectWithDetails, repo: any, gitlabRepositories: ProjectSchema[]) { + let gitlabRepo = gitlabRepositories.find(r => r.name === repo.internalRepoName) + if (!gitlabRepo) { + gitlabRepo = await this.gitlabService.createEmptyProjectRepository( + project.slug, + repo.internalRepoName, + undefined, + !!repo.externalRepoUrl, + ) + } + return gitlabRepo + } + + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: any, + projectMirrorCreds: { MIRROR_USER: string, MIRROR_TOKEN: string }, + ) { + const vaultCredsPath = `${this.configService.projectRootPath}/${project.slug}/${repo.internalRepoName}-mirror` + const currentVaultSecret = await this.vaultService.read(vaultCredsPath) + + const internalRepoUrl = await this.gitlabService.getInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split(/:\/\/(.*)/s)[1] + const internalRepoUrn = internalRepoUrl.split(/:\/\/(.*)/s)[1] + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + await this.vaultService.write(mirrorSecretData, vaultCredsPath) + } + + private async cleanupMirrorSecrets(project: ProjectWithDetails, repo: any) { + // If no external URL, destroy secret if exists + const vaultCredsPath = `${this.configService.projectRootPath}/${project.slug}/${repo.internalRepoName}-mirror` + await this.vaultService.destroy(vaultCredsPath) + } + + private async ensureSpecialRepositories(project: ProjectWithDetails, gitlabRepositories: ProjectSchema[]) { + // Ensure special repos + if (!gitlabRepositories.find(r => r.name === INFRA_APPS_REPO_NAME)) { + await this.gitlabService.createEmptyProjectRepository(project.slug, INFRA_APPS_REPO_NAME, undefined, false) + } + + const mirrorRepo = gitlabRepositories.find(r => r.name === INTERNAL_MIRROR_REPO_NAME) + if (!mirrorRepo) { + const newMirrorRepo = await this.gitlabService.createEmptyProjectRepository(project.slug, INTERNAL_MIRROR_REPO_NAME, undefined, false) + await this.gitlabService.commitMirror(newMirrorRepo.id) + } + + // Setup Trigger Token for mirror repo + const triggerToken = await this.gitlabService.getMirrorProjectTriggerToken(project.slug) + const gitlabSecret = { + PROJECT_SLUG: project.slug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + await this.vaultService.write(gitlabSecret, 'GITLAB') + } + + private async getProjectMirrorCreds(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + const currentToken = await this.gitlabService.getProjectToken(projectSlug, tokenName) + const vaultPath = `${this.configService.projectRootPath}/${projectSlug}/tech/GITLAB_MIRROR` + + if (currentToken) { + const vaultSecret = await this.vaultService.read(vaultPath) + // Verify if token works? Plugin does. + // For simplicity, return from vault if exists. + if (vaultSecret) return vaultSecret as unknown as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + + const newToken = await this.gitlabService.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + const creds = { + MIRROR_USER: newToken.name, + MIRROR_TOKEN: newToken.token, + } + await this.vaultService.write(creds, vaultPath) + return creds + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts new file mode 100644 index 000000000..cb0361eb6 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts @@ -0,0 +1,34 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' +import { mockDeep } from 'vitest-mock-extended' + +const prismaMock = mockDeep() + +function createGitlabDatastoreServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + GitlabDatastoreService, + { provide: PrismaService, useValue: prismaMock }, + ], + }) +} + +describe('gitlabDatastoreService', () => { + let service: GitlabDatastoreService + + beforeEach(async () => { + const module: TestingModule = await createGitlabDatastoreServiceTestingModule().compile() + service = module.get(GitlabDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should get user', async () => { + const user = { id: 'user-id' } + prismaMock.user.findUnique.mockResolvedValue(user as any) + await expect(service.getUser('user-id')).resolves.toEqual(user) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 000000000..97f795219 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,78 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + private readonly logger = new Logger(GitlabDatastoreService.name) + + constructor( + @Inject(PrismaService) + private readonly prisma: PrismaService, + ) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts new file mode 100644 index 000000000..977985c34 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts @@ -0,0 +1,4 @@ +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const INTERNAL_MIRROR_REPO_NAME = 'mirror' diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 000000000..fa084c26c --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { GitlabService } from './gitlab.service' + +@Module({ + providers: [GitlabService], + exports: [GitlabService], +}) +export class GitlabModule {} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 000000000..25301792f --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,273 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { GitlabService } from './gitlab.service' +import { GitlabClientService } from './gitlab-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import type { MockedFunction } from 'vitest' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import type { ExpandedGroupSchema, MemberSchema, ProjectSchema, RepositoryFileExpandedSchema } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' + +const gitlabMock = mockDeep() + +function createGitlabServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + GitlabService, + { + provide: GitlabClientService, + useValue: gitlabMock, + }, + { + provide: ConfigurationService, + useValue: { + gitlabUrl: 'https://gitlab.internal', + gitlabToken: 'token', + gitlabInternalUrl: 'https://gitlab.internal', + projectRootPath: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('gitlabService', () => { + let service: GitlabService + + beforeEach(async () => { + vi.clearAllMocks() + const module: TestingModule = await createGitlabServiceTestingModule().compile() + service = module.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + // Mock getGroupRootId logic + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + // Mock Groups.show (root) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + // Mock find infra group (not found first) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + // Mock create infra group + gitlabMock.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + // Mock find project (not found) + const gitlabProjectsAllMock = gitlabMock.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + // Mock create project + gitlabMock.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'http://gitlab/repo.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'http://gitlab/repo.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabMock.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabMock.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const content = 'content' + const filePath = 'file.txt' + + const gitlabRepositoryFilesShowMock = gitlabMock.RepositoryFiles.show as MockedFunction + const notFoundError = new GitbeakerRequestError('Not Found', { cause: { description: '404 File Not Found' } } as any) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + await service.maybeCommitUpdate(repoId, [{ content, filePath }]) + + expect(gitlabMock.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + + const gitlabRepositoryFilesShowMock = gitlabMock.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue({ + content_sha256: oldHash, + } as RepositoryFileExpandedSchema) + + await service.maybeCommitUpdate(repoId, [{ content, filePath }]) + + expect(gitlabMock.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' // sha256 of 'content' + + const gitlabRepositoryFilesShowMock = gitlabMock.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue({ + content_sha256: hash, + } as RepositoryFileExpandedSchema) + + await service.maybeCommitUpdate(repoId, [{ content, filePath }]) + + expect(gitlabMock.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.create.mockResolvedValue({ id: groupId, name: projectSlug } as ExpandedGroupSchema) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug }) + expect(gitlabMock.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabMock.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const members = [{ id: 1, name: 'user' } as MemberSchema] + const gitlabGroupMembersAllMock = gitlabMock.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(groupId) + expect(result).toEqual(members) + expect(gitlabMock.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const userId = 2 + const accessLevel = 30 + gitlabMock.GroupMembers.add.mockResolvedValue({ id: userId } as MemberSchema) + + await service.addGroupMember(groupId, userId, accessLevel) + expect(gitlabMock.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const userId = 2 + gitlabMock.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(groupId, userId) + expect(gitlabMock.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createEmptyProjectRepository', () => { + it('should create repository and first commit', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const groupId = 456 + const projectId = 789 + + // Mock getOrCreateProjectGroup + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) // root + gitlabMock.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123 }], + paginationInfo: { next: null }, + }) + + gitlabMock.Projects.create.mockResolvedValue({ id: projectId } as ProjectSchema) + + await service.createEmptyProjectRepository(projectSlug, repoName) + + expect(gitlabMock.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + expect(gitlabMock.Commits.create).toHaveBeenCalledWith(projectId, 'main', expect.any(String), []) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 000000000..c5ad81b63 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,320 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type { AccessTokenScopes, CommitAction, GroupSchema, ProjectSchema } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { readGitlabCIConfigContent, readMirrorScriptContent, find, offsetPaginate, hasFileContentChanged } from './gitlab.utils' +import { GitlabClientService } from './gitlab-client.service' +import { INFRA_GROUP_PATH, INTERNAL_MIRROR_REPO_NAME } from './gitlab.constant' +import { join } from 'node:path' + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabClientService) private readonly client: GitlabClientService, + @Inject(ConfigurationService) private readonly configService: ConfigurationService, + ) { + } + + async getGroupByPath(path: string) { + return find( + offsetPaginate(opts => this.client.Groups.all({ search: path, ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + return this.client.Groups.create(path, path) + } + + async createSubGroup(parentGroup: GroupSchema, name: string) { + return this.client.Groups.create(name, name, { parentId: parentGroup.id }) + } + + async getOrCreateGroup(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + // Find or create root + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + + // Recursively create subgroups + for (const part of parts) { + const fullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(fullPath) ?? await this.createSubGroup(parentGroup, part) + } + + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.configService.projectRootPath) throw new Error('projectRootPath not configured') + return this.getOrCreateGroup(this.configService.projectRootPath) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + if (!this.configService.projectRootPath) throw new Error('projectRootPath not configured') + return this.getOrCreateGroup(`${this.configService.projectRootPath}/${subGroupPath}`) + } + + async getGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return `${this.configService.gitlabUrl}/${projectGroup.full_path}` + } + + async getInfraGroupRepoPublicUrl(internalRepoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return `${this.configService.gitlabUrl}/${projectGroup.full_path}/${INFRA_GROUP_PATH}/${internalRepoName}.git` + } + + async getInternalRepoUrl(projectSlug: string, internalRepoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(projectSlug) + return `${this.configService.gitlabInternalUrl}/${projectGroup.full_path}/${internalRepoName}.git` + } + + async getOrCreateProjectGroupRepo(path: string) { + if (!this.configService.projectRootPath) throw new Error('projectRootPath not configured') + const repo = await find( + offsetPaginate(opts => this.client.Projects.all({ + search: `${this.configService.projectRootPath}/${path}`, + ...opts, + })), + p => p.path_with_namespace === `${this.configService.projectRootPath}/${path}`, + ) + if (repo) return repo + const parts = path.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + return this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + }) + } + + async getOrCreateInfraGroupRepo(path: string) { + return this.getOrCreateProjectGroupRepo(join(INFRA_GROUP_PATH, path)) + } + + async getFile(repoId: number, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repoId, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`File not found: ${filePath}`) + } else { + throw error + } + } + } + + async maybeCommitUpdate( + repoId: number, + files: { content: string, filePath: string }[], + message: string = 'ci: :robot_face: Update file content', + ref: string = 'main', + ): Promise { + const promises = await Promise.all(files.map(async ({ content, filePath }) => + this.generateCreateOrUpdateAction(repoId, ref, filePath, content), + )) + const actions = promises.filter(action => !!action) + if (actions.length === 0) { + this.logger.debug('No files to update') + return + } + await this.client.Commits.create(repoId, ref, message, actions) + } + + async generateCreateOrUpdateAction(repoId: number, ref, filePath, content: string) { + const file = await this.getFile(repoId, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`File content is up to date, no need to commit: ${filePath}`) + return null + } + return { + action: file ? 'update' : 'create', + filePath, + content, + } satisfies CommitAction + } + + async maybeCommitDelete(repoId: number, paths: string[], ref: string = 'main'): Promise { + const actions = paths.map(path => ({ + action: 'delete', + filePath: path, + } satisfies CommitAction)) + if (actions.length === 0) { + this.logger.debug('No files to delete') + return + } + await this.client.Commits.create(repoId, ref, 'ci: :robot_face: Delete files', actions) + } + + async listFiles(repoId: number, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + return await this.client.Repositories.allRepositoryTrees(repoId, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(groupId: number): Promise { + await this.client.Groups.remove(groupId) + } + + // --- Members --- + + async getGroupMembers(groupId: number) { + return this.client.GroupMembers.all(groupId) + } + + async addGroupMember(groupId: number, userId: number, accessLevel: number) { + return this.client.GroupMembers.add(groupId, userId, accessLevel) + } + + async removeGroupMember(groupId: number, userId: number) { + return this.client.GroupMembers.remove(groupId, userId) + } + + async getUserByEmail(email: string) { + const [user] = await this.client.Users.all({ search: email }) + return user + } + + async createUser(email: string, username: string, name: string) { + // Note: This requires admin token usually + return this.client.Users.create({ + email, + username, + name, + password: Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8), // Dummy password + skipConfirmation: true, + }) + } + + // --- Repositories --- + + async *getRepositories(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async createEmptyProjectRepository(projectSlug: string, repoName: string, description?: string, clone?: boolean) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const project = await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: group.id, + description, + // ciConfigPath: ... + }) + + if (!clone) { + // Initialize with empty commit if not cloning + try { + await this.client.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } catch (_e) { + // ignore + } + } + return project + } + + async commitMirror(repoId: number) { + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: await readGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: await readMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + } + + // --- Tokens --- + + async getProjectToken(projectSlug: string, tokenName: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + offsetPaginate(opts => this.client.GroupAccessTokens.all(group.id, opts)), + token => token.name === tokenName, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expiryDate = new Date() + expiryDate.setFullYear(expiryDate.getFullYear() + 1) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toLocaleDateString('en-CA')) + } + + async getMirrorProjectTriggerToken(projectSlug: string) { + const tokenDescription = 'mirroring-from-external-repo' + const repositoriesGenerator = await this.getRepositories(projectSlug) + let mirrorRepo: ProjectSchema | undefined + for await (const repo of repositoriesGenerator) { + if (repo.name === INTERNAL_MIRROR_REPO_NAME) { + mirrorRepo = repo + break + } + } + + if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') + + const currentTriggerToken = await find( + offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === tokenDescription, + ) + + // Note: The logic to compare with Vault and recreate if missing is in Controller. + // Here we just get or create. + // Actually, plugin recreates if missing in Vault. + // So maybe we just return current if exists. + + if (currentTriggerToken) { + return { token: currentTriggerToken.token, repoId: mirrorRepo.id, id: currentTriggerToken.id } + } + + const triggerToken = await this.client.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + return { token: triggerToken.token, repoId: mirrorRepo.id, id: triggerToken.id } + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 000000000..1e58dd0c0 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,50 @@ +import type { PaginationRequestOptions, BaseRequestOptions, OffsetPagination, RepositoryFileExpandedSchema } from '@gitbeaker/core' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +export function digestContent(content: string) { + return createHash('sha256').update(content).digest('hex') +} + +export function hasFileContentChanged(file: RepositoryFileExpandedSchema, content: string) { + return file?.content_sha256 !== digestContent(content) +} + +export function readGitlabCIConfigContent() { + return readFile(join(__dirname, './files/.gitlab-ci.yml'), 'utf-8') +} + +export async function readMirrorScriptContent() { + return await readFile(join(__dirname, './files/mirror.sh'), 'utf-8') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} + +export async function *offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, +): AsyncGenerator { + let page: number | null = 1 + while (page !== null) { + const { data, paginationInfo } = await request({ page, showExpanded: true, pagination: 'offset' }) + for (const item of data) { + yield item + } + page = paginationInfo.next ? paginationInfo.next : null + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts new file mode 100644 index 000000000..93d81fcf3 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts @@ -0,0 +1 @@ +export const CONSOLE_GROUP_NAME = 'console' diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 000000000..fa0be08c7 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,97 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + } + + private async request(method: string, path: string, options: { body?: any, token?: string, allow404?: boolean } = {}) { + const url = `${this.config.vaultInternalUrl}${path}` + const headers: Record = { + 'Content-Type': 'application/json', + } + if (options.token) { + headers['X-Vault-Token'] = options.token + } else if (this.config.vaultToken) { + headers['X-Vault-Token'] = this.config.vaultToken + } + + const response = await fetch(url, { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + + if (options.allow404 && response.status === 404) { + return undefined + } + + if (!response.ok) { + throw new Error(`Vault request failed: ${response.status} ${response.statusText}`) + } + + if (response.status === 204) return undefined + + return response.json() + } + + private async getToken() { + if (this.config.vaultToken) { + try { + const data = await this.request('POST', '/v1/auth/token/create', { token: this.config.vaultToken }) + return data.auth.client_token + } catch (error) { + this.logger.error('Failed to create vault token, falling back to env token', error) + return this.config.vaultToken + } + } + } + + async read(path: string): Promise { + if (path.startsWith('/')) path = path.slice(1) + try { + const token = await this.getToken() + const data = await this.request('GET', `/v1/${this.config.vaultKvName}/data/${path}`, { + token, + allow404: true, + }) + if (!data) return undefined + return data.data.data + } catch (error) { + this.logger.error(`Failed to read vault path ${path}: ${error}`) + throw error + } + } + + async write(data: any, path: string): Promise { + if (path.startsWith('/')) path = path.slice(1) + try { + const token = await this.getToken() + await this.request('POST', `/v1/${this.config.vaultKvName}/data/${path}`, { + token, + body: { data }, + }) + } catch (error) { + this.logger.error(`Failed to write vault path ${path}: ${error}`) + throw error + } + } + + async destroy(path: string): Promise { + if (path.startsWith('/')) path = path.slice(1) + try { + const token = await this.getToken() + await this.request('DELETE', `/v1/${this.config.vaultKvName}/metadata/${path}`, { + token, + }) + } catch (error) { + this.logger.error(`Failed to destroy vault path ${path}: ${error}`) + throw error + } + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 000000000..6216fd6c0 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { VaultClientService } from './vault-client.service' +import { VaultService } from './vault.service' + +@Module({ + imports: [ConfigurationModule], + providers: [VaultService, VaultClientService], + exports: [VaultService], +}) +export class VaultModule {} diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 000000000..d27e7d0f4 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,102 @@ +import { Test } from '@nestjs/testing' +import { VaultService } from './vault.service' +import { VaultClientService } from './vault-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { describe, beforeEach, it, expect, beforeAll, afterAll, afterEach, type Mocked } from 'vitest' +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' + +const vaultInternalUrl = 'http://vault.internal' + +const server = setupServer( + http.post(`${vaultInternalUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' } } }) + }), + http.post(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultInternalUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: 204 }) + }), +) + +function createVaultServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + VaultService, + VaultClientService, + { + provide: ConfigurationService, + useValue: { + vaultToken: 'token', + vaultUrl: 'http://vault', + vaultInternalUrl, + vaultKvName: 'kv', + } satisfies Partial, + }, + ], + }) +} + +describe('vaultService', () => { + let service: Mocked + + beforeAll(() => server.listen()) + beforeEach(async () => { + const module = await createVaultServiceTestingModule().compile() + service = module.get(VaultService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('getProjectValues', () => { + it('should get project values', async () => { + const result = await service.getProjectValues('project-id') + expect(result).toEqual({ secret: 'value' }) + }) + + it('should return empty object if undefined', async () => { + server.use( + http.get(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return new HttpResponse(null, { status: 404 }) + }), + ) + + const result = await service.getProjectValues('project-id') + expect(result).toEqual({}) + }) + }) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ secret: 'value' }) + }) + + it('should return undefined if 404', async () => { + server.use( + http.get(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return new HttpResponse(null, { status: 404 }) + }), + ) + + const result = await service.read('path') + expect(result).toBeUndefined() + }) + }) + + describe('write', () => { + it('should write secret', async () => { + await service.write({ secret: 'value' }, 'path') + }) + }) + + describe('destroy', () => { + it('should destroy secret', async () => { + await service.destroy('path') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 000000000..a80d0058f --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { VaultClientService } from './vault-client.service' + +@Injectable() +export class VaultService { + private readonly logger = new Logger(VaultService.name) + + constructor( + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + this.logger.log('VaultService initialized with config:', config) + } + + async getProjectValues(projectId: string): Promise> { + const path = this.config.projectRootPath + ? `${this.config.projectRootPath}/${projectId}` + : projectId + const values = await this.vaultClient.read(path) + return values || {} + } + + async read(path: string): Promise { + return await this.vaultClient.read(path) + } + + async write(data: any, path: string): Promise { + await this.vaultClient.write(data, path) + } + + async destroy(path: string): Promise { + await this.vaultClient.destroy(path) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dc9312a2..5cd364feb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,7 +137,7 @@ importers: version: 6.0.1(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1))(vue@3.5.23(typescript@5.9.3)) '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) '@vue/eslint-config-typescript': specifier: ^14.1.4 version: 14.6.0(eslint-plugin-vue@9.33.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) @@ -191,7 +191,7 @@ importers: version: 1.1.0(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) vue-eslint-parser: specifier: ^9.4.3 version: 9.4.3(eslint@9.39.1(jiti@2.6.1)) @@ -306,7 +306,7 @@ importers: version: 7.16.0 vitest-mock-extended: specifier: ^2.0.2 - version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) devDependencies: '@cpn-console/eslint-config': specifier: workspace:^ @@ -325,7 +325,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) fastify-plugin: specifier: ^5.0.1 version: 5.1.0 @@ -355,7 +355,7 @@ importers: version: 2.1.9(@types/node@24.10.0)(terser@5.44.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) apps/server-nestjs: dependencies: @@ -367,31 +367,31 @@ importers: version: 1.6.1(@casl/ability@6.8.0)(@prisma/client@6.19.0(prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)) '@cpn-console/argocd-plugin': specifier: workspace:^ - version: file:plugins/argocd(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/argocd(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/gitlab-plugin': specifier: workspace:^ - version: file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/harbor-plugin': specifier: workspace:^ - version: file:plugins/harbor(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/harbor(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/hooks': specifier: workspace:^ - version: file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/keycloak-plugin': specifier: workspace:^ - version: file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/nexus-plugin': specifier: workspace:^ - version: file:plugins/nexus(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/nexus(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': specifier: workspace:^ version: file:packages/shared(@types/node@22.19.3) '@cpn-console/sonarqube-plugin': specifier: workspace:^ - version: file:plugins/sonarqube(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/sonarqube(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/vault-plugin': specifier: workspace:^ - version: file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@fastify/cookie': specifier: ^9.4.0 version: 9.4.0 @@ -410,6 +410,9 @@ importers: '@gitbeaker/core': specifier: ^40.6.0 version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 '@gitbeaker/rest': specifier: ^40.6.0 version: 40.6.0 @@ -449,9 +452,6 @@ importers: '@ts-rest/open-api': specifier: ^3.52.1 version: 3.52.1(@ts-rest/core@3.52.1(@types/node@22.19.3)(zod@3.25.76))(zod@3.25.76) - axios: - specifier: 1.12.2 - version: 1.12.2 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -464,6 +464,9 @@ importers: fastify-keycloak-adapter: specifier: 2.3.2 version: 2.3.2(patch_hash=6846b953fc520dd1ca6cb2e790cf190cbc3ed9fa9ff69739100458c520293447) + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 json-2-csv: specifier: ^5.5.7 version: 5.5.10 @@ -496,7 +499,10 @@ importers: version: 7.16.0 vitest-mock-extended: specifier: ^2.0.2 - version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@cpn-console/eslint-config': specifier: workspace:^ @@ -531,6 +537,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 '@types/node': specifier: ^22.10.7 version: 22.19.3 @@ -539,7 +548,7 @@ importers: version: 6.0.3 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) eslint: specifier: ^9.18.0 version: 9.39.1(jiti@2.6.1) @@ -552,6 +561,9 @@ importers: jest: specifier: ^30.0.0 version: 30.2.0(@types/node@22.19.3)(ts-node@10.9.2(@types/node@22.19.3)(typescript@5.9.3)) + msw: + specifier: ^2.12.10 + version: 2.12.10(@types/node@22.19.3)(typescript@5.9.3) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -599,13 +611,13 @@ importers: version: 2.1.9(@types/node@22.19.3)(terser@5.44.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1) packages/eslintconfig: devDependencies: '@antfu/eslint-config': specifier: ^3.11.2 - version: 3.16.0(@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.23)(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 3.16.0(@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.23)(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) eslint: specifier: ^9.15.0 version: 9.39.1(jiti@2.6.1) @@ -620,7 +632,7 @@ importers: version: 0.4.0 vitest-mock-extended: specifier: ^2.0.2 - version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) zod: specifier: ^3.25.76 version: 3.25.76 @@ -639,7 +651,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -657,7 +669,7 @@ importers: version: 7.16.0 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) packages/shared: dependencies: @@ -688,7 +700,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -706,7 +718,7 @@ importers: version: 2.1.9(@types/node@24.10.0)(terser@5.44.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(terser@5.44.1) packages/test-utils: dependencies: @@ -800,7 +812,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -815,7 +827,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) plugins/gitlab: dependencies: @@ -858,7 +870,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -873,7 +885,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) plugins/harbor: dependencies: @@ -910,7 +922,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -928,7 +940,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) plugins/keycloak: dependencies: @@ -959,7 +971,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -974,7 +986,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) plugins/nexus: dependencies: @@ -1005,7 +1017,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -1020,7 +1032,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) plugins/sonarqube: dependencies: @@ -1054,7 +1066,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -1069,7 +1081,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) plugins/vault: dependencies: @@ -1094,7 +1106,7 @@ importers: version: 24.10.0 '@vitest/coverage-v8': specifier: ^2.1.8 - version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -1109,7 +1121,7 @@ importers: version: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) packages: @@ -2822,6 +2834,10 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -2932,6 +2948,15 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -3345,6 +3370,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -4492,6 +4520,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -5656,6 +5688,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql@16.13.0: + resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -5707,6 +5743,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + helmet@7.2.0: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} @@ -5966,6 +6005,9 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -6275,6 +6317,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} @@ -6847,6 +6893,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -7078,6 +7134,9 @@ packages: ospath@1.2.2: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -7202,6 +7261,9 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -7629,6 +7691,9 @@ packages: resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} engines: {node: '>=10'} + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7986,6 +8051,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -8185,6 +8253,10 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -8274,10 +8346,17 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.0.24: + resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} + tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true + tldts@7.0.24: + resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} + hasBin: true + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -8317,6 +8396,10 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -8463,6 +8546,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -8643,6 +8730,9 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + untildify@4.0.0: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} @@ -9210,7 +9300,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@antfu/eslint-config@3.16.0(@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.23)(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1))': + '@antfu/eslint-config@3.16.0(@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.23)(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.9.1 @@ -9219,7 +9309,7 @@ snapshots: '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - '@vitest/eslint-plugin': 1.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)) + '@vitest/eslint-plugin': 1.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)) eslint: 9.39.1(jiti@2.6.1) eslint-config-flat-gitignore: 1.0.1(eslint@9.39.1(jiti@2.6.1)) eslint-flat-config-utils: 1.1.0 @@ -10191,13 +10281,13 @@ snapshots: '@types/conventional-commits-parser': 5.0.2 chalk: 5.6.2 - '@cpn-console/argocd-plugin@file:plugins/argocd(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/argocd-plugin@file:plugins/argocd(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/gitlab-plugin': file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) - '@cpn-console/keycloak-plugin': file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/gitlab-plugin': file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + '@cpn-console/keycloak-plugin': file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) - '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@himenon/argocd-typescript-openapi': 1.2.2 '@types/js-yaml': 4.0.9 axios: 1.12.2 @@ -10208,11 +10298,11 @@ snapshots: - typescript - vitest - '@cpn-console/gitlab-plugin@file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/gitlab-plugin@file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) - '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@gitbeaker/core': 40.6.0 '@gitbeaker/requester-utils': 40.6.0 '@gitbeaker/rest': 40.6.0 @@ -10224,12 +10314,12 @@ snapshots: - typescript - vitest - '@cpn-console/harbor-plugin@file:plugins/harbor(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/harbor-plugin@file:plugins/harbor(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) - '@cpn-console/keycloak-plugin': file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + '@cpn-console/keycloak-plugin': file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) - '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) axios: 1.12.2 bytes: 3.1.2 cron-validator: 1.4.0 @@ -10239,20 +10329,20 @@ snapshots: - typescript - vitest - '@cpn-console/hooks@file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/hooks@file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) json-schema: 0.4.0 - vitest-mock-extended: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + vitest-mock-extended: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) zod: 3.25.76 transitivePeerDependencies: - '@types/node' - typescript - vitest - '@cpn-console/keycloak-plugin@file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/keycloak-plugin@file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) '@keycloak/keycloak-admin-client': 26.4.2 axios: 1.12.2 @@ -10262,12 +10352,12 @@ snapshots: - typescript - vitest - '@cpn-console/nexus-plugin@file:plugins/nexus(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/nexus-plugin@file:plugins/nexus(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/gitlab-plugin': file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/gitlab-plugin': file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) - '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) axios: 1.12.2 transitivePeerDependencies: - '@types/node' @@ -10284,13 +10374,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@cpn-console/sonarqube-plugin@file:plugins/sonarqube(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/sonarqube-plugin@file:plugins/sonarqube(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/gitlab-plugin': file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) - '@cpn-console/keycloak-plugin': file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/gitlab-plugin': file:plugins/gitlab(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) + '@cpn-console/keycloak-plugin': file:plugins/keycloak(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) - '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/vault-plugin': file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) axios: 1.12.2 transitivePeerDependencies: - '@types/node' @@ -10305,9 +10395,9 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@cpn-console/vault-plugin@file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@cpn-console/vault-plugin@file:plugins/vault(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': dependencies: - '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)) + '@cpn-console/hooks': file:packages/hooks(@types/node@22.19.3)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)) '@cpn-console/shared': file:packages/shared(@types/node@22.19.3) axios: 1.12.2 transitivePeerDependencies: @@ -10357,7 +10447,7 @@ snapshots: combined-stream: 1.0.8 extend: 3.0.2 forever-agent: 0.6.1 - form-data: 4.0.4 + form-data: 4.0.5 http-signature: 1.4.0 is-typedarray: 1.0.0 isstream: 0.1.2 @@ -10609,7 +10699,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -10733,7 +10823,7 @@ snapshots: '@gitbeaker/requester-utils@40.6.0': dependencies: picomatch-browser: 2.2.6 - qs: 6.14.0 + qs: 6.14.1 rate-limiter-flexible: 4.0.1 xcase: 2.0.1 @@ -10811,6 +10901,14 @@ snapshots: optionalDependencies: '@types/node': 22.19.3 + '@inquirer/confirm@5.1.21(@types/node@24.10.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.10.0) + '@inquirer/type': 3.0.10(@types/node@24.10.0) + optionalDependencies: + '@types/node': 24.10.0 + optional: true + '@inquirer/core@10.3.2(@types/node@22.19.3)': dependencies: '@inquirer/ansi': 1.0.2 @@ -10824,6 +10922,20 @@ snapshots: optionalDependencies: '@types/node': 22.19.3 + '@inquirer/core@10.3.2(@types/node@24.10.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.10.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.10.0 + optional: true + '@inquirer/editor@4.2.23(@types/node@22.19.3)': dependencies: '@inquirer/core': 10.3.2(@types/node@22.19.3) @@ -10932,6 +11044,11 @@ snapshots: optionalDependencies: '@types/node': 22.19.3 + '@inquirer/type@3.0.10(@types/node@24.10.0)': + optionalDependencies: + '@types/node': 24.10.0 + optional: true + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -11218,6 +11335,15 @@ snapshots: '@lukeed/ms@2.0.2': {} + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.0 @@ -11346,6 +11472,15 @@ snapshots: dependencies: consola: 3.4.2 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -11760,6 +11895,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/statuses@2.0.6': {} + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -12132,7 +12269,25 @@ snapshots: vite: 7.2.1(@types/node@24.10.0)(jiti@2.6.1)(terser@5.44.1)(tsx@4.19.3)(yaml@2.8.1) vue: 3.5.23(typescript@5.9.3) - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1))': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -12146,11 +12301,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1) + vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(terser@5.44.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1))': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -12164,18 +12319,18 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1))': + '@vitest/eslint-plugin@1.4.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1))': dependencies: '@typescript-eslint/scope-manager': 8.46.3 '@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) transitivePeerDependencies: - supports-color @@ -12186,20 +12341,31 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3)(terser@5.44.1))': + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.3)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.12.10(@types/node@22.19.3)(typescript@5.9.3) vite: 5.4.21(@types/node@22.19.3)(terser@5.44.1) - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.0)(terser@5.44.1))': + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(vite@5.4.21(@types/node@24.10.0)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.12.10(@types/node@24.10.0)(typescript@5.7.2) + vite: 5.4.21(@types/node@24.10.0)(terser@5.44.1) + + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.0)(terser@5.44.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.10.0)(typescript@5.9.3) vite: 5.4.21(@types/node@24.10.0)(terser@5.44.1) '@vitest/pretty-format@2.1.9': @@ -12750,7 +12916,7 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.7.1 on-finished: 2.4.1 - qs: 6.14.0 + qs: 6.14.1 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -13122,6 +13288,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + cookiejar@2.1.4: {} core-js-compat@3.46.0: @@ -13152,7 +13320,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -14147,7 +14315,7 @@ snapshots: router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 - statuses: 2.0.1 + statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 transitivePeerDependencies: @@ -14335,7 +14503,7 @@ snapshots: escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -14682,6 +14850,8 @@ snapshots: graphemer@1.4.0: {} + graphql@16.13.0: {} + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -14732,6 +14902,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + helmet@7.2.0: {} help-me@5.0.0: {} @@ -14974,6 +15146,8 @@ snapshots: is-negative-zero@2.0.3: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -15459,6 +15633,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsbn@0.1.1: optional: true @@ -16220,6 +16398,83 @@ snapshots: ms@2.1.3: {} + msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@22.19.3) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.10.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.7.2 + transitivePeerDependencies: + - '@types/node' + optional: true + + msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.10.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + muggle-string@0.4.1: {} multer@2.0.2: @@ -16467,6 +16722,8 @@ snapshots: ospath@1.2.2: optional: true + outvariant@1.4.3: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -16588,6 +16845,8 @@ snapshots: lru-cache: 11.2.2 minipass: 7.1.2 + path-to-regexp@6.3.0: {} + path-to-regexp@8.3.0: {} path-type@3.0.0: @@ -16993,7 +17252,7 @@ snapshots: request-oauth@1.0.1: dependencies: oauth-sign: 0.9.0 - qs: 6.14.0 + qs: 6.14.1 uuid: 8.3.2 request-progress@3.0.0: @@ -17035,6 +17294,8 @@ snapshots: ret@0.4.3: {} + rettime@0.10.1: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -17464,6 +17725,8 @@ snapshots: streamsearch@1.1.0: {} + strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} string-length@4.0.2: @@ -17754,6 +18017,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + tagged-tag@1.0.0: {} + tapable@2.3.0: {} tcp-port-used@1.0.2: @@ -17833,10 +18098,16 @@ snapshots: tldts-core@6.1.86: {} + tldts-core@7.0.24: {} + tldts@6.1.86: dependencies: tldts-core: 6.1.86 + tldts@7.0.24: + dependencies: + tldts-core: 7.0.24 + tmp@0.2.5: optional: true @@ -17868,6 +18139,10 @@ snapshots: dependencies: tldts: 6.1.86 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.24 + tr46@0.0.3: {} tr46@1.0.1: @@ -18004,6 +18279,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.4.4: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -18255,6 +18534,8 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + until-async@3.0.2: {} + untildify@4.0.0: optional: true @@ -18401,22 +18682,22 @@ snapshots: tsx: 4.19.3 yaml: 2.8.1 - vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1)): + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1)): dependencies: ts-essentials: 10.1.1(typescript@5.9.3) typescript: 5.9.3 - vitest: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1) + vitest: 2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1) - vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1)): + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1)): dependencies: ts-essentials: 10.1.1(typescript@5.9.3) typescript: 5.9.3 - vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1) + vitest: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1) - vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(terser@5.44.1): + vitest@2.1.9(@types/node@22.19.3)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)(terser@5.44.1)) + '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.3)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -18449,10 +18730,46 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(terser@5.44.1): + vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(terser@5.44.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@24.10.0)(typescript@5.7.2))(vite@5.4.21(@types/node@24.10.0)(terser@5.44.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.10.0)(terser@5.44.1) + vite-node: 2.1.9(@types/node@24.10.0)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.0 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.0)(terser@5.44.1)) + '@vitest/mocker': 2.1.9(msw@2.12.10(@types/node@24.10.0)(typescript@5.9.3))(vite@5.4.21(@types/node@24.10.0)(terser@5.44.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9