Skip to content

Commit d8d57e8

Browse files
Merge pull request #86 from leanix/feature/cid-3582-process-workflow-run-events
Feature/cid 3582 process workflow run events
2 parents bfaae4f + 57641a8 commit d8d57e8

File tree

12 files changed

+366
-38
lines changed

12 files changed

+366
-38
lines changed

src/main/kotlin/net/leanix/githubagent/client/GitHubClient.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package net.leanix.githubagent.client
22

3+
import feign.Response
34
import net.leanix.githubagent.config.FeignClientConfig
5+
import net.leanix.githubagent.dto.ArtifactsListResponse
46
import net.leanix.githubagent.dto.GitHubAppResponse
57
import net.leanix.githubagent.dto.GitHubSearchResponse
68
import net.leanix.githubagent.dto.Installation
@@ -11,6 +13,7 @@ import org.springframework.cloud.openfeign.FeignClient
1113
import org.springframework.web.bind.annotation.GetMapping
1214
import org.springframework.web.bind.annotation.PathVariable
1315
import org.springframework.web.bind.annotation.PostMapping
16+
import org.springframework.web.bind.annotation.RequestBody
1417
import org.springframework.web.bind.annotation.RequestHeader
1518
import org.springframework.web.bind.annotation.RequestParam
1619

