feat: add metadata-based script timeouts with validation and request timeout handling
This commit is contained in:
@@ -165,6 +165,7 @@ Run with compose:
|
|||||||
```bash
|
```bash
|
||||||
# optional: export HOST_API_TOKEN=your-token
|
# optional: export HOST_API_TOKEN=your-token
|
||||||
# optional: export HOST_PORT=8080
|
# optional: export HOST_PORT=8080
|
||||||
|
# optional: export MAX_RUN_CONCURRENCY=8
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ services:
|
|||||||
- "${HOST_PORT:-8080}:8080"
|
- "${HOST_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
HOST_API_TOKEN: ${HOST_API_TOKEN:-}
|
HOST_API_TOKEN: ${HOST_API_TOKEN:-}
|
||||||
|
MAX_RUN_CONCURRENCY: ${MAX_RUN_CONCURRENCY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./scripts:/app/scripts
|
- ./scripts:/app/scripts
|
||||||
command: ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"]
|
command:
|
||||||
|
- "--host=0.0.0.0"
|
||||||
|
- "--port=8080"
|
||||||
|
- "--scripts-dir=/app/scripts"
|
||||||
|
- "--max-run-concurrency=${MAX_RUN_CONCURRENCY:-}"
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.PrintStream
|
import java.io.PrintStream
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.TimeoutException
|
||||||
import kotlin.script.experimental.api.*
|
import kotlin.script.experimental.api.*
|
||||||
import kotlin.script.experimental.dependencies.*
|
import kotlin.script.experimental.dependencies.*
|
||||||
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
|
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
|
||||||
@@ -20,6 +23,11 @@ private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadat
|
|||||||
|
|
||||||
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
||||||
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
|
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
|
||||||
|
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
|
||||||
|
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
|
||||||
|
private val evalExecutor = Executors.newCachedThreadPool { r ->
|
||||||
|
Thread(r, "script-eval-worker").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
|
||||||
fun explicitClasspathFromEnv(): List<File>? {
|
fun explicitClasspathFromEnv(): List<File>? {
|
||||||
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
|
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
|
||||||
@@ -108,6 +116,7 @@ private fun injectArgsDeclaration(scriptContent: String, args: List<String>): St
|
|||||||
|
|
||||||
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
||||||
var description: String? = null
|
var description: String? = null
|
||||||
|
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
|
||||||
val params = mutableListOf<ScriptParamDefinition>()
|
val params = mutableListOf<ScriptParamDefinition>()
|
||||||
|
|
||||||
scriptContent.lines().forEach { raw ->
|
scriptContent.lines().forEach { raw ->
|
||||||
@@ -119,6 +128,11 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
|||||||
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
|
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
if (comment.startsWith("@timeout:", ignoreCase = true)) {
|
||||||
|
val raw = comment.substringAfter(":").trim()
|
||||||
|
parseTimeoutMs(raw)?.let { timeoutMs = it }
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
if (comment.startsWith("@param:", ignoreCase = true)) {
|
if (comment.startsWith("@param:", ignoreCase = true)) {
|
||||||
val payload = comment.substringAfter(":").trim()
|
val payload = comment.substringAfter(":").trim()
|
||||||
if (payload.isBlank()) return@forEach
|
if (payload.isBlank()) return@forEach
|
||||||
@@ -152,7 +166,79 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ScriptMetadata(description = description, params = params)
|
return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateScriptMetadata(scriptContent: String): List<String> {
|
||||||
|
val errors = mutableListOf<String>()
|
||||||
|
val seenParams = mutableSetOf<String>()
|
||||||
|
|
||||||
|
scriptContent.lines().forEachIndexed { idx, raw ->
|
||||||
|
val lineNo = idx + 1
|
||||||
|
val line = raw.trim()
|
||||||
|
if (!line.startsWith("//")) return@forEachIndexed
|
||||||
|
|
||||||
|
val comment = line.removePrefix("//").trim()
|
||||||
|
if (comment.startsWith("@timeout:", ignoreCase = true)) {
|
||||||
|
val rawTimeout = comment.substringAfter(":").trim()
|
||||||
|
if (parseTimeoutMs(rawTimeout) == null) {
|
||||||
|
errors += "line $lineNo: invalid @timeout '$rawTimeout'. expected format: '@timeout: 10s' or '500ms' or '1m'."
|
||||||
|
}
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.startsWith("@param:", ignoreCase = true)) {
|
||||||
|
val payload = comment.substringAfter(":").trim()
|
||||||
|
if (payload.isBlank()) {
|
||||||
|
errors += "line $lineNo: empty @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
errors += "line $lineNo: invalid @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
val name = parts.first()
|
||||||
|
if (!metadataParamNameRegex.matches(name)) {
|
||||||
|
errors += "line $lineNo: invalid param name '$name'. allowed pattern: [A-Za-z0-9._-]+."
|
||||||
|
}
|
||||||
|
if (!seenParams.add(name)) {
|
||||||
|
errors += "line $lineNo: duplicate @param name '$name'. param names must be unique."
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.drop(1).forEach { part ->
|
||||||
|
when {
|
||||||
|
part.equals("required", ignoreCase = true) -> Unit
|
||||||
|
part.startsWith("required=", ignoreCase = true) -> {
|
||||||
|
val v = part.substringAfter("=").trim()
|
||||||
|
if (!v.equals("true", ignoreCase = true) && !v.equals("false", ignoreCase = true)) {
|
||||||
|
errors += "line $lineNo: invalid required value '$v'. expected true/false."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part.startsWith("default=", ignoreCase = true) -> Unit
|
||||||
|
part.startsWith("desc=", ignoreCase = true) -> Unit
|
||||||
|
else -> {
|
||||||
|
errors += "line $lineNo: unsupported @param option '$part'. supported: required=, default=, desc=."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTimeoutMs(raw: String): Long? {
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val v = raw.trim().lowercase()
|
||||||
|
return when {
|
||||||
|
v.endsWith("ms") -> v.removeSuffix("ms").trim().toLongOrNull()
|
||||||
|
v.endsWith("s") -> v.removeSuffix("s").trim().toLongOrNull()?.times(1000)
|
||||||
|
v.endsWith("m") -> v.removeSuffix("m").trim().toLongOrNull()?.times(60_000)
|
||||||
|
else -> v.toLongOrNull()?.times(1000)
|
||||||
|
}?.takeIf { it > 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
|
private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
|
||||||
@@ -175,7 +261,8 @@ data class ScriptExecutionResult(
|
|||||||
val ok: Boolean,
|
val ok: Boolean,
|
||||||
val output: String,
|
val output: String,
|
||||||
val metadata: ScriptMetadata,
|
val metadata: ScriptMetadata,
|
||||||
val missingRequiredParams: List<String>
|
val missingRequiredParams: List<String>,
|
||||||
|
val timedOut: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
|
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
|
||||||
@@ -189,6 +276,11 @@ fun removeCachedMetadata(scriptFile: File) {
|
|||||||
metadataCache.remove(scriptFile.canonicalPath)
|
metadataCache.remove(scriptFile.canonicalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
|
||||||
|
val content = scriptFile.readText()
|
||||||
|
return metadataForFile(scriptFile, content)
|
||||||
|
}
|
||||||
|
|
||||||
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
|
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
|
||||||
synchronized(evalLock) {
|
synchronized(evalLock) {
|
||||||
val oldOut = System.out
|
val oldOut = System.out
|
||||||
@@ -232,7 +324,8 @@ fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = Scri
|
|||||||
ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(),
|
ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(),
|
||||||
output = finalText,
|
output = finalText,
|
||||||
metadata = metadata,
|
metadata = metadata,
|
||||||
missingRequiredParams = missingRequired
|
missingRequiredParams = missingRequired,
|
||||||
|
timedOut = false,
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
ps.flush()
|
ps.flush()
|
||||||
@@ -242,3 +335,27 @@ fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = Scri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun evalAndCaptureWithTimeout(
|
||||||
|
scriptFile: File,
|
||||||
|
requestContext: ScriptRequestContext = ScriptRequestContext(),
|
||||||
|
timeoutMs: Long,
|
||||||
|
): ScriptExecutionResult {
|
||||||
|
val boundedTimeout = timeoutMs.coerceAtLeast(1)
|
||||||
|
val future = evalExecutor.submit<ScriptExecutionResult> {
|
||||||
|
evalAndCapture(scriptFile, requestContext)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
future.get(boundedTimeout, TimeUnit.MILLISECONDS)
|
||||||
|
} catch (_: TimeoutException) {
|
||||||
|
future.cancel(true)
|
||||||
|
val metadata = loadMetadataFromComments(scriptFile)
|
||||||
|
ScriptExecutionResult(
|
||||||
|
ok = false,
|
||||||
|
output = "[ERROR] Script execution timed out after ${boundedTimeout}ms",
|
||||||
|
metadata = metadata,
|
||||||
|
missingRequiredParams = emptyList(),
|
||||||
|
timedOut = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ data class ScriptParamDefinition(
|
|||||||
|
|
||||||
data class ScriptMetadata(
|
data class ScriptMetadata(
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val params: List<ScriptParamDefinition> = emptyList()
|
val params: List<ScriptParamDefinition> = emptyList(),
|
||||||
|
val timeoutMs: Long = 10_000L,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ScriptRequestContext(
|
data class ScriptRequestContext(
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ private fun String.jsonEscaped(): String = buildString(length) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun metadataValidationMessage(errors: List<String>): String =
|
||||||
|
buildString {
|
||||||
|
appendLine("metadata validation failed:")
|
||||||
|
errors.forEach { appendLine("- $it") }
|
||||||
|
appendLine("examples:")
|
||||||
|
appendLine("// @desc: Demo greeting API")
|
||||||
|
appendLine("// @timeout: 10s")
|
||||||
|
appendLine("// @param: name | required=false | default=world | desc=Name to greet")
|
||||||
|
}.trim()
|
||||||
|
|
||||||
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
|
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
|
||||||
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
||||||
val params = metadata.params.joinToString(",") { param ->
|
val params = metadata.params.joinToString(",") { param ->
|
||||||
@@ -59,15 +69,14 @@ fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String):
|
|||||||
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
||||||
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}"""
|
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}"""
|
||||||
}
|
}
|
||||||
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"params":[$params]}"""
|
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"params":[$params]}"""
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
|
fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
|
||||||
val cached = cachedMetadata(script)
|
val cached = cachedMetadata(script)
|
||||||
if (cached != null) return cached to "cache"
|
if (cached != null) return cached to "cache"
|
||||||
|
val parsed = loadMetadataFromComments(script)
|
||||||
val executed = evalAndCapture(script, ScriptRequestContext(args = emptyList())).metadata
|
return parsed to "comments"
|
||||||
return executed to "parsed"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
|
suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
|
||||||
@@ -83,6 +92,14 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
|
|||||||
if (content.isBlank()) {
|
if (content.isBlank()) {
|
||||||
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
|
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
|
||||||
}
|
}
|
||||||
|
val metadataErrors = validateScriptMetadata(content)
|
||||||
|
if (metadataErrors.isNotEmpty()) {
|
||||||
|
return call.respondText(
|
||||||
|
metadataValidationMessage(metadataErrors),
|
||||||
|
status = HttpStatusCode.BadRequest,
|
||||||
|
contentType = ContentType.Text.Plain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
script.parentFile?.mkdirs()
|
script.parentFile?.mkdirs()
|
||||||
script.writeText(content)
|
script.writeText(content)
|
||||||
@@ -139,6 +156,14 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
|
|||||||
if (newContent.isBlank()) {
|
if (newContent.isBlank()) {
|
||||||
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
|
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
|
||||||
}
|
}
|
||||||
|
val metadataErrors = validateScriptMetadata(newContent)
|
||||||
|
if (metadataErrors.isNotEmpty()) {
|
||||||
|
return call.respondText(
|
||||||
|
metadataValidationMessage(metadataErrors),
|
||||||
|
status = HttpStatusCode.BadRequest,
|
||||||
|
contentType = ContentType.Text.Plain
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val previousContent = script.readText()
|
val previousContent = script.readText()
|
||||||
script.writeText(newContent)
|
script.writeText(newContent)
|
||||||
@@ -181,10 +206,16 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod
|
|||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
val requestBody = if (consumeBody) call.receiveText() else null
|
val requestBody = if (consumeBody) call.receiveText() else null
|
||||||
val result = evalAndCapture(script, ScriptRequestContext(args = requestArgs, body = requestBody))
|
val metadata = loadMetadataFromComments(script)
|
||||||
|
val result = evalAndCaptureWithTimeout(
|
||||||
|
script,
|
||||||
|
ScriptRequestContext(args = requestArgs, body = requestBody),
|
||||||
|
timeoutMs = metadata.timeoutMs,
|
||||||
|
)
|
||||||
|
|
||||||
val status = when {
|
val status = when {
|
||||||
result.ok -> HttpStatusCode.OK
|
result.ok -> HttpStatusCode.OK
|
||||||
|
result.timedOut -> HttpStatusCode.RequestTimeout
|
||||||
result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest
|
result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest
|
||||||
else -> HttpStatusCode.InternalServerError
|
else -> HttpStatusCode.InternalServerError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ fun openEditor(oldStty: String, file: File): Pair<Boolean, String> {
|
|||||||
fun initialScriptTemplate(name: String): String =
|
fun initialScriptTemplate(name: String): String =
|
||||||
"""
|
"""
|
||||||
// @desc: $name
|
// @desc: $name
|
||||||
|
// @timeout: 10s
|
||||||
// @param: sample | default=value | desc=example parameter
|
// @param: sample | default=value | desc=example parameter
|
||||||
|
|
||||||
val args: Array<String> = emptyArray()
|
val args: Array<String> = emptyArray()
|
||||||
|
|||||||
Reference in New Issue
Block a user