diff --git a/CLAUDE.md b/CLAUDE.md index 11080ae6..8f9bd356 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,14 +42,16 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar **runtime/** — App/Environment/Deployment domain - `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB) -- `AppVersion` — record: id, appId, version, jarPath +- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass - `Environment` — record: id, slug, jarRetentionCount - `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName - `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED - `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE - `DeploymentService` — createDeployment (deletes terminal deployments first), markRunning, markFailed, markStopped -- `ContainerRequest` — record: 17 fields for Docker container creation -- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, cpuShares, cpuLimit, appPort, replicas, routingMode, routeControlEnabled, replayEnabled, etc. +- `RuntimeType` — enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE +- `RuntimeDetector` — probes JAR files at upload time: detects runtime from manifest Main-Class (Spring Boot loader, Quarkus entry point, plain Java) or native binary (non-ZIP magic bytes) +- `ContainerRequest` — record: 20 fields for Docker container creation (includes runtimeType, customArgs, mainClass) +- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, cpuShares, cpuLimit, appPort, replicas, routingMode, routeControlEnabled, replayEnabled, runtimeType, customArgs, etc. - `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig - `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs @@ -100,7 +102,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar **runtime/** — Docker orchestration - `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle -- `DeploymentExecutor` — @Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Primary network for app containers is set via `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` env var (in SaaS mode: `cameleer-tenant-{slug}`); apps also connect to `cameleer-traefik` (routing) and `cameleer-env-{tenantId}-{envSlug}` (per-environment discovery) as additional networks. Sets `CAMELEER_AGENT_ROUTECONTROL_ENABLED` and `CAMELEER_AGENT_REPLAY_ENABLED` from `ResolvedContainerConfig` (default: true, configurable per environment/app via `defaultContainerConfig`/`containerConfig` JSONB). These are startup-only agent properties — changing them requires redeployment. +- `DeploymentExecutor` — @Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Primary network for app containers is set via `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` env var (in SaaS mode: `cameleer-tenant-{slug}`); apps also connect to `cameleer-traefik` (routing) and `cameleer-env-{tenantId}-{envSlug}` (per-environment discovery) as additional networks. Resolves `runtimeType: auto` to concrete type from `AppVersion.detectedRuntimeType` at PRE_FLIGHT (fails deployment if unresolvable). Builds framework-specific Docker entrypoint per runtime type (Spring Boot PropertiesLauncher, Quarkus `-jar`, plain Java classpath, native binary). Sets `CAMELEER_AGENT_*` env vars from `ResolvedContainerConfig` (routeControlEnabled, replayEnabled, health port). These are startup-only agent properties — changing them requires redeployment. - `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers - `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status - `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing @@ -171,6 +173,7 @@ PostgreSQL (Flyway): `cameleer3-server-app/src/main/resources/db/migration/` - V7 — Deployment orchestration (target_state, deployment_strategy, replica_states JSONB, deploy_stage) - V8 — Deployment active config (resolved_config JSONB on deployments) - V9 — Password hardening (failed_login_attempts, locked_until, token_revoked_before on users) +- V10 — Runtime type detection (detected_runtime_type, detected_main_class on app_versions) ClickHouse: `cameleer3-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup) @@ -229,7 +232,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments When deployed via the cameleer-saas platform, this server orchestrates customer app containers using Docker. Key components: -- **ConfigMerger** (`core/runtime/ConfigMerger.java`) — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig. Three-layer merge: global (application.yml) -> environment (defaultContainerConfig JSONB) -> app (containerConfig JSONB). +- **ConfigMerger** (`core/runtime/ConfigMerger.java`) — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig. Three-layer merge: global (application.yml) -> environment (defaultContainerConfig JSONB) -> app (containerConfig JSONB). Includes `runtimeType` (default `"auto"`) and `customArgs` (default `""`). - **TraefikLabelBuilder** (`app/runtime/TraefikLabelBuilder.java`) — generates Traefik Docker labels for path-based (`/{envSlug}/{appSlug}/`) or subdomain-based (`{appSlug}-{envSlug}.{domain}`) routing. Supports strip-prefix and SSL offloading toggles. - **DockerNetworkManager** (`app/runtime/DockerNetworkManager.java`) — manages two Docker network tiers: - `cameleer-traefik` — shared network; Traefik, server, and all app containers attach here. Server joined via docker-compose with `cameleer3-server` DNS alias. @@ -264,6 +267,17 @@ Deployments move through these statuses: - **Nightly cleanup job** (`JarRetentionJob`, Spring `@Scheduled` 03:00): purges JARs exceeding the retention limit and removes orphaned files not referenced by any app version. Skips versions currently deployed. - **Volume-based JAR mounting** for Docker-in-Docker setups: set `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` to the Docker volume name that contains the JAR storage directory. When set, the orchestrator mounts this volume into the container instead of bind-mounting the host path (required when the SaaS container itself runs inside Docker and the host path is not accessible from sibling containers). +### Runtime Type Detection + +The server detects the app framework from uploaded JARs and builds framework-specific Docker entrypoints: + +- **Detection** (`RuntimeDetector`): runs at JAR upload time. Checks ZIP magic bytes (non-ZIP = native binary), then probes `META-INF/MANIFEST.MF` Main-Class: Spring Boot loader prefix → `spring-boot`, Quarkus entry point → `quarkus`, other Main-Class → `plain-java` (extracts class name). Results stored on `AppVersion` (`detected_runtime_type`, `detected_main_class`). +- **Runtime types** (`RuntimeType` enum): `AUTO`, `SPRING_BOOT`, `QUARKUS`, `PLAIN_JAVA`, `NATIVE`. Configurable per app/environment via `containerConfig.runtimeType` (default `"auto"`). +- **Entrypoint per type**: Spring Boot uses `PropertiesLauncher` with `-Dloader.path` for log appender; Quarkus uses `-jar` (appender compiled in); plain Java uses classpath with appender JAR; native runs binary directly (agent compiled in). All JVM types get `-javaagent:/app/agent.jar`. +- **Custom arguments** (`containerConfig.customArgs`): freeform string appended to the start command. Validated against a strict pattern to prevent shell injection (entrypoint uses `sh -c`). +- **AUTO resolution**: at deploy time (PRE_FLIGHT), `"auto"` resolves to the detected type from `AppVersion`. Fails deployment if detection was unsuccessful — user must set type explicitly. +- **UI**: Resources tab shows Runtime Type dropdown (with detection hint from latest uploaded version) and Custom Arguments text field. + ### SaaS Multi-Tenant Network Isolation In SaaS mode, each tenant's server and its deployed apps are isolated at the Docker network level: @@ -283,7 +297,7 @@ In SaaS mode, each tenant's server and its deployed apps are isolated at the Doc # GitNexus — Code Intelligence -This project is indexed by GitNexus as **cameleer3-server** (5803 symbols, 14279 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **cameleer3-server** (5912 symbols, 14487 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/docs/superpowers/plans/2026-04-12-runtime-type-detection.md b/docs/superpowers/plans/2026-04-12-runtime-type-detection.md new file mode 100644 index 00000000..28d66924 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-runtime-type-detection.md @@ -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 findByAppId(UUID appId); + Optional 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 exposedPorts, + Map 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 additionalNetworks, + Map envVars, + Map labels, + long memoryLimitBytes, + Long memoryReserveBytes, + int cpuShares, + Long cpuQuota, + List 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 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 updateContainerConfig(@PathVariable String appSlug, + @RequestBody Map 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 = { + 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' && ( +
+ Container Resources +
+ Runtime Type +
+ setCustomArgs(e.target.value)} + placeholder="-Xmx256m -Dfoo=bar" className={styles.inputLg} /> + + {runtimeType === 'native' ? 'Arguments passed to the native binary' : 'Additional JVM arguments appended to the start command'} + +
+ + Memory Limit +``` + +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" +``` diff --git a/docs/superpowers/specs/2026-04-12-runtime-type-detection-design.md b/docs/superpowers/specs/2026-04-12-runtime-type-detection-design.md new file mode 100644 index 00000000..7cea210a --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-runtime-type-detection-design.md @@ -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 ` +- **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