Compare commits

...

11 Commits

23 changed files with 878 additions and 613 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

@@ -19,7 +19,6 @@ import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
private val scriptingHost = BasicJvmScriptingHost() private val scriptingHost = BasicJvmScriptingHost()
private val evalLock = Any() private val evalLock = Any()
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver()) private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
@@ -27,8 +26,6 @@ private val argsDeclarationRegexes = listOf(
Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$"""), Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$"""),
Regex("""^\s*lateinit\s+var\s+args\s*:\s*Array<String>\s*$"""), Regex("""^\s*lateinit\s+var\s+args\s*:\s*Array<String>\s*$"""),
) )
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
private val evalExecutor = Executors.newCachedThreadPool { r -> private val evalExecutor = Executors.newCachedThreadPool { r ->
Thread(r, "script-eval-worker").apply { isDaemon = true } Thread(r, "script-eval-worker").apply { isDaemon = true }
} }
@@ -85,8 +82,6 @@ private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilation
} }
} }
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
private fun injectArgsBridgeDeclaration(scriptContent: String): String { private fun injectArgsBridgeDeclaration(scriptContent: String): String {
val lines = scriptContent.lines() val lines = scriptContent.lines()
val injected = "val args: Array<String> = hostArgs" val injected = "val args: Array<String> = hostArgs"
@@ -120,151 +115,6 @@ private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List<String>
return merged return merged
} }
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
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)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
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."
}
var hasRequiredOption = false
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> {
hasRequiredOption = true
}
part.startsWith("required=", ignoreCase = true) -> {
hasRequiredOption = 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=."
}
}
}
if (!hasRequiredOption) {
errors += "line $lineNo: missing required option. expected '@param: name | required=true|false | default=value | desc=text'."
}
}
}
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 {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> { private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> {
val explicitCp = explicitClasspathFromEnv() val explicitCp = explicitClasspathFromEnv()
return runBlocking { return runBlocking {
@@ -304,21 +154,57 @@ data class ScriptExecutionResult(
val timedOut: Boolean = false, val timedOut: Boolean = false,
) )
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
fun removeCachedMetadata(scriptFile: File) { fun removeCachedMetadata(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath) clearMetadataCache(scriptFile)
compiledScriptCache.remove(scriptFile.canonicalPath) compiledScriptCache.remove(scriptFile.canonicalPath)
} }
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata { fun validateCompilationAndCapture(scriptFile: File): ScriptExecutionResult {
val content = scriptFile.readText() synchronized(evalLock) {
return metadataForFile(scriptFile, content) 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 {

View File

@@ -0,0 +1,194 @@
package work.slhaf.hub
import java.io.File
import java.util.concurrent.ConcurrentHashMap
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
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()}"
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
var responseType = ScriptResponseType.TEXT
val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw ->
val line = raw.trim()
if (!line.startsWith("//")) return@forEach
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@desc:", ignoreCase = true)) {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach
}
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim()
parseTimeoutMs(raw)?.let { timeoutMs = it }
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)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) return@forEach
val name = parts.first()
var required = false
var defaultValue: String? = null
var desc: String? = null
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> required = true
part.startsWith("required=", ignoreCase = true) ->
required = part.substringAfter("=").trim().equals("true", ignoreCase = true)
part.startsWith("default=", ignoreCase = true) ->
defaultValue = part.substringAfter("=").trim().ifBlank { null }
part.startsWith("desc=", ignoreCase = true) ->
desc = part.substringAfter("=").trim().ifBlank { null }
}
}
params += ScriptParamDefinition(
name = name,
required = required,
defaultValue = defaultValue,
description = desc
)
}
}
return ScriptMetadata(
description = description,
params = params,
timeoutMs = timeoutMs,
responseType = responseType,
)
}
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("@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)) {
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."
}
var hasRequiredOption = false
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> {
hasRequiredOption = true
}
part.startsWith("required=", ignoreCase = true) -> {
hasRequiredOption = 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=."
}
}
}
if (!hasRequiredOption) {
errors += "line $lineNo: missing required option. expected '@param: name | required=true|false | default=value | desc=text'."
}
}
}
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 }
}
internal fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
val key = scriptFile.canonicalPath
val stamp = scriptStamp(scriptFile)
val cached = metadataCache[key]
if (cached != null && cached.first == stamp) return cached.second
val parsed = parseMetadataFromComments(scriptContent)
metadataCache[key] = stamp to parsed
return parsed
}
fun cachedMetadata(scriptFile: File): ScriptMetadata? {
val key = scriptFile.canonicalPath
val cached = metadataCache[key] ?: return null
val currentStamp = scriptStamp(scriptFile)
return if (cached.first == currentStamp) cached.second else null
}
internal fun clearMetadataCache(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath)
}
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
val content = scriptFile.readText()
return metadataForFile(scriptFile, content)
}

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

