docs: update documentation for runtime type detection feature
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m25s
CI / docker (push) Successful in 1m14s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 39s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-12 13:21:43 +02:00
parent ee435985a9
commit d02a64709c
3 changed files with 1130 additions and 6 deletions

View File

@@ -0,0 +1,973 @@
# Runtime Type Detection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Detect app runtime type from uploaded JARs, construct framework-specific Docker entrypoints, and let users override via UI with custom command-line arguments.
**Architecture:** `RuntimeDetector` probes JARs at upload time, storing the detected type on `AppVersion`. At deploy time, `DeploymentExecutor` resolves `AUTO` to the detected type and passes it to `DockerRuntimeOrchestrator`, which builds the correct entrypoint per framework. Two new fields (`runtimeType`, `customArgs`) flow through the existing `containerConfig` JSONB and 3-layer `ConfigMerger`.
**Tech Stack:** Java 17, Spring Boot 3.4, PostgreSQL (Flyway), JUnit 5, React/TypeScript (Vite)
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Create | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java` | Enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE |
| Create | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java` | Probes JAR files and returns detected RuntimeType + mainClass |
| Create | `cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java` | Unit tests for detection logic |
| Create | `cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql` | Adds detected_runtime_type and detected_main_class to app_versions |
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java` | Add detectedRuntimeType, detectedMainClass fields |
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java` | Add updateDetectedRuntime method |
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java` | Persist and read new columns |
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java` | Run detection after upload |
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java` | Add runtimeType, customArgs fields |
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java` | Resolve runtimeType, customArgs |
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java` | Add runtimeType, customArgs, mainClass fields |
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java` | Build entrypoint per runtime type |
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java` | Resolve AUTO, pass runtime fields to ContainerRequest |
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java` | Validate customArgs on config save |
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java` | Validate customArgs on config save |
| Modify | `ui/src/api/queries/admin/apps.ts` | Add new fields to AppVersion type |
| Modify | `ui/src/pages/AppsTab/AppsTab.tsx` | Runtime Type select, Custom Arguments input, detection hint |
---
### Task 1: RuntimeType Enum and RuntimeDetector
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java`
- Create: `cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java`
- [ ] **Step 1: Create the RuntimeType enum**
```java
// cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java
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("_", "-");
}
}
```
- [ ] **Step 2: Write RuntimeDetector tests**
```java
// cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java
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());
}
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `mvn test -pl cameleer3-server-core -Dtest=RuntimeDetectorTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: FAIL — `RuntimeDetector` class not found
- [ ] **Step 4: Implement RuntimeDetector**
```java
// cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java
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;
}
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `mvn test -pl cameleer3-server-core -Dtest=RuntimeDetectorTest`
Expected: All 8 tests PASS
- [ ] **Step 6: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java \
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java \
cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java
git commit -m "feat: add RuntimeType enum and RuntimeDetector for JAR probing"
```
---
### Task 2: AppVersion + Migration + Repository
**Files:**
- Create: `cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql`
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java`
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java`
- [ ] **Step 1: Create Flyway migration**
```sql
-- cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql
ALTER TABLE app_versions ADD COLUMN detected_runtime_type VARCHAR;
ALTER TABLE app_versions ADD COLUMN detected_main_class VARCHAR;
```
- [ ] **Step 2: Update AppVersion record**
Replace the entire content of `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java`:
```java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
String jarFilename, Long jarSizeBytes, String detectedRuntimeType,
String detectedMainClass, Instant uploadedAt) {}
```
- [ ] **Step 3: Add updateDetectedRuntime to AppVersionRepository**
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java`, add after the `delete` method (line 12):
```java
void updateDetectedRuntime(UUID id, String detectedRuntimeType, String detectedMainClass);
```
Full file becomes:
```java
package com.cameleer3.server.core.runtime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface AppVersionRepository {
List<AppVersion> findByAppId(UUID appId);
Optional<AppVersion> findById(UUID id);
int findMaxVersion(UUID appId);
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
void delete(UUID id);
void updateDetectedRuntime(UUID id, String detectedRuntimeType, String detectedMainClass);
}
```
- [ ] **Step 4: Update PostgresAppVersionRepository**
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java`:
Update the SQL SELECT queries in `findByAppId` (line 24) and `findById` (line 31) to include the new columns:
Replace:
```
SELECT id, app_id, version, jar_path, jar_checksum, jar_filename, jar_size_bytes, uploaded_at
```
With:
```
SELECT id, app_id, version, jar_path, jar_checksum, jar_filename, jar_size_bytes, detected_runtime_type, detected_main_class, uploaded_at
```
Update `mapRow` to read the new columns — replace the full method:
```java
private AppVersion mapRow(ResultSet rs) throws SQLException {
Long sizeBytes = rs.getLong("jar_size_bytes");
if (rs.wasNull()) sizeBytes = null;
return new AppVersion(
UUID.fromString(rs.getString("id")),
UUID.fromString(rs.getString("app_id")),
rs.getInt("version"),
rs.getString("jar_path"),
rs.getString("jar_checksum"),
rs.getString("jar_filename"),
sizeBytes,
rs.getString("detected_runtime_type"),
rs.getString("detected_main_class"),
rs.getTimestamp("uploaded_at").toInstant()
);
}
```
Add the `updateDetectedRuntime` method after `delete`:
```java
@Override
public void updateDetectedRuntime(UUID id, String detectedRuntimeType, String detectedMainClass) {
jdbc.update("UPDATE app_versions SET detected_runtime_type = ?, detected_main_class = ? WHERE id = ?",
detectedRuntimeType, detectedMainClass, id);
}
```
- [ ] **Step 5: Verify compilation**
Run: `mvn clean compile`
Expected: BUILD SUCCESS
- [ ] **Step 6: Commit**
```bash
git add cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql \
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java \
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java \
cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java
git commit -m "feat: add detected_runtime_type and detected_main_class to app_versions"
```
---
### Task 3: Run Detection on Upload
**Files:**
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java`
- [ ] **Step 1: Update AppService.uploadJar to run detection after saving**
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java`, add an import at the top:
```java
import java.nio.file.Path;
```
(Note: `Path` is already imported — just verify it's there.)
Replace the `uploadJar` method (lines 47-76) with:
```java
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
getById(appId); // verify app exists
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
Files.createDirectories(versionDir);
Path jarFile = versionDir.resolve("app.jar");
MessageDigest digest;
try { digest = MessageDigest.getInstance("SHA-256"); }
catch (Exception e) { throw new RuntimeException(e); }
try (InputStream in = jarData) {
byte[] buffer = new byte[8192];
int bytesRead;
try (var out = Files.newOutputStream(jarFile)) {
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
digest.update(buffer, 0, bytesRead);
}
}
}
String checksum = HexFormat.of().formatHex(digest.digest());
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
// Detect runtime type from the saved JAR
RuntimeDetector.DetectionResult detection = RuntimeDetector.detect(jarFile);
if (detection.runtimeType() != null) {
versionRepo.updateDetectedRuntime(versionId, detection.runtimeType().toConfigValue(), detection.mainClass());
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected={}",
appId, nextVersion, size, checksum, detection.runtimeType().toConfigValue());
} else {
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected=unknown",
appId, nextVersion, size, checksum);
}
return versionRepo.findById(versionId).orElseThrow();
}
```
- [ ] **Step 2: Verify compilation**
Run: `mvn clean compile`
Expected: BUILD SUCCESS
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java
git commit -m "feat: run runtime detection on JAR upload"
```
---
### Task 4: ConfigMerger + ResolvedContainerConfig
**Files:**
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java`
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java`
- [ ] **Step 1: Add runtimeType and customArgs to ResolvedContainerConfig**
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java`, add two new fields after `replayEnabled` (line 22):
```java
boolean replayEnabled,
String runtimeType,
String customArgs
```
Full record signature becomes:
```java
public record ResolvedContainerConfig(
int memoryLimitMb,
Integer memoryReserveMb,
int cpuRequest,
Integer cpuLimit,
int appPort,
List<Integer> exposedPorts,
Map<String, String> customEnvVars,
boolean stripPathPrefix,
boolean sslOffloading,
String routingMode,
String routingDomain,
String serverUrl,
int replicas,
String deploymentStrategy,
boolean routeControlEnabled,
boolean replayEnabled,
String runtimeType,
String customArgs
) {
```
The existing helper methods (`memoryLimitBytes`, `memoryReserveBytes`, `dockerCpuShares`, `dockerCpuQuota`) remain unchanged.
- [ ] **Step 2: Update ConfigMerger.resolve() to include new fields**
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java`, add two new lines at the end of the `resolve()` return statement (after line 33, the `replayEnabled` line):
```java
boolVal(appConfig, envConfig, "routeControlEnabled", true),
boolVal(appConfig, envConfig, "replayEnabled", true),
stringVal(appConfig, envConfig, "runtimeType", "auto"),
stringVal(appConfig, envConfig, "customArgs", "")
);
```
- [ ] **Step 3: Verify compilation**
Run: `mvn clean compile`
Expected: FAIL — `DeploymentExecutor.resolvedConfigToMap()` creates `ResolvedContainerConfig` with old arity. Fix in next task.
- [ ] **Step 4: Commit (with compile fix deferred to Task 5)**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java \
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java
git commit -m "feat: add runtimeType and customArgs to ResolvedContainerConfig and ConfigMerger"
```
---
### Task 5: ContainerRequest + DeploymentExecutor + Entrypoint
**Files:**
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
- [ ] **Step 1: Add runtimeType, customArgs, mainClass to ContainerRequest**
Replace the full content of `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java`:
```java
package com.cameleer3.server.core.runtime;
import java.util.List;
import java.util.Map;
public record ContainerRequest(
String containerName,
String baseImage,
String jarPath,
String jarVolumeName,
String jarVolumeMountPath,
String network,
List<String> additionalNetworks,
Map<String, String> envVars,
Map<String, String> labels,
long memoryLimitBytes,
Long memoryReserveBytes,
int cpuShares,
Long cpuQuota,
List<Integer> exposedPorts,
int healthCheckPort,
String restartPolicyName,
int restartPolicyMaxRetries,
String runtimeType,
String customArgs,
String mainClass
) {}
```
- [ ] **Step 2: Update DeploymentExecutor — resolve AUTO, pass runtime fields, update resolvedConfigToMap**
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`:
Add import at top:
```java
import com.cameleer3.server.core.runtime.RuntimeType;
```
In `executeAsync()`, after `preFlightChecks(jarPath, config);` (after the PRE_FLIGHT stage comment block), add runtime type resolution:
```java
// === PRE-FLIGHT ===
updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
preFlightChecks(jarPath, config);
// Resolve runtime type
String resolvedRuntimeType = config.runtimeType();
String mainClass = null;
if ("auto".equalsIgnoreCase(resolvedRuntimeType)) {
AppVersion appVersion = appService.getVersion(deployment.appVersionId());
if (appVersion.detectedRuntimeType() == null) {
throw new IllegalStateException(
"Could not detect runtime type for JAR '" + appVersion.jarFilename() +
"'. Set runtimeType explicitly in app configuration.");
}
resolvedRuntimeType = appVersion.detectedRuntimeType();
mainClass = appVersion.detectedMainClass();
} else if ("plain-java".equals(resolvedRuntimeType)) {
AppVersion appVersion = appService.getVersion(deployment.appVersionId());
mainClass = appVersion.detectedMainClass();
if (mainClass == null) {
throw new IllegalStateException(
"Runtime type 'plain-java' requires a Main-Class in the JAR manifest, " +
"but none was detected for '" + appVersion.jarFilename() + "'.");
}
}
```
Update the `ContainerRequest` constructor call in the replica loop to include the three new fields at the end:
```java
ContainerRequest request = new ContainerRequest(
containerName, baseImage, jarPath,
volumeName, jarStoragePath,
primaryNetwork,
additionalNets,
baseEnvVars, labels,
config.memoryLimitBytes(), config.memoryReserveBytes(),
config.dockerCpuShares(), config.dockerCpuQuota(),
config.exposedPorts(), agentHealthPort,
"on-failure", 3,
resolvedRuntimeType, config.customArgs(), mainClass
);
```
Update `resolvedConfigToMap()` to include new fields — add at the end before the return:
```java
map.put("deploymentStrategy", config.deploymentStrategy());
map.put("runtimeType", config.runtimeType());
map.put("customArgs", config.customArgs());
return map;
```
- [ ] **Step 3: Add getVersion method to AppService**
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java`, add after `listVersions` (line 33):
```java
public AppVersion getVersion(UUID versionId) {
return versionRepo.findById(versionId)
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + versionId));
}
```
- [ ] **Step 4: Update DockerRuntimeOrchestrator entrypoint construction**
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`, replace the entrypoint block (the section starting with `// Resolve the JAR path for the entrypoint` through the `createCmd.withEntrypoint` call) with:
```java
// Resolve the JAR path for the entrypoint
String appJarPath;
if (request.jarVolumeName() != null && !request.jarVolumeName().isBlank()) {
appJarPath = request.jarPath();
} else {
appJarPath = "/app/app.jar";
}
var createCmd = dockerClient.createContainerCmd(request.baseImage())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels() != null ? request.labels() : Map.of())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL",
"wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
.withInterval(10_000_000_000L)
.withTimeout(5_000_000_000L)
.withRetries(3)
.withStartPeriod(30_000_000_000L));
// Build entrypoint based on runtime type
String customArgs = request.customArgs() != null && !request.customArgs().isBlank()
? " " + request.customArgs() : "";
String entrypoint = switch (request.runtimeType()) {
case "quarkus" -> "exec java -javaagent:/app/agent.jar" + customArgs + " -jar " + appJarPath;
case "plain-java" -> "exec java -javaagent:/app/agent.jar -cp " + appJarPath +
":/app/cameleer3-log-appender.jar" + customArgs + " " + request.mainClass();
case "native" -> "exec " + appJarPath + customArgs;
default -> // spring-boot (default)
"exec java -javaagent:/app/agent.jar -Dloader.path=/app/cameleer3-log-appender.jar" +
customArgs + " -cp " + appJarPath + " org.springframework.boot.loader.launch.PropertiesLauncher";
};
createCmd.withEntrypoint("sh", "-c", entrypoint);
```
- [ ] **Step 5: Verify compilation**
Run: `mvn clean compile`
Expected: BUILD SUCCESS
- [ ] **Step 6: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java \
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java \
cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java \
cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java
git commit -m "feat: build Docker entrypoint per runtime type with custom args support"
```
---
### Task 6: Input Validation
**Files:**
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java`
- [ ] **Step 1: Add validation to AppController.updateContainerConfig**
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java`, add a validation constant and helper method at the bottom of the class, before the `CreateAppRequest` record:
```java
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
private void validateContainerConfig(Map<String, Object> config) {
Object customArgs = config.get("customArgs");
if (customArgs instanceof String s && !s.isBlank() && !CUSTOM_ARGS_PATTERN.matcher(s).matches()) {
throw new IllegalArgumentException("customArgs contains invalid characters. Only JVM-style arguments are allowed.");
}
Object runtimeType = config.get("runtimeType");
if (runtimeType instanceof String s && !s.isBlank() && RuntimeType.fromString(s) == null) {
throw new IllegalArgumentException("Invalid runtimeType: " + s +
". Must be one of: auto, spring-boot, quarkus, plain-java, native");
}
}
```
Add import at top:
```java
import com.cameleer3.server.core.runtime.RuntimeType;
```
Update the `updateContainerConfig` method to call validation:
```java
@PutMapping("/{appSlug}/container-config")
@Operation(summary = "Update container config for an app")
@ApiResponse(responseCode = "200", description = "Container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
@RequestBody Map<String, Object> containerConfig) {
try {
validateContainerConfig(containerConfig);
App app = appService.getBySlug(appSlug);
appService.updateContainerConfig(app.id(), containerConfig);
return ResponseEntity.ok(appService.getById(app.id()));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().build();
}
}
```
- [ ] **Step 2: Add same validation to EnvironmentAdminController.updateDefaultContainerConfig**
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java`, add the same constant, helper, and import. Then call `validateContainerConfig(defaultContainerConfig)` as the first line in the try block of `updateDefaultContainerConfig`.
- [ ] **Step 3: Verify compilation**
Run: `mvn clean compile`
Expected: BUILD SUCCESS
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java \
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
git commit -m "feat: validate runtimeType and customArgs on container config save"
```
---
### Task 7: UI — AppVersion Type + Runtime Type Select + Custom Arguments Input
**Files:**
- Modify: `ui/src/api/queries/admin/apps.ts`
- Modify: `ui/src/pages/AppsTab/AppsTab.tsx`
- [ ] **Step 1: Update AppVersion TypeScript interface**
In `ui/src/api/queries/admin/apps.ts`, add to the `AppVersion` interface (after `uploadedAt`):
```typescript
export interface AppVersion {
id: string;
appId: string;
version: number;
jarPath: string;
jarChecksum: string;
jarFilename: string;
jarSizeBytes: number;
detectedRuntimeType: string | null;
detectedMainClass: string | null;
uploadedAt: string;
}
```
- [ ] **Step 2: Add state variables for runtime type and custom args**
In `ui/src/pages/AppsTab/AppsTab.tsx`, add two new state variables after `sslOffloading` (line 211):
```typescript
const [sslOffloading, setSslOffloading] = useState(true);
const [runtimeType, setRuntimeType] = useState(String(defaults.runtimeType ?? 'auto'));
const [customArgs, setCustomArgs] = useState(String(defaults.customArgs ?? ''));
```
In the `useEffect` that resets resource defaults when environment changes (lines 218-225), add:
```typescript
setRuntimeType(String(d.runtimeType ?? 'auto'));
setCustomArgs(String(d.customArgs ?? ''));
```
- [ ] **Step 3: Add runtimeType and customArgs to the save handler**
In the container config save handler (lines 888-900), add the two new fields to the `containerConfig` object:
```typescript
const containerConfig: Record<string, unknown> = {
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
cpuRequest: cpuRequest ? parseInt(cpuRequest) : null,
cpuLimit: cpuLimit ? parseInt(cpuLimit) : null,
exposedPorts: ports,
customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])),
appPort: appPort ? parseInt(appPort) : 8080,
replicas: replicas ? parseInt(replicas) : 1,
deploymentStrategy: deployStrategy,
stripPathPrefix: stripPrefix,
sslOffloading: sslOffloading,
runtimeType: runtimeType,
customArgs: customArgs || null,
};
```
- [ ] **Step 4: Add Runtime Type and Custom Arguments fields to the Resources tab UI**
In the Resources tab rendering (after `{configTab === 'resources' && (`, line 1091), add the two new fields at the top of the `configGrid`, before the Memory Limit row:
```tsx
{configTab === 'resources' && (
<div className={sectionStyles.section}>
<SectionHeader>Container Resources</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Runtime Type</span>
<div>
<Select disabled={!editing} value={runtimeType} onChange={(e) => setRuntimeType(e.target.value)}
options={[
{ value: 'auto', label: 'Auto (detect from JAR)' },
{ value: 'spring-boot', label: 'Spring Boot' },
{ value: 'quarkus', label: 'Quarkus' },
{ value: 'plain-java', label: 'Plain Java' },
{ value: 'native', label: 'Native' },
]} />
{latestVersion?.detectedRuntimeType && (
<span className={styles.configHint} style={{ color: 'var(--success)' }}>
Detected: {latestVersion.detectedRuntimeType}
</span>
)}
{latestVersion && !latestVersion.detectedRuntimeType && (
<span className={styles.configHint} style={{ color: 'var(--amber)' }}>
Detection failed select runtime type manually
</span>
)}
</div>
<span className={styles.configLabel}>Custom Arguments</span>
<div>
<Input disabled={!editing} value={customArgs} onChange={(e) => setCustomArgs(e.target.value)}
placeholder="-Xmx256m -Dfoo=bar" className={styles.inputLg} />
<span className={styles.configHint}>
{runtimeType === 'native' ? 'Arguments passed to the native binary' : 'Additional JVM arguments appended to the start command'}
</span>
</div>
<span className={styles.configLabel}>Memory Limit</span>
```
For the `latestVersion` variable, add it near the top of the `ConfigSubTab` function, after the existing hooks. Use the `useAppVersions` hook:
```typescript
const { data: versions } = useAppVersions(app?.slug);
const latestVersion = versions?.[0] ?? null; // versions are ordered DESC by version
```
Note: `useAppVersions` is already imported/available from `apps.ts`. If `ConfigSubTab` doesn't have `app` in scope, thread it from the parent. Check the component props — `app` is likely already available as a prop or from the parent component's state. The versions query uses `app.slug` (the app slug) as the parameter.
- [ ] **Step 5: Verify the UI builds**
Run: `cd ui && npm run build`
Expected: BUILD SUCCESS (no TypeScript errors)
- [ ] **Step 6: Commit**
```bash
git add ui/src/api/queries/admin/apps.ts \
ui/src/pages/AppsTab/AppsTab.tsx
git commit -m "feat: add Runtime Type and Custom Arguments fields to deployment Resources tab"
```
---
### Task 8: Full Build Verification
- [ ] **Step 1: Run full Maven build**
Run: `mvn clean verify`
Expected: BUILD SUCCESS with all tests passing
- [ ] **Step 2: Run UI build**
Run: `cd ui && npm run build`
Expected: BUILD SUCCESS
- [ ] **Step 3: Fix any compilation or test failures from record field arity changes**
The `AppVersion` record gained 2 new fields and `ResolvedContainerConfig` gained 2 new fields. Any existing test that constructs these records directly will need the new arguments added. Common fix pattern:
- `AppVersion` constructor: add `null, null` before `uploadedAt` (for `detectedRuntimeType`, `detectedMainClass`)
- `ResolvedContainerConfig` constructor: add `"auto", ""` at the end (for `runtimeType`, `customArgs`)
- `ContainerRequest` constructor: add `"spring-boot", "", null` at the end (for `runtimeType`, `customArgs`, `mainClass`)
Search for these with:
```bash
grep -rn "new AppVersion\|new ResolvedContainerConfig\|new ContainerRequest" --include="*.java" cameleer3-server-*/src/test/
```
- [ ] **Step 4: Commit fixes if any**
```bash
git add -A
git commit -m "fix: update test constructor calls for new record fields"
```

View File

@@ -0,0 +1,137 @@
# Runtime Type Detection and Custom Arguments
## Problem
The server constructs Docker container entrypoints for deployed apps, but currently hardcodes a single start command. Different app frameworks require different start commands:
- **Spring Boot**: `-cp app.jar org.springframework.boot.loader.launch.PropertiesLauncher` with `-Dloader.path` for the log appender
- **Quarkus JVM**: `-jar app.jar` (appender is a compiled-in Maven dependency)
- **Plain Java**: `-cp app.jar:/app/cameleer3-log-appender.jar <Main-Class>`
- **Native** (Quarkus native): no JVM, just run the binary directly (agent compiled in at build time)
Users also need a way to pass custom command-line arguments (JVM flags, system properties, or native binary args).
## Design
### Runtime Type Enum
New enum `RuntimeType` in the core module:
```
AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE
```
### Data Model
Two new keys in the existing `containerConfig` JSONB, participating in the 3-layer merge (app > env > global):
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `runtimeType` | `string` | `"auto"` | One of: `auto`, `spring-boot`, `quarkus`, `plain-java`, `native` |
| `customArgs` | `string` | `""` | Freeform arguments appended to the start command |
No database migration needed for these — they're new keys in existing JSONB columns.
**AppVersion additions** (requires Flyway migration `V10`):
| Column | Type | Description |
|--------|------|-------------|
| `detected_runtime_type` | `VARCHAR` (nullable) | Runtime type detected from JAR on upload |
| `detected_main_class` | `VARCHAR` (nullable) | Main-Class from manifest (needed for `PLAIN_JAVA` entrypoint) |
### Server-Side Runtime Detection
New class `RuntimeDetector` in the core module. Pure function: takes a file path, returns a detection result (runtime type + main class, both nullable).
**Detection logic** (checked in order):
1. **Not a JAR**: file doesn't start with ZIP magic bytes (`PK`) → `NATIVE`
2. **Read `META-INF/MANIFEST.MF`** from the JAR:
- `Main-Class` contains `org.springframework.boot.loader``SPRING_BOOT`
- `Main-Class` is `io.quarkus.bootstrap.runner.QuarkusEntryPoint``QUARKUS`
- `Main-Class` exists (anything else) → `PLAIN_JAVA` (also extract the Main-Class value)
3. **No Main-Class in manifest** → detection fails, return null for both fields
**When it runs**: immediately after JAR upload in `AppController.uploadJar()`. The result is stored on `AppVersion.detectedRuntimeType` and `AppVersion.detectedMainClass`. The upload API response and version listing include these fields so the UI can show the detection result immediately.
**At deploy time** (PRE_FLIGHT in `DeploymentExecutor`): if `runtimeType` is `AUTO`, use `AppVersion.detectedRuntimeType`. If that is also null, fail the deployment with:
> "Could not detect runtime type for JAR '{filename}'. Set runtimeType explicitly in app configuration."
### Entrypoint Construction
`DockerRuntimeOrchestrator.startContainer()` builds the entrypoint based on the resolved runtime type. `ContainerRequest` gains two new fields: `runtimeType` (String) and `customArgs` (String).
| Type | Entrypoint |
|------|-----------|
| `SPRING_BOOT` | `exec java -javaagent:/app/agent.jar -Dloader.path=/app/cameleer3-log-appender.jar {customArgs} -cp {jarPath} org.springframework.boot.loader.launch.PropertiesLauncher` |
| `QUARKUS` | `exec java -javaagent:/app/agent.jar {customArgs} -jar {jarPath}` |
| `PLAIN_JAVA` | `exec java -javaagent:/app/agent.jar -cp {jarPath}:/app/cameleer3-log-appender.jar {customArgs} {mainClass}` |
| `NATIVE` | `exec {jarPath} {customArgs}` |
All entrypoints are wrapped in `sh -c "..."` for consistent execution. `jarPath` is resolved per mount strategy (volume mount uses the original path, bind mount uses `/app/app.jar`).
For `PLAIN_JAVA`, `mainClass` comes from `AppVersion.detectedMainClass`. If unavailable (user set `plain-java` explicitly but no main class stored), the deployment fails at PRE_FLIGHT.
All JVM types receive `CAMELEER_AGENT_*` env vars from `buildEnvVars()` — the agent reads them directly.
### Input Validation
`customArgs` is validated on save (in `AppController` and `EnvironmentAdminController`) to prevent shell injection. The entrypoint uses `sh -c`, so shell metacharacters must be rejected.
**Allowed pattern**: `^[-a-zA-Z0-9_.=:/\s+"']*$`
This covers legitimate JVM args (`-Xmx256m`, `-Dfoo=bar`, `-XX:+UseG1GC`, `-Dpath=/some/path`) while blocking shell metacharacters (`;`, `&`, `|`, `` ` ``, `$`, `(`).
Validation error: *"customArgs contains invalid characters. Only JVM-style arguments are allowed."*
`runtimeType` is validated as one of the enum values (case-insensitive).
### UI Changes
The **Resources tab** in the app config gets two new fields at the top (before Memory Limit):
**Runtime Type**: `Select` dropdown with options:
- `Auto (detect from JAR)` (default)
- `Spring Boot`
- `Quarkus`
- `Plain Java`
- `Native`
Below the dropdown, a hint shows the detection result from the current JAR version:
- "Detected: Spring Boot" (green) — when detection succeeded
- "Detection failed — select runtime type manually" (amber) — when detection returned null
- No hint when no version has been uploaded yet
**Custom Arguments**: single-line `Input` text field:
- Placeholder: `-Xmx256m -Dfoo=bar`
- Label: "Custom Arguments"
- Sublabel adapts to runtime type: "Additional JVM arguments" for JVM types, "Arguments passed to the native binary" for `Native`
- Client-side validation feedback on invalid characters
### Files Changed
| Area | Files |
|------|-------|
| New | `RuntimeDetector.java` (core) — detection logic |
| New | `RuntimeType.java` (core) — enum |
| Modified | `ContainerRequest.java` (core) — add `runtimeType`, `customArgs`, `mainClass` fields |
| Modified | `ConfigMerger.java` (core) — resolve `runtimeType`, `customArgs` string fields |
| Modified | `ResolvedContainerConfig.java` (core) — add `runtimeType`, `customArgs` fields |
| Modified | `AppVersion.java` (core) — add `detectedRuntimeType`, `detectedMainClass` fields |
| Modified | `DockerRuntimeOrchestrator.java` (app) — entrypoint construction per runtime type |
| Modified | `DeploymentExecutor.java` (app) — resolve AUTO at PRE_FLIGHT, pass fields to ContainerRequest |
| Modified | `AppController.java` (app) — run detection on upload, validate customArgs on save |
| Modified | `EnvironmentAdminController.java` (app) — validate customArgs on save |
| Modified | `AppService.java` (core) — accept and store detection results |
| Modified | `PostgresAppVersionRepository.java` (app) — persist new columns |
| New | `V10__app_version_runtime_detection.sql` — add columns to `app_versions` |
| Modified | `AppsTab.tsx` (UI) — Runtime Type select + Custom Arguments input + detection hint |
| Modified | `apps.ts` (UI) — add fields to `AppVersion` type |
### Not in Scope
- No changes to the base Docker image — the server constructs the entrypoint fully
- No changes to the JAR upload flow (same endpoint, detection is transparent)
- No environment variable changes — agent reads `CAMELEER_AGENT_*` as-is
- No Quarkus native-specific upload handling — same upload, detected as native binary