diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 97704eb..df3a874 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,8 +3,18 @@ on: workflow_dispatch: jobs: - deploy_docker_jvm: - runs-on: ubuntu-22.04 + build_jvm_matrix: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 @@ -18,14 +28,46 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and Push JVM Docker images + - name: Build and Push JVM Docker image for ${{ matrix.platform }} run: | - make push-jvm + make push-jvm-platform PLATFORM=${{ matrix.platform }} env: GIT_TAG: ${{ github.ref }} - deploy_docker_native: + create_jvm_manifest: + needs: build_jvm_matrix runs-on: ubuntu-22.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and Push JVM multi-platform manifest + run: | + make push-jvm-manifest + env: + GIT_TAG: ${{ github.ref }} + + build_native_matrix: + strategy: + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v4 @@ -39,16 +81,38 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and Push Native Docker images + - name: Build and Push Native Docker image for ${{ matrix.platform }} + run: | + make push-native-platform PLATFORM=${{ matrix.platform }} + env: + GIT_TAG: ${{ github.ref }} + + create_native_manifest: + needs: build_native_matrix + runs-on: ubuntu-22.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and Push Native multi-platform manifest run: | - make push-native + make push-native-manifest env: GIT_TAG: ${{ github.ref }} all: name: Pushed All if: always() - needs: [ deploy_docker_native, deploy_docker_jvm ] + needs: [ create_jvm_manifest, create_native_manifest ] runs-on: ubuntu-22.04 steps: - name: Validate required tests diff --git a/.sdkmanrc b/.sdkmanrc index 802cf18..5a6900d 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,3 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below -java=22-graalce +java=21.0.9-tem diff --git a/Makefile b/Makefile index 57e721f..3bb51c7 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ IMG_NATIVE := ${NAME}:native-${TAG} LATEST_JVM := ${NAME}:jvm-latest LATEST_NATIVE := ${NAME}:native-latest LATEST := ${NAME}:latest +PLATFORM ?= linux/amd64,linux/arm64 dependency-updates: ./gradlew dependencyUpdates \ @@ -23,6 +24,20 @@ build-jvm: init-docker push-jvm: DOCKER_EXTRA_ARGS="--push" $(MAKE) build-jvm +# Build and push for a single platform (used in matrix builds) +build-jvm-platform: init-docker + $(eval PLATFORM_TAG := $(shell echo ${PLATFORM} | tr '/' '-')) + docker buildx build --platform ${PLATFORM} -f ./src/main/docker/Dockerfile.jvm -t "${IMG_JVM}-${PLATFORM_TAG}" -t "${LATEST_JVM}-${PLATFORM_TAG}" ${DOCKER_EXTRA_ARGS} . + +push-jvm-platform: + DOCKER_EXTRA_ARGS="--push" $(MAKE) build-jvm-platform + +# Create and push multi-platform manifest combining platform-specific images +push-jvm-manifest: + docker buildx imagetools create -t "${IMG_JVM}" -t "${LATEST_JVM}" \ + "${IMG_JVM}-linux-amd64" \ + "${IMG_JVM}-linux-arm64" + build-jvm-local: docker build -f ./src/main/docker/Dockerfile.jvm -t "${IMG_JVM}" -t "${LATEST_JVM}" . @@ -35,6 +50,20 @@ build-native: init-docker push-native: DOCKER_EXTRA_ARGS="--push" $(MAKE) build-native +# Build and push for a single platform (used in matrix builds) +build-native-platform: init-docker + $(eval PLATFORM_TAG := $(shell echo ${PLATFORM} | tr '/' '-')) + docker buildx build --platform ${PLATFORM} -f ./src/main/docker/Dockerfile.native -t "${IMG_NATIVE}-${PLATFORM_TAG}" -t "${LATEST_NATIVE}-${PLATFORM_TAG}" -t "${LATEST}-${PLATFORM_TAG}" ${DOCKER_EXTRA_ARGS} . + +push-native-platform: + DOCKER_EXTRA_ARGS="--push" $(MAKE) build-native-platform + +# Create and push multi-platform manifest combining platform-specific images +push-native-manifest: + docker buildx imagetools create -t "${IMG_NATIVE}" -t "${LATEST_NATIVE}" -t "${LATEST}" \ + "${IMG_NATIVE}-linux-amd64" \ + "${IMG_NATIVE}-linux-arm64" + build-native-local: docker build -f ./src/main/docker/Dockerfile.native -t "${IMG_NATIVE}" -t "${LATEST_NATIVE}" -t "${LATEST}" . diff --git a/README.md b/README.md index ba8a912..42b34bb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ than 10 MB of RAM, so it can be installed on under-powered servers. > **NOTE** > > This used to be a Haskell project, that I switched to Kotlin. The code is still available on the [v1-haskell](https://github.com/alexandru/github-webhook-listener/tree/v1-haskell) branch. +> There's also an experimental Rust branch, see [v3-rust](https://github.com/alexandru/github-webhook-listener/tree/v3-rust). ## Setup diff --git a/build.gradle.kts b/build.gradle.kts index d53d789..9850d3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { implementation(libs.kotlin.stdlib.jdk8) implementation(libs.kotlin.test.junit) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.hocon) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.server.cio) implementation(libs.ktor.server.core) diff --git a/settings.gradle.kts b/settings.gradle.kts index 9be9b19..c1b11fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,8 @@ dependencyResolutionManagement { .versionRef("kotlin") library("kotlinx-serialization-json", "org.jetbrains.kotlinx", "kotlinx-serialization-json") .versionRef("serialization") + library("kotlinx-serialization-hocon", "org.jetbrains.kotlinx", "kotlinx-serialization-hocon") + .versionRef("serialization") // https://ktor.io/ plugin("ktor", "io.ktor.plugin") diff --git a/src/main/kotlin/org/alexn/hook/AppConfig.kt b/src/main/kotlin/org/alexn/hook/AppConfig.kt index ba367a9..f29489a 100644 --- a/src/main/kotlin/org/alexn/hook/AppConfig.kt +++ b/src/main/kotlin/org/alexn/hook/AppConfig.kt @@ -1,8 +1,15 @@ +@file:OptIn(ExperimentalSerializationApi::class) + package org.alexn.hook +import arrow.core.Either import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration +import com.typesafe.config.ConfigFactory +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable +import kotlinx.serialization.hocon.Hocon +import kotlinx.serialization.hocon.decodeFromConfig import java.io.File import kotlin.time.Duration @@ -36,16 +43,77 @@ data class AppConfig( ) companion object { - fun parseYaml(string: String): AppConfig = - yamlParser.decodeFromString( - serializer(), - string, - ) + fun parseFile(file: File) = + when (file.extension.lowercase()) { + "hocon", "conf" -> parseHocon(file) + "yaml", "yml" -> parseYaml(file) + else -> + Either.Left( + ConfigException( + "Unsupported configuration file format: ${file.extension}", + ), + ) + } + + fun parseHocon(string: String): Either = + try { + val r = + Hocon.decodeFromConfig( + serializer(), + ConfigFactory.parseString(string).resolve(), + ) + Either.Right(r) + } catch (ex: Exception) { + Either.Left( + ConfigException( + "Failed to parse HOCON configuration", + ex, + ), + ) + } + + fun parseHocon(file: File): Either = + try { + val txt = file.readText() + parseHocon(txt) + } catch (ex: Exception) { + Either.Left( + ConfigException( + "Failed to read configuration file: ${file.absolutePath}", + ex, + ), + ) + } + + fun parseYaml(string: String): Either = + try { + Either.Right( + yamlParser.decodeFromString( + serializer(), + string, + ), + ) + } catch (ex: Exception) { + Either.Left( + ConfigException( + "Failed to parse YAML configuration", + ex, + ), + ) + } - fun parseYaml(file: File): AppConfig { - val txt = file.readText() - return parseYaml(txt) - } + fun parseYaml(file: File): Either = + try { + val txt = file.readText() + parseYaml(txt) + } catch (ex: Exception) { + Either.Left( + ConfigException( + "Failed to read configuration file: ${file.absolutePath}", + ex, + ), + ) + } private val yamlParser = Yaml( @@ -56,3 +124,12 @@ data class AppConfig( ) } } + +/** + * Exception thrown when there is a configuration error, + * see [AppConfig]. + */ +class ConfigException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/src/main/kotlin/org/alexn/hook/Main.kt b/src/main/kotlin/org/alexn/hook/Main.kt index 83b9217..dc3c5f9 100644 --- a/src/main/kotlin/org/alexn/hook/Main.kt +++ b/src/main/kotlin/org/alexn/hook/Main.kt @@ -1,6 +1,7 @@ package org.alexn.hook import arrow.continuations.SuspendApp +import arrow.core.getOrElse import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.main @@ -17,8 +18,8 @@ class RunServer : override fun run() = SuspendApp { - val config = AppConfig.parseYaml(File(configPath)) - startServer(config) + val config = AppConfig.parseFile(File(configPath)) + startServer(config.getOrElse { throw it }) } } diff --git a/src/test/kotlin/org/alexn/hook/AppConfigTest.kt b/src/test/kotlin/org/alexn/hook/AppConfigTest.kt index 6f64bc9..0e4bb6b 100644 --- a/src/test/kotlin/org/alexn/hook/AppConfigTest.kt +++ b/src/test/kotlin/org/alexn/hook/AppConfigTest.kt @@ -2,9 +2,8 @@ package org.alexn.hook +import arrow.core.getOrElse import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals @@ -44,19 +43,19 @@ class AppConfigTest { val config = """ http: - path: "/" - port: 8080 + path: "/" + port: 8080 runtime: - workers: 2 - output: stdout + workers: 2 + output: stdout projects: - myproject: - ref: "refs/heads/gh-pages" - directory: "/var/www/myproject" - command: "git pull" - secret: "xxxxxxxxxxxxxxxxxxxxxxxxxx" + myproject: + ref: "refs/heads/gh-pages" + directory: "/var/www/myproject" + command: "git pull" + secret: "xxxxxxxxxxxxxxxxxxxxxxxxxx" """.trimIndent() assertEquals( @@ -80,7 +79,143 @@ class AppConfigTest { ), ), ), - AppConfig.parseYaml(config), + AppConfig.parseYaml(config).getOrElse { throw it }, ) } + + @Test + fun parseHoconConfig() { + val config = + """ + http { + path = "/" + port = 8080 + } + + runtime { + workers = 2 + output = "stdout" + } + + projects { + myproject { + ref = "refs/heads/gh-pages" + directory = "/var/www/myproject" + command = "git pull" + secret = "xxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + """.trimIndent() + + assertEquals( + AppConfig( + http = + AppConfig.Http( + host = null, + port = 8080, + path = "/", + ), + projects = + mapOf( + "myproject" to + AppConfig.Project( + action = null, + ref = "refs/heads/gh-pages", + directory = "/var/www/myproject", + command = "git pull", + timeout = null, + secret = "xxxxxxxxxxxxxxxxxxxxxxxxxx", + ), + ), + ), + AppConfig.parseHocon(config).getOrElse { throw it }, + ) + } + + @Test + fun parseFileYamlAndHocon() { + val yamlConfig = + """ + http: + path: "/" + port: 8080 + + runtime: + workers: 2 + output: stdout + + projects: + myproject: + ref: "refs/heads/gh-pages" + directory: "/var/www/myproject" + command: "git pull" + secret: "xxxxxxxxxxxxxxxxxxxxxxxxxx" + """.trimIndent() + + val hoconConfig = + """ + http { + path = "/" + port = 8080 + } + + runtime { + workers = 2 + output = "stdout" + } + + projects { + myproject { + ref = "refs/heads/gh-pages" + directory = "/var/www/myproject" + command = "git pull" + secret = "xxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + """.trimIndent() + + val expectedConfig = + AppConfig( + http = + AppConfig.Http( + host = null, + port = 8080, + path = "/", + ), + projects = + mapOf( + "myproject" to + AppConfig.Project( + action = null, + ref = "refs/heads/gh-pages", + directory = "/var/www/myproject", + command = "git pull", + timeout = null, + secret = "xxxxxxxxxxxxxxxxxxxxxxxxxx", + ), + ), + ) + + val yamlFile = + kotlin.io.path + .createTempFile(suffix = ".yaml") + .toFile() + val hoconFile = + kotlin.io.path + .createTempFile(suffix = ".conf") + .toFile() + try { + yamlFile.writeText(yamlConfig) + hoconFile.writeText(hoconConfig) + + val parsedYaml = AppConfig.parseFile(yamlFile).getOrElse { throw it } + val parsedHocon = AppConfig.parseFile(hoconFile).getOrElse { throw it } + + assertEquals(expectedConfig, parsedYaml) + assertEquals(expectedConfig, parsedHocon) + } finally { + yamlFile.delete() + hoconFile.delete() + } + } }