diff --git a/pom.xml b/pom.xml index 4e0ad79..31f1d47 100644 --- a/pom.xml +++ b/pom.xml @@ -9,16 +9,16 @@ 1 3.8.1 - 1.3.41 + 1.4.30 true 1.8 1.8 UTF-8 UTF-8 - 1.1.0.Final + 1.13.2.Final quarkus-universe-bom io.quarkus - 1.1.0.Final + 1.13.2.Final 2.22.1 @@ -61,15 +61,11 @@ io.quarkus - quarkus-scheduler - - - io.quarkus - quarkus-mailer + quarkus-undertow io.quarkus - quarkus-undertow + quarkus-scheduler @@ -97,8 +93,7 @@ ${surefire-plugin.version} - org.jboss.logmanager.LogManager - + org.jboss.logmanager.LogManager @@ -133,8 +128,7 @@ - + diff --git a/src/main/kotlin/de/joshavg/Builds.kt b/src/main/kotlin/de/joshavg/Builds.kt index ad9af1e..e0d17a0 100644 --- a/src/main/kotlin/de/joshavg/Builds.kt +++ b/src/main/kotlin/de/joshavg/Builds.kt @@ -4,6 +4,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.File +import java.lang.RuntimeException import java.nio.file.Files import java.nio.file.Paths import java.time.LocalDateTime @@ -11,17 +12,11 @@ import java.time.format.DateTimeFormatter import java.util.* import javax.enterprise.context.ApplicationScoped -data class BuildConfig(val exec: String, - val user: String, - val dir: String, - val key: String, - val env: Map) { - fun asPublic() = copy(key = "") -} - @ApplicationScoped -class Builds(@ConfigProperty(name = "ALFRED_HOME") - private val home: String) { +class Builds( + @ConfigProperty(name = "ALFRED_HOME") + private val home: String +) { private val logger: Logger = LoggerFactory.getLogger(this::class.java) @@ -29,8 +24,13 @@ class Builds(@ConfigProperty(name = "ALFRED_HOME") logger.info("ALFRED_HOME is $home") } - fun buildConfig(build: String): BuildConfig { + fun buildConfig(build: BuildId): BuildConfig { val path = Paths.get(home, "builds", "$build.properties") + + if (!path.toFile().exists()) { + throw UnknownBuild(build) + } + val props = Properties() props.load(path.toFile().inputStream()) @@ -41,14 +41,16 @@ class Builds(@ConfigProperty(name = "ALFRED_HOME") .toMap() return BuildConfig( - exec = props.getProperty("exec"), user = props.getProperty("user"), - dir = props.getProperty("dir"), - key = props.getProperty("key") ?: "", - env = env) + workspace = props.getProperty("workspace"), + apikey = props.getProperty("apikey") ?: "", + script = props.getProperty("script"), + gitRepo = props.getProperty("gitrepo"), + env = env + ) } - fun createLogFile(build: String): File { + fun createLogFile(build: BuildId): File { val nowStr = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) val fileName = "$build.$nowStr.log" @@ -59,3 +61,21 @@ class Builds(@ConfigProperty(name = "ALFRED_HOME") } } + +class UnknownBuild(val build: BuildId) : RuntimeException() { + override fun toString() = + "unknown build: $build" +} + +data class BuildConfig( + val user: String, + val workspace: String, + val apikey: String, + val script: String?, + val gitRepo: String?, + val env: Map +) { + fun asPublic() = copy(apikey = "", env = emptyMap()) +} + +typealias BuildId = String diff --git a/src/main/kotlin/de/joshavg/BuildsEndpoint.kt b/src/main/kotlin/de/joshavg/BuildsEndpoint.kt deleted file mode 100644 index a35e310..0000000 --- a/src/main/kotlin/de/joshavg/BuildsEndpoint.kt +++ /dev/null @@ -1,78 +0,0 @@ -package de.joshavg - -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.io.File -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 BuildsEndpoint( - @Inject - val builds: Builds, - @Inject - val security: Security -) { - - val logger: Logger = LoggerFactory.getLogger(this::class.java) - - @GET - @Path("/{build}/info") - @Produces(MediaType.APPLICATION_JSON) - fun info(@PathParam("build") - build: String, - @QueryParam("key") - key: String?) = - security.requireKey(build, key) { - it.asPublic() - } - - @GET - @Path("/{build}/trigger") - @Produces(MediaType.APPLICATION_JSON) - fun trigger(@PathParam("build") - build: String, - @QueryParam("key") - key: String?, - @QueryParam("rev") - rev: String?) = - security.requireKey(build, key) { - val logFile = builds.createLogFile(build) - - if (rev == null) { - return@requireKey Response.status(PRECONDITION_REQUIRED).build() - } - - startBuildThread(build, rev, logFile, it) - - Response.ok(mapOf( - "log" to logFile.absolutePath - )).build() - } - - fun startBuildThread(build: String, rev: String, logFile: File, config: BuildConfig) = - Thread { - logger.info("preparing process for build $build with config ${config.asPublic()}") - logger.info("log file: $logFile") - - val pid = ProcessBuilder(config.exec) - .directory(File(config.dir)) - .redirectOutput(logFile) - .redirectError(logFile) - .apply { - environment().putAll(config.env) - environment()["ALFRED_LOG_FILE"] = logFile.absolutePath - environment()["ALFRED_GIT_REV"] = rev - } - .start() - .toHandle() - .pid() - - logger.info("pid for build $build is: $pid") - }.start() - -} - diff --git a/src/main/kotlin/de/joshavg/Endpoints.kt b/src/main/kotlin/de/joshavg/Endpoints.kt new file mode 100644 index 0000000..0a36c83 --- /dev/null +++ b/src/main/kotlin/de/joshavg/Endpoints.kt @@ -0,0 +1,77 @@ +package de.joshavg + +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 +) { + + @GET + @Path("{build}/info") + @Produces(MediaType.APPLICATION_JSON) + fun info( + @PathParam("build") + build: BuildId, + @QueryParam("key") + key: String? + ) = security.requireKey(build, key) { + it.asPublic() + } + + @POST + @Path("{build}/trigger") + @Produces(MediaType.APPLICATION_JSON) + fun trigger( + @PathParam("build") + build: BuildId, + @QueryParam("key") + key: String?, + @QueryParam("rev") + rev: String? + ) = security.requireKey(build, key) { + if (rev == null) { + return@requireKey Response.status(PRECONDITION_REQUIRED).build() + } + + val info = if (builds.buildConfig(build).script != null) { + scriptRunner.run(build, rev) + } else { + gitRunner.run(build, rev) + } + + Response.ok( + mapOf( + "log" to info.logFile.absolutePath, + "pid" to info.pid + ) + ).build() + } + + @GET + @Path("{build}/handles") + @Produces(MediaType.APPLICATION_JSON) + fun handles( + @PathParam("build") + build: BuildId, + @QueryParam("key") + key: String? + ) = security.requireKey(build, key) { + handles.active(build) + } + +} + diff --git a/src/main/kotlin/de/joshavg/GitRunner.kt b/src/main/kotlin/de/joshavg/GitRunner.kt new file mode 100644 index 0000000..b2178b0 --- /dev/null +++ b/src/main/kotlin/de/joshavg/GitRunner.kt @@ -0,0 +1,132 @@ +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/Handles.kt b/src/main/kotlin/de/joshavg/Handles.kt new file mode 100644 index 0000000..e63f145 --- /dev/null +++ b/src/main/kotlin/de/joshavg/Handles.kt @@ -0,0 +1,45 @@ +package de.joshavg + +import io.quarkus.scheduler.Scheduled +import java.time.Instant +import javax.enterprise.context.ApplicationScoped + +@ApplicationScoped +class Handles { + + private val handles = mutableListOf() + + fun add(handle: Handle) = + handles.add(handle) + + fun active(buildId: BuildId) = + handles + .filter { it.buildId == buildId } + .map { + HandleInfo( + command = it.command, + startedAt = it.startedAt, + alive = it.handle.isAlive + ) + } + + @Scheduled(every = "60s") + fun cleanup() { + handles.removeIf { !it.handle.isAlive } + } + +} + +data class Handle( + val handle: ProcessHandle, + val buildId: BuildId +) { + val command: String = handle.info().commandLine().orElse("") + val startedAt: Instant? = handle.info().startInstant().orElse(null) +} + +data class HandleInfo( + val command: String, + val startedAt: Instant?, + val alive: Boolean +) diff --git a/src/main/kotlin/de/joshavg/ScriptRunner.kt b/src/main/kotlin/de/joshavg/ScriptRunner.kt new file mode 100644 index 0000000..bf86929 --- /dev/null +++ b/src/main/kotlin/de/joshavg/ScriptRunner.kt @@ -0,0 +1,54 @@ +package de.joshavg + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.time.ZonedDateTime +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject + +@ApplicationScoped +class ScriptRunner( + @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) + + val process = ProcessBuilder(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)) + + val pid = process.pid() + + logger.info("pid for build $buildId is: $pid") + + return ProcessInfo(pid = pid, logFile = logFile) + } + + val logger: Logger = LoggerFactory.getLogger(this::class.java) + +} + +data class ProcessInfo( + val pid: Long, + val logFile: File +) + diff --git a/src/main/kotlin/de/joshavg/Security.kt b/src/main/kotlin/de/joshavg/Security.kt index 9faa97f..f98804d 100644 --- a/src/main/kotlin/de/joshavg/Security.kt +++ b/src/main/kotlin/de/joshavg/Security.kt @@ -5,17 +5,19 @@ import javax.inject.Inject import javax.ws.rs.core.Response @ApplicationScoped -class Security(@Inject - val builds: Builds) { +class Security( + @Inject + val builds: Builds +) { - fun requireKey(build: String, key: String?, block: (BuildConfig) -> Any): Response { + fun requireKey(build: BuildId, apikey: String?, block: (BuildConfig) -> Any): Response { val buildConfig = builds.buildConfig(build) - if (buildConfig.key != "" && buildConfig.key != key) { + if (buildConfig.apikey != "" && buildConfig.apikey != apikey) { return Response.status(Response.Status.UNAUTHORIZED).build() } val entity = block(buildConfig) - if(entity is Response) { + if (entity is Response) { return entity } diff --git a/src/main/kotlin/de/joshavg/process.kt b/src/main/kotlin/de/joshavg/process.kt new file mode 100644 index 0000000..324eddb --- /dev/null +++ b/src/main/kotlin/de/joshavg/process.kt @@ -0,0 +1,20 @@ +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