From 4cce50030ccb7689f5b1b219284f550a43b39910 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 13 Mar 2026 17:22:32 +0100 Subject: [PATCH] refactor(server-nestjs): migrate package managers to NestJS Signed-off-by: William Phetsinorath --- .../src/modules/nexus/nexus-client.service.ts | 33 ++ .../src/modules/nexus/nexus.module.ts | 13 + .../src/modules/nexus/nexus.service.spec.ts | 49 ++ .../src/modules/nexus/nexus.service.ts | 453 ++++++++++++++++++ .../src/modules/nexus/nexus.utils.ts | 29 ++ .../registry/registry-client.service.ts | 164 +++++++ .../src/modules/registry/registry.module.ts | 13 + .../modules/registry/registry.service.spec.ts | 55 +++ .../src/modules/registry/registry.service.ts | 286 +++++++++++ .../src/modules/registry/registry.utils.ts | 46 ++ 10 files changed, 1141 insertions(+) create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-client.service.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.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.module.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.service.spec.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 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..06f4c1eda --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,33 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +import axios from 'axios' +import type { AxiosInstance } from 'axios' +import { removeTrailingSlash } from './nexus.utils' + +@Injectable() +export class NexusClientService { + readonly axios: AxiosInstance + + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + this.axios = axios.create({ + baseURL: `${removeTrailingSlash(this.config.nexusInternalUrl!)}/service/rest/v1/`, + auth: { + username: this.config.nexusAdmin!, + password: this.config.nexusAdminPassword!, + }, + headers: { + Accept: 'application/json', + }, + }) + } + + async deleteIfExists(path: string) { + return this.axios({ + method: 'delete', + url: path, + validateStatus: code => code === 404 || code < 300, + }) + } +} 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..4c17e4a28 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.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 { NexusClientService } from './nexus-client.service' +import { NexusService } from './nexus.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [NexusService, 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..31f114dc6 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,453 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +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' + +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.axios({ + method: 'GET', + url: options.getUrl, + validateStatus: code => [200, 404].includes(code), + }) + if (existing.status === 404) { + await this.client.axios({ + method: 'post', + url: options.createUrl, + data: options.body, + validateStatus: code => [options.createStatus].includes(code), + }) + return + } + await this.client.axios({ + method: 'put', + url: options.updateUrl, + data: 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.axios({ + method: 'get', + url: `/security/privileges/${encodeURIComponent(body.name)}`, + validateStatus: code => [200, 404].includes(code), + }) + if (existing.status === 404) { + await this.client.axios({ + method: 'post', + url: '/security/privileges/repository-view', + data: body, + validateStatus: code => [201].includes(code), + }) + return + } + + await this.client.axios({ + method: 'put', + url: `/security/privileges/repository-view/${encodeURIComponent(body.name)}`, + data: 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.axios({ + method: 'post', + url: '/repositories/maven/group', + data: { + 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.axios({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + 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.axios({ + method: 'GET', + url: `security/roles/${encodeURIComponent(roleId)}`, + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await this.client.axios({ + method: 'post', + url: '/security/roles', + data: { + id: roleId, + name: `${projectSlug}-role`, + description: 'desc', + privileges, + }, + validateStatus: code => [200].includes(code), + }) + return + } + + await this.client.axios({ + method: 'PUT', + url: `security/roles/${encodeURIComponent(roleId)}`, + data: { + 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.axios({ + url: `/security/users?userId=${encodeURIComponent(projectSlug)}`, + }) as { data: { userId: string }[] } + + const existing = getUser.data.find(u => u.userId === projectSlug) + if (existing) { + await this.client.axios({ + method: 'put', + url: `/security/users/${encodeURIComponent(projectSlug)}/change-password`, + data: password, + headers: { + 'Content-Type': 'text/plain', + }, + }) + return + } + + await this.client.axios({ + method: 'post', + url: '/security/users', + data: { + userId: projectSlug, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: ownerEmail, + password, + status: 'active', + roles: [`${projectSlug}-ID`], + }, + }) + } + + async provisionProject(args: { + projectSlug: string + ownerEmail: string + enableMaven: boolean + enableNpm: boolean + mavenSnapshotWritePolicy?: string + mavenReleaseWritePolicy?: string + npmWritePolicy?: string + }) { + const projectSlug = args.projectSlug + + 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) + } + + async deleteProject(projectSlug: string) { + await Promise.all([ + this.deleteMavenRepos(projectSlug), + this.deleteNpmRepos(projectSlug), + ]) + + await Promise.all([ + this.client.deleteIfExists(`/security/roles/${encodeURIComponent(`${projectSlug}-ID`)}`), + this.client.axios({ + method: 'delete', + url: `/security/users/${encodeURIComponent(projectSlug)}`, + 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..0395ad18d --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,29 @@ +import { randomBytes } from 'node:crypto' + +const trailingSlashesRegex = /\/+$/u + +export function removeTrailingSlash(value: string) { + return value.replace(trailingSlashesRegex, '') +} + +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.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..9a19390a2 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,286 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Inject, Injectable } from '@nestjs/common' +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' + +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 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 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) { + 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) + return secret + } + + async addProjectGroupMember(projectSlug: string, groupName: string, accessLevel: number = 3) { + 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 { + 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})`) + } + } + + private async createOrUpdateProject(projectSlug: string, storageLimit: number): Promise { + 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) + } + } + return project + } + + const created = await this.client.createProject(projectSlug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + + 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 policy = this.getRetentionPolicy(projectId) + const retentionId = await this.client.getRetentionId(projectSlug) + 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})`) + } + } + + async provisionProject(projectSlug: string, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + 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}/`, + } + } + + async deleteProject(projectSlug: string) { + const existing = await this.client.getProjectByName(projectSlug) + if (existing.status === 404) 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}` +}