diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index 0c7357106..86b4c21aa 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -1,7 +1,7 @@ import type { ClusterObject, Environment, ListMinimumResources, Project, Repository, StepCall } from '@cpn-console/hooks' import { parseError, uniqueResource } from '@cpn-console/hooks' import { dump } from 'js-yaml' -import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/gitlab-project-api.js' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { PatchUtils } from '@kubernetes/client-node' import { inClusterLabel } from '@cpn-console/shared' diff --git a/plugins/gitlab/package.json b/plugins/gitlab/package.json index 2863ff8c5..0933bea97 100644 --- a/plugins/gitlab/package.json +++ b/plugins/gitlab/package.json @@ -1,7 +1,7 @@ { "name": "@cpn-console/gitlab-plugin", "type": "module", - "version": "3.3.1", + "version": "3.4.0", "private": false, "description": "", "main": "dist/index.js", diff --git a/plugins/gitlab/src/gitlab-api.ts b/plugins/gitlab/src/gitlab-api.ts new file mode 100644 index 000000000..edb3daf7b --- /dev/null +++ b/plugins/gitlab/src/gitlab-api.ts @@ -0,0 +1,168 @@ +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import type { CommitAction } from '@gitbeaker/rest' +import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' +import { objectEntries } from '@cpn-console/shared' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { getApi } from './utils.js' + +export interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +export interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +export interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +/** Abstract class providing functions to interact with Gitlab API */ +export abstract class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + }) { + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + namespaceId: groupId, + description, + }) + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + let action: CommitAction['action'] = 'create' + + const branches = await this.api.Branches.all(repoId) + if (branches.some(b => b.name === branch)) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch (_) {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/gitlab-project-api.ts similarity index 62% rename from plugins/gitlab/src/class.ts rename to plugins/gitlab/src/gitlab-project-api.ts index d76e03397..076776888 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/gitlab-project-api.ts @@ -1,236 +1,17 @@ -import { createHash } from 'node:crypto' -import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' -import type { AccessTokenScopes, CommitAction, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' -import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, ProjectSchema, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' +import type { AccessTokenScopes, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' import { AccessLevel } from '@gitbeaker/core' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' -import { objectEntries } from '@cpn-console/shared' -import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { getApi, getGroupRootId, infraAppsRepoName, internalMirrorRepoName } from './utils.js' import config from './config.js' +import { type CreateEmptyRepositoryArgs, GitlabApi, type GitlabMirrorSecret, type RepoSelect } from './gitlab-api.js' +import type { Project, UniqueRepo } from '@cpn-console/hooks' +import { GitlabZoneApi } from './gitlab-zone-api.js' type setVariableResult = 'created' | 'updated' | 'already up-to-date' type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER -const infraGroupName = 'Infra' -const infraGroupPath = 'infra' export const pluginManagedTopic = 'plugin-managed' -interface GitlabMirrorSecret { - MIRROR_USER: string - MIRROR_TOKEN: string -} - -interface RepoSelect { - mirror?: CondensedProjectSchema - target?: CondensedProjectSchema -} -type PendingCommits = Record -}> - -interface CreateEmptyRepositoryArgs { - repoName: string - description?: string -} - -export class GitlabApi extends PluginApi { - protected api: Gitlab - private pendingCommits: PendingCommits = {} - - constructor() { - super() - this.api = getApi() - } - - public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { - createFirstCommit: boolean - groupId: number - }) { - const project = await this.api.Projects.create({ - name: repoName, - path: repoName, - namespaceId: groupId, - description, - }) - // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) - if (createFirstCommit) { - await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) - } - return project - } - - public async commitCreateOrUpdate( - repoId: number, - fileContent: string, - filePath: string, - branch: string = 'main', - comment: string = 'ci: :robot_face: Update file content', - ): Promise { - let action: CommitAction['action'] = 'create' - - const branches = await this.api.Branches.all(repoId) - if (branches.some(b => b.name === branch)) { - let actualFile: RepositoryFileExpandedSchema | undefined - try { - actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) - } catch (_) {} - if (actualFile) { - const newContentDigest = createHash('sha256').update(fileContent).digest('hex') - if (actualFile.content_sha256 === newContentDigest) { - // Already up-to-date - return false - } - // Update needed - action = 'update' - } - } - - const commitAction: CommitAction = { - action, - filePath, - content: fileContent, - } - this.addActions(repoId, branch, comment, [commitAction]) - - return true - } - - /** - * Fonction pour supprimer une liste de fichiers d'un repo - * @param repoId - * @param files - * @param branch - * @param comment - */ - public async commitDelete( - repoId: number, - files: string[], - branch: string = 'main', - comment: string = 'ci: :robot_face: Delete files', - ): Promise { - if (files.length) { - const commitActions: CommitAction[] = files.map((filePath) => { - return { - action: 'delete', - filePath, - } - }) - this.addActions(repoId, branch, comment, commitActions) - return true - } - return false - } - - private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { - if (!this.pendingCommits[repoId]) { - this.pendingCommits[repoId] = { branches: {} } - } - if (this.pendingCommits[repoId].branches[branch]) { - this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) - this.pendingCommits[repoId].branches[branch].messages.push(comment) - } else { - this.pendingCommits[repoId].branches[branch] = { - actions: commitActions, - messages: [comment], - } - } - } - - public async commitFiles() { - let filesUpdated: number = 0 - for (const [id, repo] of objectEntries(this.pendingCommits)) { - for (const [branch, details] of objectEntries(repo.branches)) { - const filesNumber = details.actions.length - if (filesNumber) { - filesUpdated += filesNumber - const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') - await this.api.Commits.create(id, branch, message, details.actions) - } - } - } - return filesUpdated - } - - public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { - options.path = options?.path ?? '/' - options.ref = options?.ref ?? 'main' - options.recursive = options?.recursive ?? false - try { - const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) - // if (depth >= 0) { - // for (const file of files) { - // if (file.type !== 'tree') { - // return [] - // } - // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) - // console.trace({ file, childrenFiles }) - - // files.push(...childrenFiles) - // } - // } - return files - } catch (error) { - const { cause } = error as GitbeakerRequestError - if (cause?.description.includes('Not Found')) { - // Empty repository, with zero commit ==> Zero files - return [] - } else { - throw error - } - } - } - - public async deleteRepository(repoId: number, fullPath: string) { - await this.api.Projects.remove(repoId) // Marks for deletion - return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion - } -} - -export class GitlabZoneApi extends GitlabApi { - private infraProjectsByZoneSlug: Map - - constructor() { - super() - this.infraProjectsByZoneSlug = new Map() - } - - // Group Infra - public async getOrCreateInfraGroup(): Promise { - const rootId = await getGroupRootId() - // Get or create projects_root_dir/infra group - const searchResult = await this.api.Groups.search(infraGroupName) - const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) - return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { - parentId: rootId, - projectCreationLevel: 'maintainer', - subgroupCreationLevel: 'owner', - defaultBranchProtection: 0, - description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', - }) - } - - public async getOrCreateInfraProject(zone: string): Promise { - if (this.infraProjectsByZoneSlug.has(zone)) { - return this.infraProjectsByZoneSlug.get(zone)! - } - const infraGroup = await this.getOrCreateInfraGroup() - // Get or create projects_root_dir/infra/zone - const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { - search: zone, - simple: true, - perPage: 100, - }) - const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ - repoName: zone, - groupId: infraGroup.id, - description: 'Repository hosting deployment files for this zone.', - createFirstCommit: true, - }, - ) - this.infraProjectsByZoneSlug.set(zone, project) - return project - } -} +/** Class providing project-specific functions to interact with Gitlab API */ export class GitlabProjectApi extends GitlabApi { private project: Project | UniqueRepo private gitlabGroup: GroupSchema & { statistics: GroupStatisticsSchema } | undefined diff --git a/plugins/gitlab/src/gitlab-zone-api.ts b/plugins/gitlab/src/gitlab-zone-api.ts new file mode 100644 index 000000000..40b8493da --- /dev/null +++ b/plugins/gitlab/src/gitlab-zone-api.ts @@ -0,0 +1,54 @@ +import type { GroupSchema } from '@gitbeaker/rest' +import type { ProjectSchema } from '@gitbeaker/core' +import { getGroupRootId } from './utils.js' +import { GitlabApi } from './gitlab-api.js' + +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' + +/** Class providing zone-specific functions to interact with Gitlab API */ +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const searchResult = await this.api.Groups.search(infraGroupName) + const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) + return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + } + + public async getOrCreateInfraProject(zone: string): Promise { + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + perPage: 100, + }) + const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }, + ) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index ef72f7dd5..2a0c119a6 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -12,7 +12,8 @@ import { import { getOrCreateGroupRoot } from './utils.js' import infos from './infos.js' import monitor from './monitor.js' -import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { GitlabProjectApi } from './gitlab-project-api.js' +import { GitlabZoneApi } from './gitlab-zone-api.js' const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } diff --git a/plugins/gitlab/src/members.ts b/plugins/gitlab/src/members.ts index ec77ee45d..33dd1c035 100644 --- a/plugins/gitlab/src/members.ts +++ b/plugins/gitlab/src/members.ts @@ -1,6 +1,6 @@ import type { Project } from '@cpn-console/hooks' import type { UserSchema } from '@gitbeaker/core' -import type { GitlabProjectApi } from './class.js' +import type { GitlabProjectApi } from './gitlab-project-api.js' import { upsertUser } from './user.js' export async function ensureMembers(gitlabApi: GitlabProjectApi, project: Project) { diff --git a/plugins/gitlab/src/repositories.ts b/plugins/gitlab/src/repositories.ts index 3217eafb9..b94656186 100644 --- a/plugins/gitlab/src/repositories.ts +++ b/plugins/gitlab/src/repositories.ts @@ -2,7 +2,7 @@ import type { Project, Repository } from '@cpn-console/hooks' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' import { shallowEqual } from '@cpn-console/shared' -import { type GitlabProjectApi, pluginManagedTopic } from './class.js' +import { type GitlabProjectApi, pluginManagedTopic } from './gitlab-project-api.js' import { provisionMirror } from './project.js' import { infraAppsRepoName, internalMirrorRepoName } from './utils.js'