Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions scripts/update-sdk-checksums/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ var sdksToUpdate = []SDKConfig{
ChecksumsJSONFile: "checksums.json",
VersionVarName: "TEST_SERVER_VERSION",
},
{
Name: "Kotlin",
SDKDir: "sdks/kotlin",
InstallScriptFile: []string{"src/main/kotlin/com/google/testserver/BinaryInstaller.kt"},
ChecksumsJSONFile: "checksums.json",
VersionVarName: "TEST_SERVER_VERSION",
},
}

func fetchChecksumsTxt(version string) (string, error) {
Expand Down
33 changes: 33 additions & 0 deletions sdks/kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
plugins {
kotlin("jvm") version "1.9.22"
}

group = "com.google.testserver"
version = "0.1.0"

repositories {
mavenCentral()
}

dependencies {
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")

// For JSON parsing (checksums.json)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")

// For YAML parsing (config files)
implementation("org.yaml:snakeyaml:2.2")

testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
}

tasks.test {
useJUnitPlatform()
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = "11"
}
}
1 change: 1 addition & 0 deletions sdks/kotlin/checksums.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions sdks/kotlin/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = "test-server-sdk-kotlin"
167 changes: 167 additions & 0 deletions sdks/kotlin/src/main/kotlin/com/google/testserver/BinaryInstaller.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package com.google.testserver

import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.security.MessageDigest
import java.util.zip.ZipInputStream

