diff --git a/pom.xml b/pom.xml index 31f1d47..a4c89ba 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,7 @@ io.quarkus - quarkus-resteasy-jsonb + quarkus-resteasy-jackson org.jetbrains.kotlin diff --git a/src/main/kotlin/de/joshavg/Builds.kt b/src/main/kotlin/alfred/Builds.kt similarity index 85% rename from src/main/kotlin/de/joshavg/Builds.kt rename to src/main/kotlin/alfred/Builds.kt index e0d17a0..10aff1b 100644 --- a/src/main/kotlin/de/joshavg/Builds.kt +++ b/src/main/kotlin/alfred/Builds.kt @@ -1,5 +1,8 @@ -package de.joshavg +package alfred +import alfred.running.LogFile +import com.fasterxml.jackson.annotation.JsonIgnore +import io.quarkus.runtime.annotations.RegisterForReflection import org.eclipse.microprofile.config.inject.ConfigProperty import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -37,8 +40,7 @@ class Builds( val env = props .entries .filter { it.key.toString() == it.key.toString().toUpperCase() } - .map { Pair(it.key.toString(), it.value.toString()) } - .toMap() + .associate { Pair(it.key.toString(), it.value.toString()) } return BuildConfig( user = props.getProperty("user"), @@ -50,7 +52,7 @@ class Builds( ) } - fun createLogFile(build: BuildId): File { + fun createLogFile(build: BuildId): LogFile { val nowStr = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) val fileName = "$build.$nowStr.log" @@ -67,15 +69,16 @@ class UnknownBuild(val build: BuildId) : RuntimeException() { "unknown build: $build" } +@RegisterForReflection data class BuildConfig( val user: String, val workspace: String, + @field:JsonIgnore val apikey: String, val script: String?, val gitRepo: String?, + @field:JsonIgnore val env: Map -) { - fun asPublic() = copy(apikey = "", env = emptyMap()) -} +) typealias BuildId = String diff --git a/src/main/kotlin/de/joshavg/Endpoints.kt b/src/main/kotlin/alfred/http/Endpoints.kt similarity index 74% rename from src/main/kotlin/de/joshavg/Endpoints.kt rename to src/main/kotlin/alfred/http/Endpoints.kt index 0a36c83..dd6d53d 100644 --- a/src/main/kotlin/de/joshavg/Endpoints.kt +++ b/src/main/kotlin/alfred/http/Endpoints.kt @@ -1,24 +1,33 @@ -package de.joshavg +package alfred.http +import alfred.* +import alfred.running.GitRunner +import alfred.running.Handles +import alfred.running.ScriptRunner +import javax.enterprise.inject.Default import javax.inject.Inject import javax.ws.rs.* import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response import javax.ws.rs.core.Response.Status.PRECONDITION_REQUIRED -@Path("builds") -class Endpoints( - @Inject - val builds: Builds, - @Inject - val security: Security, - @Inject - val scriptRunner: ScriptRunner, - @Inject - val gitRunner: GitRunner, - @Inject - val handles: Handles -) { +@Path("build") +class Endpoints { + + @field:Inject + lateinit var builds: Builds + + @field:Inject + lateinit var security: Security + + @field:Inject + lateinit var scriptRunner: ScriptRunner + + @field:Inject + lateinit var gitRunner: GitRunner + + @field:Inject + lateinit var handles: Handles @GET @Path("{build}/info") @@ -29,7 +38,7 @@ class Endpoints( @QueryParam("key") key: String? ) = security.requireKey(build, key) { - it.asPublic() + it } @POST diff --git a/src/main/kotlin/de/joshavg/Security.kt b/src/main/kotlin/alfred/http/Security.kt similarity index 79% rename from src/main/kotlin/de/joshavg/Security.kt rename to src/main/kotlin/alfred/http/Security.kt index f98804d..774bf52 100644 --- a/src/main/kotlin/de/joshavg/Security.kt +++ b/src/main/kotlin/alfred/http/Security.kt @@ -1,14 +1,17 @@ -package de.joshavg +package alfred.http +import alfred.BuildConfig +import alfred.BuildId +import alfred.Builds import javax.enterprise.context.ApplicationScoped import javax.inject.Inject import javax.ws.rs.core.Response @ApplicationScoped -class Security( - @Inject - val builds: Builds -) { +class Security { + + @field:Inject + lateinit var builds: Builds fun requireKey(build: BuildId, apikey: String?, block: (BuildConfig) -> Any): Response { val buildConfig = builds.buildConfig(build) diff --git a/src/main/kotlin/alfred/running/GitRunner.kt b/src/main/kotlin/alfred/running/GitRunner.kt new file mode 100644 index 0000000..e4a26a0 --- /dev/null +++ b/src/main/kotlin/alfred/running/GitRunner.kt @@ -0,0 +1,154 @@ +package alfred.running + +import alfred.BuildConfig +import alfred.BuildId +import alfred.Builds +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* +import java.util.concurrent.TimeUnit +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject + +@ApplicationScoped +class GitRunner { + + @field:Inject + lateinit var builds: Builds + + @field:Inject + lateinit var handles: Handles + + val scriptsDir = ".alfred" + + fun run(buildId: BuildId, rev: String): ProcessInfo { + val config = builds.buildConfig(buildId) + val logFile = builds.createLogFile(buildId) + + logger.info("preparing process for build $buildId with config $config") + logger.info("log file: $logFile") + + val ctx = BuildContext( + buildId = buildId, + config = config, + wsId = UUID.randomUUID().toString(), + logFile = logFile, + rev = rev + ) + + logFile.header(buildId, rev, ctx.workspace) + + assertEmptyWorkspace(ctx) + + Thread { + try { + clone(ctx) + execScripts(ctx) + } finally { + deleteWorkspace(ctx) + logFile.footer() + } + }.start() + + return ProcessInfo(-1, logFile) + } + + private fun clone(ctx: BuildContext) { + logger.info("build ${ctx.buildId}: cloning ${ctx.config.gitRepo} into ${ctx.workspace}") + + val process = processBuilder(ctx.config, ctx.logFile, "") + .command("git", "clone", ctx.config.gitRepo, ".") + .directory(ctx.workspace.toFile()) + .start() + handles.add(Handle(process.toHandle(), ctx.buildId)) + val cloneSuccess = process.waitFor(30, TimeUnit.SECONDS) + + logger.info("build ${ctx.buildId}: checkout rev ${ctx.rev}") + processBuilder(ctx.config, ctx.logFile, "") + .command("git", "checkout", ctx.rev) + .directory(ctx.workspace.toFile()) + .start() + .waitFor() + + if (!cloneSuccess) { + throw FailedToClone(ctx.buildId, ctx.config.gitRepo ?: "[no repo configured]") + } + } + + private fun execScripts(ctx: BuildContext) { + val scriptFiles = listOf("pre.sh", "job.sh", "post.sh") + + logger.info("build ${ctx.buildId}: looking for scripts $scriptFiles in $scriptsDir/") + + scriptFiles.forEach { script -> + if (shFile(ctx, script).exists()) { + logger.info("build ${ctx.buildId}: found script $script, running it") + + ctx.logFile.append("\nRunning build file: $script\n") + + val scriptProcess = processBuilder(ctx.config, ctx.logFile, ctx.rev) + .command("$scriptsDir/$script") + .directory(ctx.workspace.toFile()) + .start() + handles.add(Handle(scriptProcess.toHandle(), ctx.buildId)) + + val ret = scriptProcess.waitFor() + logger.info("build ${ctx.buildId}: script $script returned $ret") + + ctx.logFile.append("\n$script returned $ret\n") + } else { + ctx.logFile.append("\nBuild file $scriptsDir/$script not found\n") + } + } + } + + private fun shFile(ctx: BuildContext, name: String) = + ctx.workspace.resolve(scriptsDir).resolve(name).toFile() + + private fun deleteWorkspace(ctx: BuildContext) { + ctx.workspace.toFile().deleteRecursively() + } + + private fun assertEmptyWorkspace(config: BuildContext) { + val workspace = config.workspace.toFile() + + if (!workspace.exists()) { + workspace.mkdirs() + } + + val wsList = workspace.list() + if (wsList != null && wsList.isNotEmpty()) { + throw WorkspaceIsNotEmpty(config.workspace.toString()) + } + } + + val logger: Logger = LoggerFactory.getLogger(this::class.java) + +} + +data class BuildContext( + val config: BuildConfig, + val wsId: String, + val logFile: LogFile, + val rev: String, + val buildId: BuildId +) { + val workspace: Path = Paths.get(config.workspace, wsId) +} + +class WorkspaceIsNotEmpty( + private val ws: String +) : RuntimeException() { + override fun toString() = + "${this::class}: workspace $ws is not empty" +} + +class FailedToClone( + private val buildId: BuildId, + private val gitRepo: String +) : RuntimeException() { + override fun toString() = + "${this::class}: failed to clone $gitRepo for build id $buildId" +} diff --git a/src/main/kotlin/de/joshavg/Handles.kt b/src/main/kotlin/alfred/running/Handles.kt similarity index 78% rename from src/main/kotlin/de/joshavg/Handles.kt rename to src/main/kotlin/alfred/running/Handles.kt index e63f145..e6cbd47 100644 --- a/src/main/kotlin/de/joshavg/Handles.kt +++ b/src/main/kotlin/alfred/running/Handles.kt @@ -1,5 +1,7 @@ -package de.joshavg +package alfred.running +import alfred.BuildId +import io.quarkus.runtime.annotations.RegisterForReflection import io.quarkus.scheduler.Scheduled import java.time.Instant import javax.enterprise.context.ApplicationScoped @@ -19,6 +21,7 @@ class Handles { HandleInfo( command = it.command, startedAt = it.startedAt, + user = it.user, alive = it.handle.isAlive ) } @@ -36,10 +39,13 @@ data class Handle( ) { val command: String = handle.info().commandLine().orElse("") val startedAt: Instant? = handle.info().startInstant().orElse(null) + val user: String? = handle.info().user().orElse(null) } +@RegisterForReflection data class HandleInfo( val command: String, val startedAt: Instant?, - val alive: Boolean + val alive: Boolean, + val user: String? ) diff --git a/src/main/kotlin/de/joshavg/ScriptRunner.kt b/src/main/kotlin/alfred/running/ScriptRunner.kt similarity index 60% rename from src/main/kotlin/de/joshavg/ScriptRunner.kt rename to src/main/kotlin/alfred/running/ScriptRunner.kt index bf86929..1c8799d 100644 --- a/src/main/kotlin/de/joshavg/ScriptRunner.kt +++ b/src/main/kotlin/alfred/running/ScriptRunner.kt @@ -1,40 +1,39 @@ -package de.joshavg +package alfred.running +import alfred.BuildId +import alfred.Builds +import alfred.http.Security import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File -import java.time.ZonedDateTime +import java.nio.file.Paths import javax.enterprise.context.ApplicationScoped import javax.inject.Inject @ApplicationScoped -class ScriptRunner( - @Inject - val builds: Builds, - @Inject - val handles: Handles -) { +class ScriptRunner { + + @field:Inject + lateinit var builds: Builds + + @field:Inject + lateinit var handles: Handles fun run(buildId: BuildId, rev: String): ProcessInfo { val config = builds.buildConfig(buildId) val logFile = builds.createLogFile(buildId) - logger.info("preparing process for build $buildId with config ${config.asPublic()}") + logger.info("preparing process for build $buildId with config $config") logger.info("log file: $logFile") - logFileHeader(logFile, buildId, rev) + logFile.header(buildId, rev, Paths.get(config.workspace)) - val process = ProcessBuilder(config.script) + val process = processBuilder(config, logFile, rev) + .command(config.script) .directory(File(config.workspace)) - .redirectOutput(logFile) - .redirectError(logFile) - .apply { - environment().putAll(config.env) - environment()["ALFRED_LOG_FILE"] = logFile.absolutePath - environment()["ALFRED_REV"] = rev - } .start() handles.add(Handle(process.toHandle(), buildId)) + process.onExit().whenComplete { _, _ -> logFile.footer() } val pid = process.pid() diff --git a/src/main/kotlin/alfred/running/process.kt b/src/main/kotlin/alfred/running/process.kt new file mode 100644 index 0000000..499c967 --- /dev/null +++ b/src/main/kotlin/alfred/running/process.kt @@ -0,0 +1,40 @@ +package alfred.running + +import alfred.BuildConfig +import alfred.BuildId +import java.io.File +import java.nio.file.Path +import java.time.ZonedDateTime + +fun processBuilder(config: BuildConfig, logFile: File, rev: String): ProcessBuilder = + ProcessBuilder() + .redirectOutput(ProcessBuilder.Redirect.appendTo(logFile)) + .redirectError(ProcessBuilder.Redirect.appendTo(logFile)) + .apply { + environment().putAll(config.env) + environment()["ALFRED_LOG_FILE"] = logFile.absolutePath + environment()["ALFRED_REV"] = rev + } + +typealias LogFile = File + +fun LogFile.header(buildId: BuildId, rev: String, workspace: Path) { + this.append("At your service.") + this.append("Build $buildId, rev [$rev] started at ${ZonedDateTime.now()}") + this.append("Workspace directory is: $workspace\n") +} + +fun LogFile.footer() { + this.append("Build finished at ${ZonedDateTime.now()}") +} + +fun LogFile.append(content: String) = + this.appendText( + content.lines().joinToString(separator = "\n") { + if (it.trim().isEmpty()) { + "" + } else { + "Alfred: $it" + } + } + "\n" + ) diff --git a/src/main/kotlin/de/joshavg/GitRunner.kt b/src/main/kotlin/de/joshavg/GitRunner.kt deleted file mode 100644 index b2178b0..0000000 --- a/src/main/kotlin/de/joshavg/GitRunner.kt +++ /dev/null @@ -1,132 +0,0 @@ -package de.joshavg - -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.io.File -import java.nio.file.Paths -import java.util.concurrent.TimeUnit -import javax.enterprise.context.ApplicationScoped -import javax.inject.Inject - -@ApplicationScoped -class GitRunner( - @Inject - val builds: Builds, - @Inject - val handles: Handles -) { - - fun run(buildId: BuildId, rev: String): ProcessInfo { - val config = builds.buildConfig(buildId) - val logFile = builds.createLogFile(buildId) - - logger.info("preparing process for build $buildId with config ${config.asPublic()}") - logger.info("log file: $logFile") - - logFileHeader(logFile, buildId, rev) - - assertEmptyWorkspace(config) - - Thread { - try { - clone(config, logFile, config.gitRepo!!, rev, buildId) - execScripts(config, logFile, rev, buildId) - } finally { - cleanupWorkspace(config, buildId) - } - }.start() - - return ProcessInfo(-1, logFile) - } - - private fun clone( - config: BuildConfig, - logFile: File, - gitRepo: String, - rev: String, - buildId: BuildId - ) { - logger.info("build $buildId: cloning ${config.gitRepo} into ${config.workspace}") - - val process = processBuilder(config, logFile, "") - .command("git", "clone", config.gitRepo, ".") - .start() - handles.add(Handle(process.toHandle(), buildId)) - val cloneSuccess = process.waitFor(30, TimeUnit.SECONDS) - - logger.info("build $buildId: checkout rev $rev") - processBuilder(config, logFile, "") - .command("git", "checkout", rev) - .start() - .waitFor() - - if (!cloneSuccess) { - throw FailedToClone(buildId, gitRepo) - } - } - - private fun execScripts(config: BuildConfig, logFile: File, rev: String, buildId: BuildId) { - val scriptFiles = listOf("pre.sh", "job.sh", "post.sh") - - logger.info("build $buildId: looking for scripts $scriptFiles in .alfred/") - - scriptFiles.forEach { script -> - if (Paths.get(config.workspace, ".alfred", script).toFile().exists()) { - logger.info("build $buildId: found script $script, running it") - - val scriptProcess = processBuilder(config, logFile, rev) - .command(".alfred/$script") - .start() - handles.add(Handle(scriptProcess.toHandle(), buildId)) - - val ret = scriptProcess.waitFor() - logger.info("build $buildId: script $script returned $ret") - - logFile.appendText("\n$script returned $ret\n") - } - } - } - - private fun cleanupWorkspace(config: BuildConfig, buildId: BuildId) { - val wsList = File(config.workspace).list() - if (wsList != null && wsList.isNotEmpty()) { - logger.info("build $buildId: cleaning up workspace ${config.workspace}") - - val ws = File(config.workspace) - ws.deleteRecursively() - ws.mkdirs() - } - } - - private fun assertEmptyWorkspace(config: BuildConfig) { - val workspace = File(config.workspace) - - if (!workspace.exists()) { - workspace.mkdirs() - } - - val wsList = workspace.list() - if (wsList != null && wsList.isNotEmpty()) { - throw WorkspaceIsNotEmpty(config.workspace) - } - } - - val logger: Logger = LoggerFactory.getLogger(this::class.java) - -} - - -class WorkspaceIsNotEmpty( - private val ws: String -) : RuntimeException() { - override fun toString() = - "${this::class}: workspace $ws is not empty" -} - -class FailedToClone( - private val buildId: BuildId, - private val gitRepo: String -) : RuntimeException() { - override fun toString() = - "${this::class}: failed to clone $gitRepo for build id $buildId" -} diff --git a/src/main/kotlin/de/joshavg/process.kt b/src/main/kotlin/de/joshavg/process.kt deleted file mode 100644 index 324eddb..0000000 --- a/src/main/kotlin/de/joshavg/process.kt +++ /dev/null @@ -1,20 +0,0 @@ -package de.joshavg - -import java.io.File -import java.time.ZonedDateTime - -fun processBuilder(config: BuildConfig, logFile: File, rev: String): ProcessBuilder = - ProcessBuilder() - .directory(File(config.workspace)) - .redirectOutput(logFile) - .redirectError(logFile) - .apply { - environment().putAll(config.env) - environment()["ALFRED_LOG_FILE"] = logFile.absolutePath - environment()["ALFRED_REV"] = rev - } - -fun logFileHeader(logFile: File, buildId : BuildId, rev: String) { - logFile.appendText("THE ALFRED!\n") - logFile.appendText("Build $buildId, rev $rev started at ${ZonedDateTime.now()}\n\n") -} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f654e75..96c4169 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,3 +2,5 @@ # key = value ALFRED_HOME=/tmp/alfred +ALFRED_PORT=8080 +quarkus.http.port=${ALFRED_PORT} diff --git a/src/test/kotlin/de/joshavg/ExampleResourceTest.kt b/src/test/kotlin/de/joshavg/ExampleResourceTest.kt deleted file mode 100644 index 0d7fa34..0000000 --- a/src/test/kotlin/de/joshavg/ExampleResourceTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package de.joshavg - -import io.quarkus.test.junit.QuarkusTest -import io.restassured.RestAssured.given -import org.hamcrest.CoreMatchers.`is` -import org.junit.jupiter.api.Test - -@QuarkusTest -open class ExampleResourceTest { - - @Test - fun testHelloEndpoint() { - given() - .`when`().get("/hello") - .then() - .statusCode(200) - .body(`is`("hello")) - } - -} \ No newline at end of file diff --git a/src/test/kotlin/de/joshavg/NativeExampleResourceIT.kt b/src/test/kotlin/de/joshavg/NativeExampleResourceIT.kt deleted file mode 100644 index 9ef3043..0000000 --- a/src/test/kotlin/de/joshavg/NativeExampleResourceIT.kt +++ /dev/null @@ -1,6 +0,0 @@ -package de.joshavg - -import io.quarkus.test.junit.NativeImageTest - -@NativeImageTest -open class NativeExampleResourceIT : ExampleResourceTest() \ No newline at end of file