Compare commits
17 Commits
07d5c1db52
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3d2ff1cb8 | |||
| f332159217 | |||
| bcf0a316a6 | |||
| ae94615095 | |||
| c0bb645125 | |||
| 923479b4cf | |||
| e3886ff2ed | |||
| d79ff57b89 | |||
| ab6d1204e6 | |||
| 56d0b40dd3 | |||
| 62bd88efc9 | |||
| 3933f0120b | |||
| e6a993fcf1 | |||
| c8f4695582 | |||
| 757af8d89b | |||
| ff012c3b9a | |||
| e2fdb99c91 |
@@ -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"]
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -37,15 +38,15 @@ cd slhaf-hub
|
|||||||
docker build -t slhaf-hub:latest .
|
docker build -t slhaf-hub:latest .
|
||||||
docker run --rm -p 8080:8080 \
|
docker run --rm -p 8080:8080 \
|
||||||
-v "$(pwd)/scripts:/app/scripts" \
|
-v "$(pwd)/scripts:/app/scripts" \
|
||||||
-e HOST_API_TOKEN=your-token \
|
-e SLHAF_HUB_TOKEN=your-token \
|
||||||
-e MAX_RUN_CONCURRENCY=8 \
|
-e MAX_RUN_CONCURRENCY=8 \
|
||||||
slhaf-hub:latest
|
slhaf-hub:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3) Run with Docker Compose
|
#### 3) Run with Docker Compose
|
||||||
```bash
|
```bash
|
||||||
# optional: export HOST_API_TOKEN=your-token
|
# optional: export SLHAF_HUB_TOKEN=your-token
|
||||||
# optional: export HOST_PORT=8080
|
# optional: export SLHAF_HUB_PORT=8080
|
||||||
# optional: export MAX_RUN_CONCURRENCY=8
|
# optional: export MAX_RUN_CONCURRENCY=8
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
@@ -79,7 +80,7 @@ Auth headers:
|
|||||||
- `X-Host-Token: <token>`
|
- `X-Host-Token: <token>`
|
||||||
|
|
||||||
Token source priority:
|
Token source priority:
|
||||||
1. `HOST_API_TOKEN` env var
|
1. `SLHAF_HUB_TOKEN` env var
|
||||||
2. `scripts/.host-api-token`
|
2. `scripts/.host-api-token`
|
||||||
3. Auto-generated token saved to `scripts/.host-api-token`
|
3. Auto-generated token saved to `scripts/.host-api-token`
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -37,15 +38,15 @@ cd slhaf-hub
|
|||||||
docker build -t slhaf-hub:latest .
|
docker build -t slhaf-hub:latest .
|
||||||
docker run --rm -p 8080:8080 \
|
docker run --rm -p 8080:8080 \
|
||||||
-v "$(pwd)/scripts:/app/scripts" \
|
-v "$(pwd)/scripts:/app/scripts" \
|
||||||
-e HOST_API_TOKEN=your-token \
|
-e SLHAF_HUB_TOKEN=your-token \
|
||||||
-e MAX_RUN_CONCURRENCY=8 \
|
-e MAX_RUN_CONCURRENCY=8 \
|
||||||
slhaf-hub:latest
|
slhaf-hub:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3) Docker Compose 启动
|
#### 3) Docker Compose 启动
|
||||||
```bash
|
```bash
|
||||||
# 可选:export HOST_API_TOKEN=your-token
|
# 可选:export SLHAF_HUB_TOKEN=your-token
|
||||||
# 可选:export HOST_PORT=8080
|
# 可选:export SLHAF_HUB_PORT=8080
|
||||||
# 可选:export MAX_RUN_CONCURRENCY=8
|
# 可选:export MAX_RUN_CONCURRENCY=8
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
@@ -79,7 +80,7 @@ CLI/TUI 环境变量:
|
|||||||
- `X-Host-Token: <token>`
|
- `X-Host-Token: <token>`
|
||||||
|
|
||||||
Token 来源优先级:
|
Token 来源优先级:
|
||||||
1. 环境变量 `HOST_API_TOKEN`
|
1. 环境变量 `SLHAF_HUB_TOKEN`
|
||||||
2. `scripts/.host-api-token`
|
2. `scripts/.host-api-token`
|
||||||
3. 自动生成并写入 `scripts/.host-api-token`
|
3. 自动生成并写入 `scripts/.host-api-token`
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
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:
|
||||||
- "${HOST_PORT:-8080}:8080"
|
- "${SLHAF_HUB_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
HOST_API_TOKEN: ${HOST_API_TOKEN:-}
|
SLHAF_HUB_TOKEN: ${SLHAF_HUB_TOKEN:-}
|
||||||
MAX_RUN_CONCURRENCY: ${MAX_RUN_CONCURRENCY:-}
|
MAX_RUN_CONCURRENCY: ${MAX_RUN_CONCURRENCY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./scripts:/app/scripts
|
- ./scripts:/app/scripts
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @desc: hello
|
// @desc: hello
|
||||||
// @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=true
|
// @param: upper | default=false | desc=upper text | required=false
|
||||||
|
// @response: json
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
val args: Array<String> = emptyArray()
|
lateinit var args: Array<String>
|
||||||
val kv =
|
val kv =
|
||||||
args
|
args
|
||||||
.mapNotNull {
|
.mapNotNull {
|
||||||
@@ -13,7 +14,7 @@ val kv =
|
|||||||
}.toMap()
|
}.toMap()
|
||||||
|
|
||||||
val name = kv["name"] ?: "world"
|
val name = kv["name"] ?: "world"
|
||||||
val upper = (kv["upper"]!!).toBoolean()
|
val upper = (kv["upper"] ?: "false").toBoolean()
|
||||||
val message = "Hello, $name @ ${LocalDateTime.now()}"
|
val message = "Hello, $name @ ${LocalDateTime.now()}"
|
||||||
|
|
||||||
println(if (upper) message.uppercase() else message)
|
println(if (upper) message.uppercase() else message)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
package work.slhaf.hub
|
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.PrintStream
|
|
||||||
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.dependencies.*
|
|
||||||
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
|
|
||||||
import kotlin.script.experimental.host.toScriptSource
|
|
||||||
import kotlin.script.experimental.jvm.JvmDependency
|
|
||||||
import kotlin.script.experimental.jvm.jvm
|
|
||||||
import kotlin.script.experimental.jvm.updateClasspath
|
|
||||||
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
|
|
||||||
|
|
||||||
private val scriptingHost = BasicJvmScriptingHost()
|
|
||||||
private val evalLock = Any()
|
|
||||||
private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
|
|
||||||
|
|
||||||
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
|
||||||
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>? {
|
|
||||||
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
|
|
||||||
if (value.isBlank()) return null
|
|
||||||
return value.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runtimeClasspathFromJavaProperty(): List<File> {
|
|
||||||
val raw = System.getProperty("java.class.path").orEmpty()
|
|
||||||
if (raw.isBlank()) return emptyList()
|
|
||||||
return raw
|
|
||||||
.split(File.pathSeparator)
|
|
||||||
.asSequence()
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.map(::File)
|
|
||||||
.filter { it.exists() }
|
|
||||||
.distinctBy { it.absolutePath }
|
|
||||||
.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun configureMavenDepsOnAnnotations(
|
|
||||||
context: ScriptConfigurationRefinementContext
|
|
||||||
): ResultWithDiagnostics<ScriptCompilationConfiguration> {
|
|
||||||
val annotations = context.collectedData
|
|
||||||
?.get(ScriptCollectedData.collectedAnnotations)
|
|
||||||
?.takeIf { it.isNotEmpty() }
|
|
||||||
?: return context.compilationConfiguration.asSuccess()
|
|
||||||
|
|
||||||
return runBlocking {
|
|
||||||
resolver.resolveFromScriptSourceAnnotations(annotations)
|
|
||||||
}.onSuccess { files ->
|
|
||||||
context.compilationConfiguration.with {
|
|
||||||
dependencies.append(JvmDependency(files))
|
|
||||||
}.asSuccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilationConfiguration {
|
|
||||||
return ScriptCompilationConfiguration {
|
|
||||||
baseClass(SimpleScript::class)
|
|
||||||
defaultImports(DependsOn::class, Repository::class)
|
|
||||||
|
|
||||||
jvm {
|
|
||||||
val runtimeCp = runtimeClasspathFromJavaProperty()
|
|
||||||
updateClasspath((explicitCp ?: emptyList()) + runtimeCp)
|
|
||||||
}
|
|
||||||
|
|
||||||
refineConfiguration {
|
|
||||||
onAnnotations(DependsOn::class, Repository::class, handler = ::configureMavenDepsOnAnnotations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun escapeKotlinString(value: String): String = buildString(value.length) {
|
|
||||||
value.forEach { ch ->
|
|
||||||
when (ch) {
|
|
||||||
'\\' -> append("\\\\")
|
|
||||||
'"' -> append("\\\"")
|
|
||||||
'\n' -> append("\\n")
|
|
||||||
'\r' -> append("\\r")
|
|
||||||
'\t' -> append("\\t")
|
|
||||||
else -> append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun argsInitializer(args: List<String>): String =
|
|
||||||
"val args: Array<String> = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})"
|
|
||||||
|
|
||||||
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
|
|
||||||
|
|
||||||
private fun injectArgsDeclaration(scriptContent: String, args: List<String>): String {
|
|
||||||
val lines = scriptContent.lines()
|
|
||||||
val injected = argsInitializer(args)
|
|
||||||
var replaced = false
|
|
||||||
val result = lines.map { line ->
|
|
||||||
if (!replaced && argsDeclarationRegex.matches(line)) {
|
|
||||||
replaced = true
|
|
||||||
injected
|
|
||||||
} else {
|
|
||||||
line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 evalSource(source: SourceCode): ResultWithDiagnostics<EvaluationResult> {
|
|
||||||
val explicitCp = explicitClasspathFromEnv()
|
|
||||||
return scriptingHost.eval(source, compilationConfiguration(explicitCp), null)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ScriptExecutionResult(
|
|
||||||
val ok: Boolean,
|
|
||||||
val output: String,
|
|
||||||
val metadata: ScriptMetadata,
|
|
||||||
val missingRequiredParams: List<String>,
|
|
||||||
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) {
|
|
||||||
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 {
|
|
||||||
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun evalAndCapture(
|
|
||||||
scriptFile: File,
|
|
||||||
requestContext: ScriptRequestContext = ScriptRequestContext(),
|
|
||||||
enforceRequiredParams: Boolean,
|
|
||||||
): 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 missingRequired = metadata.params
|
|
||||||
.filter { p ->
|
|
||||||
p.required && requestContext.args.none { token ->
|
|
||||||
token.substringBefore("=", missingDelimiterValue = "") == p.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.map { it.name }
|
|
||||||
|
|
||||||
val injected = injectArgsDeclaration(original, requestContext.args)
|
|
||||||
val result = evalSource(injected.toScriptSource(scriptFile.name))
|
|
||||||
val returnValueError = (result as? ResultWithDiagnostics.Success)
|
|
||||||
?.value
|
|
||||||
?.returnValue as? ResultValue.Error
|
|
||||||
val hasErrorDiagnostics = result.reports.any {
|
|
||||||
it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL
|
|
||||||
}
|
|
||||||
val diagnostics = result.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 missingMessage = if (!enforceRequiredParams || missingRequired.isEmpty()) "" else
|
|
||||||
"[ERROR] Missing required parameters: ${missingRequired.joinToString(", ")}"
|
|
||||||
|
|
||||||
val output = buffer.toString(Charsets.UTF_8.name()).trim()
|
|
||||||
val finalText = buildString {
|
|
||||||
if (output.isNotEmpty()) appendLine(output)
|
|
||||||
if (returnValueError != null) appendLine("[ERROR] ${returnValueError.error::class.simpleName}: ${returnValueError.error.message}")
|
|
||||||
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
|
|
||||||
if (missingMessage.isNotEmpty()) appendLine(missingMessage)
|
|
||||||
}.trim()
|
|
||||||
|
|
||||||
ScriptExecutionResult(
|
|
||||||
ok = result is ResultWithDiagnostics.Success &&
|
|
||||||
!hasErrorDiagnostics &&
|
|
||||||
returnValueError == null &&
|
|
||||||
(!enforceRequiredParams || missingRequired.isEmpty()),
|
|
||||||
output = finalText,
|
|
||||||
metadata = metadata,
|
|
||||||
missingRequiredParams = missingRequired,
|
|
||||||
timedOut = false,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
ps.flush()
|
|
||||||
ps.close()
|
|
||||||
System.setOut(oldOut)
|
|
||||||
System.setErr(oldErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun evalAndCaptureWithTimeout(
|
|
||||||
scriptFile: File,
|
|
||||||
requestContext: ScriptRequestContext = ScriptRequestContext(),
|
|
||||||
timeoutMs: Long,
|
|
||||||
enforceRequiredParams: Boolean = true,
|
|
||||||
): ScriptExecutionResult {
|
|
||||||
val boundedTimeout = timeoutMs.coerceAtLeast(1)
|
|
||||||
val future = evalExecutor.submit<ScriptExecutionResult> {
|
|
||||||
evalAndCapture(scriptFile, requestContext, enforceRequiredParams = enforceRequiredParams)
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
package work.slhaf.hub
|
|
||||||
|
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.application.call
|
|
||||||
import io.ktor.server.engine.embeddedServer
|
|
||||||
import io.ktor.server.netty.Netty
|
|
||||||
import io.ktor.server.request.receiveText
|
|
||||||
import io.ktor.server.response.respondText
|
|
||||||
import io.ktor.server.routing.delete
|
|
||||||
import io.ktor.server.routing.get
|
|
||||||
import io.ktor.server.routing.post
|
|
||||||
import io.ktor.server.routing.put
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
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 suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
|
||||||
val name = call.parameters["name"]
|
|
||||||
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
|
||||||
|
|
||||||
if (!name.matches(Regex("[A-Za-z0-9._-]+"))) {
|
|
||||||
return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
val scriptsRaw = call.receiveText()
|
|
||||||
val scripts =
|
|
||||||
try {
|
|
||||||
parseScriptNameSet(scriptsRaw)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
val created =
|
|
||||||
try {
|
|
||||||
security.subTokens.create(name, scripts)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
val name = call.parameters["name"]
|
|
||||||
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
|
||||||
|
|
||||||
val scriptsRaw = call.receiveText()
|
|
||||||
val scripts =
|
|
||||||
try {
|
|
||||||
parseScriptNameSet(scriptsRaw)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
val updated =
|
|
||||||
try {
|
|
||||||
security.subTokens.update(name, scripts)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleSubTokenGet(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
|
||||||
val name = call.parameters["name"]
|
|
||||||
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
|
||||||
|
|
||||||
val item = security.subTokens.get(name)
|
|
||||||
?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
|
|
||||||
|
|
||||||
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleSubTokenDelete(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
|
||||||
val name = call.parameters["name"]
|
|
||||||
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
|
||||||
|
|
||||||
val deleted = security.subTokens.delete(name)
|
|
||||||
if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
|
|
||||||
|
|
||||||
call.respondText("deleted subtoken: $name")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
|
|
||||||
routing {
|
|
||||||
get("/health") {
|
|
||||||
call.respondText("OK")
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/type") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/scripts") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
val allow = visibleScriptsFor(auth)
|
|
||||||
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/scripts/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
if (!requireRoot(call, auth)) return@get
|
|
||||||
handleGetScriptContent(call, scriptsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
post("/scripts/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@post
|
|
||||||
if (!requireRoot(call, auth)) return@post
|
|
||||||
handleCreateScript(call, scriptsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
put("/scripts/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@put
|
|
||||||
if (!requireRoot(call, auth)) return@put
|
|
||||||
handleUpdateScript(call, scriptsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete("/scripts/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@delete
|
|
||||||
if (!requireRoot(call, auth)) return@delete
|
|
||||||
handleDeleteScript(call, scriptsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/meta/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
val name = call.parameters["script"]
|
|
||||||
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
|
||||||
|
|
||||||
if (!requireScriptAccess(call, auth, name)) return@get
|
|
||||||
|
|
||||||
val script = resolveScriptFile(scriptsDir, name)
|
|
||||||
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
|
|
||||||
if (!script.exists()) {
|
|
||||||
return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
val (metadata, source) = loadMetadata(script)
|
|
||||||
call.respondText(
|
|
||||||
metadataJson(name, metadata, source),
|
|
||||||
contentType = ContentType.Application.Json,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/run/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
val name = call.parameters["script"]
|
|
||||||
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
|
||||||
if (!requireScriptAccess(call, auth, name)) return@get
|
|
||||||
runConcurrencyLimiter.withPermit {
|
|
||||||
handleRunRequest(call, scriptsDir, consumeBody = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post("/run/{script}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@post
|
|
||||||
val name = call.parameters["script"]
|
|
||||||
?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
|
||||||
if (!requireScriptAccess(call, auth, name)) return@post
|
|
||||||
runConcurrencyLimiter.withPermit {
|
|
||||||
handleRunRequest(call, scriptsDir, consumeBody = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/subtokens") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
if (!requireRoot(call, auth)) return@get
|
|
||||||
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/subtokens/{name}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@get
|
|
||||||
if (!requireRoot(call, auth)) return@get
|
|
||||||
handleSubTokenGet(call, security)
|
|
||||||
}
|
|
||||||
|
|
||||||
post("/subtokens/{name}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@post
|
|
||||||
if (!requireRoot(call, auth)) return@post
|
|
||||||
handleSubTokenCreate(call, security)
|
|
||||||
}
|
|
||||||
|
|
||||||
put("/subtokens/{name}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@put
|
|
||||||
if (!requireRoot(call, auth)) return@put
|
|
||||||
handleSubTokenUpdate(call, security)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete("/subtokens/{name}") {
|
|
||||||
val auth = requireAuth(call, security) ?: return@delete
|
|
||||||
if (!requireRoot(call, auth)) return@delete
|
|
||||||
handleSubTokenDelete(call, security)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
Authorization:
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
or X-Host-Token: <token>
|
|
||||||
GET /scripts
|
|
||||||
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 /run/{script}?k=v (root or allowed subtoken)
|
|
||||||
POST /run/{script}?k=v (root or allowed subtoken)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
314
src/main/kotlin/work/slhaf/hub/script/ScriptEngine.kt
Normal file
314
src/main/kotlin/work/slhaf/hub/script/ScriptEngine.kt
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package work.slhaf.hub
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.PrintStream
|
||||||
|
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.dependencies.*
|
||||||
|
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
|
||||||
|
import kotlin.script.experimental.host.toScriptSource
|
||||||
|
import kotlin.script.experimental.jvm.JvmDependency
|
||||||
|
import kotlin.script.experimental.jvm.jvm
|
||||||
|
import kotlin.script.experimental.jvm.updateClasspath
|
||||||
|
import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost
|
||||||
|
|
||||||
|
private val scriptingHost = BasicJvmScriptingHost()
|
||||||
|
private val evalLock = Any()
|
||||||
|
private val compiledScriptCache = ConcurrentHashMap<String, Pair<String, CompiledScript>>() // key -> stamp, compiled script
|
||||||
|
|
||||||
|
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
||||||
|
private val argsDeclarationRegexes = listOf(
|
||||||
|
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*$"""),
|
||||||
|
)
|
||||||
|
private val evalExecutor = Executors.newCachedThreadPool { r ->
|
||||||
|
Thread(r, "script-eval-worker").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun explicitClasspathFromEnv(): List<File>? {
|
||||||
|
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
|
||||||
|
if (value.isBlank()) return null
|
||||||
|
return value.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runtimeClasspathFromJavaProperty(): List<File> {
|
||||||
|
val raw = System.getProperty("java.class.path").orEmpty()
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
return raw
|
||||||
|
.split(File.pathSeparator)
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.map(::File)
|
||||||
|
.filter { it.exists() }
|
||||||
|
.distinctBy { it.absolutePath }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureMavenDepsOnAnnotations(
|
||||||
|
context: ScriptConfigurationRefinementContext
|
||||||
|
): ResultWithDiagnostics<ScriptCompilationConfiguration> {
|
||||||
|
val annotations = context.collectedData
|
||||||
|
?.get(ScriptCollectedData.collectedAnnotations)
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?: return context.compilationConfiguration.asSuccess()
|
||||||
|
|
||||||
|
return runBlocking {
|
||||||
|
resolver.resolveFromScriptSourceAnnotations(annotations)
|
||||||
|
}.onSuccess { files ->
|
||||||
|
context.compilationConfiguration.with {
|
||||||
|
dependencies.append(JvmDependency(files))
|
||||||
|
}.asSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compilationConfiguration(explicitCp: List<File>?): ScriptCompilationConfiguration {
|
||||||
|
return ScriptCompilationConfiguration {
|
||||||
|
baseClass(SimpleScript::class)
|
||||||
|
defaultImports(DependsOn::class, Repository::class)
|
||||||
|
|
||||||
|
jvm {
|
||||||
|
val runtimeCp = runtimeClasspathFromJavaProperty()
|
||||||
|
updateClasspath((explicitCp ?: emptyList()) + runtimeCp)
|
||||||
|
}
|
||||||
|
|
||||||
|
refineConfiguration {
|
||||||
|
onAnnotations(DependsOn::class, Repository::class, handler = ::configureMavenDepsOnAnnotations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun injectArgsBridgeDeclaration(scriptContent: String): String {
|
||||||
|
val lines = scriptContent.lines()
|
||||||
|
val injected = "val args: Array<String> = hostArgs"
|
||||||
|
var replaced = false
|
||||||
|
val result = lines.map { line ->
|
||||||
|
if (!replaced && argsDeclarationRegexes.any { it.matches(line) }) {
|
||||||
|
replaced = true
|
||||||
|
injected
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseArgEntry(raw: String): Pair<String, String>? {
|
||||||
|
val idx = raw.indexOf('=')
|
||||||
|
if (idx <= 0) return null
|
||||||
|
return raw.substring(0, idx) to raw.substring(idx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List<String>): List<String> {
|
||||||
|
val existingKeys = requestArgs.mapNotNull { parseArgEntry(it)?.first }.toMutableSet()
|
||||||
|
val merged = requestArgs.toMutableList()
|
||||||
|
metadata.params.forEach { param ->
|
||||||
|
if (!existingKeys.contains(param.name) && param.defaultValue != null) {
|
||||||
|
merged += "${param.name}=${param.defaultValue}"
|
||||||
|
existingKeys += param.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compileSource(source: SourceCode): ResultWithDiagnostics<CompiledScript> {
|
||||||
|
val explicitCp = explicitClasspathFromEnv()
|
||||||
|
return runBlocking {
|
||||||
|
scriptingHost.compiler(source, compilationConfiguration(explicitCp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evalCompiled(compiledScript: CompiledScript, requestContext: ScriptRequestContext): ResultWithDiagnostics<EvaluationResult> {
|
||||||
|
val evaluationConfiguration = ScriptEvaluationConfiguration {
|
||||||
|
constructorArgs(requestContext.args.toTypedArray())
|
||||||
|
}
|
||||||
|
return runBlocking {
|
||||||
|
scriptingHost.evaluator(compiledScript, evaluationConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compiledScriptFor(scriptFile: File, preparedContent: String): ResultWithDiagnostics<CompiledScript> {
|
||||||
|
val key = scriptFile.canonicalPath
|
||||||
|
val stamp = scriptStamp(scriptFile)
|
||||||
|
val cached = compiledScriptCache[key]
|
||||||
|
if (cached != null && cached.first == stamp) {
|
||||||
|
return cached.second.asSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
val compiled = compileSource(preparedContent.toScriptSource(scriptFile.name))
|
||||||
|
if (compiled is ResultWithDiagnostics.Success) {
|
||||||
|
compiledScriptCache[key] = stamp to compiled.value
|
||||||
|
}
|
||||||
|
return compiled
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ScriptExecutionResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val output: String,
|
||||||
|
val metadata: ScriptMetadata,
|
||||||
|
val missingRequiredParams: List<String>,
|
||||||
|
val timedOut: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun removeCachedMetadata(scriptFile: File) {
|
||||||
|
clearMetadataCache(scriptFile)
|
||||||
|
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 {
|
||||||
|
return evalAndCapture(scriptFile, requestContext, enforceRequiredParams = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evalAndCapture(
|
||||||
|
scriptFile: File,
|
||||||
|
requestContext: ScriptRequestContext = ScriptRequestContext(),
|
||||||
|
enforceRequiredParams: Boolean,
|
||||||
|
): 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 missingRequired = metadata.params
|
||||||
|
.filter { p ->
|
||||||
|
p.required && requestContext.args.none { token ->
|
||||||
|
token.substringBefore("=", missingDelimiterValue = "") == p.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { it.name }
|
||||||
|
val effectiveArgs = applyDefaultArgs(metadata, requestContext.args)
|
||||||
|
|
||||||
|
val injected = injectArgsBridgeDeclaration(original)
|
||||||
|
val compilationResult = compiledScriptFor(scriptFile, injected)
|
||||||
|
val evaluationResult = if (compilationResult is ResultWithDiagnostics.Success) {
|
||||||
|
evalCompiled(compilationResult.value, requestContext.copy(args = effectiveArgs))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val reports = evaluationResult?.reports ?: compilationResult.reports
|
||||||
|
val returnValueError = (evaluationResult as? ResultWithDiagnostics.Success)
|
||||||
|
?.value
|
||||||
|
?.returnValue as? ResultValue.Error
|
||||||
|
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 missingMessage = if (!enforceRequiredParams || missingRequired.isEmpty()) "" else
|
||||||
|
"[ERROR] Missing required parameters: ${missingRequired.joinToString(", ")}"
|
||||||
|
|
||||||
|
val output = buffer.toString(Charsets.UTF_8.name()).trim()
|
||||||
|
val finalText = buildString {
|
||||||
|
if (output.isNotEmpty()) appendLine(output)
|
||||||
|
if (returnValueError != null) appendLine("[ERROR] ${returnValueError.error::class.simpleName}: ${returnValueError.error.message}")
|
||||||
|
if (diagnostics.isNotEmpty()) appendLine(diagnostics)
|
||||||
|
if (missingMessage.isNotEmpty()) appendLine(missingMessage)
|
||||||
|
}.trim()
|
||||||
|
|
||||||
|
ScriptExecutionResult(
|
||||||
|
ok = compilationResult is ResultWithDiagnostics.Success &&
|
||||||
|
evaluationResult is ResultWithDiagnostics.Success &&
|
||||||
|
!hasErrorDiagnostics &&
|
||||||
|
returnValueError == null &&
|
||||||
|
(!enforceRequiredParams || missingRequired.isEmpty()),
|
||||||
|
output = finalText,
|
||||||
|
metadata = metadata,
|
||||||
|
missingRequiredParams = missingRequired,
|
||||||
|
timedOut = false,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
ps.flush()
|
||||||
|
ps.close()
|
||||||
|
System.setOut(oldOut)
|
||||||
|
System.setErr(oldErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evalAndCaptureWithTimeout(
|
||||||
|
scriptFile: File,
|
||||||
|
requestContext: ScriptRequestContext = ScriptRequestContext(),
|
||||||
|
timeoutMs: Long,
|
||||||
|
enforceRequiredParams: Boolean = true,
|
||||||
|
): ScriptExecutionResult {
|
||||||
|
val boundedTimeout = timeoutMs.coerceAtLeast(1)
|
||||||
|
val future = evalExecutor.submit<ScriptExecutionResult> {
|
||||||
|
evalAndCapture(scriptFile, requestContext, enforceRequiredParams = enforceRequiredParams)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/main/kotlin/work/slhaf/hub/script/ScriptMetadataEngine.kt
Normal file
194
src/main/kotlin/work/slhaf/hub/script/ScriptMetadataEngine.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
@@ -3,4 +3,6 @@ package work.slhaf.hub
|
|||||||
import kotlin.script.experimental.annotations.KotlinScript
|
import kotlin.script.experimental.annotations.KotlinScript
|
||||||
|
|
||||||
@KotlinScript(fileExtension = "hub.kts")
|
@KotlinScript(fileExtension = "hub.kts")
|
||||||
abstract class SimpleScript
|
abstract class SimpleScript(
|
||||||
|
val hostArgs: Array<String> = emptyArray(),
|
||||||
|
)
|
||||||
83
src/main/kotlin/work/slhaf/hub/web/WebHost.kt
Normal file
83
src/main/kotlin/work/slhaf/hub/web/WebHost.kt
Normal 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)
|
||||||
|
}
|
||||||
398
src/main/kotlin/work/slhaf/hub/web/WebRoutes.kt
Normal file
398
src/main/kotlin/work/slhaf/hub/web/WebRoutes.kt
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
package work.slhaf.hub
|
||||||
|
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.ApplicationCall
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.request.httpMethod
|
||||||
|
import io.ktor.server.request.path
|
||||||
|
import io.ktor.server.request.receiveText
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.Routing
|
||||||
|
import io.ktor.server.routing.delete
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import io.ktor.server.routing.put
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
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(
|
||||||
|
call: ApplicationCall,
|
||||||
|
endpoint: String,
|
||||||
|
authProvider: () -> AuthContext? = { null },
|
||||||
|
crossinline block: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
val startNs = System.nanoTime()
|
||||||
|
var thrown: Throwable? = null
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
thrown = t
|
||||||
|
throw t
|
||||||
|
} finally {
|
||||||
|
val durationMs = (System.nanoTime() - startNs) / 1_000_000
|
||||||
|
val auth = authProvider()
|
||||||
|
val tokenType = auth?.type?.name?.lowercase() ?: "none"
|
||||||
|
val subToken = auth?.subTokenName ?: "-"
|
||||||
|
val script = call.parameters["script"] ?: "-"
|
||||||
|
val sanitizedPath = sanitizeRequestPath(call.request.path())
|
||||||
|
val status = call.response.status()?.value ?: if (thrown == null) 200 else 500
|
||||||
|
if (thrown == null) {
|
||||||
|
requestLogger.info(
|
||||||
|
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={}",
|
||||||
|
endpoint,
|
||||||
|
call.request.httpMethod.value,
|
||||||
|
sanitizedPath,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
tokenType,
|
||||||
|
subToken,
|
||||||
|
script,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
requestLogger.warn(
|
||||||
|
"endpoint={} method={} path={} status={} durationMs={} tokenType={} subToken={} script={} error={}",
|
||||||
|
endpoint,
|
||||||
|
call.request.httpMethod.value,
|
||||||
|
sanitizedPath,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
tokenType,
|
||||||
|
subToken,
|
||||||
|
script,
|
||||||
|
"${thrown::class.simpleName}: ${thrown.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenCreate(call: ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
if (!name.matches(Regex("[A-Za-z0-9._-]+"))) {
|
||||||
|
return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scriptsRaw = call.receiveText()
|
||||||
|
val scripts =
|
||||||
|
try {
|
||||||
|
parseScriptNameSet(scriptsRaw)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val created =
|
||||||
|
try {
|
||||||
|
security.subTokens.create(name, scripts)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenUpdate(call: ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val scriptsRaw = call.receiveText()
|
||||||
|
val scripts =
|
||||||
|
try {
|
||||||
|
parseScriptNameSet(scriptsRaw)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated =
|
||||||
|
try {
|
||||||
|
security.subTokens.update(name, scripts)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenGet(call: ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val item = security.subTokens.get(name)
|
||||||
|
?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
|
||||||
|
|
||||||
|
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenDelete(call: ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val deleted = security.subTokens.delete(name)
|
||||||
|
if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
|
||||||
|
|
||||||
|
call.respondText("deleted subtoken: $name")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleTypeForAuth(call: ApplicationCall, auth: AuthContext) {
|
||||||
|
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleScriptsForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
|
||||||
|
val allow = visibleScriptsFor(auth)
|
||||||
|
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMetaForAuth(call: ApplicationCall, scriptsDir: File, auth: AuthContext) {
|
||||||
|
val name = call.parameters["script"]
|
||||||
|
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||||
|
if (!requireScriptAccess(call, auth, name)) return
|
||||||
|
|
||||||
|
val script = resolveScriptFile(scriptsDir, name)
|
||||||
|
?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
|
||||||
|
if (!script.exists()) {
|
||||||
|
return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
val (metadata, source) = loadMetadata(script)
|
||||||
|
call.respondText(
|
||||||
|
metadataJson(name, metadata, source),
|
||||||
|
contentType = ContentType.Application.Json,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleRunForAuth(
|
||||||
|
call: ApplicationCall,
|
||||||
|
scriptsDir: File,
|
||||||
|
auth: AuthContext,
|
||||||
|
runConcurrencyLimiter: Semaphore,
|
||||||
|
consumeBody: Boolean,
|
||||||
|
) {
|
||||||
|
val name = call.parameters["script"]
|
||||||
|
?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||||
|
if (!requireScriptAccess(call, auth, name)) return
|
||||||
|
runConcurrencyLimiter.withPermit {
|
||||||
|
handleRunRequest(call, scriptsDir, consumeBody = consumeBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Routing.registerHeaderAuthenticatedRoutes(
|
||||||
|
scriptsDir: File,
|
||||||
|
security: HostSecurity,
|
||||||
|
runConcurrencyLimiter: Semaphore,
|
||||||
|
) {
|
||||||
|
get("/type") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "type", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleTypeForAuth(call, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/scripts") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "scripts.list", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleScriptsForAuth(call, scriptsDir, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/meta/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "meta.get", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleMetaForAuth(call, scriptsDir, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/run/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "run.get", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/run/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "run.post", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Routing.registerSubTokenPathRoutes(
|
||||||
|
scriptsDir: File,
|
||||||
|
security: HostSecurity,
|
||||||
|
runConcurrencyLimiter: Semaphore,
|
||||||
|
) {
|
||||||
|
get("/u/{subAuth}/type") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "u.type", { authForLog }) {
|
||||||
|
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleTypeForAuth(call, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/u/{subAuth}/scripts") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "u.scripts.list", { authForLog }) {
|
||||||
|
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleScriptsForAuth(call, scriptsDir, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/u/{subAuth}/meta/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "u.meta.get", { authForLog }) {
|
||||||
|
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleMetaForAuth(call, scriptsDir, auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/u/{subAuth}/run/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "u.run.get", { authForLog }) {
|
||||||
|
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/u/{subAuth}/run/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "u.run.post", { authForLog }) {
|
||||||
|
val auth = requireSubTokenPathAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
handleRunForAuth(call, scriptsDir, auth, runConcurrencyLimiter, consumeBody = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurrencyLimiter: Semaphore) {
|
||||||
|
routing {
|
||||||
|
get("/health") {
|
||||||
|
withRequestAudit(call, "health") {
|
||||||
|
call.respondText("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHeaderAuthenticatedRoutes(scriptsDir, security, runConcurrencyLimiter)
|
||||||
|
registerSubTokenPathRoutes(scriptsDir, security, runConcurrencyLimiter)
|
||||||
|
|
||||||
|
get("/scripts/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "scripts.get", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleGetScriptContent(call, scriptsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/scripts/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "scripts.create", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleCreateScript(call, scriptsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
put("/scripts/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "scripts.update", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleUpdateScript(call, scriptsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete("/scripts/{script}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "scripts.delete", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleDeleteScript(call, scriptsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/subtokens") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "subtokens.list", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/subtokens/{name}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "subtokens.get", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleSubTokenGet(call, security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/subtokens/{name}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "subtokens.create", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleSubTokenCreate(call, security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
put("/subtokens/{name}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "subtokens.update", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleSubTokenUpdate(call, security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete("/subtokens/{name}") {
|
||||||
|
var authForLog: AuthContext? = null
|
||||||
|
withRequestAudit(call, "subtokens.delete", { authForLog }) {
|
||||||
|
val auth = requireAuth(call, security) ?: return@withRequestAudit
|
||||||
|
authForLog = auth
|
||||||
|
if (!requireRoot(call, auth)) return@withRequestAudit
|
||||||
|
handleSubTokenDelete(call, security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,12 +93,20 @@ 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> {
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import io.ktor.server.response.respondText
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
|
||||||
private const val ENV_API_TOKEN = "HOST_API_TOKEN"
|
private const val ENV_API_TOKEN = "SLHAF_HUB_TOKEN"
|
||||||
private const val TOKEN_FILE_NAME = ".host-api-token"
|
private const val TOKEN_FILE_NAME = ".host-api-token"
|
||||||
private const val SUBTOKEN_FILE_NAME = ".host-subtokens.db"
|
private const val SUBTOKEN_FILE_NAME = ".host-subtokens.db"
|
||||||
private const val ALT_TOKEN_HEADER = "X-Host-Token"
|
private const val ALT_TOKEN_HEADER = "X-Host-Token"
|
||||||
@@ -62,6 +62,13 @@ class SubTokenStore(
|
|||||||
byName.values.firstOrNull { it.token == token }
|
byName.values.firstOrNull { it.token == token }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun findByNameAndToken(name: String, token: String): SubTokenRecord? =
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
val record = byName[name] ?: return null
|
||||||
|
if (record.token == token) record else null
|
||||||
|
}
|
||||||
|
|
||||||
fun create(name: String, scripts: Set<String>): SubTokenRecord {
|
fun create(name: String, scripts: Set<String>): SubTokenRecord {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
@@ -198,6 +205,23 @@ private fun authenticateToken(call: ApplicationCall, security: HostSecurity): Au
|
|||||||
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
|
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseSubTokenPathCredential(raw: String?): Pair<String, String>? {
|
||||||
|
val value = raw?.trim().orEmpty()
|
||||||
|
if (value.isBlank()) return null
|
||||||
|
val at = value.indexOf('@')
|
||||||
|
if (at <= 0 || at == value.lastIndex) return null
|
||||||
|
val name = value.substring(0, at).trim()
|
||||||
|
val token = value.substring(at + 1).trim()
|
||||||
|
if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return null
|
||||||
|
return name to token
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authenticateSubTokenPath(pathCredential: String?, security: HostSecurity): AuthContext? {
|
||||||
|
val (name, token) = parseSubTokenPathCredential(pathCredential) ?: return null
|
||||||
|
val sub = security.subTokens.findByNameAndToken(name, token) ?: return null
|
||||||
|
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? {
|
suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? {
|
||||||
val context = authenticateToken(call, security)
|
val context = authenticateToken(call, security)
|
||||||
if (context != null) return context
|
if (context != null) return context
|
||||||
@@ -207,6 +231,21 @@ suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthCont
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun requireSubTokenPathAuth(
|
||||||
|
call: ApplicationCall,
|
||||||
|
security: HostSecurity,
|
||||||
|
pathParamName: String = "subAuth",
|
||||||
|
): AuthContext? {
|
||||||
|
val context = authenticateSubTokenPath(call.parameters[pathParamName], security)
|
||||||
|
if (context != null) return context
|
||||||
|
call.respondText(
|
||||||
|
"unauthorized subtoken path, expected /u/<subtoken_name>@<subtoken>/...",
|
||||||
|
status = HttpStatusCode.Unauthorized,
|
||||||
|
contentType = ContentType.Text.Plain,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean {
|
suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean {
|
||||||
if (context.type == TokenType.ROOT) return true
|
if (context.type == TokenType.ROOT) return true
|
||||||
call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)
|
call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)
|
||||||
@@ -1,271 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
@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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
152
src/test/kotlin/work/slhaf/hub/web/WebAuthAndScriptApiTest.kt
Normal file
152
src/test/kotlin/work/slhaf/hub/web/WebAuthAndScriptApiTest.kt
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/test/kotlin/work/slhaf/hub/web/WebHostTestSupport.kt
Normal file
51
src/test/kotlin/work/slhaf/hub/web/WebHostTestSupport.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/test/kotlin/work/slhaf/hub/web/WebRunApiTest.kt
Normal file
163
src/test/kotlin/work/slhaf/hub/web/WebRunApiTest.kt
Normal 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>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/test/kotlin/work/slhaf/hub/web/WebSubTokenApiTest.kt
Normal file
98
src/test/kotlin/work/slhaf/hub/web/WebSubTokenApiTest.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import java.net.http.HttpResponse
|
|||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
val ENV_API_BASE_URL = "HOST_API_BASE_URL"
|
val ENV_API_BASE_URL = "SLHAF_HUB_BASE_URL"
|
||||||
val ENV_API_TOKEN = "HOST_API_TOKEN"
|
val ENV_API_TOKEN = "SLHAF_HUB_TOKEN"
|
||||||
|
|
||||||
data class GlobalOptions(
|
data class GlobalOptions(
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
@@ -30,12 +30,13 @@ Usage:
|
|||||||
kotlin slhaf-hub-cli.kts [global options] <command> [command options]
|
kotlin slhaf-hub-cli.kts [global options] <command> [command options]
|
||||||
|
|
||||||
Global options:
|
Global options:
|
||||||
--base-url=<url> Default: HOST_API_BASE_URL or http://127.0.0.1:8080
|
--base-url=<url> Default: SLHAF_HUB_BASE_URL or http://127.0.0.1:8080
|
||||||
--token=<token> Authorization token
|
--token=<token> Authorization token
|
||||||
--token-file=<path> Load token from file (fallback: HOST_API_TOKEN env)
|
--token-file=<path> Load token from file (fallback: SLHAF_HUB_TOKEN env)
|
||||||
|
|
||||||
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" -> {
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ private val YELLOW = "\u001b[33m"
|
|||||||
private val RED = "\u001b[31m"
|
private val RED = "\u001b[31m"
|
||||||
private val BG_BLUE = "\u001b[44m"
|
private val BG_BLUE = "\u001b[44m"
|
||||||
private val FG_BLACK = "\u001b[30m"
|
private val FG_BLACK = "\u001b[30m"
|
||||||
val ENV_API_BASE_URL = "HOST_API_BASE_URL"
|
val ENV_API_BASE_URL = "SLHAF_HUB_BASE_URL"
|
||||||
val ENV_API_TOKEN = "HOST_API_TOKEN"
|
val ENV_API_TOKEN = "SLHAF_HUB_TOKEN"
|
||||||
|
|
||||||
private fun ok(text: String) = "$GREEN$text$RESET"
|
private fun ok(text: String) = "$GREEN$text$RESET"
|
||||||
private fun warn(text: String) = "$YELLOW$text$RESET"
|
private fun warn(text: String) = "$YELLOW$text$RESET"
|
||||||
@@ -81,8 +81,8 @@ Keys:
|
|||||||
q Quit
|
q Quit
|
||||||
|
|
||||||
Env fallback:
|
Env fallback:
|
||||||
HOST_API_BASE_URL
|
SLHAF_HUB_BASE_URL
|
||||||
HOST_API_TOKEN
|
SLHAF_HUB_TOKEN
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun parseOptions(args: List<String>): Options {
|
fun parseOptions(args: List<String>): Options {
|
||||||
@@ -113,7 +113,7 @@ fun readToken(options: Options): String {
|
|||||||
return file.readText().trim()
|
return file.readText().trim()
|
||||||
}
|
}
|
||||||
return System.getenv(ENV_API_TOKEN)?.trim()
|
return System.getenv(ENV_API_TOKEN)?.trim()
|
||||||
?: error("Missing token. Use --token or --token-file or HOST_API_TOKEN")
|
?: error("Missing token. Use --token or --token-file or SLHAF_HUB_TOKEN")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||||
@@ -476,9 +476,10 @@ 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
|
||||||
|
|
||||||
val args: Array<String> = emptyArray()
|
lateinit var args: Array<String>
|
||||||
val kv = args.mapNotNull {
|
val kv = args.mapNotNull {
|
||||||
val i = it.indexOf('=')
|
val i = it.indexOf('=')
|
||||||
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
|
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user