From cbf29a5d877d618b93ed96e682e40088003cfc16 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:59:00 +0200 Subject: [PATCH] feat: add RuntimeType enum and RuntimeDetector for JAR probing Co-Authored-By: Claude Sonnet 4.6 --- .../server/core/runtime/RuntimeDetector.java | 77 +++++++++++++ .../server/core/runtime/RuntimeType.java | 27 +++++ .../core/runtime/RuntimeDetectorTest.java | 108 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java create mode 100644 cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java new file mode 100644 index 00000000..9e7c4644 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java @@ -0,0 +1,77 @@ +package com.cameleer3.server.core.runtime; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +public final class RuntimeDetector { + + private RuntimeDetector() {} + + public record DetectionResult(RuntimeType runtimeType, String mainClass) {} + + /** + * Detect the runtime type of a file (JAR or native binary). + * Returns a result with null runtimeType if detection fails. + */ + public static DetectionResult detect(Path file) { + if (!Files.exists(file) || Files.isDirectory(file)) { + return new DetectionResult(null, null); + } + + // Check if it's a ZIP/JAR (starts with PK magic bytes) + if (!isZipFile(file)) { + // Non-ZIP file with content = native binary + try { + if (Files.size(file) > 0) { + return new DetectionResult(RuntimeType.NATIVE, null); + } + } catch (IOException e) { + // fall through + } + return new DetectionResult(null, null); + } + + // It's a JAR — read the manifest + try (JarFile jar = new JarFile(file.toFile())) { + Manifest manifest = jar.getManifest(); + if (manifest == null) { + return new DetectionResult(null, null); + } + + String mainClass = manifest.getMainAttributes().getValue("Main-Class"); + if (mainClass == null || mainClass.isBlank()) { + return new DetectionResult(null, null); + } + + // Spring Boot: any launcher in org.springframework.boot.loader + if (mainClass.startsWith("org.springframework.boot.loader")) { + return new DetectionResult(RuntimeType.SPRING_BOOT, null); + } + + // Quarkus + if (mainClass.equals("io.quarkus.bootstrap.runner.QuarkusEntryPoint")) { + return new DetectionResult(RuntimeType.QUARKUS, null); + } + + // Plain Java: has a Main-Class, not a known framework + return new DetectionResult(RuntimeType.PLAIN_JAVA, mainClass); + + } catch (IOException e) { + return new DetectionResult(null, null); + } + } + + private static boolean isZipFile(Path file) { + try (InputStream is = Files.newInputStream(file)) { + byte[] magic = new byte[2]; + int read = is.read(magic); + return read == 2 && magic[0] == 'P' && magic[1] == 'K'; + } catch (IOException e) { + return false; + } + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java new file mode 100644 index 00000000..07050cf6 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java @@ -0,0 +1,27 @@ +package com.cameleer3.server.core.runtime; + +public enum RuntimeType { + AUTO, + SPRING_BOOT, + QUARKUS, + PLAIN_JAVA, + NATIVE; + + /** Parse from containerConfig string value, case-insensitive. Returns null if unrecognized. */ + public static RuntimeType fromString(String value) { + if (value == null || value.isBlank()) return AUTO; + return switch (value.toLowerCase().replace("-", "_")) { + case "auto" -> AUTO; + case "spring_boot" -> SPRING_BOOT; + case "quarkus" -> QUARKUS; + case "plain_java" -> PLAIN_JAVA; + case "native" -> NATIVE; + default -> null; + }; + } + + /** Lowercase kebab-case for JSON serialization. */ + public String toConfigValue() { + return name().toLowerCase().replace("_", "-"); + } +} diff --git a/cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java b/cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java new file mode 100644 index 00000000..7f4aec7c --- /dev/null +++ b/cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java @@ -0,0 +1,108 @@ +package com.cameleer3.server.core.runtime; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.junit.jupiter.api.Assertions.*; + +class RuntimeDetectorTest { + + @TempDir + Path tempDir; + + private Path createJarWithMainClass(String mainClass) throws IOException { + Path jar = tempDir.resolve("app.jar"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, mainClass); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar.toFile()), manifest)) { + // empty JAR with manifest + } + return jar; + } + + private Path createJarWithoutMainClass() throws IOException { + Path jar = tempDir.resolve("no-main.jar"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar.toFile()), manifest)) { + // empty JAR, no Main-Class + } + return jar; + } + + @Test + void detectsSpringBootJarLauncher() throws IOException { + Path jar = createJarWithMainClass("org.springframework.boot.loader.launch.JarLauncher"); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar); + assertEquals(RuntimeType.SPRING_BOOT, result.runtimeType()); + assertNull(result.mainClass()); + } + + @Test + void detectsSpringBootPropertiesLauncher() throws IOException { + Path jar = createJarWithMainClass("org.springframework.boot.loader.launch.PropertiesLauncher"); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar); + assertEquals(RuntimeType.SPRING_BOOT, result.runtimeType()); + assertNull(result.mainClass()); + } + + @Test + void detectsSpringBootLegacyLauncher() throws IOException { + Path jar = createJarWithMainClass("org.springframework.boot.loader.JarLauncher"); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar); + assertEquals(RuntimeType.SPRING_BOOT, result.runtimeType()); + assertNull(result.mainClass()); + } + + @Test + void detectsQuarkus() throws IOException { + Path jar = createJarWithMainClass("io.quarkus.bootstrap.runner.QuarkusEntryPoint"); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar); + assertEquals(RuntimeType.QUARKUS, result.runtimeType()); + assertNull(result.mainClass()); + } + + @Test + void detectsPlainJava() throws IOException { + Path jar = createJarWithMainClass("com.example.MyApp"); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar); + assertEquals(RuntimeType.PLAIN_JAVA, result.runtimeType()); + assertEquals("com.example.MyApp", result.mainClass()); + } + + @Test + void detectsNativeBinary() throws IOException { + Path binary = tempDir.resolve("app"); + // ELF header (not a ZIP/JAR) + Files.write(binary, new byte[]{0x7f, 'E', 'L', 'F', 0, 0, 0, 0}); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(binary); + assertEquals(RuntimeType.NATIVE, result.runtimeType()); + assertNull(result.mainClass()); + } + + @Test + void returnsNullForJarWithoutMainClass() throws IOException { + Path jar = createJarWithoutMainClass(); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar); + assertNull(result.runtimeType()); + assertNull(result.mainClass()); + } + + @Test + void returnsNullForEmptyFile() throws IOException { + Path empty = tempDir.resolve("empty"); + Files.write(empty, new byte[0]); + RuntimeDetector.DetectionResult result = RuntimeDetector.detect(empty); + assertNull(result.runtimeType()); + assertNull(result.mainClass()); + } +}