Compare commits

..

7 Commits

22 changed files with 225 additions and 18 deletions

View File

@@ -14,10 +14,10 @@ RUN gradle --no-daemon clean installDist
FROM ${RUNTIME_IMAGE} FROM ${RUNTIME_IMAGE}
WORKDIR /app WORKDIR /app
COPY --from=build /workspace/build/install/kotlin-scripts-host /app/kotlin-scripts-host COPY --from=build /workspace/build/install/slhaf-hub /app/slhaf-hub
RUN mkdir -p /app/scripts RUN mkdir -p /app/scripts
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/kotlin-scripts-host/bin/kotlin-scripts-host"] ENTRYPOINT ["/app/slhaf-hub/bin/slhaf-hub"]
CMD ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"] CMD ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"]

View File

@@ -10,6 +10,7 @@ Language:
- Dynamic script loading from `scripts/*.hub.kts` without restarting host - Dynamic script loading from `scripts/*.hub.kts` without restarting host
- Root/Sub token authorization model - Root/Sub token authorization model
- Metadata in script comments (`@desc`, `@timeout`, `@param`) - Metadata in script comments (`@desc`, `@timeout`, `@param`)
- Compiled script cache (reuses compiled artifacts when script file is unchanged)
- Script CRUD + run + metadata APIs - Script CRUD + run + metadata APIs
- Subtoken management APIs - Subtoken management APIs
- Run concurrency limit (`--max-run-concurrency`) - Run concurrency limit (`--max-run-concurrency`)

View File

@@ -10,6 +10,7 @@
-`scripts/*.hub.kts` 动态加载脚本,无需重启 host -`scripts/*.hub.kts` 动态加载脚本,无需重启 host
- Root/Sub token 鉴权模型 - Root/Sub token 鉴权模型
- 脚本注释 metadata`@desc``@timeout``@param` - 脚本注释 metadata`@desc``@timeout``@param`
- 编译缓存(脚本文件未变化时复用已编译产物)
- 脚本 CRUD + run + meta API - 脚本 CRUD + run + meta API
- subtoken 管理 API - subtoken 管理 API
- 运行并发限制(`--max-run-concurrency` - 运行并发限制(`--max-run-concurrency`

View File

@@ -5,10 +5,6 @@ plugins {
val kotlinVersion = "2.2.20" val kotlinVersion = "2.2.20"
repositories {
mavenCentral()
}
dependencies { dependencies {
implementation(kotlin("stdlib")) implementation(kotlin("stdlib"))

View File

@@ -1,12 +1,6 @@
services: services:
slhaf-hub: slhaf-hub:
build: image: ${SLHAF_HUB_IMAGE:-docker.io/slhafzjw/slhaf-hub:latest}
context: .
dockerfile: Dockerfile
args:
BUILD_IMAGE: ${BUILD_IMAGE:-gradle:9.0.0-jdk17}
RUNTIME_IMAGE: ${RUNTIME_IMAGE:-eclipse-temurin:17-jre}
image: slhaf-hub:latest
container_name: slhaf-hub container_name: slhaf-hub
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@@ -2,6 +2,7 @@
// @timeout: 10s // @timeout: 10s
// @param: name | default=world | desc=hello <name> | required=false // @param: name | default=world | desc=hello <name> | required=false
// @param: upper | default=false | desc=upper text | required=false // @param: upper | default=false | desc=upper text | required=false
// @response: json
import java.time.LocalDateTime import java.time.LocalDateTime
lateinit var args: Array<String> lateinit var args: Array<String>

View File

@@ -1 +1,18 @@
rootProject.name = "slhaf-hub" rootProject.name = "slhaf-hub"
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/public")
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
maven("https://maven.aliyun.com/repository/public")
mavenCentral()
}
}

View File

@@ -159,6 +159,54 @@ fun removeCachedMetadata(scriptFile: File) {
compiledScriptCache.remove(scriptFile.canonicalPath) compiledScriptCache.remove(scriptFile.canonicalPath)
} }
fun validateCompilationAndCapture(scriptFile: File): ScriptExecutionResult {
synchronized(evalLock) {
val oldOut = System.out
val oldErr = System.err
val buffer = ByteArrayOutputStream()
val ps = PrintStream(buffer, true, Charsets.UTF_8.name())
return try {
System.setOut(ps)
System.setErr(ps)
val original = scriptFile.readText()
val metadata = metadataForFile(scriptFile, original)
val injected = injectArgsBridgeDeclaration(original)
val compilationResult = compiledScriptFor(scriptFile, injected)
val reports = compilationResult.reports
val hasErrorDiagnostics = reports.any {
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
}
val diagnostics = reports
.filter { it.severity > ScriptDiagnostic.Severity.DEBUG }
.joinToString("\n") {
val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: ""
"[${it.severity}] ${it.message}$ex"
}
val output = buffer.toString(Charsets.UTF_8.name()).trim()
val finalText = buildString {
if (output.isNotEmpty()) appendLine(output)
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
}.trim()
ScriptExecutionResult(
ok = compilationResult is ResultWithDiagnostics.Success && !hasErrorDiagnostics,
output = finalText,
metadata = metadata,
missingRequiredParams = emptyList(),
timedOut = false,
)
} finally {
ps.flush()
ps.close()
System.setOut(oldOut)
System.setErr(oldErr)
}
}
}
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult { fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true) return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
} }

