diff --git a/backend/src/main/kotlin/metrik/project/domain/model/PipelineType.kt b/backend/src/main/kotlin/metrik/project/domain/model/PipelineType.kt index 197cc4b3..94c4a11b 100644 --- a/backend/src/main/kotlin/metrik/project/domain/model/PipelineType.kt +++ b/backend/src/main/kotlin/metrik/project/domain/model/PipelineType.kt @@ -1,5 +1,5 @@ package metrik.project.domain.model enum class PipelineType { - JENKINS, BAMBOO, BAMBOO_DEPLOYMENT, GITHUB_ACTIONS, BUDDY, NOT_SUPPORTED + JENKINS, BAMBOO, BAMBOO_DEPLOYMENT, GITHUB_ACTIONS, AZURE_PIPELINES, BUDDY, NOT_SUPPORTED } diff --git a/backend/src/main/kotlin/metrik/project/domain/service/azurepipelines/AzurePipelinesPipelineService.kt b/backend/src/main/kotlin/metrik/project/domain/service/azurepipelines/AzurePipelinesPipelineService.kt new file mode 100644 index 00000000..450b18b9 --- /dev/null +++ b/backend/src/main/kotlin/metrik/project/domain/service/azurepipelines/AzurePipelinesPipelineService.kt @@ -0,0 +1,68 @@ +package metrik.project.domain.service.azurepipelines + +import feign.FeignException +import feign.codec.DecodeException +import metrik.project.domain.model.Execution +import metrik.project.domain.model.PipelineConfiguration +import metrik.project.domain.repository.BuildRepository +import metrik.project.domain.service.PipelineService +import metrik.project.domain.service.githubactions.GithubActionsPipelineService +import metrik.project.exception.PipelineConfigVerifyException +import metrik.project.infrastructure.azure.feign.AzureFeignClient +import metrik.project.rest.vo.response.SyncProgress +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.net.URL + +@Service("azurePipelinesPipelineService") +class AzurePipelinesPipelineService( + private val azureFeignClient: AzureFeignClient, + private val buildRepository: BuildRepository, +) : PipelineService { + private var logger = LoggerFactory.getLogger(javaClass.name) + override fun syncBuildsProgressively( + pipeline: PipelineConfiguration, + emitCb: (SyncProgress) -> Unit + ): List { + TODO("Not yet implemented") + } + + override fun verifyPipelineConfiguration(pipeline: PipelineConfiguration) { + logger.info( + "Started verification for Azure Pipelines [name: ${pipeline.name}, url: ${pipeline.url}, " + + "type: ${pipeline.type}]" + ) + + try { + val (organization, project) = getOrganizationProjectFromUrl(pipeline.url) + azureFeignClient.retrieveMultiplePipelines(pipeline.credential, organization, project) + ?: throw PipelineConfigVerifyException("Verify failed") + } catch (ex: FeignException.FeignServerException) { + throw PipelineConfigVerifyException("Verify website unavailable") + } catch (ex: FeignException.FeignClientException) { + throw PipelineConfigVerifyException("Verify failed") + } catch (ex: FeignException) { + throw PipelineConfigVerifyException("Verify failed") + } + } + + override fun getStagesSortedByName(pipelineId: String): List { + return buildRepository.getAllBuilds(pipelineId) + .flatMap { it.stages } + .map { it.name } + .distinct() + .sortedBy { it.uppercase() } + .toList() + } + + private fun getOrganizationProjectFromUrl(url: String): Pair { + val components = URL(url).path.split("/") + val organization = components[components.size - organizationIndex] + val project = components.last() + return Pair(organization, project) + } + + private companion object { + const val organizationIndex = 2 + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/metrik/project/domain/service/factory/PipelineServiceFactory.kt b/backend/src/main/kotlin/metrik/project/domain/service/factory/PipelineServiceFactory.kt index bd431603..5dca8055 100644 --- a/backend/src/main/kotlin/metrik/project/domain/service/factory/PipelineServiceFactory.kt +++ b/backend/src/main/kotlin/metrik/project/domain/service/factory/PipelineServiceFactory.kt @@ -5,6 +5,7 @@ import metrik.project.domain.service.PipelineService import metrik.project.domain.service.buddy.BuddyPipelineService import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component +import java.nio.channels.Pipe @Component class PipelineServiceFactory( @@ -13,7 +14,8 @@ class PipelineServiceFactory( @Autowired private val githubActionsPipelineService: PipelineService, @Autowired private val bambooDeploymentPipelineService: PipelineService, @Autowired private val buddyPipelineService: BuddyPipelineService, - @Autowired private val noopPipelineService: PipelineService + @Autowired private val noopPipelineService: PipelineService, + @Autowired private val azurePipelinesPipelineService: PipelineService ) { fun getService(pipelineType: PipelineType): PipelineService { return when (pipelineType) { @@ -21,6 +23,7 @@ class PipelineServiceFactory( PipelineType.BAMBOO -> this.bambooPipelineService PipelineType.GITHUB_ACTIONS -> this.githubActionsPipelineService PipelineType.BAMBOO_DEPLOYMENT -> this.bambooDeploymentPipelineService + PipelineType.AZURE_PIPELINES -> this.azurePipelinesPipelineService PipelineType.BUDDY -> this.buddyPipelineService else -> this.noopPipelineService } diff --git a/backend/src/main/kotlin/metrik/project/infrastructure/azure/feign/AzureFeignClient.kt b/backend/src/main/kotlin/metrik/project/infrastructure/azure/feign/AzureFeignClient.kt new file mode 100644 index 00000000..8c86f6a1 --- /dev/null +++ b/backend/src/main/kotlin/metrik/project/infrastructure/azure/feign/AzureFeignClient.kt @@ -0,0 +1,40 @@ +package metrik.project.infrastructure.azure.feign + +import feign.Headers +import feign.RequestInterceptor +import feign.RequestTemplate +import metrik.project.infrastructure.azure.feign.response.MultiplePipelineResponse +import metrik.project.infrastructure.github.feign.response.BranchResponse +import metrik.project.infrastructure.github.feign.response.CommitResponse +import metrik.project.infrastructure.github.feign.response.MultipleRunResponse +import metrik.project.infrastructure.github.feign.response.SingleRunResponse +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestParam +import java.util.Base64 + +@FeignClient( + value = "azure-api", + url = "https://dev.azure.com/", + configuration = [AzureFeignClientConfiguration::class] +) +interface AzureFeignClient { + @GetMapping("{organization}/{project}/_apis/pipelines?api-version=6.1-preview.1") + @Headers("Content-Type: application/json") + fun retrieveMultiplePipelines( + @RequestHeader("credential") credential: String, + @PathVariable("organization") organization: String, + @PathVariable("project") project: String, + ): MultiplePipelineResponse? +} + +class AzureFeignClientConfiguration : RequestInterceptor { + override fun apply(template: RequestTemplate?) { + val pat = template!!.headers()["credential"]!!.first() + val encode = Base64.getEncoder().encodeToString("dora:$pat".encodeToByteArray()) + template.header("Authorization", "Basic $encode") + template.removeHeader("credential") + } +} diff --git a/backend/src/main/kotlin/metrik/project/infrastructure/azure/feign/response/MultiplePipelineResponse.kt b/backend/src/main/kotlin/metrik/project/infrastructure/azure/feign/response/MultiplePipelineResponse.kt new file mode 100644 index 00000000..221ef6d7 --- /dev/null +++ b/backend/src/main/kotlin/metrik/project/infrastructure/azure/feign/response/MultiplePipelineResponse.kt @@ -0,0 +1,32 @@ +package metrik.project.infrastructure.azure.feign.response + +import com.fasterxml.jackson.databind.PropertyNamingStrategy +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) +data class MultiplePipelineResponse( + val count: Int, + val value: List +) + +data class Value( + val _links: Links, + val folder: String, + val id: Int, + val name: String, + val revision: Int, + val url: String +) + +data class Links( + val self: Self, + val web: Web +) + +data class Self( + val href: String +) + +data class Web( + val href: String +) \ No newline at end of file diff --git a/backend/src/main/kotlin/metrik/project/rest/vo/request/AzurePipelinesRequest.kt b/backend/src/main/kotlin/metrik/project/rest/vo/request/AzurePipelinesRequest.kt new file mode 100644 index 00000000..8338b5cb --- /dev/null +++ b/backend/src/main/kotlin/metrik/project/rest/vo/request/AzurePipelinesRequest.kt @@ -0,0 +1,33 @@ +package metrik.project.rest.vo.request + +import metrik.project.domain.model.PipelineConfiguration +import metrik.project.domain.model.PipelineType +import java.net.URL +import javax.validation.constraints.NotBlank + +class AzurePipelinesPipelineRequest( + @field:NotBlank(message = "Name cannot be empty") val name: String, + @field:NotBlank(message = "Credential cannot be empty") val credential: String, + url: String +) : PipelineRequest(url, PipelineType.AZURE_PIPELINES.toString()) { + override fun toPipeline(projectId: String, pipelineId: String) = PipelineConfiguration( + id = pipelineId, + projectId = projectId, + name = name, + username = null, + credential = credential, + url = url, + type = PipelineType.valueOf(type) + ) +} + +class AzurePipelinesVerificationRequest( + @field:NotBlank(message = "Credential cannot be null or empty") val credential: String, + url: String +) : PipelineVerificationRequest(url, PipelineType.AZURE_PIPELINES.toString()) { + override fun toPipeline() = PipelineConfiguration( + credential = credential, + url = url, + type = PipelineType.valueOf(type) + ) +} diff --git a/backend/src/main/kotlin/metrik/project/rest/vo/request/Request.kt b/backend/src/main/kotlin/metrik/project/rest/vo/request/Request.kt index 9c117e0b..c03dc481 100644 --- a/backend/src/main/kotlin/metrik/project/rest/vo/request/Request.kt +++ b/backend/src/main/kotlin/metrik/project/rest/vo/request/Request.kt @@ -29,14 +29,15 @@ data class UpdateProjectRequest( JsonSubTypes.Type(value = BambooDeploymentPipelineRequest::class, name = "BAMBOO_DEPLOYMENT"), JsonSubTypes.Type(value = JenkinsPipelineRequest::class, name = "JENKINS"), JsonSubTypes.Type(value = GithubActionsPipelineRequest::class, name = "GITHUB_ACTIONS"), + JsonSubTypes.Type(value = AzurePipelinesPipelineRequest::class, name = "AZURE_PIPELINES"), JsonSubTypes.Type(value = BuddyPipelineRequest::class, name = "BUDDY") ) abstract class PipelineRequest( @field:NotBlank(message = "URL cannot be empty") val url: String, @field:EnumConstraint( - acceptedValues = ["JENKINS", "BAMBOO", "BAMBOO_DEPLOYMENT", "GITHUB_ACTIONS", "BUDDY"], - message = "Allowed types: JENKINS, BAMBOO, BAMBOO_DEPLOYMENT, GITHUB_ACTIONS, BUDDY" + acceptedValues = ["JENKINS", "BAMBOO", "BAMBOO_DEPLOYMENT", "GITHUB_ACTIONS", "AZURE_PIPELINES", "BUDDY"], + message = "Allowed types: JENKINS, BAMBOO, BAMBOO_DEPLOYMENT, GITHUB_ACTIONS, AZURE_PIPELINES, BUDDY" ) var type: String ) { @@ -53,14 +54,15 @@ abstract class PipelineRequest( JsonSubTypes.Type(value = BambooDeploymentVerificationRequest::class, name = "BAMBOO_DEPLOYMENT"), JsonSubTypes.Type(value = JenkinsVerificationRequest::class, name = "JENKINS"), JsonSubTypes.Type(value = GithubActionsVerificationRequest::class, name = "GITHUB_ACTIONS"), + JsonSubTypes.Type(value = AzurePipelinesVerificationRequest::class, name = "AZURE_PIPELINES"), JsonSubTypes.Type(value = BuddyVerificationRequest::class, name = "BUDDY"), ) abstract class PipelineVerificationRequest( @field:NotBlank(message = "URL cannot be empty") val url: String, @field:EnumConstraint( - acceptedValues = ["JENKINS", "BAMBOO", "BAMBOO_DEPLOYMENT", "GITHUB_ACTIONS", "BUDDY"], - message = "Allowed types: JENKINS, BAMBOO, BAMBOO_DEPLOYMENT, GITHUB_ACTIONS, BUDDY" + acceptedValues = ["JENKINS", "BAMBOO", "BAMBOO_DEPLOYMENT", "GITHUB_ACTIONS", "AZURE_PIPELINES", "BUDDY"], + message = "Allowed types: JENKINS, BAMBOO, BAMBOO_DEPLOYMENT, GITHUB_ACTIONS, AZURE_PIPELINES, BUDDY" ) val type: String, ) { diff --git a/frontend/src/clients/pipelineApis.ts b/frontend/src/clients/pipelineApis.ts index 01fafa01..67a3e9f5 100644 --- a/frontend/src/clients/pipelineApis.ts +++ b/frontend/src/clients/pipelineApis.ts @@ -75,14 +75,24 @@ export interface GithubActions extends BasePipeline { type: PipelineTool.GITHUB_ACTIONS; } +export interface AzurePipelines extends BasePipeline { + type: PipelineTool.AZURE_PIPELINES; +} + export interface BambooDeployedPipeline extends BasePipeline { type: PipelineTool.BAMBOO_DEPLOYMENT; } -export type Pipeline = JenkinsPipeline | BambooPipeline | GithubActions | BambooDeployedPipeline; +export type Pipeline = + | JenkinsPipeline + | BambooPipeline + | GithubActions + | AzurePipelines + | BambooDeployedPipeline; export type PipelineVerification = | Omit | Omit | Omit + | Omit | Omit; diff --git a/frontend/src/components/PipelineSetup/PipelineSetup.tsx b/frontend/src/components/PipelineSetup/PipelineSetup.tsx index 3f2e992a..8e7ec0ad 100644 --- a/frontend/src/components/PipelineSetup/PipelineSetup.tsx +++ b/frontend/src/components/PipelineSetup/PipelineSetup.tsx @@ -1,4 +1,5 @@ import { + AzurePipelines, BambooDeployedPipeline, BambooPipeline, GithubActions, @@ -20,12 +21,14 @@ const { Item, useForm } = Form; type JenkinsFormValues = Omit; type BambooFormValues = Omit; type GithubActionsFormValues = Omit; +type AzurePipelinesFormValues = Omit; type BambooDeployedFormValues = Omit; export type FormValues = | JenkinsFormValues | BambooFormValues | GithubActionsFormValues + | AzurePipelinesFormValues | BambooDeployedFormValues; const groupTitleStyles = css({ fontWeight: "bold", display: "inline-block", marginBottom: 12 }); @@ -145,6 +148,9 @@ const PipelineSetup: FC<{ + diff --git a/frontend/src/models/pipeline.ts b/frontend/src/models/pipeline.ts index d91b287a..9a3e4e12 100644 --- a/frontend/src/models/pipeline.ts +++ b/frontend/src/models/pipeline.ts @@ -3,5 +3,6 @@ export enum PipelineTool { JENKINS = "JENKINS", GITHUB_ACTIONS = "GITHUB_ACTIONS", BAMBOO_DEPLOYMENT = "BAMBOO_DEPLOYMENT", + AZURE_PIPELINES = "AZURE_PIPELINES", BUDDY = "BUDDY", } diff --git a/frontend/src/utils/pipelineConfig/azurePipelinesConfig.tsx b/frontend/src/utils/pipelineConfig/azurePipelinesConfig.tsx new file mode 100644 index 00000000..b011235a --- /dev/null +++ b/frontend/src/utils/pipelineConfig/azurePipelinesConfig.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { InfoCircleOutlined } from "@ant-design/icons"; +import { PipelineConfig } from "./jenkinsConfig"; + +export const AZURE_PIPELINES_CONFIG: PipelineConfig[] = [ + { + gutter: 24, + children: [ + { + span: 8, + name: "name", + label: "Project Name", + rules: [ + { + required: true, + whitespace: true, + message: "Please input name of your project on Azure Pipeline.", + }, + ], + }, + { + span: 16, + name: "url", + label: "Project URL", + placeholder: "e.g: https://dev.azure.com/organization/project", + tooltip: { + icon: , + title: + 'URL of the project. Please ensure the URL is complete and including the organization/project name. e.g. "https://dev.azure.com/{organization}/{project}"', + }, + rules: [{ required: true, whitespace: true, message: "Please input the project URL." }], + }, + ], + }, + { + gutter: 24, + children: [ + { + span: 8, + name: "credential", + type: "password", + label: "Personal Access Token", + tooltip: { + icon: , + title: + "The PAT (Personal Access Token) will be used to invoke Azure Pipeline APIs to fetch pipeline run status. Tokens can be narrowly scoped to allow only the read access to project and pipeline. Don't know how to manage tokens? Go to: https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate", + }, + + rules: [{ required: true, whitespace: true, message: "Please input access token." }], + }, + ], + }, +]; diff --git a/frontend/src/utils/pipelineConfig/pipelineConfig.tsx b/frontend/src/utils/pipelineConfig/pipelineConfig.tsx index 310d9eb3..a7ea22fa 100644 --- a/frontend/src/utils/pipelineConfig/pipelineConfig.tsx +++ b/frontend/src/utils/pipelineConfig/pipelineConfig.tsx @@ -5,11 +5,13 @@ import { GITHUB_ACTIONS_CONFIG } from "./githubActionsConfig"; import { BAMBOO_DEPLOYED_PIPELINE_CONFIG } from "./bambooDeployedConfig"; import { BUDDY_CONFIG } from "./buddyConfig"; import { PipelineTool } from "../../models/pipeline"; +import { AZURE_PIPELINES_CONFIG } from "./azurePipelinesConfig"; export const PIPELINE_CONFIG = { [PipelineTool.JENKINS]: JENKINS_PIPELINE_CONFIG, [PipelineTool.BAMBOO]: BAMBOO_PIPELINE_CONFIG, [PipelineTool.GITHUB_ACTIONS]: GITHUB_ACTIONS_CONFIG, + [PipelineTool.AZURE_PIPELINES]: AZURE_PIPELINES_CONFIG, [PipelineTool.BAMBOO_DEPLOYMENT]: BAMBOO_DEPLOYED_PIPELINE_CONFIG, [PipelineTool.BUDDY]: BUDDY_CONFIG, }; @@ -47,6 +49,21 @@ export const PIPELINE_TYPE_NOTE = { ), + [PipelineTool.AZURE_PIPELINES]: ( +
+ Note: Deployment data is collected from pipelines execution history. All you have to provide + here is the URL of your Azure Pipeline and we can find all associated pipeline executions for + you automatically. Struggle with the terms? More details please refer to:{" "} + + https://azure.microsoft.com/en-us/products/devops/pipelines + +
+ ), [PipelineTool.BAMBOO_DEPLOYMENT]: (
Note: Deployment data is ought to be collected from Bamboo "Build Plans" and/or