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:
@@ -23,7 +23,7 @@ java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar init
|
||||
## Architecture
|
||||
|
||||
GitTally is a lightweight, declarative CI/CD build system.
|
||||
It is a dual-mode application: **CLI** (interactive, status, config) and **Server** (HTTP, persistent). It is currently CLI-only; the server mode is not yet implemented.
|
||||
It is a dual-mode application: **CLI** (interactive, status, config) and **Server** (HTTP, persistent).
|
||||
|
||||
### Entry Point and CLI Wiring
|
||||
|
||||
@@ -41,7 +41,7 @@ commands/
|
||||
ConfigPrintCommand ← "config:print [--full]"
|
||||
```
|
||||
|
||||
The web application type is set to `none` in `application.yml`. The `server` subcommand will need to restart the context with a web type when implemented.
|
||||
The web application type is set to `none` in `application.yml`, so plain CLI runs never start a web server. The `server` subcommand launches a **second** `SpringApplication` with `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown. `application-server.yml` switches the web type (`spring.main.*` properties beat programmatic builder settings), `CliRunner` is `@Profile("!server")` so the second context does not run picocli again, and the watcher poll loop starts only in the `server` profile (`ServerWatcherLifecycle`). The JSON API and artifact serving live in the `server` package; mutating endpoints are guarded by a generated control token under `.git/gittally/control-token`.
|
||||
|
||||
### Configuration System
|
||||
|
||||
@@ -68,7 +68,7 @@ Three places must stay in sync when config keys change: the `GitTallyConfig` dat
|
||||
|
||||
### Package Structure
|
||||
|
||||
All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), and `watcher` (branch polling, auto-builds, startup recovery). Tests mirror this structure under `src/test/kotlin`.
|
||||
All production code lives under `de.hoennig.gittally`, with sub-packages `commands` (picocli subcommands), `config` (YAML config loading and schema), `git` (git CLI access), `gitea` (Gitea commit-status API client), `build` (build execution, results, workspaces), `artifacts` (filesystem artifact store), `watcher` (branch polling, auto-builds, startup recovery), and `server` (JSON API controllers, artifact serving, control token, watcher lifecycle). Tests mirror this structure under `src/test/kotlin`.
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework:spring-web")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("info.picocli:picocli-spring-boot-starter:4.7.6")
|
||||
@@ -31,6 +32,8 @@ dependencies {
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
// @WebMvcTest lives in its own module since Spring Boot 4
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
|
||||
// Kotest
|
||||
testImplementation("io.kotest:kotest-runner-junit5:6.1.5")
|
||||
|
||||
@@ -26,6 +26,10 @@ Values shown are the defaults.
|
||||
server:
|
||||
# Public base URL of this GitTally installation — used for all links posted to Gitea.
|
||||
publicBaseUrl: https://ci.example.org/
|
||||
# HTTP port of the `server` subcommand (default 18080, like legacy)
|
||||
port: 18080
|
||||
# bind address of the `server` subcommand
|
||||
bindAddress: 0.0.0.0
|
||||
|
||||
# Gitea integration for fetching commits and posting build statuses.
|
||||
gitea:
|
||||
|
||||
@@ -43,3 +43,24 @@ JSON API (package `de.hoennig.gittally.server`), replacing the legacy `/control/
|
||||
|
||||
- `./gradlew ktlintFormat` then `./gradlew build` is green.
|
||||
- `java -jar ... server` starts, `GET /api/builds/latest` answers, Ctrl-C shuts down cleanly (manual smoke test; document result in this file).
|
||||
|
||||
## Implementation Notes (2026-07-07)
|
||||
|
||||
Bootstrapping was implemented as designed, with two additions.
|
||||
`application-server.yml` switches `spring.main.web-application-type` to `servlet`, because `spring.main.*` properties override the programmatic `SpringApplicationBuilder.web(...)` setting.
|
||||
`CliRunner` is excluded from the `server` profile so the second context does not run picocli again.
|
||||
After Ctrl-C, `ServerCommand` parks the command thread while the JVM shuts down; otherwise `main()` races the shutdown hooks and `SpringApplication.exit` prints a stack trace for the already-closed CLI context.
|
||||
|
||||
Deviations and decisions:
|
||||
|
||||
- The live log tail is a sibling endpoint: `GET /api/builds/current` lists the running builds (with `logSize`), and `GET /api/builds/current/{artifactKey}/log?offset=` fetches the log incrementally, addressed by artifact key as required. Responses are capped at 1 MiB per chunk.
|
||||
- The control token needs no config key. It is generated on first use and persisted to `.git/gittally/control-token` (mode 600); operators can write their own token there, deleting the file rotates it. Requests pass it via the `X-GitTally-Token` header or a `token` parameter; mismatch answers 403 like legacy.
|
||||
- `DELETE /api/builds/{artifactKey}` removes the result and then calls `ArtifactStore.prune(history)`, so no new store interface method was needed.
|
||||
- `GET /api/status/{commit}` also accepts abbreviated hashes (7–40 hex like legacy) and resolves them against the local history. The `GiteaClient` (step 03) gained 10s connect/read timeouts so the endpoint can never hang; a Gitea failure yields HTTP 200 with `status: unknown` (or the local status) plus `giteaError`.
|
||||
- `POST /api/builds/{branch}/restart` rebuilds the branch's last recorded commit. Branch names containing `/` would need an encoded slash, which Tomcat rejects by default — revisit in step 08 if the UI needs restart for such branches.
|
||||
- Spring Boot 4 moved `@WebMvcTest` into the new `spring-boot-starter-webmvc-test` test module (added as test dependency).
|
||||
- The server-profile `@SpringBootTest` mocks the `Watcher` bean, so booting the test never fetches origin or enqueues builds; watcher wiring is proven by verifying `start()` was called.
|
||||
|
||||
Manual smoke test (2026-07-07): in a scratch repository, `java -jar build/libs/gittally-0.1.0-SNAPSHOT.jar server` started on the configured port 18981.
|
||||
`GET /api/builds/latest` answered `[]` with HTTP 200, `GET /api/watcher` exposed the failing fetch of the origin-less repo as `lastFetchError`, `GET /api/status/<sha>` answered `unknown` with HTTP 200, and cancel without token answered 403.
|
||||
SIGINT (Ctrl-C) shut the process down cleanly in about 2 seconds: port closed, no exceptions in the log, exit code 130.
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ Core engine:
|
||||
|
||||
Server and UI:
|
||||
|
||||
- [ ] `07-server-mode.md` — `server` subcommand, REST/JSON endpoints, artifact serving
|
||||
- [x] `07-server-mode.md` — `server` subcommand, REST/JSON endpoints, artifact serving
|
||||
- [ ] `08-web-ui.md` — HTML views with robust live updates
|
||||
- [ ] `09-system-metrics.md` — system resource monitoring page
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.boot.ExitCodeGenerator
|
||||
import org.springframework.boot.SpringApplication
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine
|
||||
import picocli.CommandLine.IFactory
|
||||
@@ -13,7 +14,9 @@ import kotlin.system.exitProcess
|
||||
@SpringBootApplication
|
||||
class GitTallyApplication
|
||||
|
||||
/** Not in the `server` profile: the second context started by `ServerCommand` must not run picocli again. */
|
||||
@Component
|
||||
@Profile("!server")
|
||||
class CliRunner(
|
||||
private val factory: IFactory,
|
||||
private val rootCommand: GitTallyCommand,
|
||||
|
||||
@@ -104,6 +104,10 @@ class InitCommand(
|
||||
server:
|
||||
# Public base URL of this GitTally installation — used for all links posted to Gitea.
|
||||
publicBaseUrl: ""
|
||||
# HTTP port of the `server` subcommand
|
||||
port: 18080
|
||||
# bind address of the `server` subcommand
|
||||
bindAddress: 0.0.0.0
|
||||
|
||||
# Gitea integration for fetching commits and posting build statuses.
|
||||
gitea:
|
||||
|
||||
@@ -1,16 +1,79 @@
|
||||
package de.hoennig.gittally.commands
|
||||
|
||||
import de.hoennig.gittally.GitTallyApplication
|
||||
import de.hoennig.gittally.config.ConfigLoader
|
||||
import org.springframework.boot.WebApplicationType
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder
|
||||
import org.springframework.context.ApplicationListener
|
||||
import org.springframework.context.ConfigurableApplicationContext
|
||||
import org.springframework.context.event.ContextClosedEvent
|
||||
import org.springframework.stereotype.Component
|
||||
import picocli.CommandLine.Command
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
/**
|
||||
* The CLI context runs without a web server (web type `none` in `application.yml`),
|
||||
* so this command launches a second `SpringApplication` with
|
||||
* `WebApplicationType.SERVLET` and the `server` profile, then blocks until shutdown.
|
||||
* The profile switches the web type via `application-server.yml` (`spring.main.*`
|
||||
* beats programmatic builder settings), starts the watcher via `ServerWatcherLifecycle`,
|
||||
* and keeps `CliRunner` out of the second context.
|
||||
*/
|
||||
@Component
|
||||
@Command(
|
||||
name = "server",
|
||||
description = ["Start the GitTally server"],
|
||||
mixinStandardHelpOptions = true,
|
||||
)
|
||||
class ServerCommand : Runnable {
|
||||
class ServerCommand(
|
||||
private val configLoader: ConfigLoader,
|
||||
) : Runnable {
|
||||
var workingDir: Path = Paths.get(".")
|
||||
|
||||
override fun run() {
|
||||
println("server – not yet implemented")
|
||||
val config = configLoader.load(workingDir)
|
||||
val context =
|
||||
SpringApplicationBuilder(GitTallyApplication::class.java)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.profiles(SERVER_PROFILE)
|
||||
.properties(
|
||||
"server.port=${config.server.port}",
|
||||
"server.address=${config.server.bindAddress}",
|
||||
).run()
|
||||
val port = context.environment.getProperty("local.server.port", config.server.port.toString())
|
||||
println("GitTally server listening on http://${config.server.bindAddress}:$port/ — Ctrl-C to stop")
|
||||
awaitShutdown(context)
|
||||
}
|
||||
|
||||
/** Blocks until the context closes; Ctrl-C triggers the shutdown hook Spring registered. */
|
||||
private fun awaitShutdown(context: ConfigurableApplicationContext) {
|
||||
val closed = CountDownLatch(1)
|
||||
context.addApplicationListener(ApplicationListener<ContextClosedEvent> { closed.countDown() })
|
||||
if (context.isActive) {
|
||||
closed.await()
|
||||
}
|
||||
if (jvmIsShuttingDown()) {
|
||||
// Parked so that main() never races the shutdown hooks — `SpringApplication.exit`
|
||||
// on the closing CLI context would print a stack trace. The JVM halts once the
|
||||
// hooks have finished; only a programmatic context close falls through.
|
||||
Thread.currentThread().join()
|
||||
}
|
||||
}
|
||||
|
||||
/** Once the JVM shuts down, registering a shutdown hook throws. */
|
||||
private fun jvmIsShuttingDown(): Boolean =
|
||||
try {
|
||||
val probe = Thread { }
|
||||
Runtime.getRuntime().addShutdownHook(probe)
|
||||
Runtime.getRuntime().removeShutdownHook(probe)
|
||||
false
|
||||
} catch (_: IllegalStateException) {
|
||||
true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SERVER_PROFILE = "server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ data class GitTallyConfig(
|
||||
|
||||
data class ServerConfig(
|
||||
val publicBaseUrl: String = "",
|
||||
/** HTTP port of the `server` subcommand; 18080 like the legacy artifact server. */
|
||||
val port: Int = 18080,
|
||||
val bindAddress: String = "0.0.0.0",
|
||||
)
|
||||
|
||||
data class GitConfig(
|
||||
|
||||
@@ -10,11 +10,14 @@ import de.hoennig.gittally.config.ConfigLoader
|
||||
import de.hoennig.gittally.config.GitTallyConfig
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
import java.net.http.HttpClient
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.time.Duration
|
||||
|
||||
/** Outcome of [GiteaClient.readStatus]; errors are values, never exceptions. */
|
||||
sealed interface GiteaStatusResult {
|
||||
@@ -167,9 +170,16 @@ class GiteaClient(
|
||||
config.gitea.repo.isNotBlank() &&
|
||||
config.git.token.isNotBlank()
|
||||
|
||||
/** Timeouts like the legacy status proxy: callers (e.g. `/api/status`) must never hang. */
|
||||
private val requestFactory =
|
||||
JdkClientHttpRequestFactory(
|
||||
HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).build(),
|
||||
).apply { setReadTimeout(REQUEST_TIMEOUT) }
|
||||
|
||||
private fun restClient(config: GitTallyConfig): RestClient =
|
||||
RestClient
|
||||
.builder()
|
||||
.requestFactory(requestFactory)
|
||||
.baseUrl(config.gitea.baseUrl.trimEnd('/'))
|
||||
.defaultHeader("Authorization", "token ${config.git.token}")
|
||||
.build()
|
||||
@@ -178,4 +188,8 @@ class GiteaClient(
|
||||
val context: String? = null,
|
||||
val state: String? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val REQUEST_TIMEOUT: Duration = Duration.ofSeconds(10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Active only in server mode (the `server` subcommand and the server-profile tests).
|
||||
# `spring.main.*` properties override programmatic SpringApplicationBuilder settings,
|
||||
# so the web type switch must happen here, not only in ServerCommand.
|
||||
spring:
|
||||
main:
|
||||
web-application-type: servlet
|
||||
|
||||
logging:
|
||||
level:
|
||||
de.hoennig.gittally: INFO
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.hoennig.gittally
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.watcher.Watcher
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.booleans.shouldBeTrue
|
||||
import io.mockk.verify
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.web.client.RestClient
|
||||
|
||||
/**
|
||||
* Proves the `server` profile boots a real web server and starts the watcher.
|
||||
* The watcher is mocked so the test never fetches origin or enqueues builds.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("server")
|
||||
class ServerModeApplicationTest : FunSpec() {
|
||||
@MockkBean(relaxUnitFun = true)
|
||||
lateinit var watcher: Watcher
|
||||
|
||||
@LocalServerPort
|
||||
var port: Int = 0
|
||||
|
||||
init {
|
||||
test("the server profile answers the JSON API and starts the watcher") {
|
||||
val response =
|
||||
RestClient
|
||||
.create("http://localhost:$port")
|
||||
.get()
|
||||
.uri("/api/builds/latest")
|
||||
.retrieve()
|
||||
.toEntity(String::class.java)
|
||||
|
||||
response.statusCode.is2xxSuccessful.shouldBeTrue()
|
||||
response.headers.contentType!!
|
||||
.isCompatibleWith(MediaType.APPLICATION_JSON)
|
||||
.shouldBeTrue()
|
||||
|
||||
verify { watcher.start(any()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
@WebMvcTest(ArtifactFileController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class ArtifactFileControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var artifactStore: ArtifactStore
|
||||
|
||||
private val artifactDir: Path = Files.createTempDirectory("gittally-artifact-serve-test")
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(artifactStore)
|
||||
every { artifactStore.artifactDir(any()) } returns null
|
||||
every { artifactStore.artifactDir("known-key") } returns artifactDir
|
||||
}
|
||||
|
||||
test("serves an html artifact with no-cache headers") {
|
||||
Files.writeString(artifactDir.resolve("index.html"), "<html>report</html>")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/index.html"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().string("Content-Type", "text/html"))
|
||||
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
|
||||
.andExpect(content().string("<html>report</html>"))
|
||||
}
|
||||
|
||||
test("serves a log file as UTF-8 text with no-cache headers") {
|
||||
Files.writeString(artifactDir.resolve("build.log"), "line one")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/build.log"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().string("Content-Type", "text/plain;charset=UTF-8"))
|
||||
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
|
||||
}
|
||||
|
||||
test("serves nested report files without no-cache headers") {
|
||||
val nested = Files.createDirectories(artifactDir.resolve("reports/tests"))
|
||||
Files.writeString(nested.resolve("summary.css"), "body {}")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/reports/tests/summary.css"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(header().doesNotExist("Cache-Control"))
|
||||
}
|
||||
|
||||
test("unknown artifact key answers 404") {
|
||||
mockMvc
|
||||
.perform(get("/artifacts/unknown-key/index.html"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("missing file answers 404") {
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/no-such-file.html"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("path traversal out of the artifact directory is rejected") {
|
||||
val outside = Files.writeString(artifactDir.parent.resolve("outside.txt"), "secret")
|
||||
try {
|
||||
mockMvc
|
||||
.perform(get("/artifacts/known-key/../outside.txt"))
|
||||
.andExpect(status().is4xxClientError)
|
||||
} finally {
|
||||
Files.deleteIfExists(outside)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.build.ArtifactStore
|
||||
import de.hoennig.gittally.build.BuildExecutor
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.build.RunningBuild
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.verify
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(BuildsApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class BuildsApiControllerTest : FunSpec() {
|
||||
private val tempDir: Path = Files.createTempDirectory("gittally-server-test")
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var repository: BuildResultRepository
|
||||
|
||||
@MockkBean
|
||||
lateinit var buildExecutor: BuildExecutor
|
||||
|
||||
@MockkBean
|
||||
lateinit var artifactStore: ArtifactStore
|
||||
|
||||
@MockkBean
|
||||
lateinit var controlTokens: ControlTokenService
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
private val successResult =
|
||||
BuildResult(
|
||||
branch = "main",
|
||||
commit = "0123456789abcdef0123456789abcdef01234567",
|
||||
status = BuildStatus.SUCCESS,
|
||||
startedAt = startedAt,
|
||||
duration = Duration.ofSeconds(83),
|
||||
artifactKey = "main-abc123-key",
|
||||
)
|
||||
|
||||
private fun runningBuild(liveLogFile: Path) =
|
||||
RunningBuild(
|
||||
branch = "main",
|
||||
commit = successResult.commit,
|
||||
artifactKey = "main-abc123-running",
|
||||
startedAt = startedAt,
|
||||
stagingDir = liveLogFile.parent,
|
||||
liveLogFile = liveLogFile,
|
||||
)
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(repository, buildExecutor, artifactStore, controlTokens)
|
||||
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
|
||||
}
|
||||
|
||||
test("latest answers one entry per branch with lowercase status and duration in seconds") {
|
||||
every { repository.latestPerBranch() } returns listOf(successResult)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/latest"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].branch").value("main"))
|
||||
.andExpect(jsonPath("$[0].status").value("success"))
|
||||
.andExpect(jsonPath("$[0].durationSeconds").value(83))
|
||||
.andExpect(jsonPath("$[0].artifactKey").value("main-abc123-key"))
|
||||
}
|
||||
|
||||
test("history answers all builds") {
|
||||
every { repository.history() } returns listOf(successResult, successResult.copy(status = BuildStatus.FAILED))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/history"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.length()").value(2))
|
||||
.andExpect(jsonPath("$[1].status").value("failed"))
|
||||
}
|
||||
|
||||
test("current answers the running builds with live status and log size") {
|
||||
val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345")
|
||||
val build = runningBuild(liveLogFile)
|
||||
every { buildExecutor.currentBuilds() } returns listOf(build)
|
||||
every { repository.history() } returns
|
||||
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = build.artifactKey))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/current"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$[0].artifactKey").value(build.artifactKey))
|
||||
.andExpect(jsonPath("$[0].status").value("running"))
|
||||
.andExpect(jsonPath("$[0].logSize").value(5))
|
||||
}
|
||||
|
||||
test("current log answers the tail from the requested offset") {
|
||||
val liveLogFile = Files.writeString(tempDir.resolve("tail.log"), "hello world")
|
||||
val build = runningBuild(liveLogFile)
|
||||
every { buildExecutor.currentBuilds() } returns listOf(build)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/current/${build.artifactKey}/log").param("offset", "6"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.content").value("world"))
|
||||
.andExpect(jsonPath("$.nextOffset").value(11))
|
||||
}
|
||||
|
||||
test("current log of an unknown artifact key answers 404") {
|
||||
every { buildExecutor.currentBuilds() } returns emptyList()
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/builds/current/no-such-key/log"))
|
||||
.andExpect(status().isNotFound)
|
||||
.andExpect(jsonPath("$.error").exists())
|
||||
}
|
||||
|
||||
test("restart enqueues the branch's last recorded commit") {
|
||||
val liveLogFile = tempDir.resolve("restart.log")
|
||||
every { repository.latestFor("main") } returns successResult
|
||||
every { buildExecutor.startBuild("main", successResult.commit) } returns runningBuild(liveLogFile)
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
.andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.status").value("pending"))
|
||||
.andExpect(jsonPath("$.artifactKey").value("main-abc123-running"))
|
||||
|
||||
verify { buildExecutor.startBuild("main", successResult.commit) }
|
||||
}
|
||||
|
||||
test("restart of a branch without recorded builds answers 404") {
|
||||
every { repository.latestFor("gone") } returns null
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/gone/restart").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("restart with a wrong token answers 403 and does not build") {
|
||||
mockMvc
|
||||
.perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "wrong"))
|
||||
.andExpect(status().isForbidden)
|
||||
|
||||
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) }
|
||||
}
|
||||
|
||||
test("cancel answers 202 for a cancellable build and 404 otherwise") {
|
||||
every { buildExecutor.cancel("known-key") } returns true
|
||||
every { buildExecutor.cancel("unknown-key") } returns false
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/known-key/cancel").param("token", "secret"))
|
||||
.andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.cancelled").value("known-key"))
|
||||
mockMvc
|
||||
.perform(post("/api/builds/unknown-key/cancel").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("cancel without token answers 403") {
|
||||
mockMvc
|
||||
.perform(post("/api/builds/some-key/cancel"))
|
||||
.andExpect(status().isForbidden)
|
||||
|
||||
verify(exactly = 0) { buildExecutor.cancel(any()) }
|
||||
}
|
||||
|
||||
test("delete removes the result and prunes its artifacts") {
|
||||
every { repository.delete("old-key") } returns true
|
||||
every { repository.history() } returns listOf(successResult)
|
||||
every { artifactStore.prune(any()) } returns listOf("old-key")
|
||||
|
||||
mockMvc
|
||||
.perform(delete("/api/builds/old-key").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.deleted").value("old-key"))
|
||||
|
||||
verify { artifactStore.prune(listOf(successResult)) }
|
||||
}
|
||||
|
||||
test("delete of an unknown artifact key answers 404 without pruning") {
|
||||
every { repository.delete("unknown-key") } returns false
|
||||
|
||||
mockMvc
|
||||
.perform(delete("/api/builds/unknown-key").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
|
||||
verify(exactly = 0) { artifactStore.prune(any()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldMatch
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
class ControlTokenServiceTest : FunSpec() {
|
||||
private fun newTokenFile(): Path = Files.createTempDirectory("gittally-token-test").resolve("control-token")
|
||||
|
||||
init {
|
||||
test("generates a hex token once and persists it") {
|
||||
val tokenFile = newTokenFile()
|
||||
val service = ControlTokenService(tokenFile)
|
||||
|
||||
val token = service.token()
|
||||
|
||||
token shouldMatch Regex("[0-9a-f]{48}")
|
||||
Files.readString(tokenFile).trim() shouldBe token
|
||||
service.token() shouldBe token
|
||||
}
|
||||
|
||||
test("reuses an operator-provided token file") {
|
||||
val tokenFile = newTokenFile()
|
||||
Files.createDirectories(tokenFile.parent)
|
||||
Files.writeString(tokenFile, "my-own-token\n")
|
||||
|
||||
ControlTokenService(tokenFile).token() shouldBe "my-own-token"
|
||||
}
|
||||
|
||||
test("matches only the exact token") {
|
||||
val service = ControlTokenService(newTokenFile())
|
||||
val token = service.token()
|
||||
|
||||
service.matches(token) shouldBe true
|
||||
service.matches(token + "x") shouldBe false
|
||||
service.matches("") shouldBe false
|
||||
service.matches(null) shouldBe false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.build.BuildResult
|
||||
import de.hoennig.gittally.build.BuildResultRepository
|
||||
import de.hoennig.gittally.build.BuildStatus
|
||||
import de.hoennig.gittally.gitea.GiteaClient
|
||||
import de.hoennig.gittally.gitea.GiteaStatusResult
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(StatusApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class StatusApiControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var repository: BuildResultRepository
|
||||
|
||||
@MockkBean
|
||||
lateinit var giteaClient: GiteaClient
|
||||
|
||||
private val commit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
private val localResult =
|
||||
BuildResult(
|
||||
branch = "main",
|
||||
commit = commit,
|
||||
status = BuildStatus.FAILED,
|
||||
startedAt = Instant.parse("2026-07-07T10:00:00Z"),
|
||||
duration = null,
|
||||
artifactKey = "main-abc123-key",
|
||||
)
|
||||
|
||||
init {
|
||||
beforeEach {
|
||||
clearMocks(repository, giteaClient)
|
||||
every { repository.history() } returns emptyList()
|
||||
}
|
||||
|
||||
test("prefers the Gitea status over the local status") {
|
||||
every { repository.history() } returns listOf(localResult)
|
||||
every { giteaClient.readStatus(commit, any()) } returns GiteaStatusResult.Found(BuildStatus.SUCCESS)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/$commit"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("success"))
|
||||
.andExpect(jsonPath("$.giteaStatus").value("success"))
|
||||
.andExpect(jsonPath("$.localStatus").value("failed"))
|
||||
.andExpect(jsonPath("$.giteaError").doesNotExist())
|
||||
}
|
||||
|
||||
test("falls back to the local status when Gitea is disabled") {
|
||||
every { repository.history() } returns listOf(localResult)
|
||||
every { giteaClient.readStatus(commit, any()) } returns GiteaStatusResult.Disabled
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/$commit"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("failed"))
|
||||
.andExpect(jsonPath("$.giteaStatus").doesNotExist())
|
||||
}
|
||||
|
||||
test("resolves an abbreviated commit hash against the local history") {
|
||||
every { repository.history() } returns listOf(localResult)
|
||||
every { giteaClient.readStatus(any(), any()) } returns GiteaStatusResult.None
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/${commit.take(8)}"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("failed"))
|
||||
}
|
||||
|
||||
test("Gitea failure without a local build answers 200 with an explicit unknown status") {
|
||||
every { giteaClient.readStatus(commit, any()) } returns GiteaStatusResult.Error("Gitea status request failed")
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/status/$commit"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.status").value("unknown"))
|
||||
.andExpect(jsonPath("$.giteaError").value("Gitea status request failed"))
|
||||
}
|
||||
|
||||
test("rejects malformed commit hashes") {
|
||||
mockMvc
|
||||
.perform(get("/api/status/not-a-commit"))
|
||||
.andExpect(status().isBadRequest)
|
||||
mockMvc
|
||||
.perform(get("/api/status/abc123"))
|
||||
.andExpect(status().isBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import com.ninjasquad.springmockk.MockkBean
|
||||
import de.hoennig.gittally.watcher.Watcher
|
||||
import de.hoennig.gittally.watcher.WatcherState
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.Instant
|
||||
|
||||
@WebMvcTest(WatcherApiController::class, properties = ["spring.main.web-application-type=servlet"])
|
||||
class WatcherApiControllerTest : FunSpec() {
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@MockkBean
|
||||
lateinit var watcher: Watcher
|
||||
|
||||
init {
|
||||
beforeEach { clearMocks(watcher) }
|
||||
|
||||
test("watcher health answers last poll and errors") {
|
||||
every { watcher.state() } returns
|
||||
WatcherState(
|
||||
running = true,
|
||||
lastPollAt = Instant.parse("2026-07-07T10:00:00Z"),
|
||||
lastFetchError = "origin unreachable",
|
||||
lastPollError = null,
|
||||
queuedBranches = listOf("main"),
|
||||
)
|
||||
|
||||
mockMvc
|
||||
.perform(get("/api/watcher"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.running").value(true))
|
||||
.andExpect(jsonPath("$.lastPollAt").value("2026-07-07T10:00:00Z"))
|
||||
.andExpect(jsonPath("$.lastFetchError").value("origin unreachable"))
|
||||
.andExpect(jsonPath("$.lastPollError").doesNotExist())
|
||||
.andExpect(jsonPath("$.queuedBranches[0]").value("main"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user