@@ -0,0 +1,83 @@
package work.slhaf.hub
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import kotlinx.coroutines.sync.Semaphore
import java.io.File
private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0"
private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private fun usage() {
println(
"""
Usage:
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts] [--max-run-concurrency=N]'
Routes:
GET /health
GET /type
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <token>
GET /scripts
GET /u/{subtoken_name}@{subtoken}/scripts (subtoken path auth)
GET /scripts/{script} (root only)
POST /scripts/{script} (root only)
PUT /scripts/{script} (root only)
DELETE /scripts/{script} (root only)
GET /meta/{script} (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/meta/{script}
GET /run/{script}?k=v (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/run/{script}?k=v
POST /run/{script}?k=v (root or allowed subtoken)
POST /u/{subtoken_name}@{subtoken}/run/{script}?k=v
GET /subtokens (root only)
GET /subtokens/{name} (root only)
POST /subtokens/{name} (root only, body: script names list)
PUT /subtokens/{name} (root only, body: script names list)
DELETE /subtokens/{name} (root only)
""".trimIndent(),
)
}
private fun List<String>.optionValue(prefix: String): String? =
firstOrNull { it.startsWith(prefix) }?.substringAfter("=")
fun main(args: Array<String>) {
val cli = args.toList()
if ("--help" in cli || "-h" in cli) {
usage()
return
}
val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT
val host = cli.optionValue("--host=")?.ifBlank { DEFAULT_HOST } ?: DEFAULT_HOST
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()
val auth = loadOrCreateApiToken(scriptsDir)
val security = createHostSecurity(scriptsDir, auth.token)
val runConcurrencyLimiter = Semaphore(maxRunConcurrency)
println("Starting script web host on http://$host:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
println("Run concurrency limit: $maxRunConcurrency")
println("Auth token source: ${auth.source}")
when {
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
auth.source.startsWith("generated:") ->
println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}")
else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}")
}
embeddedServer(Netty, port = port, host = host) {
webModule(scriptsDir, security, runConcurrencyLimiter)
}.start(wait = true)
}

View File

