# 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 | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeType.java` | Enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE |
| Create | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeDetector.java` | Probes JAR files and returns detected RuntimeType + mainClass |
| Create | `cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/RuntimeDetectorTest.java` | Unit tests for detection logic |
| Create | `cameleer-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 | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersion.java` | Add detectedRuntimeType, detectedMainClass fields |
| Modify | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersionRepository.java` | Add updateDetectedRuntime method |
| Modify | `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppVersionRepository.java` | Persist and read new columns |
| Modify | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java` | Run detection after upload |
| Modify | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java` | Add runtimeType, customArgs fields |
| Modify | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java` | Resolve runtimeType, customArgs |
| Modify | `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java` | Add runtimeType, customArgs, mainClass fields |
| Modify | `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java` | Build entrypoint per runtime type |
| Modify | `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` | Resolve AUTO, pass runtime fields to ContainerRequest |
| Modify | `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java` | Validate customArgs on config save |
| Modify | `cameleer-server-app/src/main/java/com/cameleer/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: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeType.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeDetector.java`
- Create: `cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/RuntimeDetectorTest.java`

- [ ] **Step 1: Create the RuntimeType enum**

```java
// cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeType.java
package com.cameleer.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
// cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/RuntimeDetectorTest.java
package com.cameleer.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 cameleer-server-core -Dtest=RuntimeDetectorTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: FAIL — `RuntimeDetector` class not found

- [ ] **Step 4: Implement RuntimeDetector**

```java
// cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeDetector.java
package com.cameleer.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 cameleer-server-core -Dtest=RuntimeDetectorTest`
Expected: All 8 tests PASS

- [ ] **Step 6: Commit**

```bash
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeType.java \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/RuntimeDetector.java \
       cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/RuntimeDetectorTest.java
git commit -m "feat: add RuntimeType enum and RuntimeDetector for JAR probing"
```

---

### Task 2: AppVersion + Migration + Repository

**Files:**
- Create: `cameleer-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersion.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersionRepository.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppVersionRepository.java`

- [ ] **Step 1: Create Flyway migration**

```sql
-- cameleer-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 `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersion.java`:

```java
package com.cameleer.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 `cameleer-server-core/src/main/java/com/cameleer/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.cameleer.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 `cameleer-server-app/src/main/java/com/cameleer/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 cameleer-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersion.java \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppVersionRepository.java \
       cameleer-server-app/src/main/java/com/cameleer/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: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java`

- [ ] **Step 1: Update AppService.uploadJar to run detection after saving**

In `cameleer-server-core/src/main/java/com/cameleer/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 cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java
git commit -m "feat: run runtime detection on JAR upload"
```

---

### Task 4: ConfigMerger + ResolvedContainerConfig

**Files:**
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java`

- [ ] **Step 1: Add runtimeType and customArgs to ResolvedContainerConfig**

In `cameleer-server-core/src/main/java/com/cameleer/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 `cameleer-server-core/src/main/java/com/cameleer/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 cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java
git commit -m "feat: add runtimeType and customArgs to ResolvedContainerConfig and ConfigMerger"
```

---

### Task 5: ContainerRequest + DeploymentExecutor + Entrypoint

**Files:**
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java`

- [ ] **Step 1: Add runtimeType, customArgs, mainClass to ContainerRequest**

Replace the full content of `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java`:

```java
package com.cameleer.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 `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java`:

Add import at top:

```java
import com.cameleer.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 `cameleer-server-core/src/main/java/com/cameleer/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 `cameleer-server-app/src/main/java/com/cameleer/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/cameleer-log-appender.jar" + customArgs + " " + request.mainClass();
            case "native" -> "exec " + appJarPath + customArgs;
            default -> // spring-boot (default)
                    "exec java -javaagent:/app/agent.jar -Dloader.path=/app/cameleer-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 cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java \
       cameleer-server-app/src/main/java/com/cameleer/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: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java`

- [ ] **Step 1: Add validation to AppController.updateContainerConfig**

In `cameleer-server-app/src/main/java/com/cameleer/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.cameleer.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 `cameleer-server-app/src/main/java/com/cameleer/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 cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java \
       cameleer-server-app/src/main/java/com/cameleer/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" cameleer-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"
```
