diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/health/health.controller.ts b/apps/server-nestjs/src/cpin-module/infrastructure/health/health.controller.ts index 7cbb2d488..03fcdb2ab 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/health/health.controller.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/health/health.controller.ts @@ -4,6 +4,8 @@ import { DatabaseHealthService } from '@/cpin-module/infrastructure/database/dat import { ArgoCDHealthService } from '../../../modules/argocd/argocd-health.service' import { GitlabHealthService } from '../../../modules/gitlab/gitlab-health.service' import { KeycloakHealthService } from '../../../modules/keycloak/keycloak-health.service' +import { NexusHealthService } from '../../../modules/nexus/nexus-health.service' +import { RegistryHealthService } from '../../../modules/registry/registry-health.service' import { VaultHealthService } from '../../../modules/vault/vault-health.service' @Controller('health') @@ -14,6 +16,8 @@ export class HealthController { @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 HealthController { () => 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/cpin-module/infrastructure/health/health.module.ts b/apps/server-nestjs/src/cpin-module/infrastructure/health/health.module.ts index cedf4d3b9..c9e926b06 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/health/health.module.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/health/health.module.ts @@ -6,6 +6,8 @@ import { DatabaseHealthService } from '@/cpin-module/infrastructure/database/dat import { ArgoCDHealthService } from '../../../modules/argocd/argocd-health.service' import { GitlabHealthService } from '../../../modules/gitlab/gitlab-health.service' import { KeycloakHealthService } from '../../../modules/keycloak/keycloak-health.service' +import { NexusHealthService } from '../../../modules/nexus/nexus-health.service' +import { RegistryHealthService } from '../../../modules/registry/registry-health.service' import { VaultHealthService } from '../../../modules/vault/vault-health.service' import { HealthController } from './health.controller' @@ -17,6 +19,8 @@ import { HealthController } from './health.controller' KeycloakHealthService, GitlabHealthService, VaultHealthService, + NexusHealthService, + RegistryHealthService, ArgoCDHealthService, ], }) 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 000000000..a4dca7859 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,145 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +import { removeTrailingSlash } from './nexus.utils' + +interface NexusRequestOptions { + method?: string + path: string + body?: unknown + headers?: Record + validateStatus?: (code: number) => boolean +} + +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 NexusClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + private get baseUrl() { + if (!this.config.nexusInternalUrl) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL is required') + } + return `${removeTrailingSlash(this.config.nexusInternalUrl)}/service/rest/v1` + } + + private get defaultHeaders() { + 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}` + const encoded = Buffer.from(raw, 'utf8').toString('base64') + return { + Accept: 'application/json', + Authorization: `Basic ${encoded}`, + } as const + } + + async request(path: string, options: Omit = {}): Promise> { + const method = options.method ?? 'GET' + 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 }, + ) + }) + + const result = await this.handleResponse(response) + + if (options.validateStatus && !options.validateStatus(result.status)) { + throw new NexusError('HttpError', 'Request failed', { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + + if (!options.validateStatus && !response.ok) { + throw new NexusError('HttpError', 'Request failed', { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}` + + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + } else { + requestBody = JSON.stringify(body) + if (!headers['Content-Type']) headers['Content-Type'] = 'application/json' + } + } + + return new Request(url, { method, headers, body: requestBody }) + } + + private async 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 } + } + + async deleteIfExists(path: string) { + return this.request(path, { + method: 'DELETE', + validateStatus: code => code === 404 || code < 300, + }) + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-controller.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-controller.service.spec.ts new file mode 100644 index 000000000..f616ee2d9 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-controller.service.spec.ts @@ -0,0 +1,89 @@ +import { Test } from '@nestjs/testing' +import { describe, beforeEach, it, expect, vi } from 'vitest' +import type { Mocked } from 'vitest' +import { ENABLED } from '@cpn-console/shared' + +import { NexusControllerService } from './nexus-controller.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusService } from './nexus.service' +import { makeProjectWithDetails } from './nexus-testing.utils' + +function createNexusControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusControllerService, + { + provide: NexusService, + useValue: { + provisionProject: vi.fn(), + deleteProject: vi.fn(), + } satisfies Partial, + }, + { + provide: NexusDatastoreService, + useValue: { + getAllProjects: vi.fn(), + } satisfies Partial, + }, + ], + }) +} + +describe('nexusControllerService', () => { + let service: NexusControllerService + let nexus: Mocked + let nexusDatastore: Mocked + + beforeEach(async () => { + const moduleRef = await createNexusControllerServiceTestingModule().compile() + service = moduleRef.get(NexusControllerService) + nexus = moduleRef.get(NexusService) + nexusDatastore = moduleRef.get(NexusDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should provision project with 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(nexus.provisionProject).toHaveBeenCalledWith({ + projectSlug: 'project-1', + ownerEmail: 'owner@example.com', + enableMaven: true, + enableNpm: false, + mavenSnapshotWritePolicy: undefined, + mavenReleaseWritePolicy: undefined, + npmWritePolicy: undefined, + }) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails({ slug: 'project-1' }) + await service.handleDelete(project) + expect(nexus.deleteProject).toHaveBeenCalledWith('project-1') + }) + + it('reconcile 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.reconcile() + + expect(nexus.provisionProject).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-controller.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-controller.service.ts new file mode 100644 index 000000000..2c83f9186 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-controller.service.ts @@ -0,0 +1,83 @@ +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 { specificallyEnabled } from '@cpn-console/hooks' + +import { NexusDatastoreService } from './nexus-datastore.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import { NexusService } from './nexus.service' +import { NEXUS_CONFIG_KEYS } from './nexus.constants' +import { getPluginConfig } from './nexus.utils' +import { StartActiveSpan } from '@/cpin-module/infrastructure/telemetry/telemetry.decorator' + +@Injectable() +export class NexusControllerService { + private readonly logger = new Logger(NexusControllerService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, + @Inject(NexusService) private readonly nexus: NexusService, + ) { + this.logger.log('NexusControllerService 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.reconcileProject(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.nexus.deleteProject(project.slug) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + this.logger.log('Starting Nexus reconciliation') + await this.reconcile() + } + + @StartActiveSpan() + async reconcile() { + const projects = await this.nexusDatastore.getAllProjects() + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + + const results = await Promise.allSettled(projects.map(project => this.reconcileProject(project))) + results.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Reconciliation failed: ${result.reason}`) + } + }) + return results + } + + @StartActiveSpan() + private async reconcileProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEYS.activateMavenRepo)) === true + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEYS.activateNpmRepo)) === true + + await this.nexus.provisionProject({ + projectSlug: project.slug, + ownerEmail: project.owner.email, + enableMaven, + enableNpm, + mavenSnapshotWritePolicy: getPluginConfig(project, NEXUS_CONFIG_KEYS.mavenSnapshotWritePolicy) ?? undefined, + mavenReleaseWritePolicy: getPluginConfig(project, NEXUS_CONFIG_KEYS.mavenReleaseWritePolicy) ?? undefined, + npmWritePolicy: getPluginConfig(project, NEXUS_CONFIG_KEYS.npmWritePolicy) ?? undefined, + }) + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.spec.ts new file mode 100644 index 000000000..1208e77a4 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.spec.ts @@ -0,0 +1,37 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { mockDeep } from 'vitest-mock-extended' +import { describe, beforeEach, it, expect } from 'vitest' + +import { NexusDatastoreService } from './nexus-datastore.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +const prismaMock = mockDeep() + +function createNexusDatastoreServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusDatastoreService, + { provide: PrismaService, useValue: prismaMock }, + ], + }) +} + +describe('nexusDatastoreService', () => { + let service: NexusDatastoreService + + beforeEach(async () => { + const module: TestingModule = await createNexusDatastoreServiceTestingModule().compile() + service = module.get(NexusDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should get project', async () => { + const project = { slug: 'project-1' } + prismaMock.project.findUnique.mockResolvedValue(project as any) + await expect(service.getProject('project-id')).resolves.toEqual(project) + }) +}) 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 000000000..7b6f11455 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +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 000000000..dab691b4b --- /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 healthIndicatorService: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicatorService.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-testing.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 000000000..a57f1b25f --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,13 @@ +import { faker } from '@faker-js/faker' +import type { ProjectWithDetails } from './nexus-datastore.service' + +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 000000000..d7fe70438 --- /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 000000000..c5a0c9b36 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +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 { NexusControllerService } from './nexus-controller.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusService } from './nexus.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [NexusService, NexusControllerService, NexusDatastoreService, NexusClientService], + exports: [NexusService], +}) +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 000000000..075648d6c --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,49 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { describe, it, expect, beforeEach } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' +import { NexusService } from './nexus.service' +import { NexusClientService } from './nexus-client.service' +import { VaultService } from '../vault/vault.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' + +const nexusClientMock = mockDeep() +const vaultMock = mockDeep() + +function createNexusServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusService, + { + provide: NexusClientService, + useValue: nexusClientMock, + }, + { + provide: VaultService, + useValue: vaultMock, + }, + { + provide: ConfigurationService, + useValue: { + nexusSecretExposedUrl: 'https://nexus.example', + projectRootPath: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('nexusService', () => { + let service: NexusService + + beforeEach(async () => { + mockReset(nexusClientMock) + mockReset(vaultMock) + const module: TestingModule = await createNexusServiceTestingModule().compile() + service = module.get(NexusService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) 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 000000000..c6d4e6fab --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,449 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { VaultService } from '../vault/vault.service' +import { VaultError } from '../vault/vault-client.service' +import { NexusClientService } from './nexus-client.service' +import { assertWritePolicy, generateRandomPassword, getProjectVaultPath } from './nexus.utils' +import type { WritePolicy } from './nexus.utils' +import { StartActiveSpan } from '@/cpin-module/infrastructure/telemetry/telemetry.decorator' + +interface MavenRepoNames { + hosted: Array<{ repo: string, privilege: string }> + group: { repo: string, privilege: string } +} + +interface NpmRepoNames { + hosted: Array<{ repo: string, privilege: string }> + group: { repo: string, privilege: string } +} + +@Injectable() +export class NexusService { + constructor( + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + private getMavenRepoNames(projectSlug: string): MavenRepoNames { + return { + hosted: [ + { + repo: `${projectSlug}-repository-release`, + privilege: `${projectSlug}-privilege-release`, + }, + { + repo: `${projectSlug}-repository-snapshot`, + privilege: `${projectSlug}-privilege-snapshot`, + }, + ], + group: { + repo: `${projectSlug}-repository-group`, + privilege: `${projectSlug}-privilege-group`, + }, + } + } + + private getNpmRepoNames(projectSlug: string): NpmRepoNames { + return { + hosted: [ + { + repo: `${projectSlug}-npm`, + privilege: `${projectSlug}-npm-privilege`, + }, + ], + group: { + repo: `${projectSlug}-npm-group`, + privilege: `${projectSlug}-npm-group-privilege`, + }, + } + } + + private async upsertRepo(options: { + getUrl: string + createUrl: string + updateUrl: string + createStatus: number + updateStatus: number + body: TBody + }) { + const existing = await this.client.request(options.getUrl, { + method: 'GET', + validateStatus: code => [200, 404].includes(code), + }) + if (existing.status === 404) { + await this.client.request(options.createUrl, { + method: 'POST', + body: options.body, + validateStatus: code => [options.createStatus].includes(code), + }) + return + } + await this.client.request(options.updateUrl, { + method: 'PUT', + body: options.body, + validateStatus: code => [options.updateStatus].includes(code), + }) + } + + private async upsertPrivilege(body: { name: string, description: string, actions: string[], format: string, repository: string }) { + const existing = await this.client.request(`/security/privileges/${encodeURIComponent(body.name)}`, { + method: 'GET', + validateStatus: code => [200, 404].includes(code), + }) + if (existing.status === 404) { + await this.client.request('/security/privileges/repository-view', { + method: 'POST', + body, + validateStatus: code => [201].includes(code), + }) + return + } + + await this.client.request(`/security/privileges/repository-view/${encodeURIComponent(body.name)}`, { + method: 'PUT', + body, + validateStatus: code => [204].includes(code), + }) + } + + private async createMavenRepos(projectSlug: string, options: { snapshotWritePolicy: WritePolicy, releaseWritePolicy: WritePolicy }) { + const names = this.getMavenRepoNames(projectSlug) + + await Promise.all([ + this.upsertRepo({ + getUrl: `/repositories/maven/hosted/${encodeURIComponent(names.hosted[0].repo)}`, + createUrl: '/repositories/maven/hosted', + updateUrl: `/repositories/maven/hosted/${encodeURIComponent(names.hosted[0].repo)}`, + createStatus: 201, + updateStatus: 204, + body: { + name: names.hosted[0].repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy: options.releaseWritePolicy, + }, + cleanup: { policyNames: ['string'] }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + }), + this.upsertRepo({ + getUrl: `/repositories/maven/hosted/${encodeURIComponent(names.hosted[1].repo)}`, + createUrl: '/repositories/maven/hosted', + updateUrl: `/repositories/maven/hosted/${encodeURIComponent(names.hosted[1].repo)}`, + createStatus: 201, + updateStatus: 204, + body: { + name: names.hosted[1].repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy: options.snapshotWritePolicy, + }, + cleanup: { policyNames: ['string'] }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + }), + ]) + + await this.client.request('/repositories/maven/group', { + method: 'POST', + body: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + 'maven-public', + ], + }, + }, + validateStatus: code => [201, 400].includes(code), + }) + + for (const name of [...names.hosted, names.group]) { + await this.client.request('/security/privileges/repository-view', { + method: 'POST', + body: { + name: name.privilege, + description: `Privilege for organization ${projectSlug} for repo ${name.repo}`, + actions: ['all'], + format: 'maven2', + repository: name.repo, + }, + validateStatus: code => [201, 400].includes(code), + }) + } + + return names + } + + private async deleteMavenRepos(projectSlug: string) { + const names = this.getMavenRepoNames(projectSlug) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + ...privileges.map(({ privilege }) => `/security/privileges/${encodeURIComponent(privilege)}`), + ...repoPaths.map(repo => `/repositories/${encodeURIComponent(repo.repo)}`), + ] + for (const path of pathsToDelete) { + await this.client.deleteIfExists(path) + } + } + + private async createNpmRepos(projectSlug: string, writePolicy: WritePolicy) { + const names = this.getNpmRepoNames(projectSlug) + + await this.upsertRepo({ + getUrl: `/repositories/npm/hosted/${encodeURIComponent(names.hosted[0].repo)}`, + createUrl: '/repositories/npm/hosted', + updateUrl: `/repositories/npm/hosted/${encodeURIComponent(names.hosted[0].repo)}`, + createStatus: 201, + updateStatus: 204, + body: { + name: names.hosted[0].repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + cleanup: { policyNames: ['string'] }, + component: { proprietaryComponents: true }, + }, + }) + + await this.upsertRepo({ + getUrl: `/repositories/npm/group/${encodeURIComponent(names.group.repo)}`, + createUrl: '/repositories/npm/group', + updateUrl: `/repositories/npm/group/${encodeURIComponent(names.group.repo)}`, + createStatus: 201, + updateStatus: 204, + body: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + }) + + for (const name of [...names.hosted, names.group]) { + await this.upsertPrivilege({ + name: name.privilege, + description: `Privilege for organization ${projectSlug} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }) + } + return names + } + + private async deleteNpmRepos(projectSlug: string) { + const names = this.getNpmRepoNames(projectSlug) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + ...privileges.map(({ privilege }) => `/security/privileges/${encodeURIComponent(privilege)}`), + ...repoPaths.map(repo => `/repositories/${encodeURIComponent(repo.repo)}`), + ] + for (const path of pathsToDelete) { + await this.client.deleteIfExists(path) + } + } + + private async upsertRole(projectSlug: string, privileges: string[]) { + const roleId = `${projectSlug}-ID` + const role = await this.client.request(`security/roles/${encodeURIComponent(roleId)}`, { + method: 'GET', + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await this.client.request('/security/roles', { + method: 'POST', + body: { + id: roleId, + name: `${projectSlug}-role`, + description: 'desc', + privileges, + }, + validateStatus: code => [200].includes(code), + }) + return + } + + await this.client.request(`security/roles/${encodeURIComponent(roleId)}`, { + method: 'PUT', + body: { + id: roleId, + name: `${projectSlug}-role`, + privileges, + }, + validateStatus: code => [204].includes(code), + }) + } + + private async ensureUser(projectSlug: string, ownerEmail: string, password: string) { + const getUser = await this.client.request<{ userId: string }[]>(`/security/users?userId=${encodeURIComponent(projectSlug)}`) + + const existing = (getUser.data ?? []).find(u => u.userId === projectSlug) + if (existing) { + await this.client.request(`/security/users/${encodeURIComponent(projectSlug)}/change-password`, { + method: 'PUT', + body: password, + headers: { + 'Content-Type': 'text/plain', + }, + }) + return + } + + await this.client.request('/security/users', { + method: 'POST', + body: { + userId: projectSlug, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: ownerEmail, + password, + status: 'active', + roles: [`${projectSlug}-ID`], + }, + }) + } + + @StartActiveSpan() + async provisionProject(args: { + projectSlug: string + ownerEmail: string + enableMaven: boolean + enableNpm: boolean + mavenSnapshotWritePolicy?: string + mavenReleaseWritePolicy?: string + npmWritePolicy?: string + }) { + const projectSlug = args.projectSlug + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'nexus.maven.enabled': args.enableMaven, + 'nexus.npm.enabled': args.enableNpm, + }) + + const mavenSnapshotWritePolicy = args.mavenSnapshotWritePolicy ?? 'allow' + const mavenReleaseWritePolicy = args.mavenReleaseWritePolicy ?? 'allow_once' + const npmWritePolicy = args.npmWritePolicy ?? 'allow' + + assertWritePolicy(mavenSnapshotWritePolicy) + assertWritePolicy(mavenReleaseWritePolicy) + assertWritePolicy(npmWritePolicy) + + const privilegesToAccess: string[] = [] + + if (args.enableMaven) { + const names = await this.createMavenRepos(projectSlug, { + snapshotWritePolicy: mavenSnapshotWritePolicy, + releaseWritePolicy: mavenReleaseWritePolicy, + }) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await this.deleteMavenRepos(projectSlug) + } + + if (args.enableNpm) { + const names = await this.createNpmRepos(projectSlug, npmWritePolicy) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await this.deleteNpmRepos(projectSlug) + } + + await this.upsertRole(projectSlug, privilegesToAccess) + + const vaultPath = getProjectVaultPath(this.config.projectRootPath, 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, args.ownerEmail, password) + await this.vault.write({ + NEXUS_PASSWORD: password, + NEXUS_USERNAME: projectSlug, + }, vaultPath) + } + + @StartActiveSpan() + 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.deleteIfExists(`/security/roles/${encodeURIComponent(`${projectSlug}-ID`)}`), + this.client.request(`/security/users/${encodeURIComponent(projectSlug)}`, { + method: 'DELETE', + validateStatus: code => code === 404 || code < 300, + }), + ]) + + const vaultPath = getProjectVaultPath(this.config.projectRootPath, projectSlug, 'tech/NEXUS') + try { + await this.vault.destroy(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } + + getProjectSecrets(args: { projectSlug: string, enableMaven: boolean, enableNpm: boolean }) { + const projectSlug = args.projectSlug + const nexusUrl = this.config.nexusSecretExposedUrl! + const secrets: Record = {} + if (args.enableMaven) { + const names = this.getMavenRepoNames(projectSlug) + secrets.MAVEN_REPO_RELEASE = `${nexusUrl}/${names.hosted[0].repo}` + secrets.MAVEN_REPO_SNAPSHOT = `${nexusUrl}/${names.hosted[1].repo}` + } + if (args.enableNpm) { + const names = this.getNpmRepoNames(projectSlug) + secrets.NPM_REPO = `${nexusUrl}/${names.hosted[0].repo}` + } + return secrets + } +} 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 000000000..4406e8016 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,34 @@ +import { randomBytes } from 'node:crypto' +import type { ProjectWithDetails } from './nexus-datastore.service' + +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(projectRootPath: string | undefined, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootPath + ? `${projectRootPath}/${projectSlug}/${normalized}` + : `${projectSlug}/${normalized}` +} 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 000000000..313a6730f --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,164 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +import { encodeBasicAuth, removeTrailingSlash } from './registry.utils' + +interface HarborRequestOptions { + method?: string + headers?: Record + body?: any +} + +@Injectable() +export class RegistryClientService { + 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 request( + path: string, + options: HarborRequestOptions = {}, + ): 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 } + } + + async getProjectByName(projectName: string) { + return this.request(`/projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.request('/projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProject(projectName: string) { + return this.request(`/projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.request(`/quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.request(`/quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async listProjectMembers(projectName: string) { + return this.request(`/projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProjectMember(projectName: string, body: any) { + return this.request(`/projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async deleteProjectMember(projectName: string, memberId: number) { + return this.request(`/projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listProjectRobots(projectName: string) { + return this.request(`/projects/${encodeURIComponent(projectName)}/robots`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createRobot(body: any) { + return this.request('/robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(projectName: string, robotId: number) { + const direct = await this.request(`/robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + if (direct.status < 300 || direct.status === 404) return direct + + return this.request(`/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.request('/retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: any) { + return this.request(`/retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } +} 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 000000000..3b3bcf0d3 --- /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 healthIndicatorService: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicatorService.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.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 000000000..2fa4d8030 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +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 { RegistryService } from './registry.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [RegistryService, RegistryClientService], + exports: [RegistryService], +}) +export class RegistryModule {} diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 000000000..c222334df --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { RegistryService } from './registry.service' +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { mockDeep, mockReset } from 'vitest-mock-extended' +import { RegistryClientService } from './registry-client.service' +import { VaultService } from '../vault/vault.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' + +const registryClientMock = mockDeep() +const vaultMock = mockDeep() + +function createRegistryServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + RegistryService, + { + provide: RegistryClientService, + useValue: registryClientMock, + }, + { + provide: VaultService, + 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 * * *', + projectRootPath: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('registryService', () => { + let service: RegistryService + + beforeEach(async () => { + mockReset(registryClientMock) + mockReset(vaultMock) + const module: TestingModule = await createRegistryServiceTestingModule().compile() + service = module.get(RegistryService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) 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 000000000..1b4f68a1b --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,333 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { VaultService } from '../vault/vault.service' +import { VaultError } from '../vault/vault-client.service' +import { RegistryClientService } from './registry-client.service' +import { getHostFromUrl, getProjectVaultPath, toVaultRobotSecret } from './registry.utils' +import type { VaultRobotSecret } from './registry.utils' +import { StartActiveSpan } from '@/cpin-module/infrastructure/telemetry/telemetry.decorator' + +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 RegistryService { + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + 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.client.listProjectRobots(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 createRobot(projectSlug: string, robotName: string, access: HarborAccess[]): Promise { + const created = await this.client.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.client.deleteRobot(projectSlug, existing.id) + } + return this.createRobot(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.projectRootPath, 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.createRobot(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 + } + + 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.client.listProjectMembers(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.client.deleteProjectMember(projectSlug, Number(existing.id)) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const created = await this.client.createProjectMember(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 createOrUpdateProject(projectSlug: string, storageLimit: number): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.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.client.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.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return project + } + + const created = await this.client.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.client.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.client.getRetentionId(projectSlug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.client.updateRetention(retentionId, policy) + : await this.client.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.createOrUpdateProject(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.client.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.client.deleteProject(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.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 000000000..890b4e19a --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,46 @@ +const trailingSlashesRegex = /\/+$/u +const protocolPrefixRegex = /^https?:\/\//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(projectRootPath: string | undefined, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootPath + ? `${projectRootPath}/${projectSlug}/${normalized}` + : `${projectSlug}/${normalized}` +}