implemented 04-build-executor.md incl. concurrency amendment: async builds in per-branch worktrees, builds.maxConcurrent, cancellation, live logs; fix .gitignore build/ rule that silently excluded the de.hoennig.gittally.build package (also recovers the step-01 domain files)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ae3ae7aa04
commit
b379bc0a6b
@@ -0,0 +1,27 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Legacy-compatible artifact key naming: sanitized name plus a 12-char SHA-256 prefix,
|
||||
* so keys are filesystem- and URL-safe but still unique for branch names that
|
||||
* sanitize to the same string.
|
||||
*/
|
||||
object ArtifactKeys {
|
||||
fun branchKey(branch: String): String = "${sanitize(branch)}-${sha256Prefix(branch)}"
|
||||
|
||||
fun buildKey(
|
||||
branch: String,
|
||||
startedAt: Instant,
|
||||
): String = "${branchKey(branch)}-${sanitize(startedAt.toString())}-${sha256Prefix("$branch\t$startedAt")}"
|
||||
|
||||
private fun sanitize(value: String): String = value.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
|
||||
private fun sha256Prefix(value: String): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(value.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
.take(12)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Takes over the staging directory of a finished build.
|
||||
* The real store (naming, retention, serving) arrives in step 05.
|
||||
*/
|
||||
interface ArtifactStore {
|
||||
fun persist(
|
||||
build: BuildResult,
|
||||
stagingDir: Path,
|
||||
)
|
||||
}
|
||||
|
||||
/** Placeholder until step 05: logs and leaves the staging directory untouched. */
|
||||
@Component
|
||||
class NoOpArtifactStore : ArtifactStore {
|
||||
private val log = LoggerFactory.getLogger(NoOpArtifactStore::class.java)
|
||||
|
||||
override fun persist(
|
||||
build: BuildResult,
|
||||
stagingDir: Path,
|
||||
) {
|
||||
log.info("artifact store not implemented yet; leaving build output of {} in {}", build.artifactKey, stagingDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import de.hoennig.gittally.git.GitService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Provides an isolated build workspace per branch so multiple branches can build
|
||||
* concurrently without touching the primary checkout or each other.
|
||||
*/
|
||||
fun interface BranchWorkspaces {
|
||||
/** A ready-to-build workspace for [branch] with [commit] checked out. */
|
||||
fun prepare(
|
||||
branch: String,
|
||||
commit: String,
|
||||
repoDir: Path,
|
||||
): Path
|
||||
}
|
||||
|
||||
/**
|
||||
* One reusable git worktree per branch under `.git/gittally/worktrees/<branchKey>`,
|
||||
* checked out detached at the requested commit. Reuse keeps incremental build
|
||||
* caches; the branch's `cleanCommand` decides how much of them survives.
|
||||
*/
|
||||
@Component
|
||||
class GitWorktreeWorkspaces(
|
||||
private val gitService: GitService,
|
||||
) : BranchWorkspaces {
|
||||
private val log = LoggerFactory.getLogger(GitWorktreeWorkspaces::class.java)
|
||||
|
||||
override fun prepare(
|
||||
branch: String,
|
||||
commit: String,
|
||||
repoDir: Path,
|
||||
): Path {
|
||||
val workspace = repoDir.resolve(WORKTREES_DIR).resolve(ArtifactKeys.branchKey(branch))
|
||||
if (Files.exists(workspace.resolve(".git"))) {
|
||||
gitService.checkoutDetached(commit, workspace)
|
||||
} else {
|
||||
gitService.worktreePrune(repoDir)
|
||||
if (Files.exists(workspace)) {
|
||||
log.warn("removing broken workspace of branch {}: {}", branch, workspace)
|
||||
workspace.toFile().deleteRecursively()
|
||||
}
|
||||
gitService.worktreeAdd(workspace, commit, repoDir)
|
||||
}
|
||||
return workspace
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val WORKTREES_DIR = ".git/gittally/worktrees"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Configuration
|
||||
class BuildConfiguration {
|
||||
/**
|
||||
* Results file relative to the working directory, matching how `ConfigLoader`
|
||||
* resolves the `.git/gittally/` override file. Nothing is touched until the
|
||||
* first build runs, so the bean is safe outside a git repository.
|
||||
*/
|
||||
@Bean
|
||||
fun buildResultRepository(): BuildResultRepository = FileBuildResultRepository(Paths.get(".git/gittally/build-results.json"))
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import de.hoennig.gittally.config.BranchConfig
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import de.hoennig.gittally.gitea.GiteaClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Runs builds asynchronously: up to `builds.maxConcurrent` branches at the same time
|
||||
* (default 1), but never more than one build per branch. Each branch builds in its
|
||||
* own git worktree via [BranchWorkspaces], never in the primary checkout.
|
||||
* Every status transition is persisted via the [BuildResultRepository], published
|
||||
* to Gitea (non-fatal), and emitted as a [BuildStatusChangedEvent].
|
||||
*/
|
||||
@Service
|
||||
class BuildExecutor(
|
||||
private val repository: BuildResultRepository,
|
||||
private val configLoader: ConfigLoader,
|
||||
private val giteaClient: GiteaClient,
|
||||
private val buildRunner: BuildRunner,
|
||||
private val workspaces: BranchWorkspaces,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(BuildExecutor::class.java)
|
||||
|
||||
/** One serial worker per branch enforces at most one build per branch. */
|
||||
private val branchWorkers = ConcurrentHashMap<String, ExecutorService>()
|
||||
|
||||
/** All accepted, not yet finished builds by artifact key — queued and running. */
|
||||
private val builds = ConcurrentHashMap<String, ActiveBuild>()
|
||||
|
||||
/** Global concurrency limit; sized from `builds.maxConcurrent` on first use. */
|
||||
@Volatile
|
||||
private var slots: Semaphore? = null
|
||||
|
||||
/** The builds currently executing, newest last (queued builds are PENDING in the repository). */
|
||||
fun currentBuilds(): List<RunningBuild> = builds.values.filter { it.running }.map { it.runningBuild }
|
||||
|
||||
/**
|
||||
* Persists a PENDING result and queues the build; returns immediately.
|
||||
* A build of the same branch waits until the branch's previous build finished;
|
||||
* builds of other branches run concurrently while slots are free.
|
||||
*/
|
||||
fun startBuild(
|
||||
branch: String,
|
||||
commit: String,
|
||||
workingDir: Path = Paths.get("."),
|
||||
): RunningBuild {
|
||||
val startedAt = Instant.now()
|
||||
val stagingDir = Files.createTempDirectory("gittally-build-")
|
||||
val runningBuild =
|
||||
RunningBuild(
|
||||
branch = branch,
|
||||
commit = commit,
|
||||
artifactKey = ArtifactKeys.buildKey(branch, startedAt),
|
||||
startedAt = startedAt,
|
||||
stagingDir = stagingDir,
|
||||
liveLogFile = stagingDir.resolve(LIVE_LOG_FILE),
|
||||
)
|
||||
val pending =
|
||||
BuildResult(
|
||||
branch = branch,
|
||||
commit = commit,
|
||||
status = BuildStatus.PENDING,
|
||||
startedAt = startedAt,
|
||||
duration = null,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
)
|
||||
repository.append(pending)
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(pending))
|
||||
val build = ActiveBuild(runningBuild, workingDir)
|
||||
builds[runningBuild.artifactKey] = build
|
||||
publishGiteaStatus(build, BuildStatus.PENDING, duration = null)
|
||||
branchWorkers
|
||||
.computeIfAbsent(branch) { serialWorker(it) }
|
||||
.submit { execute(build) }
|
||||
return runningBuild
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests cancellation of the build with [artifactKey] and terminates its process
|
||||
* tree (TERM, wait, KILL — like legacy `terminate_process_tree`). A queued build
|
||||
* is recorded as CANCELLED once its worker picks it up.
|
||||
* Returns false when no such build is queued or running.
|
||||
*/
|
||||
fun cancel(artifactKey: String): Boolean {
|
||||
val build = builds[artifactKey] ?: return false
|
||||
build.cancelled.set(true)
|
||||
build.process?.let { destroyProcessTree(it) }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun execute(build: ActiveBuild) {
|
||||
var slot: Semaphore? = null
|
||||
var finalStatus = BuildStatus.FAILED
|
||||
try {
|
||||
slot = slotsFor(build.workingDir)
|
||||
slot.acquire()
|
||||
if (build.cancelled.get()) {
|
||||
finalStatus = BuildStatus.CANCELLED
|
||||
return
|
||||
}
|
||||
build.running = true
|
||||
transition(build, BuildStatus.RUNNING, duration = null)
|
||||
val workspace =
|
||||
workspaces.prepare(
|
||||
branch = build.runningBuild.branch,
|
||||
commit = build.runningBuild.commit,
|
||||
repoDir = build.workingDir,
|
||||
)
|
||||
val exitCode = runBuildCommands(build, workspace)
|
||||
finalStatus =
|
||||
when {
|
||||
build.cancelled.get() -> BuildStatus.CANCELLED
|
||||
exitCode == 0 -> BuildStatus.SUCCESS
|
||||
else -> BuildStatus.FAILED
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
finalStatus = if (build.cancelled.get()) BuildStatus.CANCELLED else BuildStatus.FAILED
|
||||
log.error("build of branch {} crashed", build.runningBuild.branch, e)
|
||||
appendToLiveLog(build, "\nbuild crashed: ${e.message}\n")
|
||||
} finally {
|
||||
val duration = Duration.between(build.runningBuild.startedAt, Instant.now())
|
||||
val result = transition(build, finalStatus, duration)
|
||||
try {
|
||||
artifactStore.persist(result, build.runningBuild.stagingDir)
|
||||
} catch (e: Exception) {
|
||||
log.warn("could not persist artifacts of {}: {}", result.artifactKey, e.message)
|
||||
}
|
||||
builds.remove(build.runningBuild.artifactKey)
|
||||
slot?.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The semaphore is sized once from the first build's config;
|
||||
* changing `builds.maxConcurrent` requires a restart.
|
||||
*/
|
||||
private fun slotsFor(workingDir: Path): Semaphore {
|
||||
slots?.let { return it }
|
||||
synchronized(this) {
|
||||
slots?.let { return it }
|
||||
val maxConcurrent =
|
||||
configLoader
|
||||
.load(workingDir)
|
||||
.builds.maxConcurrent
|
||||
.coerceAtLeast(1)
|
||||
return Semaphore(maxConcurrent, true).also { slots = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun serialWorker(branch: String): ExecutorService =
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "gittally-build-${ArtifactKeys.branchKey(branch)}").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
private fun runBuildCommands(
|
||||
build: ActiveBuild,
|
||||
workspace: Path,
|
||||
): Int {
|
||||
val branchConfig = branchConfig(build.runningBuild.branch, build.workingDir)
|
||||
val stagingDir = build.runningBuild.stagingDir
|
||||
Files.newOutputStream(stagingDir.resolve(branchConfig.stdoutLog)).use { stdoutLog ->
|
||||
Files.newOutputStream(stagingDir.resolve(branchConfig.stderrLog)).use { stderrLog ->
|
||||
Files.newOutputStream(build.runningBuild.liveLogFile).use { liveLog ->
|
||||
writeLiveLogHeader(liveLog, build.runningBuild, branchConfig, workspace)
|
||||
if (branchConfig.cleanCommand.isNotBlank()) {
|
||||
val cleanExitCode = runCommand(build, branchConfig.cleanCommand, workspace, stdoutLog, stderrLog, liveLog)
|
||||
if (cleanExitCode != 0) {
|
||||
return cleanExitCode
|
||||
}
|
||||
}
|
||||
if (build.cancelled.get()) {
|
||||
return CANCELLED_EXIT_CODE
|
||||
}
|
||||
return runCommand(build, branchConfig.buildCommand, workspace, stdoutLog, stderrLog, liveLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCommand(
|
||||
build: ActiveBuild,
|
||||
command: String,
|
||||
workspace: Path,
|
||||
stdoutLog: OutputStream,
|
||||
stderrLog: OutputStream,
|
||||
liveLog: OutputStream,
|
||||
): Int {
|
||||
val process = buildRunner.start(command, workspace, mapOf("branch" to build.runningBuild.branch))
|
||||
build.process = process
|
||||
if (build.cancelled.get()) {
|
||||
destroyProcessTree(process)
|
||||
}
|
||||
val stdoutPump = pump(process.inputStream, stdoutLog, liveLog)
|
||||
val stderrPump = pump(process.errorStream, stderrLog, liveLog)
|
||||
try {
|
||||
return process.waitFor()
|
||||
} finally {
|
||||
build.process = null
|
||||
stdoutPump.join(PUMP_DRAIN_TIMEOUT_MILLIS)
|
||||
stderrPump.join(PUMP_DRAIN_TIMEOUT_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies process output to both sinks as it arrives, flushing so the live log grows during the build. */
|
||||
private fun pump(
|
||||
input: InputStream,
|
||||
vararg sinks: OutputStream,
|
||||
): Thread =
|
||||
thread(isDaemon = true, name = "gittally-build-log") {
|
||||
val buffer = ByteArray(8192)
|
||||
try {
|
||||
while (true) {
|
||||
val length = input.read(buffer)
|
||||
if (length < 0) {
|
||||
break
|
||||
}
|
||||
for (sink in sinks) {
|
||||
synchronized(sink) {
|
||||
sink.write(buffer, 0, length)
|
||||
sink.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// the stream closes when the process dies; nothing left to copy
|
||||
}
|
||||
}
|
||||
|
||||
/** TERM to all descendants and the root, wait up to 2s, then KILL survivors. */
|
||||
private fun destroyProcessTree(process: Process) {
|
||||
val root = process.toHandle()
|
||||
val tree = root.descendants().toList() + root
|
||||
tree.forEach { it.destroy() }
|
||||
val deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos()
|
||||
while (tree.any { it.isAlive } && System.nanoTime() < deadline) {
|
||||
Thread.sleep(50)
|
||||
}
|
||||
tree.filter { it.isAlive }.forEach { it.destroyForcibly() }
|
||||
}
|
||||
|
||||
private fun transition(
|
||||
build: ActiveBuild,
|
||||
status: BuildStatus,
|
||||
duration: Duration?,
|
||||
): BuildResult {
|
||||
val runningBuild = build.runningBuild
|
||||
val updated =
|
||||
repository.updateByArtifactKey(runningBuild.artifactKey) {
|
||||
it.copy(status = status, duration = duration ?: it.duration)
|
||||
} ?: BuildResult(
|
||||
branch = runningBuild.branch,
|
||||
commit = runningBuild.commit,
|
||||
status = status,
|
||||
startedAt = runningBuild.startedAt,
|
||||
duration = duration,
|
||||
artifactKey = runningBuild.artifactKey,
|
||||
).also { repository.append(it) }
|
||||
eventPublisher.publishEvent(BuildStatusChangedEvent(updated))
|
||||
publishGiteaStatus(build, status, duration)
|
||||
return updated
|
||||
}
|
||||
|
||||
private fun publishGiteaStatus(
|
||||
build: ActiveBuild,
|
||||
status: BuildStatus,
|
||||
duration: Duration?,
|
||||
) {
|
||||
try {
|
||||
giteaClient.publishStatus(
|
||||
sha = build.runningBuild.commit,
|
||||
status = status,
|
||||
description = description(status, duration),
|
||||
targetUrl = null,
|
||||
workingDir = build.workingDir,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.warn("could not publish Gitea status {} for {}: {}", status, build.runningBuild.commit, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun description(
|
||||
status: BuildStatus,
|
||||
duration: Duration?,
|
||||
): String {
|
||||
val after = duration?.let { " after ${formatDuration(it)}" } ?: ""
|
||||
return when (status) {
|
||||
BuildStatus.PENDING -> "build queued"
|
||||
BuildStatus.RUNNING -> "build running"
|
||||
BuildStatus.SUCCESS -> "build succeeded$after"
|
||||
BuildStatus.FAILED -> "build failed$after"
|
||||
BuildStatus.INTERRUPTED -> "build interrupted$after"
|
||||
BuildStatus.CANCELLED -> "build cancelled$after"
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(duration: Duration): String = "%02d:%02d".format(duration.toMinutes(), duration.toSecondsPart())
|
||||
|
||||
private fun writeLiveLogHeader(
|
||||
liveLog: OutputStream,
|
||||
runningBuild: RunningBuild,
|
||||
branchConfig: BranchConfig,
|
||||
workspace: Path,
|
||||
) {
|
||||
val header =
|
||||
buildString {
|
||||
appendLine("building branch: ${runningBuild.branch}")
|
||||
appendLine("commit: ${runningBuild.commit}")
|
||||
appendLine("started: ${runningBuild.startedAt}")
|
||||
appendLine("workspace: $workspace")
|
||||
appendLine("build command: ${branchConfig.buildCommand}")
|
||||
if (branchConfig.cleanCommand.isNotBlank()) {
|
||||
appendLine("clean command: ${branchConfig.cleanCommand}")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
synchronized(liveLog) {
|
||||
liveLog.write(header.toByteArray())
|
||||
liveLog.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendToLiveLog(
|
||||
build: ActiveBuild,
|
||||
message: String,
|
||||
) {
|
||||
try {
|
||||
Files.writeString(
|
||||
build.runningBuild.liveLogFile,
|
||||
message,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND,
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
log.warn("could not append to live log of {}: {}", build.runningBuild.artifactKey, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun branchConfig(
|
||||
branch: String,
|
||||
workingDir: Path,
|
||||
): BranchConfig {
|
||||
val branches = configLoader.load(workingDir).branches
|
||||
return branches[branch] ?: branches["default"] ?: BranchConfig()
|
||||
}
|
||||
|
||||
private class ActiveBuild(
|
||||
val runningBuild: RunningBuild,
|
||||
val workingDir: Path,
|
||||
) {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
|
||||
@Volatile
|
||||
var running = false
|
||||
|
||||
@Volatile
|
||||
var process: Process? = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Name of the combined live log inside the staging directory. */
|
||||
const val LIVE_LOG_FILE = "build.log"
|
||||
private const val CANCELLED_EXIT_CODE = 130
|
||||
private const val PUMP_DRAIN_TIMEOUT_MILLIS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
data class BuildResult(
|
||||
val branch: String,
|
||||
val commit: String,
|
||||
val status: BuildStatus,
|
||||
val startedAt: Instant,
|
||||
val duration: Duration? = null,
|
||||
val artifactKey: String,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
interface BuildResultRepository {
|
||||
fun append(result: BuildResult)
|
||||
|
||||
/** Applies [transform] to the newest entry of [branch]; returns null if the branch has no entries. */
|
||||
fun updateLatest(
|
||||
branch: String,
|
||||
transform: (BuildResult) -> BuildResult,
|
||||
): BuildResult?
|
||||
|
||||
/** Applies [transform] to the entry with [artifactKey]; returns null if no entry matches. */
|
||||
fun updateByArtifactKey(
|
||||
artifactKey: String,
|
||||
transform: (BuildResult) -> BuildResult,
|
||||
): BuildResult?
|
||||
|
||||
fun latestFor(branch: String): BuildResult?
|
||||
|
||||
/** The newest entry of each branch, newest first. */
|
||||
fun latestPerBranch(): List<BuildResult>
|
||||
|
||||
/** All entries, newest first. */
|
||||
fun history(): List<BuildResult>
|
||||
|
||||
/** Removes all entries with the given artifact key; returns true if anything was removed. */
|
||||
fun delete(artifactKey: String): Boolean
|
||||
|
||||
/**
|
||||
* Startup recovery: RUNNING entries and PENDING entries superseded by a newer entry
|
||||
* of the same branch become INTERRUPTED. Returns the changed entries.
|
||||
*/
|
||||
fun markStaleRunningAsInterrupted(): List<BuildResult>
|
||||
|
||||
/**
|
||||
* Keeps the newest [retentionPerBranch] entries per branch and drops entries of branches
|
||||
* not contained in [originBranches]. Returns the removed entries.
|
||||
*/
|
||||
fun prune(
|
||||
originBranches: Collection<String>,
|
||||
retentionPerBranch: Int,
|
||||
): List<BuildResult>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Starts a single build or clean command and hands the [Process] back to the caller,
|
||||
* which owns log streaming and process-tree termination.
|
||||
* Native shell execution for now; a Docker runner can plug in later (step 11).
|
||||
*/
|
||||
interface BuildRunner {
|
||||
fun start(
|
||||
command: String,
|
||||
workingDir: Path,
|
||||
environment: Map<String, String>,
|
||||
): Process
|
||||
}
|
||||
|
||||
@Component
|
||||
class ProcessBuildRunner : BuildRunner {
|
||||
override fun start(
|
||||
command: String,
|
||||
workingDir: Path,
|
||||
environment: Map<String, String>,
|
||||
): Process {
|
||||
val processBuilder = ProcessBuilder("bash", "-c", command)
|
||||
processBuilder.directory(workingDir.toFile())
|
||||
processBuilder.environment().putAll(environment)
|
||||
return processBuilder.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
enum class BuildStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
INTERRUPTED,
|
||||
CANCELLED,
|
||||
;
|
||||
|
||||
val isTerminal: Boolean
|
||||
get() = this != PENDING && this != RUNNING
|
||||
|
||||
val isRestartable: Boolean
|
||||
get() = this == PENDING || this == RUNNING || this == INTERRUPTED
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.SerializationFeature
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* Stores build results as a JSON file, e.g. `.git/gittally/build-results.json`.
|
||||
* Writes are atomic (temp file + atomic move) so readers never see partial content.
|
||||
*/
|
||||
class FileBuildResultRepository(
|
||||
private val file: Path,
|
||||
) : BuildResultRepository {
|
||||
private val log = LoggerFactory.getLogger(FileBuildResultRepository::class.java)
|
||||
private val lock = Any()
|
||||
|
||||
private val json =
|
||||
ObjectMapper()
|
||||
.registerKotlinModule()
|
||||
.registerModule(JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
|
||||
.configure(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false)
|
||||
.configure(SerializationFeature.INDENT_OUTPUT, true)
|
||||
|
||||
override fun append(result: BuildResult) {
|
||||
synchronized(lock) {
|
||||
save(load() + result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateLatest(
|
||||
branch: String,
|
||||
transform: (BuildResult) -> BuildResult,
|
||||
): BuildResult? {
|
||||
synchronized(lock) {
|
||||
val results = load()
|
||||
val index = indexOfLatest(results, branch) ?: return null
|
||||
val updated = transform(results[index])
|
||||
save(results.toMutableList().also { it[index] = updated })
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateByArtifactKey(
|
||||
artifactKey: String,
|
||||
transform: (BuildResult) -> BuildResult,
|
||||
): BuildResult? {
|
||||
synchronized(lock) {
|
||||
val results = load()
|
||||
val index = results.indexOfLast { it.artifactKey == artifactKey }
|
||||
if (index < 0) {
|
||||
return null
|
||||
}
|
||||
val updated = transform(results[index])
|
||||
save(results.toMutableList().also { it[index] = updated })
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
override fun latestFor(branch: String): BuildResult? {
|
||||
val results = load()
|
||||
return indexOfLatest(results, branch)?.let { results[it] }
|
||||
}
|
||||
|
||||
override fun latestPerBranch(): List<BuildResult> =
|
||||
load()
|
||||
.groupBy { it.branch }
|
||||
.values
|
||||
.map { entries -> entries.reduce(::laterOf) }
|
||||
.sortedByDescending { it.startedAt }
|
||||
|
||||
override fun history(): List<BuildResult> = load().sortedByDescending { it.startedAt }
|
||||
|
||||
override fun delete(artifactKey: String): Boolean {
|
||||
synchronized(lock) {
|
||||
val results = load()
|
||||
val remaining = results.filterNot { it.artifactKey == artifactKey }
|
||||
if (remaining.size == results.size) {
|
||||
return false
|
||||
}
|
||||
save(remaining)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun markStaleRunningAsInterrupted(): List<BuildResult> {
|
||||
synchronized(lock) {
|
||||
val results = load()
|
||||
val changed = mutableListOf<BuildResult>()
|
||||
val updated =
|
||||
results.map { result ->
|
||||
val superseded =
|
||||
results.any { it.branch == result.branch && it.startedAt.isAfter(result.startedAt) }
|
||||
if (result.status == BuildStatus.RUNNING ||
|
||||
(result.status == BuildStatus.PENDING && superseded)
|
||||
) {
|
||||
result.copy(status = BuildStatus.INTERRUPTED).also { changed += it }
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
if (changed.isNotEmpty()) {
|
||||
save(updated)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
override fun prune(
|
||||
originBranches: Collection<String>,
|
||||
retentionPerBranch: Int,
|
||||
): List<BuildResult> {
|
||||
synchronized(lock) {
|
||||
val results = load()
|
||||
val originBranchSet = originBranches.toSet()
|
||||
val kept =
|
||||
results
|
||||
.filter { it.branch in originBranchSet }
|
||||
.groupBy { it.branch }
|
||||
.values
|
||||
.flatMap { entries ->
|
||||
entries
|
||||
.sortedByDescending { it.startedAt }
|
||||
.take(retentionPerBranch.coerceAtLeast(0))
|
||||
}.toSet()
|
||||
val removed = results.filterNot { it in kept }
|
||||
if (removed.isNotEmpty()) {
|
||||
save(results.filter { it in kept })
|
||||
}
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
/** The index of the newest entry of [branch]; on equal timestamps the later appended entry wins. */
|
||||
private fun indexOfLatest(
|
||||
results: List<BuildResult>,
|
||||
branch: String,
|
||||
): Int? {
|
||||
var latest: Int? = null
|
||||
results.forEachIndexed { index, result ->
|
||||
if (result.branch == branch &&
|
||||
(latest == null || !result.startedAt.isBefore(results[latest].startedAt))
|
||||
) {
|
||||
latest = index
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
private fun laterOf(
|
||||
first: BuildResult,
|
||||
second: BuildResult,
|
||||
): BuildResult = if (second.startedAt.isBefore(first.startedAt)) first else second
|
||||
|
||||
private fun load(): List<BuildResult> {
|
||||
if (!Files.exists(file)) {
|
||||
return emptyList()
|
||||
}
|
||||
return try {
|
||||
json.readValue<List<BuildResult>>(file.toFile())
|
||||
} catch (e: Exception) {
|
||||
log.warn("ignoring unreadable build results file {}: {}", file, e.message)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(results: List<BuildResult>) {
|
||||
Files.createDirectories(file.parent)
|
||||
val tempFile = Files.createTempFile(file.parent, file.fileName.toString(), ".tmp")
|
||||
try {
|
||||
json.writeValue(tempFile.toFile(), results)
|
||||
Files.move(tempFile, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.hoennig.gittally.build
|
||||
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
|
||||
/** Handle to a build accepted by the [BuildExecutor]; log paths become valid once the build runs. */
|
||||
data class RunningBuild(
|
||||
val branch: String,
|
||||
val commit: String,
|
||||
val artifactKey: String,
|
||||
val startedAt: Instant,
|
||||
/** Working directory for build output; handed to the [ArtifactStore] when the build ends. */
|
||||
val stagingDir: Path,
|
||||
/** Combined stdout+stderr log, written live while the build runs. */
|
||||
val liveLogFile: Path,
|
||||
)
|
||||
|
||||
/** Published via Spring's `ApplicationEventPublisher` on every persisted status transition. */
|
||||
data class BuildStatusChangedEvent(
|
||||
val result: BuildResult,
|
||||
)
|
||||
Reference in New Issue
Block a user