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 index bd84be47c..57f62db28 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -4,15 +4,20 @@ 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 { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' import { NexusClientService } from './nexus-client.service' import { NexusHttpClientService } from './nexus-http-client.service' const nexusUrl = 'https://nexus.internal' -const server = setupServer() const nexusAdminPassword = faker.internet.password() const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` +const server = setupServer() + function createNexusServiceTestingModule() { return Test.createTestingModule({ providers: [ @@ -35,6 +40,9 @@ function createNexusServiceTestingModule() { describe('nexusClientService', () => { let service: NexusClientService + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + const basicAuth = `Basic ${Buffer.from('admin:password', 'utf8').toString('base64')}` + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { @@ -75,4 +83,31 @@ describe('nexusClientService', () => { await service.updateSecurityUsersChangePassword('u1', 'pw123') }) + + 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.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index b261547da..7e9620d17 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -19,6 +19,8 @@ import { DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, DEFAULT_NPM_WRITE_POLICY, DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_READONLY_GROUP_PATH, + DEFAULT_PLATFORM_SECURITY_GROUP_PATH, DEFAULT_PLATFORM_WRITE_GROUP_PATHS, DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, @@ -28,7 +30,10 @@ import { NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, NEXUS_PLUGIN_NAME, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + PLATFORM_SECURITY_GROUP_PATH_PLUGIN_KEY, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, @@ -489,8 +494,8 @@ export class NexusService { 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 writeGroupPaths = generateProjectRoleGroupPath(project.slug, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project.slug, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) const byId = generateRolePrivilegesMapping({ readGroupPaths, @@ -504,9 +509,14 @@ export class NexusService { private async ensurePlatformRoles(projects: ProjectWithDetails[]) { const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? [ + await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_READONLY_GROUP_PATH, + await this.nexusDatastore.getAdminPluginConfig(NEXUS_PLUGIN_NAME, PLATFORM_SECURITY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_SECURITY_GROUP_PATH, + ].join(',') ?? DEFAULT_PLATFORM_READ_GROUP_PATHS const readonlyPrivileges = new Set() @@ -535,8 +545,8 @@ export class NexusService { ?? 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), + ...generateProjectRoleGroupPath(project.slug, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project.slug, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), ] const ids = [...new Set(groupPaths.map(generateRoleId))] @@ -608,12 +618,12 @@ function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { return `${generateNpmGroupPrivilegeName(project)}-ro` } -function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { return rawGroupPathSuffixes .split(',') .map(path => path.trim()) .filter(Boolean) - .map(path => `/${project.slug}${path}`) + .map(path => `/${projectSlug}${path}`) } function parseOidcGroupPaths(rawGroupPaths: string) { 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 index d71cb94e7..ada9a6541 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -3,7 +3,7 @@ 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 { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { RegistryClientService } from './registry-client.service' import { RegistryHttpClientService } from './registry-http-client.service' diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 6e5210a4b..57eed7903 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -330,3 +330,315 @@ describe('registryService', () => { }) }) }) + +import type { Mocked } from 'vitest' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { VaultClientService } from '../vault/vault-client.service' +import { projectRobotName, RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails, makeVaultSecret } 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: '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({ + 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 () => { + await service.handleUpsert(makeProjectWithDetails({ slug: 'myproj' })) + + const expected = [ + { groupName: '/myproj', roleId: 5 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: '/myproj/console/readonly', roleId: 3 }, + { groupName: '/myproj/console/security', roleId: 3 }, + { groupName: '/myproj/console/developer', roleId: 2 }, + { groupName: '/myproj/console/devops', roleId: 4 }, + { groupName: '/myproj/console/admin', roleId: 1 }, + { groupName: '/console/admin', roleId: 1 }, + ] + + expect(registry.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(registry.addGroupMember).toHaveBeenCalledWith('myproj', { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + registry.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: '/myproj/console/developer', entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(makeProjectWithDetails({ slug: 'myproj' })) + + expect(registry.removeGroupMember).toHaveBeenCalledWith('myproj', 10) + expect(registry.addGroupMember).toHaveBeenCalledWith('myproj', { + role_id: 2, + member_group: { + group_name: '/myproj/console/developer', + group_type: 3, + }, + }) + }) + + it('throws when Maintainer membership creation fails', async () => { + registry.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === '/myproj/console/devops' && body.role_id === 4) { + return { status: 400, data: null } + } + return { status: 201, data: null } + }) + + await expect(service.handleUpsert(makeProjectWithDetails({ slug: 'myproj' }))).rejects.toThrow('Harbor create member failed') + + expect(registry.addGroupMember).toHaveBeenCalledWith('myproj', { + role_id: 4, + member_group: { + group_name: '/myproj/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 () => { + await service.handleUpsert(makeProjectWithDetails({ slug: 'myproj' })) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith('forge/myproj/REGISTRY/ro-robot') + expect(vault.read).toHaveBeenCalledWith('forge/myproj/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 () => { + vault.read.mockImplementation(async (path: string) => { + if (path === 'forge/myproj/REGISTRY/ro-robot') { + return makeVaultSecret({ + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'old', + }) + } + return makeVaultSecret({ + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+rw-robot', + TOKEN: 'secret', + }) + }) + + registry.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: 'robot$myproj+ro-robot' }])) + registry.deleteRobot.mockResolvedValue(makeNoContent()) + registry.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: 'robot$myproj+ro-robot', secret: 'newsecret' })) + + await service.handleUpsert(makeProjectWithDetails({ slug: 'myproj' })) + + expect(registry.deleteRobot).toHaveBeenCalledWith('myproj', 11) + expect(registry.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'newsecret', + }), 'forge/myproj/REGISTRY/ro-robot') + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const old = makeVaultSecret({ + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+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/myproj/REGISTRY/ro-robot') return old + return makeVaultSecret({ + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+rw-robot', + TOKEN: 'secret', + }) + }) + + registry.getProjectRobots.mockResolvedValue(makeOkResponse([{ id: 11, name: 'robot$myproj+ro-robot' }])) + registry.deleteRobot.mockResolvedValue(makeNoContent()) + registry.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: 'robot$myproj+ro-robot', secret: 'newsecret' })) + + await service.handleUpsert(makeProjectWithDetails({ slug: 'myproj' })) + + expect(registry.deleteRobot).toHaveBeenCalledWith('myproj', 11) + expect(registry.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'newsecret', + }), 'forge/myproj/REGISTRY/ro-robot') + }) + + it('parses plugin config and enables project robot publishing', async () => { + registry.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: 'enabled' }, + ], + })) + + expect(registry.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith('forge/myproj/REGISTRY/ro-robot') + expect(vault.read).toHaveBeenCalledWith('forge/myproj/REGISTRY/rw-robot') + expect(vault.read).toHaveBeenCalledWith(`forge/myproj/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 () => { + await service.handleDelete(makeProjectWithDetails({ slug: 'myproj' })) + expect(registry.deleteProjectByName).toHaveBeenCalledWith('myproj') + }) + + it('should not delete project when it does not exist', async () => { + registry.getProjectByName.mockResolvedValueOnce({ status: 404, data: null }) + await service.handleDelete(makeProjectWithDetails({ slug: 'myproj' })) + 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 index 2fca977ec..359cf9172 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -114,20 +114,15 @@ export class RegistryService { 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, - }) + const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, relativeVaultPath) + let vaultRobotSecret: VaultRobotSecret | null = null + try { + vaultRobotSecret = await this.vault.read(vaultPath).then(s => s.data) + } catch (error) { + if (!(error instanceof VaultError && error.kind === 'NotFound')) { + throw error + } + } if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { span?.setAttribute('vault.secret.reused', true) @@ -160,16 +155,21 @@ export class RegistryService { ) { const span = trace.getActiveSpan() span?.setAttributes({ - 'project.slug': projectSlug, + 'project.slug': project.slug, 'registry.group.name': groupName, 'registry.group.access_level': accessLevel, }) const existing = membersByName.get(groupName) + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + const list: HarborMember[] = members.data + const existing = list.find(m => m?.entity_name === groupName) if (existing?.id) { if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { - await this.client.removeGroupMember(projectSlug, Number(existing.id)) - membersByName.delete(groupName) + await this.client.removeGroupMember(project.slug, Number(existing.id)) } else { span?.setAttribute('registry.member.exists', true) return @@ -183,7 +183,7 @@ export class RegistryService { group_type: 3, }, } - const created = await this.client.addGroupMember(projectSlug, body) + const created = await this.client.addGroupMember(project.slug, body) if (created.status >= 300) { throw new Error(`Harbor create member failed (${created.status})`) } @@ -217,7 +217,7 @@ export class RegistryService { ) } - private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + private async getOrCreateProjectQuota(project: ProjectWithDetails, storageLimit: number) { const span = trace.getActiveSpan() span?.setAttributes({ 'project.slug': project.slug, @@ -281,14 +281,14 @@ export class RegistryService { 'registry.publish_project_robot': !!options.publishProjectRobot, }) const storageLimit = options.storageLimitBytes ?? -1 - const harborProject = await this.ensureProjectQuota(project, storageLimit) - const harborProjectId = Number(harborProject.project_id) + const hardProject = await this.getOrCreateProjectQuota(project, storageLimit) + const projectId = Number(hardProject.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(), + this.ensureProjectGroupMember(project, `/${project.slug}`), + Number.isFinite(projectId) ? this.ensureRetentionPolicy(project, projectId) : Promise.resolve(), options.publishProjectRobot ? this.ensureRobotSecret(project, projectRobotName, roAccess) : Promise.resolve(), @@ -301,15 +301,15 @@ export class RegistryService { } @StartActiveSpan() - async deleteProject(projectSlug: string) { + async deleteProject(project: ProjectWithDetails) { const span = trace.getActiveSpan() - span?.setAttribute('project.slug', projectSlug) - const existing = await this.client.getProjectByName(projectSlug) + span?.setAttribute('project.slug', project.slug) + const existing = await this.client.getProjectByName(project.slug) if (existing.status === 404) { span?.setAttribute('registry.project.exists', false) return } - const deleted = await this.client.deleteProjectByName(projectSlug) + const deleted = await this.client.deleteProjectByName(project.slug) if (deleted.status >= 300 && deleted.status !== 404) { throw new Error(`Harbor delete project failed (${deleted.status})`) } @@ -335,7 +335,7 @@ export class RegistryService { const span = trace.getActiveSpan() span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project delete for ${project.slug}`) - await this.deleteProject(project.slug) + await this.deleteProject(project) } @Cron(CronExpression.EVERY_HOUR) @@ -470,7 +470,7 @@ function generateRobotPermissions(project: ProjectWithDetails, robotName: string } function generateRetentionPolicy( - projectId: number, + harborProjectId: number, options: { harborRuleTemplate?: string harborRuleCount?: string @@ -494,7 +494,7 @@ function generateRetentionPolicy( return { algorithm: 'or', - scope: { level: 'project', ref: projectId }, + scope: { level: 'project', ref: harborProjectId }, rules: [ { disabled: false,