diff --git a/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts b/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts index c4764dc7b3..b08ca51dbe 100644 --- a/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events-testing.utils.ts @@ -1,6 +1,13 @@ -import type { AdminRoleEventMember, AdminRoleEventPayload } from './app-events.service' +import type { AdminRoleEventMember, AdminRoleEventPayload, ZoneEventPayload } from './app-events.service' import { faker } from '@faker-js/faker' +export function makeZoneEventPayload(overrides: { id?: string, slug?: string } = {}): ZoneEventPayload { + return { + id: overrides.id ?? faker.string.uuid(), + slug: overrides.slug ?? faker.helpers.slugify(faker.string.alphanumeric(10)), + } +} + export function makeAdminRoleEventMember(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}): AdminRoleEventMember { return { id: overrides.id ?? faker.string.uuid(), 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 6d59ab4c0e..dd5b92274a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -34,7 +34,7 @@ import { TOPIC_SYSTEM_MANAGED, USER_ID_CUSTOM_ATTRIBUTE_KEY, } from './gitlab.constants' -import { ensure, generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged, hasGitbeakerCause, isCommitAlreadyApplied, isGitbeakerNotFound, isGitbeakerUnauthorized } from './gitlab.utils' +import { ensure, generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged, hasGitbeakerCause, isCommitAlreadyApplied, isGitbeakerUnauthorized, unlessNotFound } from './gitlab.utils' export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') @@ -225,16 +225,10 @@ export class GitlabClientService { ? `${this.config.projectRootDir}/${subGroupPath}` : subGroupPath this.logger.verbose(`Resolving a GitLab project repository by path ${fullPath}`) - try { - const existingRepo = await this.client.Projects.show(fullPath) - if (existingRepo) { - this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) - return existingRepo - } - } catch (error) { - if (!isGitbeakerNotFound(error)) { - throw error - } + const existingRepo = await unlessNotFound(() => this.client.Projects.show(fullPath)) + if (existingRepo) { + this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) + return existingRepo } const repo = await find( this.offsetPaginate(opts => this.client.Projects.all({ @@ -282,6 +276,26 @@ export class GitlabClientService { return repo } + async findInfraGroupRepo(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + return unlessNotFound(() => this.client.Projects.show(fullPath)) + } + + async deleteInfraGroupRepo(zoneSlug: string) { + const subGroupPath = join(INFRA_GROUP_PATH, zoneSlug) + const repo = await this.findInfraGroupRepo(subGroupPath) + if (!repo) { + this.logger.log(`GitLab infra repository not found for zone ${zoneSlug}; cleanup is a no-op`) + return + } + await this.client.Projects.remove(repo.id) + // GitLab requires the full path including the projects root group for permanent + // removal, and an already-scheduled deletion counts as a completed cleanup. + return unlessNotFound(() => this.client.Projects.remove(repo.id, { permanentlyRemove: true, fullPath: `${repo.path_with_namespace}-deletion_scheduled-${repo.id}` })) + } + async createGroupRepo(groupId: number, repoName: string, description?: string) { this.logger.log(`Creating a GitLab repository in a standalone group (groupId=${groupId}, repoName=${repoName})`) return ensure({ @@ -530,10 +544,7 @@ export class GitlabClientService { if (!hasGitbeakerCause(error, 'has already been taken')) throw error this.logger.warn(`GitLab group variable already exists (race); reloading (groupId=${groupId}, key=${key})`) } - const current = await this.client.GroupVariables.show(groupId, key).catch((error) => { - if (isGitbeakerNotFound(error)) return undefined - throw error - }) + const current = await unlessNotFound(() => this.client.GroupVariables.show(groupId, key)) if (current?.masked === options.masked && current.value === value && current.protected === options.protected @@ -570,10 +581,7 @@ export class GitlabClientService { if (!hasGitbeakerCause(error, 'has already been taken')) throw error this.logger.warn(`GitLab repo variable already exists (race); reloading (repoId=${repoId}, key=${key})`) } - const current = await this.client.ProjectVariables.show(repoId, key, { filter: { environment_scope: options.environmentScope } }).catch((error) => { - if (isGitbeakerNotFound(error)) return undefined - throw error - }) + const current = await unlessNotFound(() => this.client.ProjectVariables.show(repoId, key, { filter: { environment_scope: options.environmentScope } })) if (current?.masked === options.masked && current.value === value && current.protected === options.protected @@ -590,10 +598,7 @@ export class GitlabClientService { } async readGroupVariable(groupId: number, key: string) { - return this.client.GroupVariables.show(groupId, key).catch((error) => { - if (isGitbeakerNotFound(error)) return undefined - throw error - }) + return unlessNotFound(() => this.client.GroupVariables.show(groupId, key)) } async readProjectVariables(repoId: number, environmentScope: string) { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index a24dcb8a71..bfd72e6bf5 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -7,7 +7,7 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { gitlabConfigFactory } from '../../config/gitlab.config' -import { makeAdminRoleEventMember, makeAdminRoleEventPayload } from '../events/app-events-testing.utils' +import { makeAdminRoleEventMember, makeAdminRoleEventPayload, makeZoneEventPayload } from '../events/app-events-testing.utils' import { OBSERVABILITY_REPOSITORY } from '../observability/observability.constants' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' @@ -772,6 +772,50 @@ describe('gitlabService', () => { }) }) + describe('handleUpsertZone', () => { + it('should ensure the zone infra repo and report an OK plugin result', async () => { + const payload = makeZoneEventPayload({ slug: 'zone-1' }) + gitlab.ensureInfraGroupRepo.mockResolvedValue(makeProjectSchema({ id: 100 })) + + const result = await service.handleUpsertZone(payload) + + expect(gitlab.ensureInfraGroupRepo).toHaveBeenCalledWith('zone-1') + expect(result.gitlab.status).toBe('OK') + }) + + it('should report a KO plugin result when the infra repo cannot be ensured', async () => { + const payload = makeZoneEventPayload({ slug: 'zone-1' }) + gitlab.ensureInfraGroupRepo.mockRejectedValue(new Error('gitlab down')) + + const result = await service.handleUpsertZone(payload) + + expect(result.gitlab.status).toBe('KO') + expect(result.gitlab.message).toBe('gitlab down') + }) + }) + + describe('handleDeleteZone', () => { + it('should delete the zone infra repo and report an OK plugin result', async () => { + const payload = makeZoneEventPayload({ slug: 'zone-1' }) + gitlab.deleteInfraGroupRepo.mockResolvedValue(undefined) + + const result = await service.handleDeleteZone(payload) + + expect(gitlab.deleteInfraGroupRepo).toHaveBeenCalledWith('zone-1') + expect(result.gitlab.status).toBe('OK') + }) + + it('should report a KO plugin result when the infra repo cannot be deleted', async () => { + const payload = makeZoneEventPayload({ slug: 'zone-1' }) + gitlab.deleteInfraGroupRepo.mockRejectedValue(new Error('gitlab down')) + + const result = await service.handleDeleteZone(payload) + + expect(result.gitlab.status).toBe('KO') + expect(result.gitlab.message).toBe('gitlab down') + }) + }) + describe('handleAdminRoleDelete', () => { it('should clear the admin flag on revoke', async () => { await service.handleAdminRoleDelete(makeAdminRoleEventPayload({ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index 6132a242ef..a5b0f91611 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,6 +1,6 @@ import type { MemberSchema } from '@gitbeaker/core' import type { ConfigType } from '@nestjs/config' -import type { AdminRoleEventPayload, RepositorySyncEventPayload } from '../events/app-events.service' +import type { AdminRoleEventPayload, RepositorySyncEventPayload, ZoneEventPayload } from '../events/app-events.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { MirrorUserSecret, VaultSecret } from '../vault/vault-client.service' import type { GroupSchemaWith } from './gitlab-client.service' @@ -133,6 +133,34 @@ export class GitlabService { return capturePluginResult('gitlab', () => this.syncRepositoryMirror(payload)) } + @OnEvent('zone.upsert') + async handleUpsertZone(payload: ZoneEventPayload): Promise> { + return capturePluginResult('gitlab', () => this.syncZone(payload)) + } + + @StartActiveSpan() + private async syncZone(payload: ZoneEventPayload) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', payload.slug) + this.logger.log(`Handling a zone upsert event for ${payload.slug}`) + await this.gitlab.ensureInfraGroupRepo(payload.slug) + this.logger.log(`GitLab zone sync completed for ${payload.slug}`) + } + + @OnEvent('zone.delete') + async handleDeleteZone(payload: ZoneEventPayload): Promise> { + return capturePluginResult('gitlab', () => this.cleanupZone(payload)) + } + + @StartActiveSpan() + private async cleanupZone(payload: ZoneEventPayload) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', payload.slug) + this.logger.log(`Handling a zone delete event for ${payload.slug}`) + await this.gitlab.deleteInfraGroupRepo(payload.slug) + this.logger.log(`GitLab zone cleanup completed for ${payload.slug}`) + } + @StartActiveSpan() private async syncRepositoryMirror(payload: RepositorySyncEventPayload) { const { projectSlug, internalRepoName, syncAllBranches } = payload diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts index 482c33bc1d..619cf151fb 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -251,6 +251,15 @@ export function isGitbeakerNotFound(error: unknown): error is GitbeakerRequestEr return error instanceof GitbeakerRequestError && error.cause?.response?.status === 404 } +export async function unlessNotFound(fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + if (isGitbeakerNotFound(error)) return undefined + throw error + } +} + export function hasGitbeakerCause(error: unknown, pattern: string | RegExp): error is GitbeakerRequestError { if (!(error instanceof GitbeakerRequestError)) return false // gitbeaker types `cause.description` as a string but copies the raw GitLab `message` field,