From 490914de0bd40c837920fb819546793499603cff Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 7 Jul 2026 11:27:27 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 6 +- build.gradle.kts | 3 + docs/configuration.md | 4 + docs/plan/07-server-mode.md | 21 ++ docs/plan/README.md | 2 +- .../hoennig/gittally/GitTallyApplication.kt | 3 + .../hoennig/gittally/commands/InitCommand.kt | 4 + .../gittally/commands/ServerCommand.kt | 67 +++++- .../hoennig/gittally/config/GitTallyConfig.kt | 3 + .../de/hoennig/gittally/gitea/GiteaClient.kt | 14 ++ .../de/hoennig/gittally/server/ApiDtos.kt | 65 ++++++ .../gittally/server/ArtifactFileController.kt | 65 ++++++ .../gittally/server/BuildsApiController.kt | 167 ++++++++++++++ .../gittally/server/ControlTokenService.kt | 53 +++++ .../gittally/server/ServerConfiguration.kt | 16 ++ .../gittally/server/ServerWatcherLifecycle.kt | 29 +++ .../gittally/server/StatusApiController.kt | 55 +++++ .../gittally/server/WatcherApiController.kt | 15 ++ src/main/resources/application-server.yml | 10 + .../gittally/ServerModeApplicationTest.kt | 45 ++++ .../server/ArtifactFileControllerTest.kt | 89 ++++++++ .../server/BuildsApiControllerTest.kt | 206 ++++++++++++++++++ .../server/ControlTokenServiceTest.kt | 42 ++++ .../server/StatusApiControllerTest.kt | 102 +++++++++ .../server/WatcherApiControllerTest.kt | 48 ++++ 25 files changed, 1128 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt create mode 100644 src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt create mode 100644 src/main/resources/application-server.yml create mode 100644 src/test/kotlin/de/hoennig/gittally/ServerModeApplicationTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/ControlTokenServiceTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/StatusApiControllerTest.kt create mode 100644 src/test/kotlin/de/hoennig/gittally/server/WatcherApiControllerTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index c9d4a33..9cdcbce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 09901be..159fe28 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/docs/configuration.md b/docs/configuration.md index 3010acf..abce42c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: diff --git a/docs/plan/07-server-mode.md b/docs/plan/07-server-mode.md index bf7e8fe..4ee7c60 100644 --- a/docs/plan/07-server-mode.md +++ b/docs/plan/07-server-mode.md @@ -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/` 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. diff --git a/docs/plan/README.md b/docs/plan/README.md index c661422..cb052d0 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -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 diff --git a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt b/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt index 286a2a6..f6847a0 100644 --- a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt +++ b/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt @@ -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, diff --git a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt index 225d1bd..2f8b6ec 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/InitCommand.kt @@ -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: diff --git a/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt b/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt index f7399a8..c39c4e8 100644 --- a/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/commands/ServerCommand.kt @@ -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 { 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" } } diff --git a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt index 0c54b9e..245f22c 100644 --- a/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt +++ b/src/main/kotlin/de/hoennig/gittally/config/GitTallyConfig.kt @@ -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( diff --git a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt index 230a24d..64ff6cd 100644 --- a/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt +++ b/src/main/kotlin/de/hoennig/gittally/gitea/GiteaClient.kt @@ -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) + } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt new file mode 100644 index 0000000..95b875a --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -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" + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt new file mode 100644 index 0000000..ec4b6d3 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt @@ -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 { + 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") + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt new file mode 100644 index 0000000..daa8c1c --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -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 = repository.latestPerBranch().map { BuildResultDto.from(it) } + + @GetMapping("/api/builds/history") + fun history(): List = repository.history().map { BuildResultDto.from(it) } + + /** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */ + @GetMapping("/api/builds/current") + fun current(): List { + 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 { + 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 { + 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 { + 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 { + 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? = + 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 = 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 + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt b/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt new file mode 100644 index 0000000..88e3e0f --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/ControlTokenService.kt @@ -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 + } + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt new file mode 100644 index 0000000..b2b09ea --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerConfiguration.kt @@ -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")) +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt b/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt new file mode 100644 index 0000000..c839bd2 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/ServerWatcherLifecycle.kt @@ -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() + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt new file mode 100644 index 0000000..1a5be9b --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/StatusApiController.kt @@ -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 { + 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}") + } +} diff --git a/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt new file mode 100644 index 0000000..3d47c57 --- /dev/null +++ b/src/main/kotlin/de/hoennig/gittally/server/WatcherApiController.kt @@ -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() +} diff --git a/src/main/resources/application-server.yml b/src/main/resources/application-server.yml new file mode 100644 index 0000000..488fd38 --- /dev/null +++ b/src/main/resources/application-server.yml @@ -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 diff --git a/src/test/kotlin/de/hoennig/gittally/ServerModeApplicationTest.kt b/src/test/kotlin/de/hoennig/gittally/ServerModeApplicationTest.kt new file mode 100644 index 0000000..d95a4de --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/ServerModeApplicationTest.kt @@ -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()) } + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt new file mode 100644 index 0000000..9f46148 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt @@ -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"), "report") + + 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("report")) + } + + 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) + } + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt new file mode 100644 index 0000000..d5b6fe6 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt @@ -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() == "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()) } + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/ControlTokenServiceTest.kt b/src/test/kotlin/de/hoennig/gittally/server/ControlTokenServiceTest.kt new file mode 100644 index 0000000..60a35b7 --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/ControlTokenServiceTest.kt @@ -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 + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/StatusApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/StatusApiControllerTest.kt new file mode 100644 index 0000000..e5d15ea --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/StatusApiControllerTest.kt @@ -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) + } + } +} diff --git a/src/test/kotlin/de/hoennig/gittally/server/WatcherApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/WatcherApiControllerTest.kt new file mode 100644 index 0000000..b5815af --- /dev/null +++ b/src/test/kotlin/de/hoennig/gittally/server/WatcherApiControllerTest.kt @@ -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")) + } + } +}