Add CiCommand.kt, CiConfig.kt, and CiRunner.kt for CI daemon implementation
All checks were successful
SteamWarCI Build successful

This commit is contained in:
2026-01-22 18:14:22 +01:00
parent f098a482a3
commit bad8d762e4
5 changed files with 376 additions and 0 deletions

View File

@@ -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: <oldref> <newref> <branch>")
try {
while (true) {
val input = readlnOrNull() ?: break
val parts = input.split(" ")
if (parts.size < 3) {
echo("Invalid input format. Expected: <oldref> <newref> <branch>", 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()
}
}
}

View File

@@ -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<String>? = null,
val build: List<String>,
val artifacts: Map<String, String>? = null,
val release: List<String>? = 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<GiteaRepoInfo>(response)
return repoInfo.defaultBranch
}
val udir: String get() = "$cidir/$user"
val wdir: String get() = "$udir/$repo"
}

View File

@@ -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()
}
}