@@ -5,8 +5,6 @@ import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.request.httpMethod import io.ktor.server.request.httpMethod
import io.ktor.server.request.path import io.ktor.server.request.path
import io.ktor.server.request.receiveText import io.ktor.server.request.receiveText
@@ -22,11 +20,17 @@ import kotlinx.coroutines.sync.withPermit
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.File import java.io.File
private const val DEFAULT_PORT = 8080
private const val DEFAULT_SCRIPTS_DIR = "scripts"
private const val DEFAULT_HOST = "0.0.0.0"
private val DEFAULT_MAX_RUN_CONCURRENCY = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val requestLogger = LoggerFactory.getLogger("work.slhaf.hub.RequestAudit") private val requestLogger = LoggerFactory.getLogger("work.slhaf.hub.RequestAudit")
private val subTokenPathRegex = Regex("^/u/([^/]+)/")
private fun sanitizeRequestPath(path: String): String {
val match = subTokenPathRegex.find(path) ?: return path
val credential = match.groupValues[1]
val at = credential.indexOf('@')
if (at <= 0 || at == credential.lastIndex) return path
val nameOnly = credential.substring(0, at)
return path.replaceFirst("/u/$credential/", "/u/$nameOnly@***/")
}
private suspend inline fun withRequestAudit( private suspend inline fun withRequestAudit(
call: ApplicationCall, call: ApplicationCall,
@@ -47,13 +51,14 @@ private suspend inline fun withRequestAudit(
val tokenType = auth?.type?.name?.lowercase() ?: "none" val tokenType = auth?.type?.name?.lowercase() ?: "none"
val subToken = auth?.subTokenName ?: "-" val subToken = auth?.subTokenName ?: "-"
val script = call.parameters["script"] ?: "-" val script = call.parameters["script"] ?: "-"
val sanitizedPath = sanitizeRequestPath(call.request.path())
val status = call.response.status()?.value ?: if (thrown == null) 200 else 500 val status = call.response.status()?.value ?: if (thrown == null) 200 else 500
if (thrown == null) { if (thrown == null) {
requestLogger.info( requestLogger.info(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={}", "endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={}",
endpoint, endpoint,
call.request.httpMethod.value, call.request.httpMethod.value,
call.request.path(), sanitizedPath,
status, status,
durationMs, durationMs,
tokenType, tokenType,
@@ -65,7 +70,7 @@ private suspend inline fun withRequestAudit(
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}", "endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}",
endpoint, endpoint,
call.request.httpMethod.value, call.request.httpMethod.value,
call.request.path(), sanitizedPath,
status, status,
durationMs, durationMs,
tokenType, tokenType,
@@ -77,7 +82,7 @@ private suspend inline fun withRequestAudit(
} }
} }
private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { private suspend fun handleSubTokenCreate(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"] val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
@@ -103,7 +108,7 @@ private suspend fun handleSubTokenCreate(call: io.ktor.server.application.Applic
call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created) call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
} }
private suspend fun handleSubTokenUpdate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { private suspend fun handleSubTokenUpdate(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"] val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
@@ -125,7 +130,7 @@ private suspend fun handleSubTokenUpdate(call: io.ktor.server.application.Applic
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json) call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
} }
private suspend fun handleSubTokenGet(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { private suspend fun handleSubTokenGet(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"] val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
@@ -135,7 +140,7 @@ private suspend fun handleSubTokenGet(call: io.ktor.server.application.Applicati
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json) call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
} }
private suspend fun handleSubTokenDelete(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) { private suspend fun handleSubTokenDelete(call: ApplicationCall, security: HostSecurity) {
val name = call.parameters["name"] val name = call.parameters["name"]
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest) ?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
@@ -222,16 +227,18 @@ private fun Routing.registerHeaderAuthenticatedRoutes(
get("/run/{script}") { get("/run/{script}") {
var authForLog: AuthContext? = null var authForLog: AuthContext? = null
withRequestAudit(call, "run.get", { authForLog }) { withRequestAudit(call, "run.get", { authForLog }) {
authForLog = requireAuth(call, security) ?: return@withRequestAudit val auth = requireAuth(call, security) ?: return@withRequestAudit
handleRunForAuth(call, scriptsDir, authForLog, runConcurrencyLimiter, consumeBody = false) authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
} }
} }
post("/run/{script}") { post("/run/{script}") {
var authForLog: AuthContext? = null var authForLog: AuthContext? = null
withRequestAudit(call, "run.post", { authForLog }) { withRequestAudit(call, "run.post", { authForLog }) {
authForLog = requireAuth(call, security) ?: return@withRequestAudit val auth = requireAuth(call, security) ?: return@withRequestAudit
handleRunForAuth(call, scriptsDir, authForLog, runConcurrencyLimiter, consumeBody = true) authForLog = auth
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
} }
} }
} }
@@ -389,75 +396,3 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
} }
} }
} }
private fun usage() {
println(
"""
Usage:
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts] [--max-run-concurrency=N]'
Routes:
GET /health
GET /type
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <token>
GET /scripts
GET /u/{subtoken_name}@{subtoken}/scripts (subtoken path auth)
GET /scripts/{script} (root only)
POST /scripts/{script} (root only)
PUT /scripts/{script} (root only)
DELETE /scripts/{script} (root only)
GET /meta/{script} (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/meta/{script}
GET /run/{script}?k=v (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/run/{script}?k=v
POST /run/{script}?k=v (root or allowed subtoken)
POST /u/{subtoken_name}@{subtoken}/run/{script}?k=v
GET /subtokens (root only)
GET /subtokens/{name} (root only)
POST /subtokens/{name} (root only, body: script names list)
PUT /subtokens/{name} (root only, body: script names list)
DELETE /subtokens/{name} (root only)
""".trimIndent(),
)
}
private fun List<String>.optionValue(prefix: String): String? =
firstOrNull { it.startsWith(prefix) }?.substringAfter("=")
fun main(args: Array<String>) {
val cli = args.toList()
if ("--help" in cli || "-h" in cli) {
usage()
return
}
val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT
val host = cli.optionValue("--host=")?.ifBlank { DEFAULT_HOST } ?: DEFAULT_HOST
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()
val auth = loadOrCreateApiToken(scriptsDir)
val security = createHostSecurity(scriptsDir, auth.token)
val runConcurrencyLimiter = Semaphore(maxRunConcurrency)
println("Starting script web host on http://$host:$port")
println("Scripts directory: ${scriptsDir.absolutePath}")
println("Run concurrency limit: $maxRunConcurrency")
println("Auth token source: ${auth.source}")
when {
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
auth.source.startsWith("generated:") ->
println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}")
else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}")
}
embeddedServer(Netty, port = port, host = host) {
webModule(scriptsDir, security, runConcurrencyLimiter)
}.start(wait = true)
}

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

