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:
Michael Hoennig
2026-07-07 11:27:27 +02:00
parent a1db450bdb
commit 490914de0b
25 changed files with 1128 additions and 6 deletions
@@ -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"
}
}