View File

@@ -6,12 +6,14 @@ import java.util.concurrent.ConcurrentHashMap
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+") private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
private val supportedResponseValues = setOf("text", "json", "html")
internal fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}" internal fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
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 var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
var responseType = ScriptResponseType.TEXT
val params = mutableListOf<ScriptParamDefinition>() val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw -> scriptContent.lines().forEach { raw ->
@@ -28,6 +30,15 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
parseTimeoutMs(raw)?.let { timeoutMs = it } parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach return@forEach
} }
if (comment.startsWith("@response:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim().lowercase()
responseType = when (raw) {
"json" -> ScriptResponseType.JSON
"html" -> ScriptResponseType.HTML
else -> ScriptResponseType.TEXT
}
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
@@ -61,7 +72,12 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
} }
} }
return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs) return ScriptMetadata(
description = description,
params = params,
timeoutMs = timeoutMs,
responseType = responseType,
)
} }
fun validateScriptMetadata(scriptContent: String): List<String> { fun validateScriptMetadata(scriptContent: String): List<String> {
@@ -81,6 +97,13 @@ fun validateScriptMetadata(scriptContent: String): List<String> {
} }
return@forEachIndexed return@forEachIndexed
} }
if (comment.startsWith("@response:", ignoreCase = true)) {
val rawResponse = comment.substringAfter(":").trim().lowercase()
if (rawResponse !in supportedResponseValues) {
errors += "line $lineNo: invalid @response '$rawResponse'. expected one of: text, json, html."
}
return@forEachIndexed
}
if (comment.startsWith("@param:", ignoreCase = true)) { if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim() val payload = comment.substringAfter(":").trim()

View File

@@ -1,5 +1,11 @@
package work.slhaf.hub package work.slhaf.hub
enum class ScriptResponseType {
TEXT,
JSON,
HTML,
}
data class ScriptParamDefinition( data class ScriptParamDefinition(
val name: String, val name: String,
val required: Boolean = false, val required: Boolean = false,
@@ -11,6 +17,7 @@ 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, val timeoutMs: Long = 10_000L,
val responseType: ScriptResponseType = ScriptResponseType.TEXT,
) )
data class ScriptRequestContext( data class ScriptRequestContext(

View File

@@ -59,6 +59,7 @@ private fun metadataValidationMessage(errors: List<String>): String =
appendLine("examples:") appendLine("examples:")
appendLine("// @desc: Demo greeting API") appendLine("// @desc: Demo greeting API")
appendLine("// @timeout: 10s") appendLine("// @timeout: 10s")
appendLine("// @response: text")
appendLine("// @param: name | required=false | default=world | desc=Name to greet") appendLine("// @param: name | required=false | default=world | desc=Name to greet")
}.trim() }.trim()
@@ -92,14 +93,22 @@ private fun runFailureMessage(result: ScriptExecutionResult): String {
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 responseType = metadata.responseType.name.lowercase()
val params = metadata.params.joinToString(",") { param -> val params = metadata.params.joinToString(",") { param ->
val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null" val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
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,"timeoutMs":${metadata.timeoutMs},"params":[$params]}""" return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"responseType":"$responseType","params":[$params]}"""
} }
private fun contentTypeFor(metadata: ScriptMetadata): ContentType =
when (metadata.responseType) {
ScriptResponseType.TEXT -> ContentType.Text.Plain
ScriptResponseType.JSON -> ContentType.Application.Json
ScriptResponseType.HTML -> ContentType.Text.Html
}
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"
@@ -133,7 +142,7 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
script.writeText(content) script.writeText(content)
removeCachedMetadata(script) removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false) val result = validateCompilationAndCapture(script)
if (!result.ok) { if (!result.ok) {
script.delete() script.delete()
removeCachedMetadata(script) removeCachedMetadata(script)
@@ -197,7 +206,7 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
script.writeText(newContent) script.writeText(newContent)
removeCachedMetadata(script) removeCachedMetadata(script)
val result = evalAndCapture(script, ScriptRequestContext(), enforceRequiredParams = false) val result = validateCompilationAndCapture(script)
if (!result.ok) { if (!result.ok) {
script.writeText(previousContent) script.writeText(previousContent)
removeCachedMetadata(script) removeCachedMetadata(script)
@@ -255,7 +264,7 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod
runFailureMessage(result) runFailureMessage(result)
}, },
status = status, status = status,
contentType = ContentType.Text.Plain contentType = if (result.ok) contentTypeFor(metadata) else ContentType.Text.Plain
) )
} }

View File

