From 8729f02aaf18b9112913a434a40a703aa0bd60a7 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 17 Apr 2026 16:16:20 +0200 Subject: [PATCH] refactor(server-nestjs): migrate package managers to NestJS Signed-off-by: William Phetsinorath Change-Id: I37010cbbfad6e0ac2dbd2adb8e560be36a6a6964 --- .../src/modules/healthz/healthz.controller.ts | 6 + .../src/modules/healthz/healthz.module.ts | 4 + .../nexus/nexus-client.service.spec.ts | 35 ++ .../src/modules/nexus/nexus-client.service.ts | 299 ++++++++++++ .../modules/nexus/nexus-datastore.service.ts | 48 ++ .../src/modules/nexus/nexus-health.service.ts | 30 ++ .../nexus/nexus-http-client.service.ts | 123 +++++ .../src/modules/nexus/nexus-testing.utils.ts | 13 + .../src/modules/nexus/nexus.constants.ts | 13 + .../src/modules/nexus/nexus.module.ts | 17 + .../src/modules/nexus/nexus.service.spec.ts | 139 ++++++ .../src/modules/nexus/nexus.service.ts | 422 ++++++++++++++++ .../src/modules/nexus/nexus.utils.ts | 68 +++ .../registry/registry-client.service.spec.ts | 50 ++ .../registry/registry-client.service.ts | 449 ++++++++++++++++++ .../registry/registry-datastore.service.ts | 43 ++ .../registry/registry-health.service.ts | 30 ++ .../registry/registry-http-client.service.ts | 53 +++ .../modules/registry/registry.constants.ts | 6 + .../src/modules/registry/registry.module.ts | 17 + .../src/modules/registry/registry.service.ts | 59 +++ .../src/modules/registry/registry.utils.ts | 71 +++ apps/server-nestjs/test/nexus.e2e-spec.ts | 148 ++++++ apps/server-nestjs/test/registry.e2e-spec.ts | 99 ++++ 24 files changed, 2242 insertions(+) create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-client.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-health.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.constants.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.module.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.utils.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-client.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-health.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-http-client.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.constants.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.module.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.utils.ts create mode 100644 apps/server-nestjs/test/nexus.e2e-spec.ts create mode 100644 apps/server-nestjs/test/registry.e2e-spec.ts diff --git a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts index ab01a10357..c389446303 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -4,6 +4,8 @@ import { DatabaseHealthService } from '../../cpin-module/infrastructure/database import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' import { VaultHealthService } from '../vault/vault-health.service' @Controller('api/v1/healthz') @@ -14,6 +16,8 @@ export class HealthzController { @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, @Inject(GitlabHealthService) private readonly gitlab: GitlabHealthService, @Inject(VaultHealthService) private readonly vault: VaultHealthService, + @Inject(NexusHealthService) private readonly nexus: NexusHealthService, + @Inject(RegistryHealthService) private readonly registry: RegistryHealthService, @Inject(ArgoCDHealthService) private readonly argocd: ArgoCDHealthService, ) {} @@ -25,6 +29,8 @@ export class HealthzController { () => this.keycloak.check('keycloak'), () => this.gitlab.check('gitlab'), () => this.vault.check('vault'), + () => this.nexus.check('nexus'), + () => this.registry.check('registry'), () => this.argocd.check('argocd'), ]) } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 995ae24a5f..5c466de626 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -4,6 +4,8 @@ import { DatabaseModule } from '../../cpin-module/infrastructure/database/databa import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' import { VaultModule } from '../vault/vault.module' import { HealthzController } from './healthz.controller' @@ -14,6 +16,8 @@ import { HealthzController } from './healthz.controller' KeycloakModule, GitlabModule, VaultModule, + NexusModule, + RegistryModule, ArgoCDModule, ], controllers: [HealthzController], diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..754dc2a144 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,35 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' + +function createNexusServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: ConfigurationService, + useValue: { + nexusSecretExposedUrl: 'https://nexus.example', + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('nexusClientService', () => { + let service: NexusClientService + + beforeEach(async () => { + const module: TestingModule = await createNexusServiceTestingModule().compile() + service = module.get(NexusClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..951c57a56f --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,299 @@ +import type { NexusFetchOptions, NexusResponse } from './nexus-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' +import { generateMavenHostedRepoName, generateNpmHostedRepoName } from './nexus.utils' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +interface NexusMavenHostedRepositoryUpsertRequest { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenGroupRepositoryUpsertRequest { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmHostedRepositoryUpsertRequest { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmGroupRepositoryUpsertRequest { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusRepositoryViewPrivilegeUpsertRequest { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRoleCreateRequest { + id: string + name: string + description: string + privileges: string[] +} + +interface NexusRoleUpdateRequest { + id: string + name: string + privileges: string[] +} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + getProjectSecrets(args: { projectSlug: string, enableMaven: boolean, enableNpm: boolean }) { + const projectSlug = args.projectSlug + const nexusUrl = this.config.nexusSecretExposedUrl! + const secrets: Record = {} + if (args.enableMaven) { + secrets.MAVEN_REPO_RELEASE = `${nexusUrl}/${generateMavenHostedRepoName(projectSlug, 'release')}` + secrets.MAVEN_REPO_SNAPSHOT = `${nexusUrl}/${generateMavenHostedRepoName(projectSlug, 'snapshot')}` + } + if (args.enableNpm) { + secrets.NPM_REPO = `${nexusUrl}/${generateNpmHostedRepoName(projectSlug)}` + } + return secrets + } + + @StartActiveSpan() + private async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + return this.http.fetch(path, options) + } + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string): Promise { + try { + const res = await this.fetch(`/repositories/maven/hosted/${name}`, { method: 'GET' }) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest): Promise { + await this.fetch('/repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest): Promise { + await this.fetch(`/repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest): Promise { + await this.fetch('/repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string): Promise { + try { + const res = await this.fetch(`/repositories/maven/group/${name}`, { method: 'GET' }) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string): Promise { + try { + const res = await this.fetch(`/repositories/npm/hosted/${name}`, { method: 'GET' }) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest): Promise { + await this.fetch('/repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest): Promise { + await this.fetch(`/repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.fetch(`/repositories/npm/group/${name}`, { method: 'GET' }) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest): Promise { + await this.fetch('/repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest): Promise { + await this.fetch(`/repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.fetch(`/security/privileges/${name}`, { method: 'GET' }) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest): Promise { + await this.fetch('/security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest): Promise { + await this.fetch(`/security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string): Promise { + try { + await this.fetch(`/security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.fetch(`/security/roles/${id}`, { method: 'GET' }) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest): Promise { + await this.fetch('/security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest): Promise { + await this.fetch(`/security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string): Promise { + try { + await this.fetch(`/security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.fetch<{ userId: string }[]>(`/security/users?${query}`, { method: 'GET' }) + return (res.data as any) ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string): Promise { + await this.fetch(`/security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest): Promise { + await this.fetch('/security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string): Promise { + try { + await this.fetch(`/security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string): Promise { + try { + await this.fetch(`/repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..3d276d4bce --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,48 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../../cpin-module/infrastructure/database/prisma.service' +import { NEXUS_PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + }, + }, + plugins: { + select: { + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class NexusDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: NEXUS_PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..2e894a96d0 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + if (!this.config.nexusInternalUrl) return indicator.down('Not configured') + + const url = new URL('/service/rest/v1/status', this.config.nexusInternalUrl).toString() + const headers: Record = {} + if (this.config.nexusAdmin && this.config.nexusAdminPassword) { + headers.Authorization = `Basic ${Buffer.from(`${this.config.nexusAdmin}:${this.config.nexusAdminPassword}`).toString('base64')}` + } + + try { + const response = await fetch(url, { method: 'GET', headers }) + if (response.status < 500) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..08130900fc --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,123 @@ +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: number + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: number + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: number, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', 'Request failed', { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + if (!this.config.nexusInternalUrl) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL is required') + } + return new URL('service/rest/v1/', this.config.nexusInternalUrl).toString() + } + + private get basicAuth() { + if (!this.config.nexusAdmin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.config.nexusAdminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.baseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === 204) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..7598f97d58 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,13 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..d7fe704387 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,13 @@ +export const NEXUS_PLUGIN_NAME = 'nexus' + +export const NEXUS_CONFIG_KEYS = { + activateMavenRepo: 'activateMavenRepo', + activateNpmRepo: 'activateNpmRepo', + mavenSnapshotWritePolicy: 'mavenSnapshotWritePolicy', + mavenReleaseWritePolicy: 'mavenReleaseWritePolicy', + npmWritePolicy: 'npmWritePolicy', +} as const + +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..c6fe082f2d --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../../cpin-module/infrastructure/infrastructure.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusService } from './nexus.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [HealthIndicatorService, NexusHealthService, NexusService, NexusDatastoreService, NexusHttpClientService, NexusClientService], + exports: [NexusClientService, NexusHealthService], +}) +export class NexusModule {} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..4be28bbde2 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,139 @@ +import type { Mocked } from 'vitest' +import { ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service.js' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { NexusService } from './nexus.service' + +function createNexusControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusService, + { + provide: NexusClientService, + useValue: { + getRepositoriesMavenHosted: vi.fn(), + createRepositoriesMavenHosted: vi.fn(), + updateRepositoriesMavenHosted: vi.fn(), + createRepositoriesMavenGroup: vi.fn(), + getRepositoriesNpmHosted: vi.fn(), + createRepositoriesNpmHosted: vi.fn(), + updateRepositoriesNpmHosted: vi.fn(), + getRepositoriesNpmGroup: vi.fn(), + postRepositoriesNpmGroup: vi.fn(), + putRepositoriesNpmGroup: vi.fn(), + getSecurityPrivileges: vi.fn(), + createSecurityPrivilegesRepositoryView: vi.fn(), + updateSecurityPrivilegesRepositoryView: vi.fn(), + deleteSecurityPrivileges: vi.fn(), + getSecurityRoles: vi.fn(), + createSecurityRoles: vi.fn(), + updateSecurityRoles: vi.fn(), + deleteSecurityRoles: vi.fn(), + getSecurityUsers: vi.fn(), + updateSecurityUsersChangePassword: vi.fn(), + createSecurityUsers: vi.fn(), + deleteSecurityUsers: vi.fn(), + deleteRepositoriesByName: vi.fn(), + } satisfies Partial, + }, + { + provide: NexusDatastoreService, + useValue: { + getAllProjects: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultClientService, + useValue: { + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('nexusService', () => { + let service: NexusService + let client: Mocked + let nexusDatastore: Mocked + let vault: Mocked + + beforeEach(async () => { + const moduleRef = await createNexusControllerServiceTestingModule().compile() + service = moduleRef.get(NexusService) + client = moduleRef.get(NexusClientService) + nexusDatastore = moduleRef.get(NexusDatastoreService) + vault = moduleRef.get(VaultClientService) + + client.getRepositoriesMavenHosted.mockResolvedValue(null) + client.getRepositoriesNpmHosted.mockResolvedValue(null) + client.getRepositoriesNpmGroup.mockResolvedValue(null) + client.getSecurityPrivileges.mockResolvedValue(null) + client.getSecurityRoles.mockResolvedValue(null) + client.getSecurityUsers.mockResolvedValue([]) + vault.read.mockRejectedValue(new VaultError('NotFound', 'Not Found')) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + owner: { email: 'owner@example.com' }, + plugins: [ + { key: 'activateMavenRepo', value: ENABLED }, + { key: 'activateNpmRepo', value: 'disabled' }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: 'project-1', + NEXUS_PASSWORD: expect.any(String), + }), + 'forge/project-1/tech/NEXUS', + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails({ slug: 'project-1' }) + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith('project-1-ID') + expect(client.deleteSecurityUsers).toHaveBeenCalledWith('project-1') + expect(vault.delete).toHaveBeenCalledWith('forge/project-1/tech/NEXUS') + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ slug: 'project-1', plugins: [{ key: 'activateMavenRepo', value: ENABLED }] }), + makeProjectWithDetails({ slug: 'project-2', plugins: [{ key: 'activateNpmRepo', value: ENABLED }] }), + ] + + nexusDatastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..63d0a76822 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,422 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service.js' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NEXUS_CONFIG_KEYS } from './nexus.constants' +import { generateMavenGroupPrivilegeName, generateMavenGroupRepoName, generateMavenHostedPrivilegeName, generateMavenHostedRepoName, generateNpmGroupPrivilegeName, generateNpmGroupRepoName, generateNpmHostedPrivilegeName, generateNpmHostedRepoName, generateRandomPassword, getPluginConfig, getProjectVaultPath } from './nexus.utils' + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + @StartActiveSpan() + async handleUpsert(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + } + + @OnEvent('project.delete') + @StartActiveSpan() + async handleDelete(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.nexusDatastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + const projectSlug = project.slug + span?.setAttribute('project.slug', projectSlug) + + const ownerEmail = project.owner.email + await Promise.all([ + this.ensureMavenRepo(project), + this.ensureNpmRepo(project), + ]) + + await this.ensureRole(projectSlug) + + const vaultPath = getProjectVaultPath(this.config.projectRootDir, projectSlug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + const secret = await this.vault.read(vaultPath) + existingPassword = secret.data?.NEXUS_PASSWORD + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + const password = existingPassword ?? generateRandomPassword(30) + + await this.ensureUser(projectSlug, ownerEmail, password) + await this.vault.write({ + NEXUS_PASSWORD: password, + NEXUS_USERNAME: projectSlug, + }, vaultPath) + } + + private async ensureMavenRepo(project: ProjectWithDetails): Promise { + const enabled = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEYS.activateMavenRepo)) + const span = trace.getActiveSpan() + span?.setAttribute('nexus.maven.enabled', enabled ?? false) + + if (!enabled) { + await this.deleteMavenRepos(project.slug) + } + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEYS.mavenSnapshotWritePolicy) ?? 'allow' + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEYS.mavenReleaseWritePolicy) ?? 'allow_once' + await this.ensureMavenRepos(project.slug, { + snapshotWritePolicy: mavenSnapshotWritePolicy, + releaseWritePolicy: mavenReleaseWritePolicy, + }) + } + + private async ensureNpmRepo(project: ProjectWithDetails): Promise { + const enabled = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEYS.activateNpmRepo)) + const span = trace.getActiveSpan() + span?.setAttribute('nexus.npm.enabled', enabled ?? false) + + if (!enabled) { + await this.deleteNpmRepos(project.slug) + return [] + } + + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEYS.npmWritePolicy) ?? 'allow' + await this.createNpmRepos(project.slug, npmWritePolicy) + return [ + generateNpmGroupPrivilegeName(project.slug), + generateNpmHostedPrivilegeName(project.slug), + ] + } + + private async upsertPrivilege(body: { name: string, description: string, actions: string[], format: string, repository: string }) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + cleanup: { policyNames: ['string'] }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + cleanup: { policyNames: ['string'] }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(projectSlug: string, options: { snapshotWritePolicy: string, releaseWritePolicy: string }) { + const releaseRepoName = generateMavenHostedRepoName(projectSlug, 'release') + const snapshotRepoName = generateMavenHostedRepoName(projectSlug, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(projectSlug) + + const releasePrivilege = generateMavenHostedPrivilegeName(projectSlug, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(projectSlug, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(projectSlug) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsure = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + await this.ensureMavenPrivileges(projectSlug, privilegesToEnsure) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + try { + await this.client.createRepositoriesMavenGroup({ + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + }) + } catch { + } + } + + private async ensureMavenPrivileges(projectSlug: string, entries: Array<{ repo: string, privilege: string }>) { + for (const entry of entries) { + try { + await this.client.createSecurityPrivilegesRepositoryView({ + name: entry.privilege, + description: `Privilege for organization ${projectSlug} for repo ${entry.repo}`, + actions: ['all'], + format: 'maven2', + repository: entry.repo, + }) + } catch { + } + } + } + + private async deleteMavenRepos(projectSlug: string) { + const repoPaths = [ + { repo: generateMavenGroupRepoName(projectSlug) }, + { repo: generateMavenHostedRepoName(projectSlug, 'release') }, + { repo: generateMavenHostedRepoName(projectSlug, 'snapshot') }, + ] + const privileges = [ + { privilege: generateMavenGroupPrivilegeName(projectSlug) }, + { privilege: generateMavenHostedPrivilegeName(projectSlug, 'release') }, + { privilege: generateMavenHostedPrivilegeName(projectSlug, 'snapshot') }, + ] + const pathsToDelete = [ + ...privileges.map(({ privilege }) => `/security/privileges/${encodeURIComponent(privilege)}`), + ...repoPaths.map(repo => `/repositories/${encodeURIComponent(repo.repo)}`), + ] + for (const path of pathsToDelete) { + if (path.startsWith('/security/privileges/')) { + const name = decodeURIComponent(path.split('/').pop()!) + await this.client.deleteSecurityPrivileges(name) + } else if (path.startsWith('/repositories/')) { + const name = decodeURIComponent(path.split('/').pop()!) + await this.client.deleteRepositoriesByName(name) + } + } + } + + private async createNpmRepos(projectSlug: string, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(projectSlug) + const groupRepoName = generateNpmGroupRepoName(projectSlug) + + const hostedPrivilege = generateNpmHostedPrivilegeName(projectSlug) + const groupPrivilege = generateNpmGroupPrivilegeName(projectSlug) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + for (const name of [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ]) { + await this.upsertPrivilege({ + name: name.privilege, + description: `Privilege for organization ${projectSlug} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }) + } + } + + private async deleteNpmRepos(projectSlug: string) { + const repoPaths = [ + { repo: generateNpmGroupRepoName(projectSlug) }, + { repo: generateNpmHostedRepoName(projectSlug) }, + ] + const privileges = [ + { privilege: generateNpmGroupPrivilegeName(projectSlug) }, + { privilege: generateNpmHostedPrivilegeName(projectSlug) }, + ] + const pathsToDelete = [ + ...privileges.map(({ privilege }) => `/security/privileges/${encodeURIComponent(privilege)}`), + ...repoPaths.map(repo => `/repositories/${encodeURIComponent(repo.repo)}`), + ] + for (const path of pathsToDelete) { + if (path.startsWith('/security/privileges/')) { + const name = decodeURIComponent(path.split('/').pop()!) + await this.client.deleteSecurityPrivileges(name) + } else if (path.startsWith('/repositories/')) { + const name = decodeURIComponent(path.split('/').pop()!) + await this.client.deleteRepositoriesByName(name) + } + } + } + + private async ensureRole(projectSlug: string) { + const privileges = [ + generateMavenGroupPrivilegeName(projectSlug), + generateMavenHostedPrivilegeName(projectSlug, 'release'), + generateMavenHostedPrivilegeName(projectSlug, 'snapshot'), + generateNpmGroupPrivilegeName(projectSlug), + generateNpmHostedPrivilegeName(projectSlug), + ] + const roleId = `${projectSlug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${projectSlug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${projectSlug}-role`, + privileges, + }) + } + + private async ensureUser(projectSlug: string, ownerEmail: string, password: string) { + const users = await this.client.getSecurityUsers(projectSlug) + const existing = users.find(u => u.userId === projectSlug) + if (existing) { + await this.client.updateSecurityUsersChangePassword(projectSlug, password) + return + } + + await this.client.createSecurityUsers({ + userId: projectSlug, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: ownerEmail, + password, + status: 'active', + roles: [`${projectSlug}-ID`], + }) + } + + @StartActiveSpan() + private async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + await Promise.all([ + this.deleteMavenRepos(projectSlug), + this.deleteNpmRepos(projectSlug), + ]) + + await Promise.all([ + this.client.deleteSecurityRoles(`${projectSlug}-ID`), + this.client.deleteSecurityUsers(projectSlug), + ]) + + const vaultPath = getProjectVaultPath(this.config.projectRootDir, projectSlug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..cfa36fe390 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,68 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +const trailingSlashesRegex = /\/+$/u + +export function removeTrailingSlash(value: string) { + return value.replace(trailingSlashesRegex, '') +} + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export type WritePolicy = 'allow' | 'allow_once' | 'deny' | 'replication_only' + +export const writePolicies: WritePolicy[] = ['allow', 'allow_once', 'deny', 'replication_only'] + +export function assertWritePolicy(value: string): asserts value is WritePolicy { + if (!writePolicies.includes(value as WritePolicy)) { + throw new Error(`Invalid writePolicy: ${value}`) + } +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string | undefined, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${projectSlug}/${normalized}` + : `${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(projectSlug: string, kind: MavenHostedRepoKind) { + return `${projectSlug}-repository-${kind}` +} + +export function generateMavenHostedPrivilegeName(projectSlug: string, kind: MavenHostedRepoKind) { + return `${projectSlug}-privilege-${kind}` +} + +export function generateMavenGroupRepoName(projectSlug: string) { + return `${projectSlug}-repository-group` +} + +export function generateMavenGroupPrivilegeName(projectSlug: string) { + return `${projectSlug}-privilege-group` +} + +export function generateNpmHostedRepoName(projectSlug: string) { + return `${projectSlug}-npm` +} + +export function generateNpmHostedPrivilegeName(projectSlug: string) { + return `${projectSlug}-npm-privilege` +} + +export function generateNpmGroupRepoName(projectSlug: string) { + return `${projectSlug}-npm-group` +} + +export function generateNpmGroupPrivilegeName(projectSlug: string) { + return `${projectSlug}-npm-group-privilege` +} diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..64e7f97ec1 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,50 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const vaultMock = mockDeep() + +function createRegistryServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: vaultMock, + }, + { + provide: ConfigurationService, + useValue: { + harborUrl: 'https://harbor.example', + harborInternalUrl: 'https://harbor.example', + harborAdmin: 'admin', + harborAdminPassword: 'password', + harborRuleTemplate: 'latestPushedK', + harborRuleCount: '10', + harborRetentionCron: '0 22 2 * * *', + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('registryService', () => { + let service: RegistryClientService + + beforeEach(async () => { + mockReset(vaultMock) + const module: TestingModule = await createRegistryServiceTestingModule().compile() + service = module.get(RegistryClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..6227537a82 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,449 @@ +import type { VaultRobotSecret } from './registry.utils' +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service.js' +import { RegistryHttpClientService } from './registry-http-client.service' +import { + getHostFromUrl, + getProjectVaultPath, + toVaultRobotSecret, +} from './registry.utils' + +export interface HarborAccess { + resource: string + action: string +} + +export const roRobotName = 'ro-robot' +export const rwRobotName = 'rw-robot' +export const projectRobotName = 'project-robot' + +export const roAccess: HarborAccess[] = [ + { resource: 'repository', action: 'pull' }, + { resource: 'artifact', action: 'read' }, +] + +export const rwAccess: HarborAccess[] = [ + ...roAccess, + { resource: 'repository', action: 'list' }, + { resource: 'tag', action: 'list' }, + { resource: 'artifact', action: 'list' }, + { resource: 'scan', action: 'create' }, + { resource: 'scan', action: 'stop' }, + { resource: 'repository', action: 'push' }, + { resource: 'artifact-label', action: 'create' }, + { resource: 'artifact-label', action: 'delete' }, + { resource: 'tag', action: 'create' }, + { resource: 'tag', action: 'delete' }, +] + +interface HarborProject { + project_id?: number + metadata?: Record +} + +interface HarborRobot { + id?: number + name?: string +} + +interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +const allowedRuleTemplates = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +type RuleTemplate = typeof allowedRuleTemplates[number] + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string) { + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('/projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`/quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`/quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: any) { + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async getProjectRobots(projectName: string) { + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}/robots`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createRobot(body: any) { + return this.http.fetch('/robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(projectName: string, robotId: number) { + const direct = await this.http.fetch(`/robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + if (direct.status < 300 || direct.status === 404) return direct + + return this.http.fetch(`/projects/${encodeURIComponent(projectName)}/robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number((project.data as any)?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: any) { + return this.http.fetch('/retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: any) { + return this.http.fetch(`/retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } + + private get harborHost() { + return getHostFromUrl(this.config.harborUrl!) + } + + private getRobotFullName(projectSlug: string, robotName: string) { + return `robot$${projectSlug}+${robotName}` + } + + private async getRobot(projectSlug: string, robotName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.robot.name': robotName, + }) + const robots = await this.getProjectRobots(projectSlug) + if (robots.status !== 200 || !robots.data) return undefined + const fullName = this.getRobotFullName(projectSlug, robotName) + return (robots.data as any[]).find(r => r?.name === fullName) + } + + private getRobotPermissions(projectSlug: string, robotName: string, access: HarborAccess[]) { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: projectSlug, + kind: 'project', + access, + }], + } + } + + private async createProjectRobot(projectSlug: string, robotName: string, access: HarborAccess[]): Promise { + const created = await this.createRobot( + this.getRobotPermissions(projectSlug, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data as HarborRobotCreated + } + + private async regenerateRobot(projectSlug: string, robotName: string, access: HarborAccess[]): Promise { + const existing = await this.getRobot(projectSlug, robotName) + if (existing?.id) { + await this.deleteRobot(projectSlug, existing.id) + } + return this.createProjectRobot(projectSlug, robotName, access) + } + + private async ensureRobot(projectSlug: string, robotName: string, access: HarborAccess[]): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.robot.name': robotName, + }) + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(this.config.projectRootDir, projectSlug, relativeVaultPath) + let vaultRobotSecret: VaultRobotSecret | null = null + try { + const secret = await this.vault.read(vaultPath) + vaultRobotSecret = secret.data as VaultRobotSecret + } catch (error) { + if (!(error instanceof VaultError && error.kind === 'NotFound')) { + throw error + } + } + + if (vaultRobotSecret?.HOST === this.harborHost) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret + } + + const existing = await this.getRobot(projectSlug, robotName) + const created = existing + ? await this.regenerateRobot(projectSlug, robotName, access) + : await this.createProjectRobot(projectSlug, robotName, access) + const fullName = this.getRobotFullName(projectSlug, robotName) + const secret = toVaultRobotSecret(this.harborHost, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + @StartActiveSpan() + async addProjectGroupMember(projectSlug: string, groupName: string, accessLevel: number = 3) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const members = await this.getGroupMembers(projectSlug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + const list = members.data as any[] + const existing = list.find(m => m?.entity_name === groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel && existing.entity_type !== 'g') { + await this.removeGroupMember(projectSlug, Number(existing.id)) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const created = await this.addGroupMember(projectSlug, { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + }) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async upsertProject(projectSlug: string, storageLimit: number): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.getProjectByName(projectSlug) + if (existing.status === 200 && existing.data) { + const project = existing.data as HarborProject + const projectId = Number(project.project_id) + if (!Number.isFinite(projectId)) return project + + const quotas = await this.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = (quotas.data as any[]).find(q => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return project + } + + const created = await this.createProject(projectSlug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + span?.setAttribute('registry.project.created', true) + + const fetched = await this.getProjectByName(projectSlug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data as HarborProject + } + + private getRetentionPolicy(projectId: number) { + const template = allowedRuleTemplates.includes(this.config.harborRuleTemplate as RuleTemplate) + ? this.config.harborRuleTemplate as RuleTemplate + : 'latestPushedK' + + const rawCount = Number(this.config.harborRuleCount) + const count = Number.isFinite(rawCount) && rawCount > 0 + ? rawCount + : template === 'always' + ? 1 + : 10 + + const cron = this.config.harborRetentionCron?.trim() || '0 22 2 * * *' + + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template, + params: { [template]: count }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron }, + references: [], + }, + } + } + + private async upsertRetentionPolicy(projectSlug: string, projectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.project.id': projectId, + }) + const policy = this.getRetentionPolicy(projectId) + const retentionId = await this.getRetentionId(projectSlug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.updateRetention(retentionId, policy) + : await this.createRetention(policy) + if (result.status >= 300) { + throw new Error(`Harbor retention policy failed (${result.status})`) + } + } + + @StartActiveSpan() + async provisionProject(projectSlug: string, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const project = await this.upsertProject(projectSlug, storageLimit) + const projectId = Number(project.project_id) + + const groupName = `/${projectSlug}` + + await Promise.all([ + this.ensureRobot(projectSlug, roRobotName, roAccess), + this.ensureRobot(projectSlug, rwRobotName, rwAccess), + this.addProjectGroupMember(projectSlug, groupName), + Number.isFinite(projectId) ? this.upsertRetentionPolicy(projectSlug, projectId) : Promise.resolve(), + options.publishProjectRobot + ? this.ensureRobot(projectSlug, projectRobotName, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: Number.isFinite(projectId) ? projectId : undefined, + basePath: `${this.harborHost}/${projectSlug}/`, + } + } + + @StartActiveSpan() + async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const existing = await this.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..20173b55dd --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,43 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../../cpin-module/infrastructure/database/prisma.service' +import { REGISTRY_PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + select: { + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: REGISTRY_PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..4494667af0 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + if (!this.config.harborInternalUrl) return indicator.down('Not configured') + + const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() + const headers: Record = {} + if (this.config.harborAdmin && this.config.harborAdminPassword) { + headers.Authorization = `Basic ${Buffer.from(`${this.config.harborAdmin}:${this.config.harborAdminPassword}`).toString('base64')}` + } + + try { + const response = await fetch(url, { method: 'GET', headers }) + if (response.status < 500) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..25a45e04e4 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable } from '@nestjs/common' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { encodeBasicAuth, removeTrailingSlash } from './registry.utils' + +export interface RegistryHttpRequestOptions { + method?: string + headers?: Record + body?: any +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + private get baseUrl() { + return `${removeTrailingSlash(this.config.harborInternalUrl!)}/api/v2.0` + } + + private get defaultHeaders() { + return { + Accept: 'application/json', + Authorization: `Basic ${encodeBasicAuth(this.config.harborAdmin!, this.config.harborAdminPassword!)}`, + } as const + } + + async fetch( + path: string, + options: RegistryHttpRequestOptions = {}, + ): Promise<{ status: number, data: T | null }> { + const url = `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}` + const headers: Record = { + ...this.defaultHeaders, + ...options.headers, + } + if (options.body) headers['Content-Type'] = 'application/json' + const response = await fetch(url, { + method: options.method ?? 'GET', + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + + if (response.status === 204) return { status: response.status, data: null } + + const contentType = response.headers.get('content-type') ?? '' + const body = contentType.includes('application/json') + ? await response.json() + : await response.text() + + return { status: response.status, data: body as T } + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry.constants.ts b/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..b7e47ad8ff --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,6 @@ +export const REGISTRY_PLUGIN_NAME = 'harbor' + +export const REGISTRY_CONFIG_KEYS = { + quotaHardLimit: 'quotaHardLimit', + publishProjectRobot: 'publishProjectRobot', +} as const diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..69bebfba42 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../../cpin-module/infrastructure/infrastructure.module' +import { VaultModule } from '../vault/vault.module' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryService } from './registry.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [HealthIndicatorService, RegistryHealthService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryService], +}) +export class RegistryModule {} diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..3fd428048f --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,59 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { REGISTRY_CONFIG_KEYS } from './registry.constants' +import { parseBytes } from './registry.utils' + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly registryDatastore: RegistryDatastoreService, + ) { + this.logger.log('RegistryService initialized') + } + + @OnEvent('project.upsert') + @StartActiveSpan() + async handleUpsert(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEYS.quotaHardLimit) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEYS.publishProjectRobot) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota === 1 ? -1 : parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.client.provisionProject(project.slug, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + @StartActiveSpan() + async handleDelete(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.client.deleteProject(project.slug) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.registryDatastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.handleUpsert(p))) + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..51d1ec6632 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,71 @@ +const trailingSlashesRegex = /\/+$/u +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +export function removeTrailingSlash(value: string) { + return value.replace(trailingSlashesRegex, '') +} + +export function getHostFromUrl(url: string) { + return removeTrailingSlash(url).replace(protocolPrefixRegex, '').split('/')[0] +} + +export function encodeBasicAuth(username: string, password: string) { + return Buffer.from(`${username}:${password}`).toString('base64') +} + +export interface VaultRobotSecret { + DOCKER_CONFIG: string + HOST: string + TOKEN: string + USERNAME: string +} + +export function toVaultRobotSecret(host: string, robotName: string, robotSecret: string): VaultRobotSecret { + const auth = `${robotName}:${robotSecret}` + const b64auth = Buffer.from(auth).toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: host, + TOKEN: robotSecret, + USERNAME: robotName, + } +} + +export function getProjectVaultPath(projectRootDir: string | undefined, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${projectSlug}/${normalized}` + : `${projectSlug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = raw.match(parseBytesRegex) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[3] ?? 'b').toLowerCase() + const pow + = unit === 'kb' || unit === 'k' + ? 1 + : unit === 'mb' || unit === 'm' + ? 2 + : unit === 'gb' || unit === 'g' + ? 3 + : unit === 'tb' || unit === 't' + ? 4 + : 0 + return Math.round(value * 1024 ** pow) +} diff --git a/apps/server-nestjs/test/nexus.e2e-spec.ts b/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..94cbc58e15 --- /dev/null +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,148 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/cpin-module/infrastructure/configuration/configuration.module' +import { ConfigurationService } from '../src/cpin-module/infrastructure/configuration/configuration.service' +import { PrismaService } from '../src/cpin-module/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/cpin-module/infrastructure/infrastructure.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { NEXUS_PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: ConfigurationService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigurationModule, InfrastructureModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(ConfigurationService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete({ slug: testProjectSlug } as any).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: NEXUS_PLUGIN_NAME, key: 'activateMavenRepo', value: 'enabled' }, + { pluginName: NEXUS_PLUGIN_NAME, key: 'activateNpmRepo', value: 'enabled' }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/apps/server-nestjs/test/registry.e2e-spec.ts b/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..1ef440de49 --- /dev/null +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,99 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationService } from '../src/cpin-module/infrastructure/configuration/configuration.service' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultError } from '../src/modules/vault/vault-http-client.service.js' + +describe('RegistryController (e2e)', () => { + let moduleRef: TestingModule + let client: RegistryClientService + let vault: VaultClientService + + beforeAll(async () => { + const state = { + projectExists: true, + retentionId: 456 as number | null, + } + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: any, init?: any) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input) + const method = init?.method ?? (input instanceof Request ? input.method : 'GET') + const json = (status: number, data: unknown) => { + const headers = new Headers() + headers.set('content-type', 'application/json') + return new Response(JSON.stringify(data), { status, headers }) + } + const noContent = (status: number = 204) => { + return new Response(null, { status }) + } + if (url.includes('/api/v2.0/projects/') && url.endsWith('/members') && method === 'GET') { + return json(200, [{ id: 1, entity_name: '/proj', entity_type: 'g', role_id: 3 }]) + } + if (url.includes('/api/v2.0/projects/') && url.endsWith('/members') && method === 'POST') { + return json(201, {}) + } + if (url.includes('/api/v2.0/projects/') && url.endsWith('/robots') && method === 'GET') { + return json(200, []) + } + if (url.endsWith('/api/v2.0/robots') && method === 'POST') { + return json(201, { id: Math.floor(Math.random() * 1000), name: 'robot$proj+name', secret: 'secret' }) + } + if (url.includes('/api/v2.0/robots/') && method === 'DELETE') { + return noContent(204) + } + if (url.includes('/api/v2.0/retentions') && method === 'POST') { + return json(201, { id: 456 }) + } + if (url.includes('/api/v2.0/quotas?') && method === 'GET') { + return json(200, [{ ref: { id: 123 }, hard: { storage: -1 } }]) + } + if (url.includes('/api/v2.0/projects/') && method === 'GET') { + if (!state.projectExists) return json(404, {}) + return json(200, { project_id: 123, metadata: { retention_id: state.retentionId } }) + } + if (url.endsWith('/api/v2.0/projects') && method === 'POST') { + state.projectExists = true + return json(201, { project_id: 123 }) + } + return json(200, {}) + }) + const vaultMock = { + read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'not found')), + write: vi.fn(), + delete: vi.fn(), + } as unknown as VaultClientService + moduleRef = await Test.createTestingModule({ + imports: [RegistryModule], + }) + .overrideProvider(ConfigurationService) + .useValue({ + harborUrl: 'https://harbor.example', + harborInternalUrl: 'https://harbor.example', + harborAdmin: 'admin', + harborAdminPassword: 'password', + harborRuleTemplate: 'latestPushedK', + harborRuleCount: '10', + harborRetentionCron: '0 22 2 * * *', + projectRootDir: 'forge', + } satisfies Partial) + .overrideProvider(VaultClientService) + .useValue(vaultMock) + .compile() + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + }) + + afterAll(async () => { + await moduleRef.close() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should provision project robots and retention with Vault secret writes', async () => { + const result = await client.provisionProject('proj', { publishProjectRobot: true }) + expect(result.basePath).toBe('harbor.example/proj/') + expect(vault.write).toHaveBeenCalledTimes(3) + }) +})