@@ -43,7 +46,8 @@ interface GitHubClient {
4346
@PostMapping("/api/v3/app/installations/{installationId}/access_tokens")
4447
fun createInstallationToken(
4548
@PathVariable("installationId") installationId: Long,
46-
@RequestHeader("Authorization") jwt: String
49+
@RequestHeader("Authorization") jwt: String,
50+
@RequestBody emptyBody: String = ""
4751
): InstallationTokenResponse
4852

4953
@GetMapping("/api/v3/organizations")
@@ -64,4 +68,20 @@ interface GitHubClient {
6468
@RequestHeader("Authorization") token: String,
6569
@RequestParam("q") query: String,
6670
): GitHubSearchResponse
71+
72+
@GetMapping("/api/v3/repos/{owner}/{repo}/actions/runs/{runId}/artifacts")
73+
fun getRunArtifacts(
74+
@PathVariable("owner") owner: String,
75+
@PathVariable("repo") repo: String,
76+
@PathVariable("runId") runId: Long,
77+
@RequestHeader("Authorization") token: String
78+
): ArtifactsListResponse
79+
80+
@GetMapping("/api/v3/repos/{owner}/{repo}/actions/artifacts/{artifactId}/zip")
81+
fun downloadArtifact(
82+
@PathVariable("owner") owner: String,
83+
@PathVariable("repo") repo: String,
84+
@PathVariable("artifactId") artifactId: Long,
85+
@RequestHeader("Authorization") token: String
86+
): Response
6787
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package net.leanix.githubagent.dto
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
data class ArtifactsListResponse(
8+
@JsonProperty("total_count")
9+
val totalCount: Int,
10+
val artifacts: List<Artifact>
11+
)
12+
13+
@JsonIgnoreProperties(ignoreUnknown = true)
14+
data class Artifact(
15+
val id: Long,
16+
val name: String,
17+
val url: String,
18+
@JsonProperty("archive_download_url")
19+
val archiveDownloadUrl: String,
20+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package net.leanix.githubagent.dto
2+
3+
data class SbomConfig(
4+
val source: GitHubSbomSource = GitHubSbomSource.GITHUB_ARTIFACT,
5+
val namingConventions: String = "leanix-serviceName-sbom",
6+
val defaultBranch: String = "main",
7+
) {
8+
fun isFileNameValid(fileName: String, branchName: String): Boolean {
9+
return fileName == generateExpectedFileName(fileName) && branchName == defaultBranch
10+
}
11+
fun extractFactSheetName(fileName: String): String {
12+
val parts = fileName.split("-")
13+
return parts.drop(1).dropLast(1).joinToString("-")
14+
}
15+
16+
private fun generateExpectedFileName(fileName: String): String {
17+
val parts = fileName.split("-")
18+
if (parts.size < 3) return ""
19+
20+
val serviceName = parts.drop(1).dropLast(1).joinToString("-")
21+
22+
val conventionParts = namingConventions.split("-")
23+
if (conventionParts.size < 3) return ""
24+
25+
val orgPrefix = conventionParts.first()
26+
val sbomSuffix = conventionParts.last()
27+
28+
return "$orgPrefix-$serviceName-$sbomSuffix"
29+
}
30+
}
31+
32+
enum class GitHubSbomSource {
33+
GITHUB_ARTIFACT,
34+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.leanix.githubagent.dto
2+
3+
data class SbomEventDTO(
4+
val repositoryName: String,
5+
val factSheetName: String,
6+
val sbomFileName: String,
7+
val sbomFileContent: String,
8+
)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package net.leanix.githubagent.dto
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
data class WorkflowRunEventDto(
8+
val action: String,
9+
@JsonProperty("workflow_run") val workflowRun: WorkflowRun,
10+
val repository: WorkflowRepository,
11+
val installation: InstallationDTO
12+
) {
13+
fun isCompleted() = (
14+
action == "completed" && workflowRun.conclusion == "success"
15+
)
16+
}
17+
18+
@JsonIgnoreProperties(ignoreUnknown = true)
19+
data class WorkflowRun(
20+
val id: Long,
21+
@JsonProperty("head_branch") val headBranch: String?,
22+
@JsonProperty("url") val runUrl: String?,
23+
@JsonProperty("run_attempt") val runAttempt: Int?,
24+
@JsonProperty("node_id") val nodeId: String,
25+
@JsonProperty("head_sha") val headSha: String?,
26+
val status: String,
27+
val conclusion: String?,
28+
@JsonProperty("created_at") val createdAt: String?,
29+
@JsonProperty("run_started_at") val startedAt: String?,
30+
val name: String?
31+
)
32+
33+
@JsonIgnoreProperties(ignoreUnknown = true)
34+
data class WorkflowRepository(
35+
val id: Long,
36+
@JsonProperty("node_id") val nodeId: String,
37+
val name: String,
38+
@JsonProperty("full_name") val fullName: String,
39+
val private: Boolean,
40+
@JsonProperty("html_url") val htmlUrl: String?,
41+
@JsonProperty("default_branch") val defaultBranch: String?,
42+
val owner: RepositoryOwner
43+
)
44+
45+
@JsonIgnoreProperties(ignoreUnknown = true)
46+
data class RepositoryOwner(
47+
val login: String
48+
)
49+
50+
@JsonIgnoreProperties(ignoreUnknown = true)
51+
data class InstallationDTO(
52+
val id: Int
53+
)

src/main/kotlin/net/leanix/githubagent/services/GitHubAuthenticationService.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,14 @@ class GitHubAuthenticationService(
121121
.replace(System.lineSeparator().toRegex(), "")
122122
.replace(pemSuffix, "")
123123
}
124+
125+
fun getInstallationToken(installationId: Int): String {
126+
var installationToken = cachingService.get("installationToken:$installationId")?.toString()
127+
if (installationToken == null) {
128+
refreshTokens()
129+
installationToken = cachingService.get("installationToken:$installationId")?.toString()
130+
require(installationToken != null) { "Installation token not found/ expired" }
131+
}
132+
return installationToken
133+
}
124134
}

src/main/kotlin/net/leanix/githubagent/services/WebhookEventService.kt

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import net.leanix.githubagent.dto.PushEventPayload
1111
import net.leanix.githubagent.exceptions.JwtTokenNotFound
1212
import net.leanix.githubagent.shared.INSTALLATION_LABEL
1313
import net.leanix.githubagent.shared.MANIFEST_FILE_NAME
14+
import net.leanix.githubagent.shared.WORKFLOW_RUN_EVENT
1415
import net.leanix.githubagent.shared.fileNameMatchRegex
1516
import net.leanix.githubagent.shared.generateFullPath
1617
import org.slf4j.LoggerFactory
@@ -27,7 +28,8 @@ class WebhookEventService(
2728
private val syncLogService: SyncLogService,
2829
@Value("\${webhookEventService.waitingTime}") private val waitingTime: Long,
2930
private val gitHubClient: GitHubClient,
30-
private val gitHubEnterpriseService: GitHubEnterpriseService
31+
private val gitHubEnterpriseService: GitHubEnterpriseService,
32+
private val workflowRunService: WorkflowRunService
3133
) {
3234

3335
private val logger = LoggerFactory.getLogger(WebhookEventService::class.java)
@@ -37,6 +39,7 @@ class WebhookEventService(
3739
when (eventType.uppercase()) {
3840
"PUSH" -> handlePushEvent(payload)
3941
"INSTALLATION" -> handleInstallationEvent(payload)
42+
WORKFLOW_RUN_EVENT -> workflowRunService.consumeWebhookPayload(payload)
4043
else -> {
4144
logger.info("Sending event of type: $eventType")
4245
webSocketService.sendMessage("/events/other", payload)
@@ -52,7 +55,7 @@ class WebhookEventService(
5255
val owner = pushEventPayload.repository.owner.name
5356
val defaultBranch = pushEventPayload.repository.defaultBranch
5457

55-
val installationToken = getInstallationToken(pushEventPayload.installation.id)
58+
val installationToken = gitHubAuthenticationService.getInstallationToken(pushEventPayload.installation.id)
5659

5760
if (headCommit != null && pushEventPayload.ref == "refs/heads/$defaultBranch") {
5861
handleManifestFileChanges(
@@ -145,16 +148,6 @@ class WebhookEventService(
145148

146149
private fun isLeanixManifestFile(it: String) = it == MANIFEST_FILE_NAME || it.endsWith("/$MANIFEST_FILE_NAME")
147150

148-
private fun getInstallationToken(installationId: Int): String {
149-
var installationToken = cachingService.get("installationToken:$installationId")?.toString()
150-
if (installationToken == null) {
151-
gitHubAuthenticationService.refreshTokens()
152-
installationToken = cachingService.get("installationToken:$installationId")?.toString()
153-
require(installationToken != null) { "Installation token not found/ expired" }
154-
}
155-
return installationToken
156-
}
157-
158151
@SuppressWarnings("LongParameterList")
159152
private fun handleAddedOrModifiedManifestFile(
160153
repositoryFullName: String,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package net.leanix.githubagent.services
2+
3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
4+
import com.fasterxml.jackson.module.kotlin.readValue
5+
import net.leanix.githubagent.client.GitHubClient
6+
import net.leanix.githubagent.dto.Artifact
7+
import net.leanix.githubagent.dto.SbomConfig
8+
import net.leanix.githubagent.dto.SbomEventDTO
9+
import net.leanix.githubagent.dto.WorkflowRunEventDto
10+
import org.slf4j.LoggerFactory
11+
import org.springframework.stereotype.Service
12+
import java.util.*
13+
14+
@Service
15+
class WorkflowRunService(
16+
private val gitHubAuthenticationService: GitHubAuthenticationService,
17+
private val webSocketService: WebSocketService,
18+
private val gitHubClient: GitHubClient,
19+
) {
20+
21+
private val logger = LoggerFactory.getLogger(WorkflowRunService::class.java)
22+
private val objectMapper = jacksonObjectMapper()
23+
private val sbomConfig = SbomConfig()
24+
25+
fun consumeWebhookPayload(payload: String) {
26+
runCatching {
27+
val event = parseEvent(payload) ?: return
28+
if (!event.isCompleted()) return
29+
30+
logger.info("Detected workflow event that successfully completed")
31+
val installationToken = "Bearer ${gitHubAuthenticationService.getInstallationToken(event.installation.id)}"
32+
33+
getValidArtifacts(event, installationToken)
34+
.takeIf { it.isNotEmpty() }
35+
?.let { artifacts ->
36+
logger.info("Found ${artifacts.size} artifact(s).")
37+
fetchAndProcessArtifacts(artifacts, event, installationToken)
38+
} ?: logger.info("No artifacts found for the event")
39+
}.onFailure {
40+
logger.error("Failed to consume workflow webhook", it)
41+
}
42+
}
43+
private fun parseEvent(payload: String): WorkflowRunEventDto? {
44+
return try {
45+
objectMapper.readValue(payload)
46+
} catch (e: Exception) {
47+
logger.error("Failed to parse webhook payload", e)
48+
null
49+
}
50+
}
51+
private fun getValidArtifacts(event: WorkflowRunEventDto, token: String): List<Artifact> {
52+
val owner = event.repository.owner.login
53+
val repo = event.repository.name
54+
val runId = event.workflowRun.id
55+
56+
return gitHubClient.getRunArtifacts(owner, repo, runId, token)
57+
.artifacts
58+
.filter { sbomConfig.isFileNameValid(it.name, event.workflowRun.headBranch ?: "") }
59+
}
60+
61+
private fun fetchAndProcessArtifacts(
62+
artifacts: List<Artifact>,
63+
event: WorkflowRunEventDto,
64+
installationToken: String
65+
) {
66+
val owner = event.repository.owner.login
67+
val repo = event.repository.name
68+
69+
artifacts.forEach { artifact ->
70+
logger.info("Processing artifact: ${artifact.name}")
71+
downloadAndSendArtifact(owner, repo, artifact, installationToken)
72+
}
73+
}
74+
75+
private fun downloadAndSendArtifact(owner: String, repo: String, artifact: Artifact, token: String) = runCatching {
76+
gitHubClient.downloadArtifact(owner, repo, artifact.id, token).body()?.use { body ->
77+
val sbomContent = Base64.getEncoder().encodeToString(body.asInputStream().readAllBytes())
78+
sendSbomEvent(repo, artifact.name, sbomContent)
79+
} ?: logger.error("Failed to download artifact: ${artifact.name}")
80+
}.onFailure {
81+
logger.error("Error processing artifact: ${artifact.name}", it)
82+
}
83+
84+
private fun sendSbomEvent(repo: String, artifactName: String, sbomContent: String) {
85+
logger.info("Sending sbom file: $repo - $artifactName")
86+
webSocketService.sendMessage(
87+
"/events/sbom",
88+
SbomEventDTO(
89+
repositoryName = repo,
90+
factSheetName = sbomConfig.extractFactSheetName(artifactName),
91+
sbomFileName = artifactName,
92+
sbomFileContent = sbomContent
93+
)
94+
)
95+
}
96+
}

src/main/kotlin/net/leanix/githubagent/shared/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ const val TOPIC_PREFIX = "/app/ghe/"
44
const val APP_NAME_TOPIC = "appName"
55
const val LOGS_TOPIC = "logs"
66
const val MANIFEST_FILE_NAME = "leanix.yaml"
7+
const val WORKFLOW_RUN_EVENT = "WORKFLOW_RUN"
78

89
val SUPPORTED_EVENT_TYPES = listOf(
910
"REPOSITORY",
1011
"PUSH",
1112
"ORGANIZATION",
1213
"INSTALLATION",
14+
WORKFLOW_RUN_EVENT
1315
)
1416

1517
val fileNameMatchRegex = Regex("/?$MANIFEST_FILE_NAME\$", RegexOption.IGNORE_CASE)

0 commit comments

Comments
 (0)