docs: update documentation for runtime type detection feature
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
973
docs/superpowers/plans/2026-04-12-runtime-type-detection.md
Normal file
973
docs/superpowers/plans/2026-04-12-runtime-type-detection.md
Normal 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"
|
||||
```
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user