feat: add RuntimeType enum and RuntimeDetector for JAR probing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("_", "-");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user