refactor: add configurable max run concurrency limit for script run routes

This commit is contained in:
2026-02-24 22:09:24 +08:00
parent 29e32f1800
commit 97b337e143

View File

@@ -13,11 +13,14 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.put import io.ktor.server.routing.put
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File import java.io.File
private const val DEFAULT_PORT = 8080 private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts" private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0" private const val DEFAULT_HOST = "0.0.0.0"
private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"] val name = call.parameters["name"]
@@ -87,7 +90,7 @@ private suspend fun handleSubTokenDelete(call: io.ktor.server.application.Applic
call.respondText("deleted subtoken: $name") call.respondText("deleted subtoken: $name")
} }
private fun Application.module(scriptsDir: File, security: HostSecurity) { private fun Application.module(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
routing { routing {
get("/health") { get("/health") {
call.respondText("OK") call.respondText("OK")
@@ -153,7 +156,9 @@ private fun Application.module(scriptsDir: File, security: HostSecurity) {
val name = call.parameters["script"] val name = call.parameters["script"]
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest) ?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return@get if (!requireScriptAccess(call, auth, name)) return@get
handleRunRequest(call, scriptsDir, consumeBody = false) runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = false)
}
} }
post("/run/{script}") { post("/run/{script}") {
@@ -161,7 +166,9 @@ private fun Application.module(scriptsDir: File, security: HostSecurity) {
val name = call.parameters["script"] val name = call.parameters["script"]
?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest) ?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return@post if (!requireScriptAccess(call, auth, name)) return@post
handleRunRequest(call, scriptsDir, consumeBody = true) runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = true)
}
} }
get("/subtokens") { get("/subtokens") {
@@ -200,7 +207,7 @@ private fun usage() {
println( println(
""" """
Usage: Usage:
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts]' ./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts] [--max-run-concurrency=N]'
Routes: Routes:
GET /health GET /health
GET /type GET /type
@@ -238,13 +245,17 @@ fun main(args: Array<String>) {
val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT
val host = cli.optionValue("--host=")?.ifBlank { DEFAULT_HOST } ?: DEFAULT_HOST val host = cli.optionValue("--host=")?.ifBlank { DEFAULT_HOST } ?: DEFAULT_HOST
val scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile val scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile
val maxRunConcurrency = cli.optionValue("--max-run-concurrency=")?.toIntOrNull() ?: DEFAULT_MAX_RUN_CONCURRENCY
require(maxRunConcurrency > 0) { "--max-run-concurrency must be > 0" }
if (!scriptsDir.exists()) scriptsDir.mkdirs() if (!scriptsDir.exists()) scriptsDir.mkdirs()
val auth = loadOrCreateApiToken(scriptsDir) val auth = loadOrCreateApiToken(scriptsDir)
val security = createHostSecurity(scriptsDir, auth.token) val security = createHostSecurity(scriptsDir, auth.token)
val runConcurrencyLimiter = Semaphore(maxRunConcurrency)
println("Starting script web host on http://$host:$port") println("Starting script web host on http://$host:$port")
println("Scripts directory: ${scriptsDir.absolutePath}") println("Scripts directory: ${scriptsDir.absolutePath}")
println("Run concurrency limit: $maxRunConcurrency")
println("Auth token source: ${auth.source}") println("Auth token source: ${auth.source}")
when { when {
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.") auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
@@ -255,6 +266,6 @@ fun main(args: Array<String>) {
} }
embeddedServer(Netty, port = port, host = host) { embeddedServer(Netty, port = port, host = host) {
module(scriptsDir, security) module(scriptsDir, security, runConcurrencyLimiter)
}.start(wait = true) }.start(wait = true)
} }