object BinaryInstaller {
private const val GITHUB_OWNER = "google"
private const val GITHUB_REPO = "test-server"
private const val PROJECT_NAME = "test-server"
const val TEST_SERVER_VERSION = "v0.2.9"

fun ensureBinary(outDir: File, version: String = TEST_SERVER_VERSION): File {
val platformDetails = getPlatformDetails()
val archiveName = "${PROJECT_NAME}_${platformDetails.goOs}_${platformDetails.archPart}${platformDetails.archiveExt}"

val binaryName = if (platformDetails.platform == "win32") "$PROJECT_NAME.exe" else PROJECT_NAME
val finalBinaryPath = File(outDir, binaryName)

if (finalBinaryPath.exists()) {
println("[SDK] Binary already exists at ${finalBinaryPath.absolutePath}. Skipping download.")
ensureExecutable(finalBinaryPath)
return finalBinaryPath
}

val downloadUrl = "https://github.com/$GITHUB_OWNER/$GITHUB_REPO/releases/download/$version/$archiveName"
val archiveFile = File(outDir, archiveName)

outDir.mkdirs()

try {
downloadFile(downloadUrl, archiveFile)

// Verification (Checksums can be loaded from checksums.json later)
// For now, we can skip or implement placeholder verification
// verifyChecksum(archiveFile, "expectedChecksum")

extractArchive(archiveFile, platformDetails.archiveExt, outDir)
ensureExecutable(finalBinaryPath)

println("[SDK] $PROJECT_NAME ready at ${finalBinaryPath.absolutePath}")
return finalBinaryPath
} finally {
if (archiveFile.exists()) {
archiveFile.delete()
}
}
}

private fun getPlatformDetails(): PlatformDetails {
val osName = System.getProperty("os.name").lowercase()
val osArch = System.getProperty("os.arch").lowercase()

val platform = when {
osName.contains("mac") || osName.contains("darwin") -> "darwin"
osName.contains("linux") -> "linux"
osName.contains("win") -> "win32"
else -> throw UnsupportedOperationException("Unsupported OS: $osName")
}

val archPart = when {
osArch.contains("amd64") || osArch.contains("x86_64") -> "x86_64"
osArch.contains("aarch64") || osArch.contains("arm64") -> "arm64"
else -> throw UnsupportedOperationException("Unsupported Architecture: $osArch")
}

val goOs = when (platform) {
"darwin" -> "Darwin"
"linux" -> "Linux"
"win32" -> "Windows"
else -> throw IllegalArgumentException()
}

val archiveExt = if (platform == "win32") ".zip" else ".tar.gz"

return PlatformDetails(goOs, archPart, archiveExt, platform)
}

private fun downloadFile(url: String, destination: File) {
println("[SDK] Downloading $url -> ${destination.absolutePath}...")
val client = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder()
.uri(URI.create(url))
.build()

val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
if (response.statusCode() != 200) {
throw IOException("[SDK] Failed to download binary. Status: ${response.statusCode()}")
}

response.body().use { input ->
FileOutputStream(destination).use { output ->
input.copyTo(output)
}
}
println("[SDK] Download complete.")
}

private fun extractArchive(archiveFile: File, ext: String, destDir: File) {
println("[SDK] Extracting ${archiveFile.absoluteFile} to ${destDir.absolutePath}...")
if (ext == ".zip") {
unzip(archiveFile, destDir)
} else {
// For .tar.gz on Unix system, using tar command via ProcessBuilder is easiest and avoids heavy dependencies
val processBuilder = ProcessBuilder("tar", "-xzf", archiveFile.absolutePath, "-C", destDir.absolutePath)
val process = processBuilder.start()
val exitCode = process.waitFor()
if (exitCode != 0) {
val errorStream = process.errorStream.bufferedReader().readText()
throw IOException("[SDK] Failed to extract tar.gz: $errorStream")
}
}
println("[SDK] Extraction complete.")
}

private fun unzip(zipFile: File, destDir: File) {
ZipInputStream(FileInputStream(zipFile)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
val newFile = File(destDir, entry.name)
if (entry.isDirectory) {
newFile.mkdirs()
} else {
newFile.parentFile.mkdirs()
FileOutputStream(newFile).use { fos ->
zis.copyTo(fos)
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}

private fun ensureExecutable(file: File) {
if (!System.getProperty("os.name").lowercase().contains("win")) {
file.setExecutable(true)
}
}

fun computeSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
val buffer = ByteArray(1024)
var bytesRead = input.read(buffer)
while (bytesRead != -1) {
digest.update(buffer, 0, bytesRead)
bytesRead = input.read(buffer)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}

private data class PlatformDetails(
val goOs: String,
val archPart: String,
val archiveExt: String,
val platform: String
)
}
120 changes: 120 additions & 0 deletions sdks/kotlin/src/main/kotlin/com/google/testserver/TestServer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.google.testserver

import org.yaml.snakeyaml.Yaml
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.util.concurrent.TimeUnit

class TestServer(private val options: TestServerOptions) {

private var process: Process? = null

fun start(): Process {
val binaryFile = options.binaryPath?.let { File(it) } ?: BinaryInstaller.ensureBinary(options.outDir)

val args = mutableListOf<String>()
args.add(binaryFile.absolutePath)
args.add(options.mode)
args.add("--config")
args.add(options.configPath)
args.add("--recording-dir")
args.add(options.recordingDir)

println("[TestServer] Starting test-server: ${args.joinToString(" ")}")

val processBuilder = ProcessBuilder(args)
processBuilder.redirectErrorStream(true) // Merge stdout and stderr for simplicity, or we can handle separately

val env = processBuilder.environment()
options.env?.forEach { (k, v) -> env[k] = v }
options.testServerSecrets?.let { env["TEST_SERVER_SECRETS"] = it }

val p = processBuilder.start()
process = p

// Log output in a separate thread
Thread {
p.inputStream.bufferedReader().use { reader ->
var line = reader.readLine()
while (line != null) {
println("[test-server] $line")
line = reader.readLine()
}
}
}.start()

// Wait for it to be healthy
awaitHealthy()

return p
}

fun stop() {
process?.let { p ->
if (p.isAlive) {
println("[TestServer] Stopping test-server process (PID: ${p.pid()})...")
p.destroy()
if (!p.waitFor(5, TimeUnit.SECONDS)) {
println("[TestServer] Process did not exit in time. Forcibly destroying...")
p.destroyForcibly()
}
println("[TestServer] Stopped.")
}
}
}

private fun awaitHealthy() {
val yaml = Yaml()
val configStream = FileInputStream(options.configPath)
val config = yaml.load<Map<String, Any>>(configStream)

val endpoints = config["endpoints"] as? List<Map<String, Any>> ?: return

for (endpoint in endpoints) {
val healthPath = endpoint["health"] as? String ?: continue
val sourceType = endpoint["source_type"] as? String ?: "http"
val sourcePort = endpoint["source_port"]?.toString() ?: continue

val url = "$sourceType://localhost:$sourcePort$healthPath"
healthCheck(url)
}
}

private fun healthCheck(url: String) {
val maxRetries = 10
var delay = 100L
val client = HttpClient.newHttpClient()

for (i in 0 until maxRetries) {
try {
val request = HttpRequest.newBuilder().uri(URI.create(url)).build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("[TestServer] Health check passed for $url")
return
}
} catch (e: Exception) {
// Ignore and retry
println("[TestServer] Health check attempt ${i + 1} failed for $url: ${e.message}")
}
Thread.sleep(delay)
delay *= 2
}
throw IOException("[TestServer] Health check failed for $url after $maxRetries retries.")
}
}

data class TestServerOptions(
val configPath: String,
val recordingDir: String,
val mode: String, // "record" or "replay"
val outDir: File, // Where to download/resolve binary
val binaryPath: String? = null, // Optional, if provided use this instead of downloading
val testServerSecrets: String? = null,
val env: Map<String, String>? = null
)
Loading