@@ -57,6 +57,7 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
val metaText = meta.bodyAsText() val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\"")) assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000")) assertTrue(metaText.contains("\"timeoutMs\":10000"))
assertTrue(metaText.contains("\"responseType\":\"text\""))
val run = client.get("/run/demo?name=Alice") { bearerRoot() } val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status) assertEquals(HttpStatusCode.OK, run.status)
@@ -103,4 +104,49 @@ class WebAuthAndScriptApiTest : WebHostTestSupport() {
assertTrue(body.contains("metadata validation failed")) assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("missing required option")) assertTrue(body.contains("missing required option"))
} }
@Test
fun metadataRejectsUnsupportedResponseType() = withApp { _ ->
val create = client.post("/scripts/bad-response") {
bearerRoot()
setBody(
"""
// @desc: bad response
// @response: xml
// @param: name | required=false | default=world | desc=name
val args: Array<String> = emptyArray()
println("ok")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, create.status)
val body = create.bodyAsText()
assertTrue(body.contains("metadata validation failed"))
assertTrue(body.contains("invalid @response"))
assertTrue(body.contains("text, json, html"))
}
@Test
fun createWithRequiredParamsValidatesByCompilationOnly() = withApp { _ ->
val create = client.post("/scripts/required-param-script") {
bearerRoot()
setBody(
"""
// @desc: required param demo
// @param: owner | required=true | desc=Repository owner
lateinit var args: Array<String>
val owner = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()["owner"] ?: error("missing required param: owner")
println(owner)
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runMissing = client.get("/run/required-param-script") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, runMissing.status)
assertTrue(runMissing.bodyAsText().contains("missing required params: owner"))
}
} }

View File

@@ -5,6 +5,7 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -120,4 +121,43 @@ class WebRunApiTest : WebHostTestSupport() {
assertEquals(HttpStatusCode.OK, run.status) assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("name=world")) assertTrue(run.bodyAsText().contains("name=world"))
} }
@Test
fun runSuccessContentTypeCanBeJsonOrHtml() = withApp { _ ->
val createJson = client.post("/scripts/json-out") {
bearerRoot()
setBody(
"""
// @desc: json output
// @response: json
val args: Array<String> = emptyArray()
println("{\"ok\":true}")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, createJson.status)
val runJson = client.get("/run/json-out") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runJson.status)
assertTrue((runJson.headers[HttpHeaders.ContentType] ?: "").startsWith("application/json"))
assertTrue(runJson.bodyAsText().contains("\"ok\":true"))
val createHtml = client.post("/scripts/html-out") {
bearerRoot()
setBody(
"""
// @desc: html output
// @response: html
val args: Array<String> = emptyArray()
println("<h1>ok</h1>")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, createHtml.status)
val runHtml = client.get("/run/html-out") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runHtml.status)
assertTrue((runHtml.headers[HttpHeaders.ContentType] ?: "").startsWith("text/html"))
assertTrue(runHtml.bodyAsText().contains("<h1>ok</h1>"))
}
} }

View File

@@ -36,6 +36,7 @@ Global options:
Commands: Commands:
health health
template <script>
type type
list list
show <script> show <script>
@@ -52,6 +53,7 @@ Commands:
sub-delete <name> sub-delete <name>
Examples: Examples:
kotlin slhaf-hub-cli.kts template hello
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token type kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token type
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-list kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-list
kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-create demo --scripts=hello,time kotlin slhaf-hub-cli.kts --token-file=./scripts/.host-api-token sub-create demo --scripts=hello,time
@@ -159,6 +161,23 @@ fun parseScriptsArg(args: List<String>): Set<String> {
return items return items
} }
fun initialScriptTemplate(name: String): String =
"""
// @desc: $name
// @timeout: 10s
// @response: text
// @param: sample | required=false | default=value | desc=example parameter
lateinit var args: Array<String>
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("script=$name")
println("sample=" + (kv["sample"] ?: "value"))
""".trimIndent()
fun request( fun request(
client: HttpClient, client: HttpClient,
baseUrl: String, baseUrl: String,
@@ -198,6 +217,10 @@ fun main(args: Array<String>) {
val (status, body) = val (status, body) =
when (input.command) { when (input.command) {
"health" -> request(client, base, null, "GET", "/health") "health" -> request(client, base, null, "GET", "/health")
"template" -> {
val script = requireScriptName(input.commandArgs)
200 to initialScriptTemplate(script)
}
"type" -> request(client, base, token, "GET", "/type") "type" -> request(client, base, token, "GET", "/type")
"list" -> request(client, base, token, "GET", "/scripts") "list" -> request(client, base, token, "GET", "/scripts")
"show" -> { "show" -> {

View File

@@ -476,6 +476,7 @@ fun initialScriptTemplate(name: String): String =
""" """
// @desc: $name // @desc: $name
// @timeout: 10s // @timeout: 10s
// @response: text
// @param: sample | required=false | default=value | desc=example parameter // @param: sample | required=false | default=value | desc=example parameter
lateinit var args: Array<String> lateinit var args: Array<String>