From 066c3fb803a1c6a190acf5f544028b8b13dbe47c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 30 Apr 2026 10:15:49 +0200 Subject: [PATCH] refactor(server-nestjs): migrate package managers to NestJS Signed-off-by: William Phetsinorath Change-Id: I37010cbbfad6e0ac2dbd2adb8e560be36a6a6964 --- .../MODULARISATION-CARTOGRAPHIE.md | 6 +- .../src/modules/argocd/argocd.service.spec.ts | 2 +- .../gitlab/gitlab-client.service.spec.ts | 8 +- .../modules/gitlab/gitlab-client.service.ts | 4 +- .../src/modules/healthz/healthz.controller.ts | 6 + .../src/modules/healthz/healthz.module.ts | 4 + .../configuration/configuration.service.ts | 3 +- .../nexus/nexus-client.service.spec.ts | 78 ++ .../src/modules/nexus/nexus-client.service.ts | 297 ++++++++ .../modules/nexus/nexus-datastore.service.ts | 68 ++ .../src/modules/nexus/nexus-health.service.ts | 32 + .../nexus/nexus-http-client.service.ts | 127 ++++ .../src/modules/nexus/nexus-testing.utils.ts | 15 + .../src/modules/nexus/nexus.constants.ts | 32 + .../src/modules/nexus/nexus.module.ts | 17 + .../src/modules/nexus/nexus.service.spec.ts | 202 ++++++ .../src/modules/nexus/nexus.service.ts | 676 ++++++++++++++++++ .../src/modules/nexus/nexus.utils.ts | 28 + .../registry/registry-client.service.spec.ts | 95 +++ .../registry/registry-client.service.ts | 224 ++++++ .../registry/registry-datastore.service.ts | 61 ++ .../registry/registry-health.service.ts | 32 + .../registry/registry-http-client.service.ts | 113 +++ .../registry/registry-testing.utils.ts | 23 + .../modules/registry/registry.constants.ts | 33 + .../src/modules/registry/registry.module.ts | 17 + .../modules/registry/registry.service.spec.ts | 332 +++++++++ .../src/modules/registry/registry.service.ts | 528 ++++++++++++++ .../src/modules/registry/registry.utils.ts | 79 ++ .../src/modules/vault/vault-client.service.ts | 24 +- .../src/modules/vault/vault-testing.utils.ts | 42 ++ .../src/modules/vault/vault.service.spec.ts | 115 ++- apps/server-nestjs/test/argocd.e2e-spec.ts | 2 +- apps/server-nestjs/test/keycloak.e2e-spec.ts | 4 +- apps/server-nestjs/test/nexus.e2e-spec.ts | 149 ++++ apps/server-nestjs/test/registry.e2e-spec.ts | 85 +++ apps/server-nestjs/test/vault.e2e-spec.ts | 3 +- plugins/harbor/src/policy.ts | 6 +- 38 files changed, 3477 insertions(+), 95 deletions(-) create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-client.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-health.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.constants.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.module.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.service.ts create mode 100644 apps/server-nestjs/src/modules/nexus/nexus.utils.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-client.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-health.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-http-client.service.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.constants.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.module.ts create mode 100644 apps/server-nestjs/src/modules/registry/registry.service.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 create mode 100644 apps/server-nestjs/src/modules/vault/vault-testing.utils.ts create mode 100644 apps/server-nestjs/test/nexus.e2e-spec.ts create mode 100644 apps/server-nestjs/test/registry.e2e-spec.ts diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md index 20d279c313..6d5545e876 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -195,11 +195,11 @@ Plus le score est eleve, plus le module est prioritaire. | 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | | 18 | repository | Metier | 5.8 | V3 | S7-S8 | | | 19 | cluster | Metier | 5.7 | V3 | S7 | | -| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | | 21 | project-service | Metier | 5.6 | V3 | S8 | | | 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | | | 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | -| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | | 25 | project-member | Metier | 4.7 | V3 | S8 | | | 26 | project-secrets | Metier | 4.6 | V4 | S9 | | | 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | @@ -754,7 +754,7 @@ NestJS injectables. **Fichiers** : - `src/cpin-module/service-chain/service-chain.*.ts` - `src/cpin-module/service-chain/open-cds-client.*.ts` -- `src/cpin-module/infrastructure/auth/` (AuthModule partage) +- `src/modules/infrastructure/auth/` (AuthModule partage) --- diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index 7434171ec7..1f59ccd750 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -356,7 +356,7 @@ describe('argoCDService', () => { gitlab.listFiles.mockResolvedValue([]) vault.readProjectValues.mockResolvedValue({ secret: 'value' }) - gitlab.generateCreateOrUpdateAction.mockResolvedValue(null as any) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) await expect(service.handleCron()).resolves.not.toThrow() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index e58c4e5d5f..9beba03223 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -374,7 +374,9 @@ describe('gitlab-client', () => { name: 'Admin Auditor', } const gitlabUsersAllMock = gitlabMock.Users.all as MockedFunction - gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true })]) + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), + ]) await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) @@ -391,7 +393,9 @@ describe('gitlab-client', () => { name: 'Auditor Admin', } const gitlabUsersAllMock = gitlabMock.Users.all as MockedFunction - gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email, ...({ is_auditor: true } as any) })]) + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 51fa121503..2fc7235402 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -295,7 +295,7 @@ export class GitlabClientService { this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) } - async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string) { + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string): Promise { const file = await this.getFile(repo, filePath, ref) if (file && !hasFileContentChanged(file, content)) { this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) @@ -306,7 +306,7 @@ export class GitlabClientService { action: file ? 'update' : 'create', filePath, content, - } satisfies CommitAction + } } async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { diff --git a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts index 24a54ba5c7..38882cf4fc 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -4,6 +4,8 @@ import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' import { DatabaseHealthService } from '../infrastructure/database/database-health.service' import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' import { VaultHealthService } from '../vault/vault-health.service' @Controller('api/v1/healthz') @@ -14,6 +16,8 @@ export class HealthzController { @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, @Inject(GitlabHealthService) private readonly gitlab: GitlabHealthService, @Inject(VaultHealthService) private readonly vault: VaultHealthService, + @Inject(NexusHealthService) private readonly nexus: NexusHealthService, + @Inject(RegistryHealthService) private readonly registry: RegistryHealthService, @Inject(ArgoCDHealthService) private readonly argocd: ArgoCDHealthService, ) {} @@ -25,6 +29,8 @@ export class HealthzController { () => this.keycloak.check('keycloak'), () => this.gitlab.check('gitlab'), () => this.vault.check('vault'), + () => this.nexus.check('nexus'), + () => this.registry.check('registry'), () => this.argocd.check('argocd'), ]) } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 8d78c3ea36..54fa3eec0b 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -4,6 +4,8 @@ import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' import { VaultModule } from '../vault/vault.module' import { HealthzController } from './healthz.controller' @@ -14,6 +16,8 @@ import { HealthzController } from './healthz.controller' KeycloakModule, GitlabModule, VaultModule, + NexusModule, + RegistryModule, ArgoCDModule, ], controllers: [HealthzController], diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts index 3bf33db4b1..67155ecfb6 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts @@ -77,7 +77,8 @@ export class ConfigurationService { harborAdminPassword = process.env.HARBOR_ADMIN_PASSWORD harborRuleTemplate = process.env.HARBOR_RULE_TEMPLATE harborRuleCount = process.env.HARBOR_RULE_COUNT - harborRetentionCron = process.env.HARBOR_RETENTION_CRON + harborRetentionCron = process.env.HARBOR_RETENTION_CRON ?? '0 22 2 * * *' + harborRobotRotationThresholdDays = Number(process.env.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS ?? 90) // nexus nexusUrl = process.env.NEXUS_URL diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..bd84be47c0 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,78 @@ +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' + +const nexusUrl = 'https://nexus.internal' + +const server = setupServer() +const nexusAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` + +function createNexusServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: ConfigurationService, + useValue: { + nexusSecretExposedUrl: 'https://nexus.example', + nexusInternalUrl: nexusUrl, + nexusAdmin: 'admin', + nexusAdminPassword, + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('nexusClientService', () => { + let service: NexusClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const module = await createNexusServiceTestingModule().compile() + service = module.get(NexusClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return null on 404 (getRepositoriesMavenHosted)', async () => { + server.use( + http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(basicAuth) + return HttpResponse.json({}, { status: 404 }) + }), + ) + + await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() + }) + + it('should send basic auth and plain text body on change-password', async () => { + server.use( + http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { + expect(request.method).toBe('PUT') + expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) + expect(params.userId).toBe('u1') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('text/plain') + expect(await request.text()).toBe('pw123') + return new HttpResponse(null, { status: 204 }) + }), + ) + + await service.updateSecurityUsersChangePassword('u1', 'pw123') + }) +}) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..85f9e81d62 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +export interface NexusMavenHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenHostedRepositoryUpsertRequest extends NexusMavenHostedRepository {} + +export interface NexusMavenGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusMavenGroupRepositoryUpsertRequest extends NexusMavenGroupRepository {} + +export interface NexusNpmHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmHostedRepositoryUpsertRequest extends NexusNpmHostedRepository {} + +export interface NexusNpmGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmGroupRepositoryUpsertRequest extends NexusNpmGroupRepository {} + +interface NexusRepositoryViewPrivilege { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRepositoryViewPrivilegeUpsertRequest extends NexusRepositoryViewPrivilege {} + +interface NexusRole { + id: string + name: string + privileges: string[] + source?: string + roles?: string[] + description?: string +} + +interface NexusRoleCreateRequest extends NexusRole { + description: string +} + +interface NexusRoleUpdateRequest extends NexusRole {} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +export interface NexusPrivilege extends NexusRepositoryViewPrivilege { + type: string +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenGroup(name: string, body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/npm/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.http.fetch(`repositories/npm/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.http.fetch(`security/privileges/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch('security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch(`security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string) { + try { + await this.http.fetch(`security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.http.fetch(`security/roles/${id}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest) { + await this.http.fetch('security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest) { + await this.http.fetch(`security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string) { + try { + await this.http.fetch(`security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.http.fetch<{ userId: string }[]>(`security/users?${query}`) + return res.data ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string) { + await this.http.fetch(`security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest) { + await this.http.fetch('security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string) { + try { + await this.http.fetch(`security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string) { + try { + await this.http.fetch(`repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..dcecc8efa9 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { NEXUS_PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + plugins: { + where: { + pluginName: NEXUS_PLUGIN_NAME, + }, + 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, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..8cf4223b4c --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + if (!this.config.nexusInternalUrl) return indicator.down('Not configured') + + const url = new URL('/service/rest/v1/status', this.config.nexusInternalUrl).toString() + const headers: Record = {} + if (this.config.nexusAdmin && this.config.nexusAdminPassword) { + const credentials = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` + } + + try { + const response = await fetch(url, { headers }) + if (response.status < 500) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..45908d3e16 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,127 @@ +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: number + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: number + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: number, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', 'Request failed', { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + if (!this.config.nexusInternalUrl) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL is required') + } + return this.config.nexusInternalUrl + } + + private get apiBaseUrl() { + return new URL('service/rest/v1/', this.baseUrl).toString() + } + + private get basicAuth() { + if (!this.config.nexusAdmin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.config.nexusAdminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === 204) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..4cdc734919 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,15 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..218d15c79f --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,32 @@ +// Name of the Nexus plugin used throughout the application +export const NEXUS_PLUGIN_NAME = 'nexus' + +// Configuration keys for enabling Maven and NPM repositories +export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' +export const NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO = 'activateNpmRepo' + +// Configuration keys for Maven snapshot, release, and NPM write policies +export const NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY = 'mavenSnapshotWritePolicy' +export const NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY = 'mavenReleaseWritePolicy' +export const NEXUS_CONFIG_KEY_NPM_WRITE_POLICY = 'npmWritePolicy' + +// Default write policy values for Maven snapshots, releases, and NPM packages +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' + +// Default group paths granting write and read access at the platform level +export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' + +// Default group path suffixes granting write and read access at the project level +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops,/console/developer' +export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security' + +// Plugin configuration keys for platform-level group paths +export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths' +export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' + +// Plugin configuration keys for project-level group path suffixes +export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' +export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..1e7e4db0fd --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusService } from './nexus.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [HealthIndicatorService, NexusHealthService, NexusService, NexusDatastoreService, NexusHttpClientService, NexusClientService], + exports: [NexusClientService, NexusHealthService], +}) +export class NexusModule {} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..98025d9ac7 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,202 @@ +import type { Mocked } from 'vitest' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service.js' +import { makeVaultSecret } from '../vault/vault-testing.utils.js' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NexusService } from './nexus.service' + +function createNexusControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + NexusService, + { + provide: NexusClientService, + useValue: { + getRepositoriesMavenHosted: vi.fn(), + createRepositoriesMavenHosted: vi.fn(), + updateRepositoriesMavenHosted: vi.fn(), + createRepositoriesMavenGroup: vi.fn(), + updateRepositoriesMavenGroup: vi.fn(), + getRepositoriesMavenGroup: vi.fn(), + getRepositoriesNpmHosted: vi.fn(), + createRepositoriesNpmHosted: vi.fn(), + updateRepositoriesNpmHosted: vi.fn(), + getRepositoriesNpmGroup: vi.fn(), + postRepositoriesNpmGroup: vi.fn(), + putRepositoriesNpmGroup: vi.fn(), + getSecurityPrivileges: vi.fn(), + createSecurityPrivilegesRepositoryView: vi.fn(), + updateSecurityPrivilegesRepositoryView: vi.fn(), + deleteSecurityPrivileges: vi.fn(), + getSecurityRoles: vi.fn(), + createSecurityRoles: vi.fn(), + updateSecurityRoles: vi.fn(), + deleteSecurityRoles: vi.fn(), + getSecurityUsers: vi.fn(), + updateSecurityUsersChangePassword: vi.fn(), + createSecurityUsers: vi.fn(), + deleteSecurityUsers: vi.fn(), + deleteRepositoriesByName: vi.fn(), + } satisfies Partial, + }, + { + provide: NexusDatastoreService, + useValue: { + getAllProjects: vi.fn(), + getAdminPluginConfig: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultClientService, + useValue: { + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('nexusService', () => { + let service: NexusService + let client: Mocked + let nexusDatastore: Mocked + let vault: Mocked + + beforeEach(async () => { + const moduleRef = await createNexusControllerServiceTestingModule().compile() + service = moduleRef.get(NexusService) + client = moduleRef.get(NexusClientService) + nexusDatastore = moduleRef.get(NexusDatastoreService) + vault = moduleRef.get(VaultClientService) + + nexusDatastore.getAllProjects.mockResolvedValue([]) + nexusDatastore.getAdminPluginConfig.mockResolvedValue(null) + + client.getRepositoriesMavenHosted.mockResolvedValue(null) + client.getRepositoriesMavenGroup.mockResolvedValue(null) + client.getRepositoriesNpmHosted.mockResolvedValue(null) + client.getRepositoriesNpmGroup.mockResolvedValue(null) + client.getSecurityPrivileges.mockResolvedValue(null) + client.getSecurityRoles.mockResolvedValue(null) + client.getSecurityUsers.mockResolvedValue([]) + vault.read.mockRejectedValue(new VaultError('NotFound', 'Not Found')) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: expect.any(String), + }), + `forge/${project.slug}/tech/NEXUS`, + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) + expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) + expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + ] + + nexusDatastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) + + it('reuses existing vault password and does not rotate Nexus user password', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + + nexusDatastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + vault.read.mockResolvedValue(makeVaultSecret({ data: { NEXUS_PASSWORD: 'existing' } })) + client.getSecurityUsers.mockResolvedValue([{ userId: project.slug }]) + + await service.handleUpsert(project) + + expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled() + expect(client.createSecurityUsers).not.toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: 'existing', + }), `forge/${project.slug}/tech/NEXUS`) + }) + + it('dedupes project group roles by role id and keeps the highest privileges', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + ], + }) + + nexusDatastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + await service.handleUpsert(project) + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ + id: `${project.slug}-console-devops`, + privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), + })) + }) +}) diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..b261547daa --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,676 @@ +import type { NexusPrivilege } from './nexus-client.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import type { + MavenHostedRepoKind, +} from './nexus.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service.js' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { + DEFAULT_MAVEN_RELEASE_WRITE_POLICY, + DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, + DEFAULT_NPM_WRITE_POLICY, + DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_WRITE_GROUP_PATHS, + DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, + NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, + NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, + NEXUS_PLUGIN_NAME, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { + generateMavenHostedRepoName, + generateNpmHostedRepoName, + generateRandomPassword, + getPluginConfig, + getProjectVaultPath, +} from './nexus.utils' + +export interface EnsureMavenReposOptions { + snapshotWritePolicy: string + releaseWritePolicy: string +} + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + @StartActiveSpan() + async handleUpsert(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + const projects = await this.nexusDatastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @OnEvent('project.delete') + @StartActiveSpan() + async handleDelete(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project) + const projects = await this.nexusDatastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.nexusDatastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + await this.ensurePlatformRoles(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY) ?? DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY) ?? DEFAULT_MAVEN_RELEASE_WRITE_POLICY + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY) ?? DEFAULT_NPM_WRITE_POLICY + + await Promise.all([ + enableMaven + ? this.ensureMavenRepos(project, { snapshotWritePolicy: mavenSnapshotWritePolicy, releaseWritePolicy: mavenReleaseWritePolicy }) + : this.deleteMavenRepos(project), + enableNpm + ? this.ensureNpmRepos(project, npmWritePolicy) + : this.deleteNpmRepos(project), + ]) + + const privileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + const readOnlyPrivileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + await this.ensureRole(project, privileges) + await this.ensureUser(project) + await this.ensureProjectGroupRoles(project, { readOnlyPrivileges, writePrivileges: privileges }) + } + + private async upsertPrivilege(body: NexusPrivilege) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(project: ProjectWithDetails, options: EnsureMavenReposOptions) { + const releaseRepoName = generateMavenHostedRepoName(project, 'release') + const snapshotRepoName = generateMavenHostedRepoName(project, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(project) + + const releasePrivilege = generateMavenHostedPrivilegeName(project, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(project, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(project) + const releasePrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'release') + const snapshotPrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'snapshot') + const groupPrivilegeReadonly = generateMavenGroupPrivilegeNameReadonly(project) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsureWrite = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: releaseRepoName, privilege: releasePrivilegeReadonly }, + { repo: snapshotRepoName, privilege: snapshotPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesMavenGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.createRepositoriesMavenGroup(body) + return + } + await this.client.updateRepositoriesMavenGroup(repoName, body) + } + + private async ensureRepositoryViewPrivileges(args: { + project: ProjectWithDetails + type: string + format: string + entries: Array<{ repo: string, privilege: string }> + actions: string[] + }) { + for (const entry of args.entries) { + await this.upsertPrivilege({ + type: args.type, + name: entry.privilege, + description: `Privilege for organization ${args.project.slug} for repo ${entry.repo}`, + actions: args.actions, + format: args.format, + repository: entry.repo, + }) + } + } + + private async deleteMavenRepos(project: ProjectWithDetails) { + const repoPaths = [ + generateMavenGroupRepoName(project), + generateMavenHostedRepoName(project, 'release'), + generateMavenHostedRepoName(project, 'snapshot'), + ] + const privileges = [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + await Promise.all(repoPaths.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureNpmRepos(project: ProjectWithDetails, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(project) + const groupRepoName = generateNpmGroupRepoName(project) + + const hostedPrivilege = generateNpmHostedPrivilegeName(project) + const groupPrivilege = generateNpmGroupPrivilegeName(project) + const hostedPrivilegeReadonly = generateNpmHostedPrivilegeNameReadonly(project) + const groupPrivilegeReadonly = generateNpmGroupPrivilegeNameReadonly(project) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + const privilegesToEnsureWrite = [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: hostedRepoName, privilege: hostedPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async deleteNpmRepos(project: ProjectWithDetails) { + const repoPaths = [ + generateNpmGroupRepoName(project), + generateNpmHostedRepoName(project), + ] + const privileges = [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + await Promise.all(repoPaths.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureRole(project: ProjectWithDetails, privileges: string[]) { + const roleId = `${project.slug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${project.slug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${project.slug}-role`, + privileges, + }) + } + + private async ensureUser(project: ProjectWithDetails) { + const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + + const ensuredPassword = existingPassword ?? generateRandomPassword(30) + const users = await this.client.getSecurityUsers(project.slug) + const existing = users.find(u => u.userId === project.slug) + if (existing) { + if (!existingPassword || existingPassword !== ensuredPassword) { + await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword) + } + } else { + await this.client.createSecurityUsers({ + userId: project.slug, + firstName: project.owner.firstName, + lastName: project.owner.lastName, + emailAddress: project.owner.email, + password: ensuredPassword, + status: 'active', + roles: [`${project.slug}-ID`], + }) + } + + await this.vault.write({ + NEXUS_PASSWORD: ensuredPassword, + NEXUS_USERNAME: project.slug, + }, vaultPath) + } + + private async ensureSecurityRole(id: string, privileges: string[]) { + const role = await this.client.getSecurityRoles(id) + if (!role) { + await this.client.createSecurityRoles({ + id, + name: id, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(id, { + id, + name: id, + privileges, + }) + } + + private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { + const projectValue = getPluginConfig(project, key) + if (projectValue) return projectValue + return await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, key) + } + + private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) + + const byId = generateRolePrivilegesMapping({ + readGroupPaths, + writeGroupPaths, + readOnlyPrivileges: args.readOnlyPrivileges, + writePrivileges: args.writePrivileges, + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async ensurePlatformRoles(projects: ProjectWithDetails[]) { + const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS + + const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_READ_GROUP_PATHS + + const readonlyPrivileges = new Set() + const writePrivileges = new Set() + for (const project of projects) { + const computed = computeProjectPrivileges(project) + for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) + for (const privilege of computed.write) writePrivileges.add(privilege) + } + + const byId = generateRolePrivilegesMapping({ + readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), + writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), + readOnlyPrivileges: [...readonlyPrivileges], + writePrivileges: [...writePrivileges], + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async deleteProjectGroupRoles(project: ProjectWithDetails) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const groupPaths = [ + ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), + ] + + const ids = [...new Set(groupPaths.map(generateRoleId))] + await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) + } + + @StartActiveSpan() + private async deleteProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.deleteMavenRepos(project), + this.deleteNpmRepos(project), + ]) + + await Promise.all([ + this.deleteProjectGroupRoles(project), + this.client.deleteSecurityRoles(`${project.slug}-ID`), + this.client.deleteSecurityUsers(project.slug), + ]) + + const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} + +function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-privilege-${kind}` +} + +function generateMavenGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-repository-group` +} + +function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-privilege-group` +} + +function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-privilege` +} + +function generateNpmGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm-group` +} + +function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-group-privilege` +} + +function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${generateMavenHostedPrivilegeName(project, kind)}-ro` +} + +function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateMavenGroupPrivilegeName(project)}-ro` +} + +function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmHostedPrivilegeName(project)}-ro` +} + +function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmGroupPrivilegeName(project)}-ro` +} + +function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function parseOidcGroupPaths(rawGroupPaths: string) { + return rawGroupPaths + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { + const byId = new Map() + for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) + for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) + return byId +} + +function generateRoleId(groupPath: string) { + const trimmed = groupPath.trim() + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed + return withoutLeadingSlash.replaceAll('/', '-') +} + +function computeProjectPrivileges(project: ProjectWithDetails) { + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const write = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + + const readOnly = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + + return { readOnly, write } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..1bd4450861 --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,28 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string | undefined, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${projectSlug}/${normalized}` + : `${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-repository-${kind}` +} + +export function generateNpmHostedRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm` +} diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..d71cb94e76 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,95 @@ +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const harborUrl = 'https://harbor.example' +const harborAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` + +const server = setupServer() + +function createRegistryServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: ConfigurationService, + useValue: { + harborUrl, + harborInternalUrl: harborUrl, + harborAdmin: 'admin', + harborAdminPassword, + harborRuleTemplate: 'latestPushedK', + harborRuleCount: '10', + harborRetentionCron: '0 22 2 * * *', + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('registryService', () => { + let service: RegistryClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const module = await createRegistryServiceTestingModule().compile() + service = module.get(RegistryClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should send basic auth and JSON body on createProject', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, async ({ request }) => { + expect(request.method).toBe('POST') + expect(request.url).toBe(`${harborUrl}/api/v2.0/projects`) + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('application/json') + expect(await request.json()).toEqual({ + project_name: 'myproj', + metadata: { auto_scan: 'true' }, + storage_limit: -1, + }) + return HttpResponse.json({}, { status: 201 }) + }), + ) + + await service.createProject('myproj', -1) + }) + + it('should send X-Is-Resource-Name on getProjectByName', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { + expect(request.method).toBe('GET') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('x-is-resource-name')).toBe('true') + expect(params.projectName).toBe('myproj') + return HttpResponse.json({ project_id: 123, metadata: {} }) + }), + ) + + const res = await service.getProjectByName('myproj') + + expect(res).toMatchObject({ status: 200, data: { project_id: 123 } }) + }) +}) diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..67c6196dee --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,224 @@ +import type { RegistryResponse } from './registry-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { RegistryHttpClientService } from './registry-http-client.service' + +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' }, +] + +export interface HarborProject { + project_id?: number + metadata?: { + retention_id?: number | string + } +} + +export interface HarborRobot { + id?: number + name?: string +} + +export interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +export interface HarborMember { + id?: number + entity_name?: string + entity_type?: string + role_id?: number +} + +export interface HarborGroupMemberRequest { + role_id: number + member_group: { + group_name: string + group_type: number + } +} + +export interface HarborProjectQuota { + ref?: { id?: number } + hard?: { storage?: number } +} + +export interface HarborRobotPermission { + namespace: string + kind: 'project' + access: HarborAccess[] +} + +export interface HarborRobotCreateRequest { + name: string + duration: number + description: string + disable: boolean + level: 'project' + permissions: HarborRobotPermission[] +} + +export interface HarborRetentionRule { + disabled: boolean + action: 'retain' + template: string + params: Record + tag_selectors: Array<{ kind: string, decoration: string, pattern: string }> + scope_selectors: { + repository: Array<{ kind: string, decoration: string, pattern: string }> + } +} + +export interface HarborRetentionPolicy { + algorithm: 'or' | 'and' + scope: { level: 'project', ref: number } + rules: HarborRetentionRule[] + trigger: { + kind: 'Schedule' + settings: { cron?: string } + references: unknown[] + } +} + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string): Promise> { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: HarborGroupMemberRequest) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async getProjectRobots(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/robots`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createRobot(body: HarborRobotCreateRequest) { + return this.http.fetch('robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(projectName: string, robotId: number) { + const direct = await this.http.fetch(`robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + if (direct.status < 300 || direct.status === 404) return direct + + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number(project.data?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: HarborRetentionPolicy) { + return this.http.fetch('retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: HarborRetentionPolicy) { + return this.http.fetch(`retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..24cfbf6f2e --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,61 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { REGISTRY_PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + where: { + pluginName: REGISTRY_PLUGIN_NAME, + }, + select: { + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: REGISTRY_PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..fa3569b4ca --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + if (!this.config.harborInternalUrl) return indicator.down('Not configured') + + const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() + const headers: Record = {} + if (this.config.harborAdmin && this.config.harborAdminPassword) { + const credentials = `${this.config.harborAdmin}:${this.config.harborAdminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` + } + + try { + const response = await fetch(url, { method: 'GET', headers }) + if (response.status < 500) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..dc93d7588f --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,113 @@ +import { Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { encodeBasicAuth } from './registry.utils' + +export interface RegistryFetchOptions { + method?: string + headers?: Record + body?: unknown +} + +export interface RegistryResponse { + status: number + data: T | null +} + +export type RegistryErrorKind + = | 'NotConfigured' + | 'Unexpected' + +export class RegistryError extends Error { + readonly kind: RegistryErrorKind + readonly status?: number + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: RegistryErrorKind, + message: string, + details: { status?: number, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'RegistryError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) {} + + private get baseUrl() { + if (!this.config.harborInternalUrl) { + throw new RegistryError('NotConfigured', 'HARBOR_INTERNAL_URL is required') + } + return this.config.harborInternalUrl + } + + private get apiBaseUrl() { + return new URL('api/v2.0/', this.baseUrl).toString() + } + + private get defaultHeaders() { + if (!this.config.harborAdmin) { + throw new RegistryError('NotConfigured', 'HARBOR_ADMIN is required') + } + if (!this.config.harborAdminPassword) { + throw new RegistryError('NotConfigured', 'HARBOR_ADMIN_PASSWORD is required') + } + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.config.harborAdmin, this.config.harborAdminPassword)}` } + } + + async fetch( + path: string, + options: RegistryFetchOptions = {}, + ): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('registry.method', method) + span?.setAttribute('registry.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new RegistryError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('registry.http.status', response.status) + return await handleResponse(response) + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === 204) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts new file mode 100644 index 0000000000..ddb9a48c8f --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -0,0 +1,23 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service.js' +import { faker } from '@faker-js/faker' + +export function makeOkResponse(data: T): RegistryResponse { + return { status: 200, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: 201, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: 204, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/apps/server-nestjs/src/modules/registry/registry.constants.ts b/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..d2f6874c16 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,33 @@ +// Registry plugin identification +export const REGISTRY_PLUGIN_NAME = 'harbor' + +// Registry configuration keys +export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' +export const REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT = 'publishProjectRobot' + +// Default platform-level group paths +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' + +// Default project-level group path suffixes +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' +export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' + +// Platform group path plugin configuration keys +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY = 'platformGuestGroupPaths' + +// Project group path suffixes plugin configuration keys +export const PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectAdminGroupPathSuffixes' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintainerGroupPathSuffixes' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' +export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' + +// Harbor role identifiers +export const HARBOR_ROLE_PROJECT_ADMIN = 1 +export const HARBOR_ROLE_DEVELOPER = 2 +export const HARBOR_ROLE_GUEST = 3 +export const HARBOR_ROLE_MAINTAINER = 4 +export const HARBOR_ROLE_LIMITED_GUEST = 5 diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..3214e9e351 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { VaultModule } from '../vault/vault.module' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryService } from './registry.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [HealthIndicatorService, RegistryHealthService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryService], +}) +export class RegistryModule {} diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 0000000000..6e5210a4b6 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,332 @@ +import type { Mocked } from 'vitest' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils.js' +import { projectRobotName, RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils.js' +import { + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, +} from './registry.constants' +import { RegistryService } from './registry.service' + +function createRegistryControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + RegistryService, + { + provide: RegistryClientService, + useValue: { + getProjectRobots: vi.fn(), + createRobot: vi.fn(), + deleteRobot: vi.fn(), + getGroupMembers: vi.fn(), + addGroupMember: vi.fn(), + removeGroupMember: vi.fn(), + getProjectByName: vi.fn(), + listQuotas: vi.fn(), + updateQuota: vi.fn(), + createProject: vi.fn(), + getRetentionId: vi.fn(), + updateRetention: vi.fn(), + createRetention: vi.fn(), + deleteProjectByName: vi.fn(), + } satisfies Partial, + }, + { + provide: RegistryDatastoreService, + useValue: { + getAdminPluginConfig: vi.fn(), + getAllProjects: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultClientService, + useValue: { + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + harborUrl: 'https://harbor.example', + harborInternalUrl: 'https://harbor.example', + harborAdmin: 'admin', + harborAdminPassword: faker.internet.password(), + harborRuleTemplate: 'latestPushedK', + harborRuleCount: '10', + harborRetentionCron: '0 22 2 * * *', + harborRobotRotationThresholdDays: 90, + projectRootDir: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('registryService', () => { + let service: RegistryService + let registry: Mocked + let vault: Mocked + let registryDatastore: Mocked + + beforeEach(async () => { + const moduleRef = await createRegistryControllerServiceTestingModule().compile() + service = moduleRef.get(RegistryService) + registry = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + registryDatastore = moduleRef.get(RegistryDatastoreService) + + registryDatastore.getAdminPluginConfig.mockResolvedValue(null) + + registry.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })) + registry.listQuotas.mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + registry.getRetentionId.mockResolvedValue(null) + registry.createRetention.mockResolvedValue(makeCreatedResponse(null)) + + vault.read.mockResolvedValue(makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'secret', + }, + })) + vault.write.mockResolvedValue(undefined) + + registry.getGroupMembers.mockResolvedValue(makeOkResponse([])) + registry.addGroupMember.mockResolvedValue(makeCreatedResponse(null)) + registry.removeGroupMember.mockResolvedValue(makeNoContent()) + + registry.deleteProjectByName.mockResolvedValue(makeNoContent()) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('adds expected Harbor group memberships based on defaults', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + const expected = [ + { groupName: `/${project.slug}`, roleId: 5 }, + { groupName: '/console/admin', roleId: 1 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: `/${project.slug}/console/readonly`, roleId: 3 }, + { groupName: `/${project.slug}/console/security`, roleId: 3 }, + { groupName: `/${project.slug}/console/developer`, roleId: 2 }, + { groupName: `/${project.slug}/console/devops`, roleId: 4 }, + { groupName: `/${project.slug}/console/admin`, roleId: 1 }, + ] + + expect(registry.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + const project = makeProjectWithDetails() + registry.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: `/${project.slug}/console/developer`, entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(project) + + expect(registry.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/developer`, + group_type: 3, + }, + }) + }) + + it('throws when Maintainer membership creation fails', async () => { + const project = makeProjectWithDetails() + registry.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === `/${project.slug}/console/devops` && body.role_id === 4) { + return { status: 400, data: null } + } + return { status: 201, data: null } + }) + + await expect(service.handleUpsert(project)).rejects.toThrow('Harbor create member failed') + + expect(registry.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 4, + member_group: { + group_name: `/${project.slug}/console/devops`, + group_type: 3, + }, + }) + }) + + it('updates quota when it differs', async () => { + registry.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1024' }, + ], + })) + + expect(registry.updateQuota).toHaveBeenCalledWith(123, 1024) + }) + + it('reuses robot secret when vault secret host matches', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(registry.getProjectRobots).not.toHaveBeenCalled() + expect(registry.createRobot).not.toHaveBeenCalled() + expect(registry.deleteRobot).not.toHaveBeenCalled() + expect(vault.write).not.toHaveBeenCalled() + }) + + it('rotates robot and writes secret when vault secret host differs', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) { + return makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + } + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + registry.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: `robot$${project.slug}+ro-robot` }])) + registry.deleteRobot.mockResolvedValue(makeNoContent()) + registry.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(registry.deleteRobot).toHaveBeenCalledWith(project.slug, 11) + expect(registry.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const project = makeProjectWithDetails() + const old = makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + old.metadata.created_time = new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString() + + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) return old + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + registry.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: `robot$${project.slug}+ro-robot` }])) + registry.deleteRobot.mockResolvedValue(makeNoContent()) + registry.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(registry.deleteRobot).toHaveBeenCalledWith(project.slug, 11) + expect(registry.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('parses plugin config and enables project robot publishing', async () => { + const project = makeProjectWithDetails({ + plugins: [ + { key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: 'enabled' }, + ], + }) + registry.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(project) + + expect(registry.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${projectRobotName}`) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + registryDatastore.getAllProjects.mockResolvedValue([ + makeProjectWithDetails({ slug: 'project-1' }), + makeProjectWithDetails({ slug: 'project-2' }), + ]) + + await service.handleCron() + + expect(registry.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(registry.getGroupMembers).toHaveBeenCalledWith('project-2') + }) + }) + + describe('handleDelete', () => { + it('should delete project when it exists', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(registry.deleteProjectByName).toHaveBeenCalledWith(project.slug) + }) + + it('should not delete project when it does not exist', async () => { + registry.getProjectByName.mockResolvedValueOnce({ status: 404, data: null }) + await service.handleDelete(makeProjectWithDetails()) + expect(registry.deleteProjectByName).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..2fca977eca --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,528 @@ +import type { VaultSecret } from '../vault/vault-client.service' +import type { + HarborAccess, + HarborGroupMemberRequest, + HarborMember, + HarborProjectQuota, + HarborRetentionPolicy, + HarborRobotCreateRequest, +} from './registry-client.service' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { VaultRobotSecret } from './registry.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service.js' +import { projectRobotName, RegistryClientService, roAccess, roRobotName, rwAccess, rwRobotName } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { + DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, + DEFAULT_PLATFORM_GUEST_GROUP_PATHS, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES, + HARBOR_ROLE_DEVELOPER, + HARBOR_ROLE_GUEST, + HARBOR_ROLE_LIMITED_GUEST, + HARBOR_ROLE_MAINTAINER, + HARBOR_ROLE_PROJECT_ADMIN, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + REGISTRY_PLUGIN_NAME, +} from './registry.constants' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' + +const allowedRuleTemplates = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +type RuleTemplate = typeof allowedRuleTemplates[number] + +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly registryDatastore: RegistryDatastoreService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('RegistryService initialized') + } + + private get host() { + if (!this.config.harborUrl) { + throw new Error('HARBOR_URL is required') + } + return getHostFromUrl(this.config.harborUrl) + } + + private async getRobot(project: ProjectWithDetails, robotName: string) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const robots = await this.client.getProjectRobots(project.slug) + if (robots.status !== 200 || !robots.data) return undefined + const fullName = generateRobotFullName(project, robotName) + return robots.data.find(r => r?.name === fullName) + } + + private async createProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const created = await this.client.createRobot( + generateRobotPermissions(project, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data + } + + private async rotateRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const existing = await this.getRobot(project, robotName) + if (existing?.id) { + await this.client.deleteRobot(project.slug, existing.id) + } + return this.createProjectRobot(project, robotName, access) + } + + private async ensureRobotSecret(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + if (!this.config.projectRootDir) { + throw new Error('PROJECTS_ROOT_DIR is required') + } + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(project, this.config.projectRootDir, relativeVaultPath) + const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + + const expiring = vaultRobotSecret + ? this.isRobotSecretExpiring(vaultRobotSecret) + : false + + span?.setAttributes({ + 'vault.secret.exists': !!vaultRobotSecret, + 'registry.robot.secret.expiring': expiring, + }) + + if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret.data + } + + const existing = await this.getRobot(project, robotName) + const created = existing + ? await this.rotateRobot(project, robotName, access) + : await this.createProjectRobot(project, robotName, access) + const fullName = generateRobotFullName(project, robotName) + const secret = generateVaultRobotSecret(this.host, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + private isRobotSecretExpiring(vaultSecret: VaultSecret): boolean { + const createdTimeRaw = vaultSecret?.metadata?.created_time + if (!createdTimeRaw) return false + const createdTime = new Date(createdTimeRaw) + return daysAgoFromNow(createdTime) > this.config.harborRobotRotationThresholdDays + } + + private async ensureProjectGroupMember( + projectSlug: string, + groupName: string, + accessLevel: number, + membersByName: Map, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const existing = membersByName.get(groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { + await this.client.removeGroupMember(projectSlug, Number(existing.id)) + membersByName.delete(groupName) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const body: HarborGroupMemberRequest = { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + } + const created = await this.client.addGroupMember(projectSlug, body) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async ensureProjectGroupMembers(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + + const membersByName = new Map() + for (const member of members.data) { + const name = member?.entity_name + if (name) membersByName.set(name, member) + } + + const byGroupName = await this.generateAccessLevelMapping(project) + + await Promise.all( + Array.from(byGroupName.entries(), ([groupName, accessLevel]) => this.ensureProjectGroupMember( + project.slug, + groupName, + accessLevel, + membersByName, + )), + ) + } + + private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.getProjectByName(project.slug) + if (existing.status === 200 && existing.data) { + const projectId = Number(existing.data.project_id) + if (!Number.isFinite(projectId)) return existing.data + + const quotas = await this.client.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = quotas.data.find((q: HarborProjectQuota) => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return existing.data + } + + const created = await this.client.createProject(project.slug, 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(project.slug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data + } + + private async ensureRetentionPolicy(project: ProjectWithDetails, harborProjectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.project.id': harborProjectId, + }) + const policy = generateRetentionPolicy(harborProjectId, { + harborRuleTemplate: this.config.harborRuleTemplate, + harborRuleCount: this.config.harborRuleCount, + harborRetentionCron: this.config.harborRetentionCron, + }) + const retentionId = await this.client.getRetentionId(project.slug) + 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 ensureProject(project: ProjectWithDetails, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const harborProject = await this.ensureProjectQuota(project, storageLimit) + const harborProjectId = Number(harborProject.project_id) + + await Promise.all([ + this.ensureRobotSecret(project, roRobotName, roAccess), + this.ensureRobotSecret(project, rwRobotName, rwAccess), + this.ensureProjectGroupMembers(project), + Number.isFinite(harborProjectId) ? this.ensureRetentionPolicy(project, harborProjectId) : Promise.resolve(), + options.publishProjectRobot + ? this.ensureRobotSecret(project, projectRobotName, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: Number.isFinite(harborProjectId) ? harborProjectId : undefined, + basePath: `${this.host}/${project.slug}/`, + } + } + + @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.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } + + @OnEvent('project.upsert') + @StartActiveSpan() + async handleUpsert(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.ensureProject(project, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + @StartActiveSpan() + async handleDelete(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.registryDatastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { + const adminPluginConfig = await this.registryDatastore.getAdminPluginConfig(REGISTRY_PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getPluginConfig(project, key) + } + + private async getPlatformAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getPlatformGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_GUEST_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async generateAccessLevelMapping(project: ProjectWithDetails) { + const [ + platformAdminGroupPaths, + platformGuestGroupPaths, + projectAdminGroupPaths, + projectMaintainerGroupPaths, + projectDeveloperGroupPaths, + projectGuestGroupPaths, + ] = await Promise.all([ + this.getPlatformAdminGroupPaths(project), + this.getPlatformGuestGroupPaths(project), + this.getProjectAdminGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectGuestGroupPaths(project), + ]) + + const platformRoles = generateHarborAccessLevelMapping({ + guest: platformGuestGroupPaths, + developer: [], + maintainer: [], + admin: platformAdminGroupPaths, + }) + + const projectRoles = generateHarborAccessLevelMapping({ + guest: projectGuestGroupPaths, + developer: projectDeveloperGroupPaths, + maintainer: projectMaintainerGroupPaths, + admin: projectAdminGroupPaths, + }) + return new Map([ + [`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST], + ...platformRoles, + ...projectRoles, + ]) + } +} + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function parseGroupPaths(raw: string): string[] { + return raw + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`) +} + +function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[] }) { + const byGroupName = new Map() + for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER) + for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_MAINTAINER) + for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN) + return byGroupName +} + +function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +function generateRobotFullName(project: ProjectWithDetails, robotName: string) { + return `robot$${project.slug}+${robotName}` +} + +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: project.slug, + kind: 'project', + access, + }], + } +} + +function generateRetentionPolicy( + projectId: number, + options: { + harborRuleTemplate?: string + harborRuleCount?: string + harborRetentionCron?: string + }, +): HarborRetentionPolicy { + let template: RuleTemplate = 'latestPushedK' + if (isRuleTemplate(options.harborRuleTemplate)) { + template = options.harborRuleTemplate + } + + const rawCount = Number(options.harborRuleCount) + let count: number + if (Number.isFinite(rawCount) && rawCount > 0) { + count = rawCount + } else if (template === 'always') { + count = 1 + } else { + count = 10 + } + + 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: options.harborRetentionCron }, + references: [], + }, + } +} + +function isRuleTemplate(value: unknown): value is RuleTemplate { + if (typeof value !== 'string') return false + for (const template of allowedRuleTemplates) { + if (template === value) return true + } + return false +} diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..7a80bae487 --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,79 @@ +import type { ProjectWithDetails } from './registry-datastore.service.js' +import { removeTrailingSlash } from '@cpn-console/shared' + +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +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 generateVaultRobotSecret(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(project: ProjectWithDetails, projectRootDir: string | undefined, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${project.slug}/${normalized}` + : `${project.slug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = parseBytesRegex.exec(raw) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[2] ?? 'b').toLowerCase() + const pow = parseUnit(unit) + return Math.round(value * 1024 ** pow) +} + +function parseUnit(unit: string) { + switch (unit) { + case 'kb': + case 'k': + return 1 + case 'mb': + case 'm': + return 2 + case 'gb': + case 'g': + return 3 + case 'tb': + case 't': + return 4 + default: + return 0 + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index f22e6879a9..1de60fe04a 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -5,11 +5,11 @@ import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator import { VaultError, VaultHttpClientService } from './vault-http-client.service' import { generateGitlabMirrorCredPath, generateProjectPath, generateTechReadOnlyCredPath } from './vault.utils' -interface VaultSysPoliciesAclUpsertRequest { +export interface VaultSysPoliciesAclUpsertRequest { policy: string } -interface VaultSysMountCreateRequest { +export interface VaultSysMountCreateRequest { type: string config: { force_no_cache: boolean @@ -19,13 +19,13 @@ interface VaultSysMountCreateRequest { } } -interface VaultSysMountTuneRequest { +export interface VaultSysMountTuneRequest { options: { version: number } } -interface VaultAuthApproleRoleUpsertRequest { +export interface VaultAuthApproleRoleUpsertRequest { secret_id_num_uses: string secret_id_ttl: string token_max_ttl: string @@ -35,29 +35,29 @@ interface VaultAuthApproleRoleUpsertRequest { token_policies: string[] } -interface VaultIdentityGroupUpsertRequest { +export interface VaultIdentityGroupUpsertRequest { name: string type: string policies: string[] } -interface VaultIdentityGroupAliasCreateRequest { +export interface VaultIdentityGroupAliasCreateRequest { name: string mount_accessor: string canonical_id: string } -interface VaultAuthMethod { +export interface VaultAuthMethod { accessor: string type: string description?: string } -interface VaultSysAuthResponse { +export interface VaultSysAuthResponse { data: Record } -interface VaultIdentityGroupResponse { +export interface VaultIdentityGroupResponse { data: { id: string name: string @@ -85,19 +85,19 @@ export interface VaultResponse { data: VaultSecret } -interface VaultListResponse { +export interface VaultListResponse { data: { keys: string[] } } -interface VaultRoleIdResponse { +export interface VaultRoleIdResponse { data: { role_id: string } } -interface VaultSecretIdResponse { +export interface VaultSecretIdResponse { data: { secret_id: string } diff --git a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts new file mode 100644 index 0000000000..d3bf1da198 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -0,0 +1,42 @@ +import type { VaultSecret } from './vault-client.service.js' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecret(overrides: Partial = {}): VaultSecret { + return { + data: {}, + metadata: makeVaultSecretMetadata(), + ...overrides, + } satisfies VaultSecret +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index e0d217c52f..86f94f1139 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -1,11 +1,12 @@ import type { TestingModule } from '@nestjs/testing' import type { Mocked } from 'vitest' -import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils.js' import { VaultService } from './vault.service' const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ @@ -72,9 +73,9 @@ describe('vaultService', () => { client.upsertAuthApproleRole.mockResolvedValue(undefined) client.deleteAuthApproleRole.mockResolvedValue(undefined) client.upsertIdentityGroupName.mockResolvedValue(undefined) - client.getIdentityGroupName.mockImplementation(async (groupName: string) => ({ data: { id: 'gid', name: groupName } } as any)) + client.getIdentityGroupName.mockImplementation(async (groupName: string) => makeVaultSecret({ data: { id: 'gid', name: groupName } })) client.deleteIdentityGroupName.mockResolvedValue(undefined) - client.getSysAuth.mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } } as any) + client.getSysAuth.mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } }) client.createIdentityGroupAlias.mockResolvedValue(undefined) client.listKvMetadata.mockResolvedValue([]) client.delete.mockResolvedValue(undefined) @@ -85,42 +86,28 @@ describe('vaultService', () => { }) it('should reconcile on cron', async () => { - const mockProjects = [ - { - slug: 'project-1', - name: 'Project 1', - id: '550e8400-e29b-41d4-a716-446655440000', - description: '', - plugins: [], - environments: [], - }, - { - slug: 'project-2', - name: 'Project 2', - id: '660e8400-e29b-41d4-a716-446655440001', - description: '', - plugins: [], - environments: [], - }, - ] satisfies ProjectWithDetails[] - const mockZones = [ - { slug: 'z1', id: 'z1' }, - ] satisfies ZoneWithDetails[] + const projects = faker.helpers.multiple(() => makeProjectWithDetails()) + const zones = faker.helpers.multiple(() => makeZoneWithDetails()) - datastore.getAllProjects.mockResolvedValue(mockProjects) - datastore.getAllZones.mockResolvedValue(mockZones as any) + datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAllZones.mockResolvedValue(zones) await service.handleCron() expect(datastore.getAllProjects).toHaveBeenCalled() expect(datastore.getAllZones).toHaveBeenCalled() - expect(client.createSysMount).toHaveBeenCalledTimes(3) - expect(client.createSysMount).toHaveBeenCalledWith('project-1', expect.any(Object)) - expect(client.createSysMount).toHaveBeenCalledWith('project-2', expect.any(Object)) - expect(client.createSysMount).toHaveBeenCalledWith('zone-z1', expect.any(Object)) + expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) + projects.forEach((project) => { + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + }) + zones.forEach((zone) => { + expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.any(Object)) + }) }) it('should upsert project on event', async () => { + const project = makeProjectWithDetails() + client.getIdentityGroupName.mockImplementation(async (groupName: string) => { const projectRoleMatch = groupName.match(projectRoleGroupNameRegex) if (projectRoleMatch) { @@ -136,52 +123,46 @@ describe('vaultService', () => { return { data: { id: 'gid', name: groupName } } }) - await service.handleUpsert({ slug: 'project-1' } as any) - expect(client.createSysMount).toHaveBeenCalledWith('project-1', expect.any(Object)) - expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('app--project-1--admin', expect.any(Object)) - expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('tech--project-1--ro', expect.any(Object)) - expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--devops', expect.any(Object)) - expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--developer', expect.any(Object)) - expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--readonly', expect.any(Object)) - expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--security', expect.any(Object)) + await service.handleUpsert(project) + + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`, expect.any(Object)) expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--admin', expect.any(Object)) expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--readonly', expect.any(Object)) expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--security', expect.any(Object)) expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-admin', expect.any(Object)) expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-readonly', expect.any(Object)) expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-security', expect.any(Object)) - expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('project-project-1-admin', expect.any(Object)) - expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('project-project-1-devops', expect.any(Object)) - expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('project-project-1-developer', expect.any(Object)) - expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('project-project-1-readonly', expect.any(Object)) - expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('project-project-1-security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() }) it('should delete project and destroy secrets on event', async () => { - const mockProject = { - slug: 'project-1', - name: 'Project 1', - id: '550e8400-e29b-41d4-a716-446655440000', - description: '', - plugins: [], - environments: [], - } satisfies ProjectWithDetails - - await service.handleDelete(mockProject) - - expect(client.deleteSysMounts).toHaveBeenCalledWith('project-1') - expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('app--project-1--admin') - expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('tech--project-1--ro') - expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--devops') - expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--developer') - expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--readonly') - expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith('project--project-1--security') - expect(client.deleteAuthApproleRole).toHaveBeenCalledWith('project-1') - expect(client.deleteIdentityGroupName).toHaveBeenCalledWith('project-project-1-admin') - expect(client.deleteIdentityGroupName).toHaveBeenCalledWith('project-project-1-devops') - expect(client.deleteIdentityGroupName).toHaveBeenCalledWith('project-project-1-developer') - expect(client.deleteIdentityGroupName).toHaveBeenCalledWith('project-project-1-readonly') - expect(client.deleteIdentityGroupName).toHaveBeenCalledWith('project-project-1-security') + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`) + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(project.slug) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) }) }) diff --git a/apps/server-nestjs/test/argocd.e2e-spec.ts b/apps/server-nestjs/test/argocd.e2e-spec.ts index 06643e19aa..3aa125cad7 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -206,7 +206,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { namespaceId: infraGroup.id, initializeWithReadme: true, defaultBranch: 'main', - } as any) + }) infraRepoId = created.id } diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts index c5eb924602..9f03ed0dc4 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -85,8 +85,8 @@ describeWithKeycloak('KeycloakController (e2e)', () => { try { // Clean Keycloak const group = await keycloak.getGroupByPath(`/${testProjectSlug}`) - if (group) { - await keycloak.deleteGroup(group.id!) + if (group?.id) { + await keycloak.deleteGroup(group.id) } // Clean owner user diff --git a/apps/server-nestjs/test/nexus.e2e-spec.ts b/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..02407e3870 --- /dev/null +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,149 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, NEXUS_PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: ConfigurationService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigurationModule, InfrastructureModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(ConfigurationService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: NEXUS_PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: 'enabled' }, + { pluginName: NEXUS_PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: 'enabled' }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/apps/server-nestjs/test/registry.e2e-spec.ts b/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..ffd8943272 --- /dev/null +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,85 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { projectRobotName, RegistryClientService, roRobotName, rwRobotName } from '../src/modules/registry/registry-client.service' +import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { RegistryService } from '../src/modules/registry/registry.service' +import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' + +const canRunRegistryE2E + = Boolean(process.env.E2E) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithRegistry = describe.runIf(canRunRegistryE2E) + +describeWithRegistry('RegistryService (e2e)', () => { + let moduleRef: TestingModule + let registry: RegistryService + let client: RegistryClientService + let vault: VaultClientService + let config: ConfigurationService + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigurationModule, RegistryModule], + }) + .compile() + + await moduleRef.init() + + registry = moduleRef.get(RegistryService) + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + config = moduleRef.get(ConfigurationService) + + projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) + }) + + afterAll(async () => { + if (vault && config && projectSlug) { + const paths = [roRobotName, rwRobotName, projectRobotName].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) + } + + if (registry && projectSlug) { + await registry.deleteProject(projectSlug).catch(() => {}) + } + + await moduleRef.close() + }) + + it('should provision project in Harbor and write robot secrets to Vault', async () => { + const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) + expect(result.basePath).toBe(`${getHostFromUrl(config.harborUrl!)}/${projectSlug}/`) + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(200) + + const robots = await client.getProjectRobots(projectSlug) + expect(robots.status).toBe(200) + const robotNames = (robots.data ?? []).flatMap(r => r.name ? [r.name] : []) + expect(robotNames).toContain(`robot$${projectSlug}+${roRobotName}`) + expect(robotNames).toContain(`robot$${projectSlug}+${rwRobotName}`) + expect(robotNames).toContain(`robot$${projectSlug}+${projectRobotName}`) + + const vaultPaths = [roRobotName, rwRobotName, projectRobotName].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${roRobotName}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${rwRobotName}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${projectRobotName}`) + }) +}) diff --git a/apps/server-nestjs/test/vault.e2e-spec.ts b/apps/server-nestjs/test/vault.e2e-spec.ts index 6c34f746ea..e3bcc97403 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -7,6 +7,7 @@ import { PrismaService } from '../src/modules/infrastructure/database/prisma.ser import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { projectSelect } from '../src/modules/vault/vault-datastore.service' +import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' import { VaultModule } from '../src/modules/vault/vault.module' import { VaultService } from '../src/modules/vault/vault.service' @@ -56,7 +57,7 @@ describeWithVault('VaultController (e2e)', () => { afterAll(async () => { if (testProjectSlug) { - await vaultController.handleDelete({ slug: testProjectSlug } as any).catch(() => {}) + await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) } if (prisma) { diff --git a/plugins/harbor/src/policy.ts b/plugins/harbor/src/policy.ts index cb25bf6153..4945ad51cc 100644 --- a/plugins/harbor/src/policy.ts +++ b/plugins/harbor/src/policy.ts @@ -94,11 +94,11 @@ export function makeDefaultPolicy(projectId: number): Policy { export async function addRetentionPolicy( projectName: string, - projectId: number, + harborProjectId: number, ): Promise { const api = getApi() - const ref = Number(projectId) - if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${projectId}`) + const ref = Number(harborProjectId) + if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${harborProjectId}`) const policy: Policy = makeDefaultPolicy(ref) const project = await api.projects.getProject(projectName)