implemented 07-server-mode.md: added server profile with REST API for builds, artifacts, live logs, and control tokens; lifecycle management for watcher; controller and service tests
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import java.time.Instant
|
||||
|
||||
/** JSON statuses are lowercase like the legacy TSV/HTML statuses. */
|
||||
val BuildStatus.jsonName: String
|
||||
get() = name.lowercase()
|
||||
|
||||
data class BuildResultDto(
|
||||
val branch: String,
|
||||
val commit: String,
|
||||
val status: String,
|
||||
val startedAt: Instant,
|
||||
val durationSeconds: Long?,
|
||||
val artifactKey: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: BuildResult) =
|
||||
BuildResultDto(
|
||||
branch = result.branch,
|
||||
commit = result.commit,
|
||||
status = result.status.jsonName,
|
||||
startedAt = result.startedAt,
|
||||
durationSeconds = result.duration?.seconds,
|
||||
artifactKey = result.artifactKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of `GET /api/builds/current`; the log grows while the build runs. */
|
||||
data class CurrentBuildDto(
|
||||
val branch: String,
|
||||
val commit: String,
|
||||
val artifactKey: String,
|
||||
val status: String,
|
||||
val startedAt: Instant,
|
||||
val logSize: Long,
|
||||
)
|
||||
|
||||
/** Incremental live-log chunk; poll again with `offset = nextOffset`. */
|
||||
data class LogTailDto(
|
||||
val artifactKey: String,
|
||||
val offset: Long,
|
||||
val nextOffset: Long,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Effective status of a commit: the Gitea status when available, otherwise the
|
||||
* local repository status, otherwise `unknown`. A Gitea failure is an explicit
|
||||
* [giteaError] value — the request answers HTTP 200 and never hangs.
|
||||
*/
|
||||
data class CommitStatusDto(
|
||||
val commit: String,
|
||||
val status: String,
|
||||
val localStatus: String?,
|
||||
val giteaStatus: String?,
|
||||
val giteaError: String?,
|
||||
) {
|
||||
companion object {
|
||||
const val UNKNOWN_STATUS = "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import org.springframework.core.io.FileSystemResource
|
||||
import org.springframework.core.io.Resource
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.MediaTypeFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Streams stored build artifacts. Status pages, JSON, and logs are served with
|
||||
* no-cache headers like legacy, so browsers always see the current build state.
|
||||
*/
|
||||
@RestController
|
||||
class ArtifactFileController(
|
||||
private val artifactStore: ArtifactStore,
|
||||
) {
|
||||
@GetMapping("/artifacts/{artifactKey}/{*path}")
|
||||
fun serve(
|
||||
@PathVariable artifactKey: String,
|
||||
@PathVariable path: String,
|
||||
): ResponseEntity<Resource> {
|
||||
val artifactDir =
|
||||
artifactStore.artifactDir(artifactKey)
|
||||
?: return ResponseEntity.notFound().build()
|
||||
val relativePath = path.removePrefix("/")
|
||||
if (relativePath.isBlank()) {
|
||||
return ResponseEntity.notFound().build()
|
||||
}
|
||||
val file = artifactDir.resolve(relativePath).normalize()
|
||||
if (!file.startsWith(artifactDir) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return ResponseEntity.notFound().build()
|
||||
}
|
||||
val headers = HttpHeaders()
|
||||
headers.contentType = mediaType(file)
|
||||
if (file.extension() in NO_CACHE_EXTENSIONS) {
|
||||
headers.cacheControl = "no-store, max-age=0"
|
||||
headers.pragma = "no-cache"
|
||||
headers.expires = 0
|
||||
}
|
||||
return ResponseEntity.ok().headers(headers).body(FileSystemResource(file))
|
||||
}
|
||||
|
||||
private fun mediaType(file: Path): MediaType =
|
||||
when (file.extension()) {
|
||||
"log" -> MediaType(MediaType.TEXT_PLAIN, Charsets.UTF_8)
|
||||
else ->
|
||||
MediaTypeFactory
|
||||
.getMediaType(file.fileName.toString())
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
}
|
||||
|
||||
private fun Path.extension(): String = fileName.toString().substringAfterLast('.', "").lowercase()
|
||||
|
||||
companion object {
|
||||
private val NO_CACHE_EXTENSIONS = setOf("html", "json", "log")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import de.hoennig.gittally.build.BuildExecutor
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption
|
||||
|
||||
/**
|
||||
* JSON API over build results and running builds, replacing the legacy
|
||||
* `/control/…` endpoints. Mutating endpoints are guarded by the control token
|
||||
* (header [TOKEN_HEADER] or parameter `token`), like the legacy cancel token.
|
||||
*/
|
||||
@RestController
|
||||
class BuildsApiController(
|
||||
private val repository: BuildResultRepository,
|
||||
private val buildExecutor: BuildExecutor,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val controlTokens: ControlTokenService,
|
||||
) {
|
||||
@GetMapping("/api/builds/latest")
|
||||
fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it) }
|
||||
|
||||
@GetMapping("/api/builds/history")
|
||||
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it) }
|
||||
|
||||
/** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */
|
||||
@GetMapping("/api/builds/current")
|
||||
fun current(): List<CurrentBuildDto> {
|
||||
val results = repository.history()
|
||||
return buildExecutor.currentBuilds().map { build ->
|
||||
CurrentBuildDto(
|
||||
branch = build.branch,
|
||||
commit = build.commit,
|
||||
artifactKey = build.artifactKey,
|
||||
status =
|
||||
(results.firstOrNull { it.artifactKey == build.artifactKey }?.status ?: BuildStatus.RUNNING)
|
||||
.jsonName,
|
||||
startedAt = build.startedAt,
|
||||
logSize = liveLogSize(build.liveLogFile),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Incremental live-log fetch of one running build; poll again with `offset = nextOffset`. */
|
||||
@GetMapping("/api/builds/current/{artifactKey}/log")
|
||||
fun currentLog(
|
||||
@PathVariable artifactKey: String,
|
||||
@RequestParam(defaultValue = "0") offset: Long,
|
||||
): ResponseEntity<Any> {
|
||||
val build =
|
||||
buildExecutor.currentBuilds().firstOrNull { it.artifactKey == artifactKey }
|
||||
?: return notFound("no running build with artifact key '$artifactKey'")
|
||||
return ResponseEntity.ok(readLogTail(artifactKey, build.liveLogFile, offset))
|
||||
}
|
||||
|
||||
/** Re-enqueues the branch's last recorded commit, like the legacy `/control/restart`. */
|
||||
@PostMapping("/api/builds/{branch}/restart")
|
||||
fun restart(
|
||||
@PathVariable branch: String,
|
||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||
@RequestParam(name = "token", required = false) paramToken: String?,
|
||||
): ResponseEntity<Any> {
|
||||
rejectBadToken(headerToken ?: paramToken)?.let { return it }
|
||||
val latest =
|
||||
repository.latestFor(branch)
|
||||
?: return notFound("branch '$branch' has no recorded build")
|
||||
val running = buildExecutor.startBuild(branch, latest.commit)
|
||||
return ResponseEntity.accepted().body(
|
||||
BuildResultDto(
|
||||
branch = running.branch,
|
||||
commit = running.commit,
|
||||
status = BuildStatus.PENDING.jsonName,
|
||||
startedAt = running.startedAt,
|
||||
durationSeconds = null,
|
||||
artifactKey = running.artifactKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Cancels by artifact key because multiple builds can run concurrently. */
|
||||
@PostMapping("/api/builds/{artifactKey}/cancel")
|
||||
fun cancel(
|
||||
@PathVariable artifactKey: String,
|
||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||
@RequestParam(name = "token", required = false) paramToken: String?,
|
||||
): ResponseEntity<Any> {
|
||||
rejectBadToken(headerToken ?: paramToken)?.let { return it }
|
||||
if (!buildExecutor.cancel(artifactKey)) {
|
||||
return notFound("no queued or running build with artifact key '$artifactKey'")
|
||||
}
|
||||
return ResponseEntity.accepted().body(mapOf("cancelled" to artifactKey))
|
||||
}
|
||||
|
||||
/** Removes the stored result and its artifact directory, like the legacy `/control/delete`. */
|
||||
@DeleteMapping("/api/builds/{artifactKey}")
|
||||
fun delete(
|
||||
@PathVariable artifactKey: String,
|
||||
@RequestHeader(name = TOKEN_HEADER, required = false) headerToken: String?,
|
||||
@RequestParam(name = "token", required = false) paramToken: String?,
|
||||
): ResponseEntity<Any> {
|
||||
rejectBadToken(headerToken ?: paramToken)?.let { return it }
|
||||
if (!repository.delete(artifactKey)) {
|
||||
return notFound("no build with artifact key '$artifactKey'")
|
||||
}
|
||||
artifactStore.prune(repository.history())
|
||||
return ResponseEntity.ok(mapOf("deleted" to artifactKey))
|
||||
}
|
||||
|
||||
private fun rejectBadToken(submittedToken: String?): ResponseEntity<Any>? =
|
||||
if (controlTokens.matches(submittedToken)) {
|
||||
null
|
||||
} else {
|
||||
ResponseEntity
|
||||
.status(HttpStatus.FORBIDDEN)
|
||||
.body(mapOf("error" to "missing or wrong control token"))
|
||||
}
|
||||
|
||||
private fun notFound(message: String): ResponseEntity<Any> = ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("error" to message))
|
||||
|
||||
private fun liveLogSize(liveLogFile: Path): Long = if (Files.isRegularFile(liveLogFile)) Files.size(liveLogFile) else 0L
|
||||
|
||||
/** At most [MAX_LOG_CHUNK] bytes per response; a chunk may split a multi-byte character. */
|
||||
private fun readLogTail(
|
||||
artifactKey: String,
|
||||
liveLogFile: Path,
|
||||
requestedOffset: Long,
|
||||
): LogTailDto {
|
||||
if (!Files.isRegularFile(liveLogFile)) {
|
||||
return LogTailDto(artifactKey, offset = 0, nextOffset = 0, content = "")
|
||||
}
|
||||
val size = Files.size(liveLogFile)
|
||||
val offset = requestedOffset.coerceIn(0L, size)
|
||||
val length = (size - offset).coerceAtMost(MAX_LOG_CHUNK).toInt()
|
||||
if (length == 0) {
|
||||
return LogTailDto(artifactKey, offset = offset, nextOffset = size, content = "")
|
||||
}
|
||||
FileChannel.open(liveLogFile, StandardOpenOption.READ).use { channel ->
|
||||
channel.position(offset)
|
||||
val buffer = ByteBuffer.allocate(length)
|
||||
while (buffer.hasRemaining() && channel.read(buffer) >= 0) {
|
||||
// keep reading until the requested chunk is complete or the file ends
|
||||
}
|
||||
buffer.flip()
|
||||
val content = String(buffer.array(), 0, buffer.limit(), Charsets.UTF_8)
|
||||
return LogTailDto(artifactKey, offset = offset, nextOffset = offset + buffer.limit(), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TOKEN_HEADER = "X-GitTally-Token"
|
||||
private const val MAX_LOG_CHUNK = 1024L * 1024L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Guards the mutating build endpoints with a shared secret, like the legacy cancel
|
||||
* token. The token is generated once and persisted (mode 600) so operators — and
|
||||
* the step-08 UI, server-side — can read it; delete the file to rotate it.
|
||||
*/
|
||||
class ControlTokenService(
|
||||
private val tokenFile: Path,
|
||||
) {
|
||||
@Synchronized
|
||||
fun token(): String {
|
||||
if (Files.isRegularFile(tokenFile)) {
|
||||
Files
|
||||
.readAllLines(tokenFile)
|
||||
.firstOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { return it }
|
||||
}
|
||||
val token = generateToken()
|
||||
Files.createDirectories(tokenFile.parent)
|
||||
Files.writeString(tokenFile, token + "\n")
|
||||
restrictToOwner(tokenFile)
|
||||
return token
|
||||
}
|
||||
|
||||
/** Constant-time comparison; null or blank never matches. */
|
||||
fun matches(submittedToken: String?): Boolean =
|
||||
!submittedToken.isNullOrBlank() &&
|
||||
MessageDigest.isEqual(submittedToken.toByteArray(), token().toByteArray())
|
||||
|
||||
/** 24 random bytes as hex, like legacy `openssl rand -hex 24`. */
|
||||
private fun generateToken(): String {
|
||||
val bytes = ByteArray(24)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun restrictToOwner(file: Path) {
|
||||
try {
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"))
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// non-POSIX filesystem; the file stays with default permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.nio.file.Paths
|
||||
|
||||
@Configuration
|
||||
class ServerConfiguration {
|
||||
/**
|
||||
* Token file relative to the working directory, matching how `ConfigLoader`
|
||||
* and the `BuildResultRepository` bean resolve their files. Nothing is written
|
||||
* until the first guarded request, so the bean is safe outside a git repository.
|
||||
*/
|
||||
@Bean
|
||||
fun controlTokenService(): ControlTokenService = ControlTokenService(Paths.get(".git/gittally/control-token"))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.watcher.Watcher
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* Starts the watcher poll loop once the server context is ready and stops it on
|
||||
* shutdown. Only in the `server` profile — CLI commands and tests never start
|
||||
* the loop (see [Watcher]).
|
||||
*/
|
||||
@Component
|
||||
@Profile("server")
|
||||
class ServerWatcherLifecycle(
|
||||
private val watcher: Watcher,
|
||||
) {
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
watcher.start()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun onShutdown() {
|
||||
watcher.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.gitea.GiteaClient
|
||||
import de.hoennig.gittally.gitea.GiteaStatusResult
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* Replaces the legacy `/control/status` proxy. The response always answers with
|
||||
* HTTP 200 and an explicit status — a Gitea failure becomes `giteaError` plus the
|
||||
* local fallback (or `unknown`), never a hanging request (the [GiteaClient]
|
||||
* requests time out).
|
||||
*/
|
||||
@RestController
|
||||
class StatusApiController(
|
||||
private val repository: BuildResultRepository,
|
||||
private val giteaClient: GiteaClient,
|
||||
) {
|
||||
@GetMapping("/api/status/{commit}")
|
||||
fun status(
|
||||
@PathVariable commit: String,
|
||||
): ResponseEntity<Any> {
|
||||
if (!COMMIT_PATTERN.matches(commit)) {
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.body(mapOf("error" to "'$commit' is not an abbreviated or full commit hash"))
|
||||
}
|
||||
val localStatus =
|
||||
repository
|
||||
.history()
|
||||
.firstOrNull { it.commit.startsWith(commit, ignoreCase = true) }
|
||||
?.status
|
||||
val giteaResult = giteaClient.readStatus(commit)
|
||||
val giteaStatus = (giteaResult as? GiteaStatusResult.Found)?.status
|
||||
val effective = giteaStatus ?: localStatus
|
||||
return ResponseEntity.ok(
|
||||
CommitStatusDto(
|
||||
commit = commit,
|
||||
status = effective?.jsonName ?: CommitStatusDto.UNKNOWN_STATUS,
|
||||
localStatus = localStatus?.jsonName,
|
||||
giteaStatus = giteaStatus?.jsonName,
|
||||
giteaError = (giteaResult as? GiteaStatusResult.Error)?.message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Like the legacy handler: 7 to 40 hex digits. */
|
||||
private val COMMIT_PATTERN = Regex("[0-9a-fA-F]{7,40}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.watcher.Watcher
|
||||
import de.hoennig.gittally.watcher.WatcherState
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
class WatcherApiController(
|
||||
private val watcher: Watcher,
|
||||
) {
|
||||
/** Watcher health: whether the loop runs, last poll, last fetch/poll error. */
|
||||
@GetMapping("/api/watcher")
|
||||
fun watcher(): WatcherState = watcher.state()
|
||||
}
|
||||
Reference in New Issue
Block a user