added client command

This commit is contained in:
2025-01-16 11:16:01 +08:00
parent 225f156864
commit 747168cda3
18 changed files with 594 additions and 34 deletions

View File

@@ -1,26 +0,0 @@
plugins {
alias catalog.plugins.gradle.jmh
alias catalog.plugins.lombok
}
import me.champeau.jmh.JMHTask
dependencies {
implementation rootProject
implementation catalog.jwo
implementation catalog.xz
implementation catalog.jackson.databind
jmhAnnotationProcessor catalog.lombok
}
jmh {
threads = 4
iterations = 2
fork = 1
warmupIterations = 1
warmupForks = 0
resultFormat = 'JSON'
}

View File

@@ -1 +0,0 @@
gbcs.server.url= http://localhost:8080

View File

@@ -7,12 +7,11 @@ plugins {
id 'maven-publish'
}
import net.woggioni.gradle.envelope.EnvelopeJarTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import net.woggioni.gradle.graalvm.NativeImagePlugin
import net.woggioni.gradle.graalvm.NativeImageTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
Property<String> mainClassName = objects.property(String.class)
mainClassName.set('net.woggioni.gbcs.cli.GradleBuildCacheServerCli')
@@ -43,6 +42,7 @@ dependencies {
implementation catalog.netty.codec.http
implementation catalog.picocli
implementation project(":gbcs-client")
implementation rootProject
// runtimeOnly catalog.slf4j.jdk14

View File

@@ -3,6 +3,7 @@ module net.woggioni.gbcs.cli {
requires net.woggioni.gbcs;
requires info.picocli;
requires net.woggioni.gbcs.base;
requires net.woggioni.gbcs.client;
requires kotlin.stdlib;
requires net.woggioni.jwo;

View File

@@ -8,6 +8,8 @@ import net.woggioni.gbcs.base.debug
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.cli.impl.AbstractVersionProvider
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.cli.impl.commands.BenchmarkCommand
import net.woggioni.gbcs.cli.impl.commands.ClientCommand
import net.woggioni.gbcs.cli.impl.commands.PasswordHashCommand
import net.woggioni.jwo.Application
import net.woggioni.jwo.JWO
@@ -22,7 +24,7 @@ import java.nio.file.Path
@CommandLine.Command(
name = "gbcs", versionProvider = GradleBuildCacheServerCli.VersionProvider::class
)
class GradleBuildCacheServerCli(application : Application, private val log : Logger) : GbcsCommand() {
class GradleBuildCacheServerCli(application: Application, private val log: Logger) : GbcsCommand() {
class VersionProvider : AbstractVersionProvider()
companion object {
@@ -42,6 +44,15 @@ class GradleBuildCacheServerCli(application : Application, private val log : Log
CommandLine.ExitCode.SOFTWARE
}
commandLine.addSubcommand(PasswordHashCommand())
val clientApp = Application.builder("gbcs-client")
.configurationDirectoryEnvVar("GBCS_CLIENT_CONFIGURATION_DIR")
.configurationDirectoryPropertyKey("net.woggioni.gbcs.client.conf.dir")
.build()
commandLine.addSubcommand(
CommandLine(ClientCommand(clientApp)).apply {
addSubcommand(BenchmarkCommand())
})
System.exit(commandLine.execute(*args))
}
}
@@ -60,13 +71,13 @@ class GradleBuildCacheServerCli(application : Application, private val log : Log
@CommandLine.Spec
private lateinit var spec: CommandSpec
private fun findConfigurationFile(app : Application): Path {
private fun findConfigurationFile(app: Application): Path {
val confDir = app.computeConfigurationDirectory()
val configurationFile = confDir.resolve("gbcs.xml")
return configurationFile
}
private fun createDefaultConfigurationFile(configurationFile : Path) {
private fun createDefaultConfigurationFile(configurationFile: Path) {
log.info {
"Creating default configuration file at '$configurationFile'"
}

View File

@@ -0,0 +1,119 @@
package net.woggioni.gbcs.cli.impl.commands
import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.base.error
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.client.GbcsClient
import picocli.CommandLine
import java.security.SecureRandom
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import kotlin.random.Random
@CommandLine.Command(
name = "benchmark",
description = ["Run a load test against the server"],
showDefaultValues = true
)
class BenchmarkCommand : GbcsCommand() {
private val log = contextLogger()
@CommandLine.Spec
private lateinit var spec: CommandLine.Model.CommandSpec
@CommandLine.Option(
names = ["-e", "--entries"],
description = ["Total number of elements to be added to the cache"],
paramLabel = "NUMBER_OF_ENTRIES"
)
private var numberOfEntries = 1000
override fun run() {
val clientCommand = spec.parent().userObject() as ClientCommand
val profile = clientCommand.profileName.let { profileName ->
clientCommand.configuration.profiles[profileName]
?: throw IllegalArgumentException("Profile $profileName does not exist in configuration")
}
val client = GbcsClient(profile)
val entryGenerator = sequence {
val random = Random(SecureRandom.getInstance("NativePRNGNonBlocking").nextLong())
while (true) {
val key = Base64.getUrlEncoder().encode(random.nextBytes(16)).toString(Charsets.UTF_8)
val value = random.nextBytes(0x1000)
yield(key to value)
}
}
val entries = let {
val completionQueue = LinkedBlockingQueue<Future<Pair<String, ByteArray>>>(numberOfEntries)
val start = Instant.now()
entryGenerator.take(numberOfEntries).forEach { entry ->
val future = client.put(entry.first, entry.second).thenApply { entry }
future.whenComplete { _, _ ->
completionQueue.put(future)
}
}
val inserted = sequence<Pair<String, ByteArray>> {
var completionCounter = 0
while (completionCounter < numberOfEntries) {
val future = completionQueue.take()
try {
yield(future.get())
} catch (ee: ExecutionException) {
val cause = ee.cause ?: ee
log.error(cause.message, cause)
}
completionCounter += 1
}
}.toList()
val end = Instant.now()
log.info {
val elapsed = Duration.between(start, end).toMillis()
"Insertion rate: ${numberOfEntries.toDouble() / elapsed * 1000} ops/s"
}
inserted
}
log.info {
"Inserted ${entries.size} entries"
}
if (entries.isNotEmpty()) {
val completionQueue = LinkedBlockingQueue<Future<Unit>>(entries.size)
val start = Instant.now()
entries.forEach { entry ->
val future = client.get(entry.first).thenApply {
if (it == null) {
log.error {
"Missing entry for key '${entry.first}'"
}
} else if (!entry.second.contentEquals(it)) {
log.error {
"Retrieved a value different from what was inserted for key '${entry.first}'"
}
}
}
future.whenComplete { _, _ ->
completionQueue.put(future)
}
}
var completionCounter = 0
while (completionCounter < entries.size) {
completionQueue.take()
completionCounter += 1
}
val end = Instant.now()
log.info {
val elapsed = Duration.between(start, end).toMillis()
"Retrieval rate: ${entries.size.toDouble() / elapsed * 1000} ops/s"
}
} else {
log.error("Skipping retrieval benchmark as it was not possible to insert any entry in the cache")
}
}
}

View File

@@ -0,0 +1,50 @@
package net.woggioni.gbcs.cli.impl.commands
import net.woggioni.gbcs.client.GbcsClient
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.jwo.Application
import picocli.CommandLine
import java.nio.file.Path
@CommandLine.Command(
name = "client",
description = ["GBCS client"],
showDefaultValues = true
)
class ClientCommand(app : Application) : GbcsCommand() {
companion object {
private fun findConfigurationFile(app: Application): Path {
val confDir = app.computeConfigurationDirectory()
val configurationFile = confDir.resolve("gbcs-client.xml")
return configurationFile
}
}
@CommandLine.Option(
names = ["-c", "--configuration"],
description = ["Path to the client configuration file"],
paramLabel = "CONFIGURATION_FILE"
)
private var configurationFile : Path = findConfigurationFile(app)
@CommandLine.Option(
names = ["-p", "--profile"],
description = ["Name of the client profile to be used"],
paramLabel = "PROFILE",
required = true
)
var profileName : String? = null
val configuration : GbcsClient.Configuration by lazy {
GbcsClient.Configuration.parse(configurationFile)
}
override fun run() {
println("Available profiles:")
configuration.profiles.forEach { (profileName, _) ->
println(profileName)
}
}
}

13
gbcs-client/build.gradle Normal file
View File

@@ -0,0 +1,13 @@
plugins {
id 'java-library'
alias catalog.plugins.kotlin.jvm
}
dependencies {
implementation project(':gbcs-base')
implementation catalog.picocli
implementation catalog.netty.buffer
implementation catalog.netty.codec.http
}

View File

@@ -0,0 +1,6 @@
gbcs.server.url= https://gbcs.woggioni.net:443
gbcs.client.ssl.keystore.file=conf/woggioni@c962475fa38.p12
gbcs.client.ssl.keystore.password=password
gbcs.client.ssl.key.password=password
gbcs.client.ssl.truststore.file=conf/truststore.pfx
gbcs.client.ssl.truststore.password=password

View File

@@ -0,0 +1,15 @@
module net.woggioni.gbcs.client {
requires io.netty.handler;
requires io.netty.codec.http;
requires io.netty.transport;
requires kotlin.stdlib;
requires io.netty.common;
requires io.netty.buffer;
requires java.xml;
requires net.woggioni.gbcs.base;
requires io.netty.codec;
exports net.woggioni.gbcs.client;
opens net.woggioni.gbcs.client.schema;
}

View File

@@ -0,0 +1,209 @@
package net.woggioni.gbcs.client
import io.netty.bootstrap.Bootstrap
import io.netty.buffer.ByteBuf
import io.netty.buffer.Unpooled
import io.netty.channel.Channel
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInitializer
import io.netty.channel.ChannelPipeline
import io.netty.channel.SimpleChannelInboundHandler
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioSocketChannel
import io.netty.handler.codec.DecoderException
import io.netty.handler.codec.http.DefaultFullHttpRequest
import io.netty.handler.codec.http.FullHttpRequest
import io.netty.handler.codec.http.FullHttpResponse
import io.netty.handler.codec.http.HttpClientCodec
import io.netty.handler.codec.http.HttpContentDecompressor
import io.netty.handler.codec.http.HttpHeaderNames
import io.netty.handler.codec.http.HttpHeaderValues
import io.netty.handler.codec.http.HttpMethod
import io.netty.handler.codec.http.HttpObjectAggregator
import io.netty.handler.codec.http.HttpResponseStatus
import io.netty.handler.codec.http.HttpVersion
import io.netty.handler.ssl.SslContext
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.stream.ChunkedWriteHandler
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.client.impl.Parser
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.Base64
import java.util.concurrent.CompletableFuture
import io.netty.util.concurrent.Future as NettyFuture
class GbcsClient(private val profile: Configuration.Profile) : AutoCloseable {
private val group: NioEventLoopGroup
private var sslContext: SslContext
data class Configuration(
val profiles : Map<String, Profile>
) {
sealed class Authentication {
data class TlsClientAuthenticationCredentials(val key: PrivateKey, val certificateChain: Array<X509Certificate>) : Authentication()
data class BasicAuthenticationCredentials(val username: String, val password: String) : Authentication()
}
data class Profile(
val serverURI: URI,
val authentication : Authentication?
)
companion object {
fun parse(path : Path) : Configuration {
return Files.newInputStream(path).use {
Xml.parseXml(path.toUri().toURL(), it)
}.let(Parser::parse)
}
}
}
init {
group = NioEventLoopGroup()
this.sslContext = SslContextBuilder.forClient().also { builder ->
(profile.authentication as? Configuration.Authentication.TlsClientAuthenticationCredentials)?.let { tlsClientAuthenticationCredentials ->
builder.keyManager(
tlsClientAuthenticationCredentials.key,
*tlsClientAuthenticationCredentials.certificateChain
)
}
}.build()
}
fun get(key: String): CompletableFuture<ByteArray?> {
return sendRequest(profile.serverURI.resolve(key), HttpMethod.GET, null)
.thenApply {
val status = it.status()
if (it.status() == HttpResponseStatus.NOT_FOUND) {
null
} else if (it.status() != HttpResponseStatus.OK) {
throw HttpException(status)
} else {
it.content()
}
}.thenApply { maybeByteBuf ->
maybeByteBuf?.let {
val result = ByteArray(it.readableBytes())
it.getBytes(0, result)
result
}
}
}
fun put(key: String, content: ByteArray): CompletableFuture<Unit> {
return sendRequest(profile.serverURI.resolve(key), HttpMethod.PUT, content).thenApply {
val status = it.status()
if (it.status() != HttpResponseStatus.CREATED) {
throw HttpException(status)
}
}
}
private fun sendRequest(uri: URI, method: HttpMethod, body: ByteArray?): CompletableFuture<FullHttpResponse> {
val responseFuture = CompletableFuture<FullHttpResponse>()
try {
val scheme = if (uri.scheme == null) "http" else uri.scheme
val host = uri.host
var port = uri.port
if (port == -1) {
port = if ("https".equals(scheme, ignoreCase = true)) 443 else 80
}
val bootstrap = Bootstrap()
bootstrap.group(group)
.channel(NioSocketChannel::class.java)
.handler(object : ChannelInitializer<SocketChannel>() {
override fun initChannel(ch: SocketChannel) {
val pipeline: ChannelPipeline = ch.pipeline()
// Add SSL handler if needed
if ("https".equals(scheme, ignoreCase = true)) {
pipeline.addLast("ssl", sslContext.newHandler(ch.alloc(), host, port))
}
// HTTP handlers
pipeline.addLast("codec", HttpClientCodec())
pipeline.addLast("decompressor", HttpContentDecompressor())
pipeline.addLast("aggregator", HttpObjectAggregator(1048576))
pipeline.addLast("chunked", ChunkedWriteHandler())
// Custom handler for processing responses
pipeline.addLast("handler", object : SimpleChannelInboundHandler<FullHttpResponse>() {
override fun channelRead0(
ctx: ChannelHandlerContext,
response: FullHttpResponse
) {
responseFuture.complete(response)
ctx.close()
}
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
val ex = when (cause) {
is DecoderException -> cause.cause
else -> cause
}
responseFuture.completeExceptionally(ex)
ctx.close()
}
})
}
})
// Connect to host
val channel: Channel = bootstrap.connect(host, port).sync().channel()
// Prepare the HTTP request
val request: FullHttpRequest = let {
val content: ByteBuf? = body?.takeIf(ByteArray::isNotEmpty)?.let(Unpooled::wrappedBuffer)
DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri.rawPath, content ?: Unpooled.buffer(0)).apply {
headers().apply {
if (content != null) {
set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.APPLICATION_OCTET_STREAM)
set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes())
}
set(HttpHeaderNames.HOST, host)
set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
set(
HttpHeaderNames.ACCEPT_ENCODING,
HttpHeaderValues.GZIP.toString() + "," + HttpHeaderValues.DEFLATE.toString()
)
// Add basic auth if configured
(profile.authentication as? Configuration.Authentication.BasicAuthenticationCredentials)?.let { credentials ->
val auth = "${credentials.username}:${credentials.password}"
val encodedAuth = Base64.getEncoder().encodeToString(auth.toByteArray())
set(HttpHeaderNames.AUTHORIZATION, "Basic $encodedAuth")
}
}
}
}
// Set headers
// Send the request
channel.writeAndFlush(request)
} catch (e: Exception) {
responseFuture.completeExceptionally(e)
}
return responseFuture
}
fun shutDown(): NettyFuture<*> {
return group.shutdownGracefully()
}
override fun close() {
shutDown().sync()
}
}

View File

@@ -0,0 +1,9 @@
package net.woggioni.gbcs.client
import io.netty.handler.codec.http.HttpResponseStatus
class HttpException(private val status : HttpResponseStatus) : RuntimeException(status.reasonPhrase()) {
override val message: String
get() = "Http status ${status.code()}: ${status.reasonPhrase()}"
}

View File

@@ -0,0 +1,38 @@
package net.woggioni.gbcs.client
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.X509Certificate
import kotlin.random.Random
//object Main {
// @JvmStatic
// fun main(vararg args : String) {
// val pwd = "PO%!*bW9p'Zp#=uu\$fl{Ij`Ad.8}x#ho".toCharArray()
// val keystore = KeyStore.getInstance("PKCS12").apply{
// Files.newInputStream(Path.of("/home/woggioni/ssl/woggioni@c962475fa38.pfx")).use {
// load(it, pwd)
// }
// }
// val key = keystore.getKey("woggioni@c962475fa38", pwd) as PrivateKey
// val certChain = keystore.getCertificateChain("woggioni@c962475fa38").asSequence()
// .map { it as X509Certificate }
// .toList()
// .toTypedArray()
// GbcsClient.Configuration(
// serverURI = URI("https://gbcs.woggioni.net/"),
// GbcsClient.TlsClientAuthenticationCredentials(
// key, certChain
// )
// ).let(::GbcsClient).use { client ->
// val random = Random(101325)
// val entry = "something" to ByteArray(0x1000).also(random::nextBytes)
// client.put(entry.first, entry.second)
// val retrieved = client.get(entry.first).get()
// println(retrieved.contentEquals(entry.second))
// }
// }
//}

View File

@@ -0,0 +1,64 @@
package net.woggioni.gbcs.client.impl
import net.woggioni.gbcs.base.Xml.Companion.asIterable
import net.woggioni.gbcs.client.GbcsClient
import org.w3c.dom.Document
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.X509Certificate
object Parser {
fun parse(document: Document): GbcsClient.Configuration {
val root = document.documentElement
val profiles = mutableMapOf<String, GbcsClient.Configuration.Profile>()
for (child in root.asIterable()) {
val tagName = child.localName
when (tagName) {
"profile" -> {
val name = child.getAttribute("name")
val uri = child.getAttribute("base-url").let(::URI)
var authentication: GbcsClient.Configuration.Authentication? = null
for (gchild in child.asIterable()) {
when (gchild.localName) {
"tls-client-auth" -> {
val keyStoreFile = gchild.getAttribute("key-store-file")
val keyStorePassword =
gchild.getAttribute("key-store-password").takeIf(String::isNotEmpty)
val keyAlias = gchild.getAttribute("key-alias")
val keyPassword = gchild.getAttribute("key-password").takeIf(String::isNotEmpty)
val keystore = KeyStore.getInstance("PKCS12").apply {
Files.newInputStream(Path.of(keyStoreFile)).use {
load(it, keyStorePassword?.toCharArray())
}
}
val key = keystore.getKey(keyAlias, keyPassword?.toCharArray()) as PrivateKey
val certChain = keystore.getCertificateChain(keyAlias).asSequence()
.map { it as X509Certificate }
.toList()
.toTypedArray()
authentication =
GbcsClient.Configuration.Authentication.TlsClientAuthenticationCredentials(key, certChain)
}
"basic-auth" -> {
val username = gchild.getAttribute("user")
val password = gchild.getAttribute("password")
authentication =
GbcsClient.Configuration.Authentication.BasicAuthenticationCredentials(username, password)
}
}
}
profiles[name] = GbcsClient.Configuration.Profile(uri, authentication)
}
}
}
return GbcsClient.Configuration(profiles)
}
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xs:schema targetNamespace="urn:net.woggioni.gbcs.client"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:gbcs-client="urn:net.woggioni.gbcs.client"
elementFormDefault="unqualified"
>
<xs:element name="profiles" type="gbcs-client:profilesType"/>
<xs:complexType name="profilesType">
<xs:sequence minOccurs="0">
<xs:element name="profile" type="gbcs-client:profileType" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="profileType">
<xs:choice>
<xs:element name="basic-auth" type="gbcs-client:basicAuthType"/>
<xs:element name="tls-client-auth" type="gbcs-client:tlsClientAuthType"/>
</xs:choice>
<xs:attribute name="name" type="xs:token" use="required"/>
<xs:attribute name="base-url" type="xs:anyURI" use="required"/>
</xs:complexType>
<xs:complexType name="basicAuthType">
<xs:attribute name="user" type="xs:string" use="required"/>
<xs:attribute name="password" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="tlsClientAuthType">
<xs:attribute name="key-store-file" type="xs:anyURI" use="required"/>
<xs:attribute name="key-store-password" type="xs:string" use="required"/>
<xs:attribute name="key-alias" type="xs:string" use="required"/>
<xs:attribute name="key-password" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<gbcs-client:profiles xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gbcs-client="urn:net.woggioni.gbcs.client"
xs:schemaLocation="urn:net.woggioni.gbcs.client jms://net.woggioni.gbcs.client/net/woggioni/gbcs/client/schema/gbcs-client.xsd"
>
<profile name="profile1" base-url="https://gbcs1.example.com/">
<tls-client-auth
key-store-file="keystore.pfx"
key-store-password="password"
key-alias="woggioni@c962475fa38"
key-password="key-password"/>
</profile>
<profile name="profile2" base-url="https://gbcs2.example.com/">
<basic-auth user="user" password="password"/>
</profile>
</gbcs-client:profiles>

View File

@@ -31,5 +31,5 @@ include 'gbcs-base'
include 'gbcs-memcached'
include 'gbcs-cli'
include 'docker'
include 'benchmark'
include 'gbcs-client'