@@ -1,347 +0,0 @@
package work.slhaf.hub
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.sync.Semaphore
import kotlin.io.path.createTempDirectory
import kotlin.io.path.writeText
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WebHostApiTest {
private val tempDirs = mutableListOf<java.nio.file.Path>()
@AfterTest
fun cleanup() {
tempDirs.forEach { path ->
runCatching { path.toFile().deleteRecursively() }
}
tempDirs.clear()
}
@Test
fun healthAndUnauthorized() = withApp { _ ->
val health = client.get("/health")
assertEquals(HttpStatusCode.OK, health.status)
assertEquals("OK", health.bodyAsText())
val scripts = client.get("/scripts")
assertEquals(HttpStatusCode.Unauthorized, scripts.status)
assertTrue(scripts.bodyAsText().contains("unauthorized"))
}
@Test
fun scriptCrudMetaRunAndValidation() = withApp { scriptsDir ->
val create = client.post("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: demo api
// @timeout: 10s
// @param: name | required=false | default=world | desc=Name to greet
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("hi " + (kv["name"] ?: "world"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val list = client.get("/scripts") { bearerRoot() }
assertEquals(HttpStatusCode.OK, list.status)
assertTrue(list.bodyAsText().contains("demo"))
val source = client.get("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, source.status)
assertTrue(source.bodyAsText().contains("@desc: demo api"))
val meta = client.get("/meta/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, meta.status)
val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000"))
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("hi Alice"))
val invalidUpdate = client.put("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: user name | required=maybe | xxx=1
val args: Array<String> = emptyArray()
println("bad")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, invalidUpdate.status)
val invalidText = invalidUpdate.bodyAsText()
assertTrue(invalidText.contains("metadata validation failed"))
assertTrue(invalidText.contains("examples:"))
assertTrue(invalidText.contains("@param:"))
val remove = client.delete("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, remove.status)
assertFalse((scriptsDir.resolve("demo.hub.kts")).toFile().exists())
}
@Test
fun subTokenAccessControlAndFiltering() = withApp { scriptsDir ->
scriptsDir.resolve("allowed.hub.kts").writeText(
"""
// @desc: allowed script
val args: Array<String> = emptyArray()
println("allowed")
""".trimIndent()
)
scriptsDir.resolve("blocked.hub.kts").writeText(
"""
// @desc: blocked script
val args: Array<String> = emptyArray()
println("blocked")
""".trimIndent()
)
val createSub = client.post("/subtokens/demo-sub") {
bearerRoot()
setBody("allowed")
}
assertEquals(HttpStatusCode.Created, createSub.status)
val token = extractJsonField(createSub.bodyAsText(), "token")
assertNotNull(token)
val type = client.get("/type") { bearer(token) }
assertEquals(HttpStatusCode.OK, type.status)
val typeText = type.bodyAsText()
assertTrue(typeText.contains("\"tokenType\":\"sub\""))
assertTrue(typeText.contains("\"subTokenName\":\"demo-sub\""))
val scripts = client.get("/scripts") { bearer(token) }
assertEquals(HttpStatusCode.OK, scripts.status)
val scriptList = scripts.bodyAsText()
assertTrue(scriptList.contains("allowed"))
assertFalse(scriptList.contains("blocked"))
val metaAllowed = client.get("/meta/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, metaAllowed.status)
val metaBlocked = client.get("/meta/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, metaBlocked.status)
val runAllowed = client.get("/run/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, runAllowed.status)
val runBlocked = client.get("/run/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, runBlocked.status)
val createScript = client.post("/scripts/not-allowed") {
bearer(token)
setBody("val args: Array<String> = emptyArray()\nprintln(\"x\")")
}
assertEquals(HttpStatusCode.Forbidden, createScript.status)
val listSubTokens = client.get("/subtokens") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status)
val typeByPath = client.get("/u/demo-sub@$token/type")
assertEquals(HttpStatusCode.OK, typeByPath.status)
assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\""))
val scriptsByPath = client.get("/u/demo-sub@$token/scripts")
assertEquals(HttpStatusCode.OK, scriptsByPath.status)
assertTrue(scriptsByPath.bodyAsText().contains("allowed"))
assertFalse(scriptsByPath.bodyAsText().contains("blocked"))
val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed")
assertEquals(HttpStatusCode.OK, metaByPathAllowed.status)
val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked")
assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status)
val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed")
assertEquals(HttpStatusCode.OK, runByPathAllowed.status)
val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked")
assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status)
val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts")
assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status)
}
@Test
fun runTimeoutReturnsRequestTimeout() = withApp { _ ->
val create = client.post("/scripts/slow") {
bearerRoot()
setBody(
"""
// @desc: slow script
// @timeout: 1ms
val args: Array<String> = emptyArray()
Thread.sleep(100)
println("done")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/slow") { bearerRoot() }
assertEquals(HttpStatusCode.RequestTimeout, run.status)
assertTrue(run.bodyAsText().contains("timed out"))
}
@Test
fun metadataRequiresExplicitRequiredField() = withApp { _ ->
val create = client.post("/scripts/badmeta") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: name | default=world | desc=missing required
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("missing required option"))
}
@Test
fun runErrorResponseIncludesParamsAndRequiredCheck() = withApp { _ ->
val create = client.post("/scripts/runner") {
bearerRoot()
setBody(
"""
// @desc: run test
// @param: must | required=true | default=world | desc=Must be provided explicitly
// @param: boom | required=false | default=false | desc=Trigger runtime failure
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
if ((kv["boom"] ?: "false").equals("true", ignoreCase = true)) {
error("boom")
}
println("must=" + (kv["must"] ?: "none"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val missingRequired = client.get("/run/runner") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, missingRequired.status)
val missingBody = missingRequired.bodyAsText()
assertTrue(missingBody.contains("missing required params: must"))
assertTrue(missingBody.contains("params:"))
assertTrue(missingBody.contains("must (required=true"))
val runtimeError = client.get("/run/runner?must=ok&boom=true") { bearerRoot() }
assertEquals(HttpStatusCode.InternalServerError, runtimeError.status)
val runtimeErrorBody = runtimeError.bodyAsText()
assertTrue(runtimeErrorBody.contains("script execution failed"))
assertTrue(runtimeErrorBody.contains("params:"))
assertTrue(runtimeErrorBody.contains("boom (required=false"))
}
@Test
fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ ->
val create = client.post("/scripts/defaults") {
bearerRoot()
setBody(
"""
// @desc: default args test
// @param: name | required=false | default=world | desc=Name fallback
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runWithoutArg = client.get("/run/defaults") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithoutArg.status)
assertTrue(runWithoutArg.bodyAsText().contains("name=world"))
val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithArg.status)
assertTrue(runWithArg.bodyAsText().contains("name=alice"))
}
@Test
fun lateinitArgsDeclarationIsSupported() = withApp { _ ->
val create = client.post("/scripts/lateinit-args") {
bearerRoot()
setBody(
"""
// @desc: lateinit args
// @param: name | required=false | default=world | desc=Name fallback
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("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/lateinit-args") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("name=world"))
}
private fun withApp(testBlock: suspend io.ktor.server.testing.ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
val scriptsDir = createTempDirectory("webhost-api-test-")
tempDirs.add(scriptsDir)
testApplication {
val security = createHostSecurity(scriptsDir.toFile(), ROOT_TOKEN)
application {
webModule(scriptsDir.toFile(), security, Semaphore(4))
}
testBlock(scriptsDir)
}
}
private fun io.ktor.client.request.HttpRequestBuilder.bearer(token: String) {
headers.append(HttpHeaders.Authorization, "Bearer $token")
}
private fun io.ktor.client.request.HttpRequestBuilder.bearerRoot() {
bearer(ROOT_TOKEN)
}
private fun extractJsonField(json: String, field: String): String? {
val regex = Regex("\"" + Regex.escape(field) + "\":\"([^\"]*)\"")
return regex.find(json)?.groupValues?.getOrNull(1)
}
companion object {
private const val ROOT_TOKEN = "root-test-token"
}
}

View File

@@ -0,0 +1,152 @@
package work.slhaf.hub
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WebAuthAndScriptApiTest : WebHostTestSupport() {
@Test
fun healthAndUnauthorized() = withApp { _ ->
val health = client.get("/health")
assertEquals(HttpStatusCode.OK, health.status)
assertEquals("OK", health.bodyAsText())
val scripts = client.get("/scripts")
assertEquals(HttpStatusCode.Unauthorized, scripts.status)
assertTrue(scripts.bodyAsText().contains("unauthorized"))
}
@Test
fun scriptCrudMetaRunAndValidation() = withApp { scriptsDir ->
val create = client.post("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: demo api
// @timeout: 10s
// @param: name | required=false | default=world | desc=Name to greet
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("hi " + (kv["name"] ?: "world"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val list = client.get("/scripts") { bearerRoot() }
assertEquals(HttpStatusCode.OK, list.status)
assertTrue(list.bodyAsText().contains("demo"))
val source = client.get("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, source.status)
assertTrue(source.bodyAsText().contains("@desc: demo api"))
val meta = client.get("/meta/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, meta.status)
val metaText = meta.bodyAsText()
assertTrue(metaText.contains("\"script\":\"demo\""))
assertTrue(metaText.contains("\"timeoutMs\":10000"))
assertTrue(metaText.contains("\"responseType\":\"text\""))
val run = client.get("/run/demo?name=Alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
assertTrue(run.bodyAsText().contains("hi Alice"))
val invalidUpdate = client.put("/scripts/demo") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: user name | required=maybe | xxx=1
val args: Array<String> = emptyArray()
println("bad")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.BadRequest, invalidUpdate.status)
val invalidText = invalidUpdate.bodyAsText()
assertTrue(invalidText.contains("metadata validation failed"))
assertTrue(invalidText.contains("examples:"))
assertTrue(invalidText.contains("@param:"))
val remove = client.delete("/scripts/demo") { bearerRoot() }
assertEquals(HttpStatusCode.OK, remove.status)
assertFalse((scriptsDir.resolve("demo.hub.kts")).toFile().exists())
}
@Test
fun metadataRequiresExplicitRequiredField() = withApp { _ ->
val create = client.post("/scripts/badmeta") {
bearerRoot()
setBody(
"""
// @desc: bad metadata
// @param: name | default=world | desc=missing required
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("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

@@ -0,0 +1,51 @@
package work.slhaf.hub
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.http.HttpHeaders
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.sync.Semaphore
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
abstract class WebHostTestSupport {
private val tempDirs = mutableListOf<java.nio.file.Path>()
@AfterTest
fun cleanup() {
tempDirs.forEach { path ->
runCatching { path.toFile().deleteRecursively() }
}
tempDirs.clear()
}
protected fun withApp(testBlock: suspend ApplicationTestBuilder.(java.nio.file.Path) -> Unit) {
val scriptsDir = createTempDirectory("webhost-api-test-")
tempDirs.add(scriptsDir)
testApplication {
val security = createHostSecurity(scriptsDir.toFile(), ROOT_TOKEN)
application {
webModule(scriptsDir.toFile(), security, Semaphore(4))
}
testBlock(scriptsDir)
}
}
protected fun HttpRequestBuilder.bearer(token: String) {
headers.append(HttpHeaders.Authorization, "Bearer $token")
}
protected fun HttpRequestBuilder.bearerRoot() {
bearer(ROOT_TOKEN)
}
protected fun extractJsonField(json: String, field: String): String? {
val regex = Regex("\\\"" + Regex.escape(field) + "\\\":\\\"([^\\\"]*)\\\"")
return regex.find(json)?.groupValues?.getOrNull(1)
}
companion object {
private const val ROOT_TOKEN = "root-test-token"
}
}

View File

@@ -0,0 +1,163 @@
package work.slhaf.hub
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.http.HttpHeaders
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class WebRunApiTest : WebHostTestSupport() {
@Test
fun runTimeoutReturnsRequestTimeout() = withApp { _ ->
val create = client.post("/scripts/slow") {
bearerRoot()
setBody(
"""
// @desc: slow script
// @timeout: 1ms
val args: Array<String> = emptyArray()
Thread.sleep(100)
println("done")
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/slow") { bearerRoot() }
assertEquals(HttpStatusCode.RequestTimeout, run.status)
assertTrue(run.bodyAsText().contains("timed out"))
}
@Test
fun runErrorResponseIncludesParamsAndRequiredCheck() = withApp { _ ->
val create = client.post("/scripts/runner") {
bearerRoot()
setBody(
"""
// @desc: run test
// @param: must | required=true | default=world | desc=Must be provided explicitly
// @param: boom | required=false | default=false | desc=Trigger runtime failure
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
if ((kv["boom"] ?: "false").equals("true", ignoreCase = true)) {
error("boom")
}
println("must=" + (kv["must"] ?: "none"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val missingRequired = client.get("/run/runner") { bearerRoot() }
assertEquals(HttpStatusCode.BadRequest, missingRequired.status)
val missingBody = missingRequired.bodyAsText()
assertTrue(missingBody.contains("missing required params: must"))
assertTrue(missingBody.contains("params:"))
assertTrue(missingBody.contains("must (required=true"))
val runtimeError = client.get("/run/runner?must=ok&boom=true") { bearerRoot() }
assertEquals(HttpStatusCode.InternalServerError, runtimeError.status)
val runtimeErrorBody = runtimeError.bodyAsText()
assertTrue(runtimeErrorBody.contains("script execution failed"))
assertTrue(runtimeErrorBody.contains("params:"))
assertTrue(runtimeErrorBody.contains("boom (required=false"))
}
@Test
fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ ->
val create = client.post("/scripts/defaults") {
bearerRoot()
setBody(
"""
// @desc: default args test
// @param: name | required=false | default=world | desc=Name fallback
val args: Array<String> = emptyArray()
val kv = args.mapNotNull {
val i = it.indexOf('=')
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
}.toMap()
println("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val runWithoutArg = client.get("/run/defaults") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithoutArg.status)
assertTrue(runWithoutArg.bodyAsText().contains("name=world"))
val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() }
assertEquals(HttpStatusCode.OK, runWithArg.status)
assertTrue(runWithArg.bodyAsText().contains("name=alice"))
}
@Test
fun lateinitArgsDeclarationIsSupported() = withApp { _ ->
val create = client.post("/scripts/lateinit-args") {
bearerRoot()
setBody(
"""
// @desc: lateinit args
// @param: name | required=false | default=world | desc=Name fallback
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("name=" + (kv["name"] ?: "missing"))
""".trimIndent()
)
}
assertEquals(HttpStatusCode.Created, create.status)
val run = client.get("/run/lateinit-args") { bearerRoot() }
assertEquals(HttpStatusCode.OK, run.status)
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

@@ -0,0 +1,98 @@
package work.slhaf.hub
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WebSubTokenApiTest : WebHostTestSupport() {
@Test
fun subTokenAccessControlAndFiltering() = withApp { scriptsDir ->
scriptsDir.resolve("allowed.hub.kts").writeText(
"""
// @desc: allowed script
val args: Array<String> = emptyArray()
println("allowed")
""".trimIndent()
)
scriptsDir.resolve("blocked.hub.kts").writeText(
"""
// @desc: blocked script
val args: Array<String> = emptyArray()
println("blocked")
""".trimIndent()
)
val createSub = client.post("/subtokens/demo-sub") {
bearerRoot()
setBody("allowed")
}
assertEquals(HttpStatusCode.Created, createSub.status)
val token = extractJsonField(createSub.bodyAsText(), "token")
assertNotNull(token)
val type = client.get("/type") { bearer(token) }
assertEquals(HttpStatusCode.OK, type.status)
val typeText = type.bodyAsText()
assertTrue(typeText.contains("\"tokenType\":\"sub\""))
assertTrue(typeText.contains("\"subTokenName\":\"demo-sub\""))
val scripts = client.get("/scripts") { bearer(token) }
assertEquals(HttpStatusCode.OK, scripts.status)
val scriptList = scripts.bodyAsText()
assertTrue(scriptList.contains("allowed"))
assertFalse(scriptList.contains("blocked"))
val metaAllowed = client.get("/meta/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, metaAllowed.status)
val metaBlocked = client.get("/meta/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, metaBlocked.status)
val runAllowed = client.get("/run/allowed") { bearer(token) }
assertEquals(HttpStatusCode.OK, runAllowed.status)
val runBlocked = client.get("/run/blocked") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, runBlocked.status)
val createScript = client.post("/scripts/not-allowed") {
bearer(token)
setBody("val args: Array<String> = emptyArray()\nprintln(\"x\")")
}
assertEquals(HttpStatusCode.Forbidden, createScript.status)
val listSubTokens = client.get("/subtokens") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status)
val typeByPath = client.get("/u/demo-sub@$token/type")
assertEquals(HttpStatusCode.OK, typeByPath.status)
assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\""))
val scriptsByPath = client.get("/u/demo-sub@$token/scripts")
assertEquals(HttpStatusCode.OK, scriptsByPath.status)
assertTrue(scriptsByPath.bodyAsText().contains("allowed"))
assertFalse(scriptsByPath.bodyAsText().contains("blocked"))
val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed")
assertEquals(HttpStatusCode.OK, metaByPathAllowed.status)
val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked")
assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status)
val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed")
assertEquals(HttpStatusCode.OK, runByPathAllowed.status)
val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked")
assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status)
val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts")
assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status)
}
}

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>