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:
@@ -29,6 +29,9 @@ dependencies {
|
|||||||
implementation("org.jetbrains.exposed:exposed-jdbc:1.0.0-rc-2")
|
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-dao:1.0.0-rc-2")
|
||||||
implementation("org.jetbrains.exposed:exposed-kotlin-datetime: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 {
|
tasks.test {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.steamwar
|
|||||||
import com.github.ajalt.clikt.core.main
|
import com.github.ajalt.clikt.core.main
|
||||||
import com.github.ajalt.clikt.core.subcommands
|
import com.github.ajalt.clikt.core.subcommands
|
||||||
import de.steamwar.commands.SteamWar
|
import de.steamwar.commands.SteamWar
|
||||||
|
import de.steamwar.commands.ci.CiCommand
|
||||||
import de.steamwar.commands.database.DatabaseCommand
|
import de.steamwar.commands.database.DatabaseCommand
|
||||||
import de.steamwar.commands.database.InfoCommand
|
import de.steamwar.commands.database.InfoCommand
|
||||||
import de.steamwar.commands.database.ResetCommand
|
import de.steamwar.commands.database.ResetCommand
|
||||||
@@ -14,6 +15,7 @@ import de.steamwar.commands.user.UserSearchCommand
|
|||||||
|
|
||||||
fun main(args: Array<String>) = SteamWar()
|
fun main(args: Array<String>) = SteamWar()
|
||||||
.subcommands(
|
.subcommands(
|
||||||
|
CiCommand(),
|
||||||
DatabaseCommand().subcommands(InfoCommand(), ResetCommand()),
|
DatabaseCommand().subcommands(InfoCommand(), ResetCommand()),
|
||||||
UserCommand().subcommands(UserInfoCommand(), UserSearchCommand()),
|
UserCommand().subcommands(UserInfoCommand(), UserSearchCommand()),
|
||||||
DevCommand(),
|
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