Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down
51 changes: 28 additions & 23 deletions apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
46 changes: 45 additions & 1 deletion apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down
30 changes: 29 additions & 1 deletion apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -133,6 +133,34 @@ export class GitlabService {
return capturePluginResult('gitlab', () => this.syncRepositoryMirror(payload))
}

Comment thread
shikanime marked this conversation as resolved.
@OnEvent('zone.upsert')
async handleUpsertZone(payload: ZoneEventPayload): Promise<RequiredPluginResult<'gitlab'>> {
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<RequiredPluginResult<'gitlab'>> {
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
Expand Down
9 changes: 9 additions & 0 deletions apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(fn: () => Promise<T>): Promise<T | undefined> {
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,
Expand Down
Loading