Add CiCommand.kt, CiConfig.kt, and CiRunner.kt for CI daemon implementation
All checks were successful
SteamWarCI Build successful
All checks were successful
SteamWarCI Build successful
This commit is contained in:
@@ -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<String>) = SteamWar()
|
||||
.subcommands(
|
||||
CiCommand(),
|
||||
DatabaseCommand().subcommands(InfoCommand(), ResetCommand()),
|
||||
UserCommand().subcommands(UserInfoCommand(), UserSearchCommand()),
|
||||
DevCommand(),
|
||||
|
||||
57
src/main/kotlin/commands/ci/CiCommand.kt
Normal file
57
src/main/kotlin/commands/ci/CiCommand.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/main/kotlin/commands/ci/CiConfig.kt
Normal file
152
src/main/kotlin/commands/ci/CiConfig.kt
Normal 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"
|
||||
}
|
||||
162
src/main/kotlin/commands/ci/CiRunner.kt
Normal file
162
src/main/kotlin/commands/ci/CiRunner.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user