Script monitoring

This commit is contained in:
2026-09-01 11:47:10 +02:00
parent 8da391b6ce
commit 85902620da
37 changed files with 803 additions and 71 deletions
+26 -2
View File
@@ -5,10 +5,11 @@ plugins {
application
alias(libs.plugins.serialization)
alias(libs.plugins.shadow)
alias(libs.plugins.buildconfig)
}
group = "com.jaytux.phoebench"
version = PhoebenchVersion(1, 1, 1)
version = rootProject.version as PhoebenchVersion
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version")
@@ -28,12 +29,15 @@ val generateVersion = tasks.register<Task>("serverVersion") {
}
}
val isDebug = envValue("PHOEBENCH_BUILD_RELEASE") == null
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.clikt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.websocket)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization)
implementation(project(":common"))
@@ -42,6 +46,7 @@ dependencies {
implementation(libs.slf4j.simple)
implementation(libs.java.keystore)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.process)
}
application {
@@ -75,5 +80,24 @@ kotlin {
}
tasks.withType<ShadowJar> {
archiveFileName = "phoebench-cli.jar"
archiveFileName = if(isDebug) "phoebench-cli-debug.jar" else "phoebench-cli.jar"
}
buildConfig {
generateAtSync = false
useKotlinOutput { internalVisibility = false }
println("Source sets: ${kotlin.sourceSets.toList().map { it.name }}")
className("PersistenceConstants")
packageName("com.jaytux.phoebench.clients.cli")
if(isDebug) {
println("Using debug CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli.debug")
}
else {
println("Using release CLI service")
buildConfigField("SERVICE", "com.jaytux.phoebench.cli")
}
}
@@ -1,6 +1,8 @@
package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
@@ -227,6 +229,24 @@ object CLI {
}
}
@Suppress("unused")
class Monitor : CliktCommand(name = "monitor") {
val finder by mutuallyExclusiveOptions<Project.IProjectIdentification>(
option("--id", help = "Find a project by UUID.").convert { Project.ID(Uuid.parse(it)) },
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
val split = it.split('/')
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
Project.ProjectName(split[0], split[1])
}
).single()
val command by argument("command", help = "The command to be run")
val commandArgs by argument("arguments", help = "Arguments to pass to the command").multiple()
override fun run() {
MonitorHandler.monitor(finder, command, commandArgs)
}
}
@Suppress("unused")
class Version : CliktCommand(name = "version") {
override fun run() {
@@ -1,6 +1,7 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.CSERoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
@@ -9,15 +10,31 @@ import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import com.jaytux.phoebench.common.value
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.sendSerialized
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.reflect.TypeInfo
import io.ktor.util.reflect.typeInfo
import io.ktor.utils.io.CancellationException
import io.ktor.websocket.CloseReason
import kotlinx.coroutines.asExecutor
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import java.net.URL
import kotlin.uuid.Uuid
object Client {
@@ -68,6 +85,10 @@ object Client {
}
}
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
}
init {
@@ -91,6 +112,19 @@ object Client {
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body)
suspend fun <TParams: Any, TEvent: Any> callCSE(
route: CSERoute<TParams, TEvent>, params: TParams,
body: suspend (sender: suspend (TEvent) -> Unit) -> Unit
): Either<ErrorResponse, Unit> {
try {
val client = IClient.Default(_client, _server ?: throw IllegalStateException("No server URL set."))
return route.call(client, params, body)
}
catch(e: CancellationException) {
return ErrorResponse("Event stream disconnected.").error()
}
}
fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh
_accessToken = tokens.access
@@ -0,0 +1,83 @@
package com.jaytux.phoebench.clients.cli
import com.github.pgreze.process.Redirect
import com.github.pgreze.process.process
import com.jaytux.phoebench.clients.cli.ProjectHandlers.toId
import com.jaytux.phoebench.common.ANSI
import com.jaytux.phoebench.common.ClientMonitorEvent
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.Stream
import com.jaytux.phoebench.common.bind
import com.jaytux.phoebench.common.fold
import io.ktor.http.HttpMethod
import io.ktor.utils.io.CancellationException
import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Instant
object MonitorHandler {
private fun filterAnsi(line: String): Pair<String, List<ANSI>> {
val builder = StringBuilder()
val codes = mutableSetOf<ANSI>()
var remaining = line
while(remaining.isNotEmpty()) {
val next = remaining.indexOf("\u001B[")
if(next < 0) {
builder.append(remaining)
remaining = ""
}
else {
builder.append(remaining.substring(0, next))
val end = remaining.indexOf('m', startIndex = next + 2)
if(end == -1) return (line to listOf()) // invalid...
codes += remaining.substring(next + 2, end).split(';').mapNotNull {
it.toIntOrNull()?.let { i -> ANSI.ansiMapping[i] }
}
remaining = remaining.substring(end + 1)
}
}
return builder.toString() to codes.toList()
}
fun monitor(find: CLI.Commands.Project.IProjectIdentification?, command: String, args: List<String>) {
val project = ProjectHandlers.ensureProjectIdentification(find)
val combinedCommand = arrayOf(command, *args.toTypedArray())
tryAuthenticated {
project.toId().bind { id ->
Client.callCSE(Routes.CSE.monitor, id) { sender ->
try {
val res = process(
*combinedCommand,
stdin = null,
stdout = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDOUT, options = options))
println(line)
}
},
stderr = Redirect.Consume { flow ->
flow.collect { line ->
val (updLine, options) = filterAnsi(line)
sender(ClientMonitorEvent.Message(msg = updLine, stream = Stream.STDERR, options = options))
System.err.println(line)
}
}
)
sender(ClientMonitorEvent.ApplicationFinished(res.resultCode))
}
catch(e: CancellationException) {
throw e
}
catch(e: Exception) {
System.err.println("Launch failed: ${e.message} (${e::class.simpleName})")
}
}
}
}.fold({
System.err.println("Failed to run: ${it.msg}")
}) {}
}
}
@@ -10,7 +10,7 @@ import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
const val SERVICE = PersistenceConstants.SERVICE
private var _backend: IBackend = KeyringBackend
interface IBackend {