Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/cid 2776/create a webhook listener endpoint #14

Merged
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)
}
}

ahmed-ali-55 marked this conversation as resolved.
Show resolved Hide resolved
@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)
}
}
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
Loading