diff --git a/build.gradle.kts b/build.gradle.kts index dbecb5e..2aa2578 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,9 @@ dependencies { implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0-rc-2") implementation("org.jetbrains.exposed:exposed-dao:1.0.0-rc-2") implementation("org.jetbrains.exposed:exposed-kotlin-datetime:1.0.0-rc-2") + + // YAML parsing + implementation("net.mamoe.yamlkt:yamlkt:0.13.0") } tasks.test { diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index fd4a9eb..1501ed1 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -3,6 +3,7 @@ package de.steamwar import com.github.ajalt.clikt.core.main import com.github.ajalt.clikt.core.subcommands import de.steamwar.commands.SteamWar +import de.steamwar.commands.ci.CiCommand import de.steamwar.commands.database.DatabaseCommand import de.steamwar.commands.database.InfoCommand import de.steamwar.commands.database.ResetCommand @@ -14,6 +15,7 @@ import de.steamwar.commands.user.UserSearchCommand fun main(args: Array) = SteamWar() .subcommands( + CiCommand(), DatabaseCommand().subcommands(InfoCommand(), ResetCommand()), UserCommand().subcommands(UserInfoCommand(), UserSearchCommand()), DevCommand(), diff --git a/src/main/kotlin/commands/ci/CiCommand.kt b/src/main/kotlin/commands/ci/CiCommand.kt new file mode 100644 index 0000000..9201713 --- /dev/null +++ b/src/main/kotlin/commands/ci/CiCommand.kt @@ -0,0 +1,57 @@ +package de.steamwar.commands.ci + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import java.io.File +import java.io.PrintStream +import kotlin.concurrent.thread + +class CiCommand : CliktCommand("ci") { + override val hiddenFromHelp = true + + override fun help(context: Context): String = "CI daemon for processing git push events" + + override fun run() { + echo("SteamWar CI daemon started. Waiting for push events...") + echo("Format: ") + + try { + while (true) { + val input = readlnOrNull() ?: break + val parts = input.split(" ") + + if (parts.size < 3) { + echo("Invalid input format. Expected: ", err = true) + continue + } + + val (oldref, newref, branch) = parts + + // Fork/detach the build process + thread(isDaemon = false) { + processBuild(oldref, newref, branch) + } + } + } catch (e: Exception) { + echo("CI daemon error: ${e.message}", err = true) + } + } + + private fun processBuild(oldref: String, newref: String, branch: String) { + try { + val config = CiConfig(oldref, newref, branch) + + // Create log file + val logFile = File(config.logpath) + logFile.parentFile?.mkdirs() + + PrintStream(logFile).use { logStream -> + val runner = CiRunner(config) + runner.run(logStream) + } + } catch (e: Exception) { + System.err.println("Build failed: ${e.message}") + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/commands/ci/CiConfig.kt b/src/main/kotlin/commands/ci/CiConfig.kt new file mode 100644 index 0000000..4b61730 --- /dev/null +++ b/src/main/kotlin/commands/ci/CiConfig.kt @@ -0,0 +1,152 @@ +package de.steamwar.commands.ci + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import net.mamoe.yamlkt.Yaml +import java.io.File +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.channels.FileChannel +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.util.Base64 + +@Serializable +data class CiDaemonConfig( + val token: String, + val nextlog: Int, + val logpath: String +) + +@Serializable +data class ProjectCiConfig( + val setup: List? = null, + val build: List, + val artifacts: Map? = null, + val release: List? = null, + @SerialName("release-branches") + val releaseBranches: Boolean = false +) + +@Serializable +data class GiteaRepoInfo( + @SerialName("default_branch") + val defaultBranch: String +) + +class CiConfig( + oldref: String, + newref: String, + val branch: String +) { + val oldcommit: String = oldref + val commit: String = newref + + val user: String = System.getenv("GITEA_REPO_USER_NAME") + ?: throw IllegalStateException("GITEA_REPO_USER_NAME not set") + val repo: String = (System.getenv("GITEA_REPO_NAME") + ?: throw IllegalStateException("GITEA_REPO_NAME not set")).lowercase() + val repopath: String = System.getenv("PWD") + ?: System.getProperty("user.dir") + + private val homeDir: String = System.getProperty("user.home") + val cidir: String = "$homeDir/ci" + + val token: String + val lognumber: Int + val logpath: String + + private val httpClient = HttpClient.newHttpClient() + private val json = Json { ignoreUnknownKeys = true } + private val yaml = Yaml { encodeDefaultValues = false } + + init { + val configPath = "$homeDir/steamwarci.yml" + val configFile = File(configPath) + + FileChannel.open( + Path.of(configPath), + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE + ).use { channel -> + val lock = channel.lock() + try { + val daemonConfig = if (configFile.length() == 0L) { + throw IllegalStateException("$configPath is empty or missing required keys") + } else { + yaml.decodeFromString(CiDaemonConfig.serializer(), configFile.readText()) + } + + token = daemonConfig.token + lognumber = daemonConfig.nextlog + logpath = "${daemonConfig.logpath}/${daemonConfig.nextlog}.txt" + + val updated = daemonConfig.copy(nextlog = daemonConfig.nextlog + 1) + configFile.writeText(yaml.encodeToString(CiDaemonConfig.serializer(), updated)) + } finally { + lock.release() + } + } + } + + fun apiQuery(method: String, endpoint: String, body: String? = null): String { + val requestBuilder = HttpRequest.newBuilder() + .uri(URI.create("https://git.steamwar.de/api/v1/repos/$user/$repo$endpoint?token=$token")) + .header("Content-Type", "application/json") + + val request = when (method.uppercase()) { + "GET" -> requestBuilder.GET().build() + "POST" -> requestBuilder.POST( + body?.let { HttpRequest.BodyPublishers.ofString(it) } + ?: HttpRequest.BodyPublishers.noBody() + ).build() + + else -> throw IllegalArgumentException("Unsupported HTTP method: $method") + } + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() !in 200..299) { + throw RuntimeException("Could not execute API request: ${response.statusCode()} ${response.body()}") + } + return response.body() + } + + fun updateState(state: String, description: String) { + println("Statusupdate $state $description") + val body = """ + { + \"context\": \"SteamWarCI\", + \"description\": \"$description\", + \"state\": \"$state\", + \"target_url\": \"https://steamwar.de/buildlogs/$lognumber.txt\" + } + """.trimIndent() + apiQuery("POST", "/statuses/$commit", body) + } + + fun getCiConfig(): ProjectCiConfig { + println("Loading master/steamwarci.yml") + val response = apiQuery("GET", "/contents/steamwarci.yml") + val jsonObj = json.parseToJsonElement(response) as JsonObject + val content = jsonObj["content"]?.jsonPrimitive?.content + ?: throw RuntimeException("Could not get steamwarci.yml content") + val decoded = String(Base64.getDecoder().decode(content.replace("\n", ""))) + return yaml.decodeFromString(ProjectCiConfig.serializer(), decoded) + } + + fun getDefaultBranch(): String { + println("Getting default branch") + val response = apiQuery("GET", "") + val repoInfo = json.decodeFromString(response) + return repoInfo.defaultBranch + } + + val udir: String get() = "$cidir/$user" + val wdir: String get() = "$udir/$repo" +} diff --git a/src/main/kotlin/commands/ci/CiRunner.kt b/src/main/kotlin/commands/ci/CiRunner.kt new file mode 100644 index 0000000..03fdcf0 --- /dev/null +++ b/src/main/kotlin/commands/ci/CiRunner.kt @@ -0,0 +1,162 @@ +package de.steamwar.commands.ci + +import java.io.File +import java.io.PrintStream +import java.nio.channels.FileChannel +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +class CiRunner(private val config: CiConfig) { + + private var logFile: PrintStream? = null + + fun run(logStream: PrintStream) { + this.logFile = logStream + + try { + config.updateState("pending", "Waiting for previous builds") + + // Use file lock to ensure exclusive CI access + FileChannel.open( + Path.of(System.getProperty("user.home"), ".cilock"), + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE + ).use { channel -> + val lock = channel.lock() + try { + ciMain() + } finally { + lock.release() + } + } + } catch (e: Exception) { + log("Error: ${e.message}") + e.printStackTrace(logStream) + config.updateState("failure", "Build failed") + throw e + } + } + + private fun ciMain() { + config.updateState("pending", "Building project") + + val ciConfig = config.getCiConfig() + val freshClone = !File(config.wdir).exists() + + if (freshClone) { + File(config.udir).mkdirs() + runCommand("git clone -n ${config.repopath}", "Could not clone repository", config.udir) + } else { + runCommand("git fetch", "Could not fetch updates", config.wdir) + } + + runCommand("git checkout ${config.commit}", "Could not checkout commit", config.wdir) + runCommand("git submodule update --init", "Could not init submodules", config.wdir) + + if (freshClone && ciConfig.setup != null) { + for (cmd in ciConfig.setup) { + log("Setup: Executing $cmd") + runCommand(cmd, "Could not run setup command", config.wdir) + } + } + + for (cmd in ciConfig.build) { + log("Build: Executing $cmd") + runCommand(cmd, "Could not run build command", config.wdir) + } + + val onMaster = config.branch == "refs/heads/${config.getDefaultBranch()}" + val releaseBranchName = getReleaseBranchName() + val onReleaseBranch = releaseBranchName != null && ciConfig.releaseBranches + + if (ciConfig.artifacts != null) { + log("Checking artifact existence") + for ((_, source) in ciConfig.artifacts) { + if (!File("${config.wdir}/$source").exists()) { + throw RuntimeException("Artifact $source missing") + } + } + + if (onMaster) { + log("Deploying artifacts") + for ((target, source) in ciConfig.artifacts) { + log("Copying $source to $target") + val expandedTarget = expandPath(target) + runCommand("rm -f $expandedTarget", "Could not delete artifact", config.wdir) + runCommand("cp -r $source $expandedTarget", "Could not copy artifacts", config.wdir) + runCommand("chmod o-w $expandedTarget", "Could not change artifact perms", config.wdir) + } + } else if (onReleaseBranch) { + log("Deploying artifacts for release branch: $releaseBranchName") + for ((target, source) in ciConfig.artifacts) { + val expandedTarget = expandPath(target) + val targetFile = File(expandedTarget) + val parentDir = targetFile.parentFile?.absolutePath ?: expandedTarget.substringBeforeLast("/") + val fileName = targetFile.name + val releaseTargetDir = "$parentDir/$releaseBranchName" + val releaseTarget = "$releaseTargetDir/$fileName" + + log("Copying $source to $releaseTarget") + runCommand("mkdir -p $releaseTargetDir", "Could not create release directory", config.wdir) + runCommand("rm -f $releaseTarget", "Could not delete artifact", config.wdir) + runCommand("cp -r $source $releaseTarget", "Could not copy artifacts", config.wdir) + runCommand("chmod o-w $releaseTarget", "Could not change artifact perms", config.wdir) + } + } + } + + if (ciConfig.release != null && (onMaster || onReleaseBranch)) { + log("Running release commands" + if (onReleaseBranch) " for release branch: $releaseBranchName" else "") + for (cmd in ciConfig.release) { + log("Release: Executing $cmd") + runCommand(cmd, "Could not run release command", config.wdir) + } + } + + config.updateState("success", "Build successful") + } + + private fun runCommand(command: String, errorMessage: String, workingDir: String) { + log("Running: $command") + + val processBuilder = ProcessBuilder("/bin/sh", "-c", command) + .directory(File(workingDir)) + .redirectErrorStream(true) + + val process = processBuilder.start() + + // Read and log output + process.inputStream.bufferedReader().forEachLine { line -> + log(line) + } + + val exitCode = process.waitFor() + if (exitCode != 0) { + throw RuntimeException("$errorMessage (exit code: $exitCode)") + } + } + + private fun expandPath(path: String): String { + return if (path.startsWith("~")) { + path.replaceFirst("~", System.getProperty("user.home")) + } else { + path + } + } + + private fun getReleaseBranchName(): String? { + val releaseBranchPrefix = "refs/heads/release/" + return if (config.branch.startsWith(releaseBranchPrefix)) { + config.branch.removePrefix(releaseBranchPrefix) + } else { + null + } + } + + private fun log(message: String) { + println(message) + logFile?.println(message) + logFile?.flush() + } +}