Skip to content

Commit

Permalink
Merge pull request #14 from leanix/feature/CID-2776/create-a-webhook-…
Browse files Browse the repository at this point in the history
…listener-endpoint

Feature/cid 2776/create a webhook listener endpoint
  • Loading branch information
mohamedlajmileanix authored Aug 6, 2024
2 parents 107a2c4 + 58618dd commit d05f8f4
Show file tree
Hide file tree
Showing 15 changed files with 428 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.leanix.githubagent.controllers

import net.leanix.githubagent.services.WebhookService
import net.leanix.githubagent.shared.SUPPORTED_EVENT_TYPES
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/github")
class GitHubWebhookController(private val webhookService: WebhookService) {

private val logger = LoggerFactory.getLogger(GitHubWebhookController::class.java)

@PostMapping("/webhook")
@ResponseStatus(HttpStatus.ACCEPTED)
fun hook(
@RequestHeader("X-Github-Event") eventType: String,
@RequestBody payload: String
) {
runCatching {
if (SUPPORTED_EVENT_TYPES.contains(eventType.uppercase())) {
webhookService.consumeWebhookEvent(eventType, payload)
} else {
logger.warn("Received an unsupported event of type: $eventType")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.leanix.githubagent.dto

data class ManifestFileUpdateDto(
val repositoryFullName: String,
val action: ManifestFileAction,
val manifestContent: String?
)

enum class ManifestFileAction {
ADDED,
MODIFIED,
REMOVED
}
41 changes: 41 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/dto/PushEventPayload.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package net.leanix.githubagent.dto

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class PushEventPayload(
val ref: String,
val repository: PushEventRepository,
val installation: PushEventInstallation,
@JsonProperty("head_commit")
val headCommit: PushEventCommit
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class PushEventRepository(
@JsonProperty("name")
val name: String,
@JsonProperty("full_name")
val fullName: String,
@JsonProperty("default_branch")
val defaultBranch: String,
val owner: PushEventOwner
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class PushEventInstallation(
val id: Int
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class PushEventCommit(
val added: List<String>,
val removed: List<String>,
val modified: List<String>
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class PushEventOwner(
val name: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class PostStartupRunner(

override fun run(args: ApplicationArguments?) {
webSocketService.initSession()
githubAuthenticationService.generateJwtToken()
githubAuthenticationService.generateAndCacheJwtToken()
gitHubScanningService.scanGitHubResources()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package net.leanix.githubagent.services

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import net.leanix.githubagent.client.GitHubClient
import net.leanix.githubagent.config.GitHubEnterpriseProperties
import net.leanix.githubagent.dto.Installation
import net.leanix.githubagent.exceptions.FailedToCreateJWTException
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.slf4j.LoggerFactory
Expand All @@ -24,7 +26,8 @@ class GitHubAuthenticationService(
private val cachingService: CachingService,
private val githubEnterpriseProperties: GitHubEnterpriseProperties,
private val resourceLoader: ResourceLoader,
private val gitHubEnterpriseService: GitHubEnterpriseService
private val gitHubEnterpriseService: GitHubEnterpriseService,
private val gitHubClient: GitHubClient,
) {

companion object {
Expand All @@ -34,16 +37,25 @@ class GitHubAuthenticationService(
private val logger = LoggerFactory.getLogger(GitHubAuthenticationService::class.java)
}

fun generateJwtToken() {
fun refreshTokens() {
generateAndCacheJwtToken()
val jwtToken = cachingService.get("jwtToken")
generateAndCacheInstallationTokens(
gitHubClient.getInstallations("Bearer $jwtToken"),
jwtToken.toString()
)
}

fun generateAndCacheJwtToken() {
runCatching {
logger.info("Generating JWT token")
Security.addProvider(BouncyCastleProvider())
val rsaPrivateKey: String = readPrivateKey()
val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(rsaPrivateKey))
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(keySpec)
val jwt = createJwtToken(privateKey)
cachingService.set("jwtToken", jwt.getOrThrow(), JWT_EXPIRATION_DURATION)
gitHubEnterpriseService.verifyJwt(jwt.getOrThrow())
cachingService.set("jwtToken", jwt.getOrThrow(), JWT_EXPIRATION_DURATION)
}.onFailure {
logger.error("Failed to generate/validate JWT token", it)
if (it is InvalidKeySpecException) {
Expand All @@ -67,6 +79,16 @@ class GitHubAuthenticationService(
}
}

fun generateAndCacheInstallationTokens(
installations: List<Installation>,
jwtToken: String
) {
installations.forEach { installation ->
val installationToken = gitHubClient.createInstallationToken(installation.id, "Bearer $jwtToken").token
cachingService.set("installationToken:${installation.id}", installationToken, 3600L)
}
}

@Throws(IOException::class)
private fun readPrivateKey(): String {
val pemFile = File(resourceLoader.getResource("file:${githubEnterpriseProperties.pemFile}").uri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import net.leanix.githubagent.dto.PagedRepositories
import net.leanix.githubagent.dto.RepositoryDto
import net.leanix.githubagent.exceptions.GraphQLApiException
import net.leanix.githubagent.graphql.data.GetRepositories
import net.leanix.githubagent.graphql.data.GetRepositoryManifestContent
import net.leanix.githubagent.graphql.data.getrepositories.Blob
import net.leanix.githubagent.shared.MANIFEST_FILE_NAME
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
Expand All @@ -20,7 +22,6 @@ class GitHubGraphQLService(
companion object {
private val logger = LoggerFactory.getLogger(GitHubGraphQLService::class.java)
private const val PAGE_COUNT = 20
private const val MANIFEST_FILE_NAME = "leanix.yaml"
}

fun getRepositories(
Expand Down Expand Up @@ -67,6 +68,37 @@ class GitHubGraphQLService(
}
}

fun getManifestFileContent(
owner: String,
repositoryName: String,
filePath: String,
token: String
): String {
val client = buildGitHubGraphQLClient(token)

val query = GetRepositoryManifestContent(
GetRepositoryManifestContent.Variables(
owner = owner,
repositoryName = repositoryName,
filePath = filePath
)
)

val result = runBlocking {
client.execute(query)
}

return if (result.errors != null && result.errors!!.isNotEmpty()) {
logger.error("Error getting file content: ${result.errors}")
throw GraphQLApiException(result.errors!!)
} else {
(
result.data!!.repository!!.`object`
as net.leanix.githubagent.graphql.`data`.getrepositorymanifestcontent.Blob
).text.toString()
}
}

private fun buildGitHubGraphQLClient(
token: String
) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import net.leanix.githubagent.client.GitHubClient
import net.leanix.githubagent.dto.Installation
import net.leanix.githubagent.dto.OrganizationDto
import net.leanix.githubagent.exceptions.JwtTokenNotFound
import net.leanix.githubagent.shared.TOPIC_PREFIX
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
Expand All @@ -14,7 +13,8 @@ class GitHubScanningService(
private val gitHubClient: GitHubClient,
private val cachingService: CachingService,
private val webSocketService: WebSocketService,
private val gitHubGraphQLService: GitHubGraphQLService
private val gitHubGraphQLService: GitHubGraphQLService,
private val gitHubAuthenticationService: GitHubAuthenticationService
) {

private val logger = LoggerFactory.getLogger(GitHubScanningService::class.java)
Expand All @@ -38,20 +38,10 @@ class GitHubScanningService(

private fun getInstallations(jwtToken: String): List<Installation> {
val installations = gitHubClient.getInstallations("Bearer $jwtToken")
generateAndCacheInstallationTokens(installations, jwtToken)
gitHubAuthenticationService.generateAndCacheInstallationTokens(installations, jwtToken)
return installations
}

private fun generateAndCacheInstallationTokens(
installations: List<Installation>,
jwtToken: String
) {
installations.forEach { installation ->
val installationToken = gitHubClient.createInstallationToken(installation.id, "Bearer $jwtToken").token
cachingService.set("installationToken:${installation.id}", installationToken, 3600L)
}
}

private fun fetchAndSendOrganisationsData(
installations: List<Installation>
) {
Expand All @@ -65,7 +55,7 @@ class GitHubScanningService(
}
}
logger.info("Sending organizations data")
webSocketService.sendMessage("$TOPIC_PREFIX${cachingService.get("runId")}/organizations", organizations)
webSocketService.sendMessage("${cachingService.get("runId")}/organizations", organizations)
}

private fun fetchAndSendRepositoriesData(installation: Installation) {
Expand All @@ -80,7 +70,7 @@ class GitHubScanningService(
)
logger.info("Sending page $page of repositories")
webSocketService.sendMessage(
"$TOPIC_PREFIX${cachingService.get("runId")}/repositories",
"${cachingService.get("runId")}/repositories",
repositoriesPage.repositories
)
cursor = repositoriesPage.cursor
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.leanix.githubagent.services

import net.leanix.githubagent.config.WebSocketClientConfig
import net.leanix.githubagent.shared.TOPIC_PREFIX
import org.slf4j.LoggerFactory
import org.springframework.messaging.simp.stomp.StompSession
import org.springframework.stereotype.Service
Expand All @@ -19,6 +20,6 @@ class WebSocketService(
}

fun sendMessage(topic: String, data: Any) {
stompSession!!.send(topic, data)
stompSession!!.send("$TOPIC_PREFIX$topic", data)
}
}
85 changes: 85 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/services/WebhookService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package net.leanix.githubagent.services

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import net.leanix.githubagent.config.GitHubEnterpriseProperties
import net.leanix.githubagent.dto.ManifestFileAction
import net.leanix.githubagent.dto.ManifestFileUpdateDto
import net.leanix.githubagent.dto.PushEventPayload
import net.leanix.githubagent.shared.MANIFEST_FILE_NAME
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service

@Service
class WebhookService(
private val webSocketService: WebSocketService,
private val gitHubGraphQLService: GitHubGraphQLService,
private val gitHubEnterpriseProperties: GitHubEnterpriseProperties,
private val cachingService: CachingService,
private val gitHubAuthenticationService: GitHubAuthenticationService
) {

private val logger = LoggerFactory.getLogger(WebhookService::class.java)
private val objectMapper = jacksonObjectMapper()

fun consumeWebhookEvent(eventType: String, payload: String) {
when (eventType.uppercase()) {
"PUSH" -> handlePushEvent(payload)
else -> {
logger.info("Sending event of type: $eventType")
webSocketService.sendMessage("/events/other", payload)
}
}
}

private fun handlePushEvent(payload: String) {
val pushEventPayload: PushEventPayload = objectMapper.readValue(payload)
val repositoryName = pushEventPayload.repository.name
val repositoryFullName = pushEventPayload.repository.fullName
val headCommit = pushEventPayload.headCommit
val organizationName = pushEventPayload.repository.owner.name

var installationToken = cachingService.get("installationToken:${pushEventPayload.installation.id}")?.toString()
if (installationToken == null) {
gitHubAuthenticationService.refreshTokens()
installationToken = cachingService.get("installationToken:${pushEventPayload.installation.id}")?.toString()
require(installationToken != null) { "Installation token not found/ expired" }
}

if (pushEventPayload.ref == "refs/heads/${pushEventPayload.repository.defaultBranch}") {
when {
MANIFEST_FILE_NAME in headCommit.added -> {
logger.info("Manifest file added to repository $repositoryFullName")
val fileContent = getManifestFileContent(organizationName, repositoryName, installationToken)
sendManifestData(repositoryFullName, ManifestFileAction.ADDED, fileContent)
}
MANIFEST_FILE_NAME in headCommit.modified -> {
logger.info("Manifest file modified in repository $repositoryFullName")
val fileContent = getManifestFileContent(organizationName, repositoryName, installationToken)
sendManifestData(repositoryFullName, ManifestFileAction.MODIFIED, fileContent)
}
MANIFEST_FILE_NAME in headCommit.removed -> {
logger.info("Manifest file removed from repository $repositoryFullName")
sendManifestData(repositoryFullName, ManifestFileAction.REMOVED, null)
}
}
}
}

private fun getManifestFileContent(organizationName: String, repositoryName: String, token: String): String {
return gitHubGraphQLService.getManifestFileContent(
owner = organizationName,
repositoryName,
"HEAD:${gitHubEnterpriseProperties.manifestFileDirectory}$MANIFEST_FILE_NAME",
token
)
}

private fun sendManifestData(repositoryFullName: String, action: ManifestFileAction, manifestContent: String?) {
logger.info("Sending manifest file update event for repository $repositoryFullName")
webSocketService.sendMessage(
"/events/manifestFile",
ManifestFileUpdateDto(repositoryFullName, action, manifestContent)
)
}
}
9 changes: 9 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/shared/Constants.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
package net.leanix.githubagent.shared

const val TOPIC_PREFIX = "/app/ghe/"

const val MANIFEST_FILE_NAME = "leanix.yaml"

val SUPPORTED_EVENT_TYPES = listOf(
"REPOSITORY",
"PUSH",
"ORGANIZATION",
"INSTALLATION",
)
Loading

0 comments on commit d05f8f4

Please sign in to comment.