docs: update documentation for runtime type detection feature
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
CLAUDE.md
26
CLAUDE.md
@@ -42,14 +42,16 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
|||||||
|
|
||||||
**runtime/** — App/Environment/Deployment domain
|
**runtime/** — App/Environment/Deployment domain
|
||||||
- `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
|
- `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
|
- `Environment` — record: id, slug, jarRetentionCount
|
||||||
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
|
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
|
||||||
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
|
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
|
||||||
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
|
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
|
||||||
- `DeploymentService` — createDeployment (deletes terminal deployments first), markRunning, markFailed, markStopped
|
- `DeploymentService` — createDeployment (deletes terminal deployments first), markRunning, markFailed, markStopped
|
||||||
- `ContainerRequest` — record: 17 fields for Docker container creation
|
- `RuntimeType` — enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE
|
||||||
- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, cpuShares, cpuLimit, appPort, replicas, routingMode, routeControlEnabled, replayEnabled, etc.
|
- `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
|
- `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig
|
||||||
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs
|
- `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
|
**runtime/** — Docker orchestration
|
||||||
- `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle
|
- `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
|
- `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers
|
||||||
- `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status
|
- `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status
|
||||||
- `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing
|
- `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)
|
- V7 — Deployment orchestration (target_state, deployment_strategy, replica_states JSONB, deploy_stage)
|
||||||
- V8 — Deployment active config (resolved_config JSONB on deployments)
|
- V8 — Deployment active config (resolved_config JSONB on deployments)
|
||||||
- V9 — Password hardening (failed_login_attempts, locked_until, token_revoked_before on users)
|
- 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)
|
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:
|
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.
|
- **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:
|
- **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.
|
- `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.
|
- **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).
|
- **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
|
### SaaS Multi-Tenant Network Isolation
|
||||||
|
|
||||||
In SaaS mode, each tenant's server and its deployed apps are isolated at the Docker network level:
|
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:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
973
docs/superpowers/plans/2026-04-12-runtime-type-detection.md
Normal file
973
docs/superpowers/plans/2026-04-12-runtime-type-detection.md
Normal file
@@ -0,0 +1,973 @@
|
|||||||
|
# Runtime Type Detection Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Detect app runtime type from uploaded JARs, construct framework-specific Docker entrypoints, and let users override via UI with custom command-line arguments.
|
||||||
|
|
||||||
|
**Architecture:** `RuntimeDetector` probes JARs at upload time, storing the detected type on `AppVersion`. At deploy time, `DeploymentExecutor` resolves `AUTO` to the detected type and passes it to `DockerRuntimeOrchestrator`, which builds the correct entrypoint per framework. Two new fields (`runtimeType`, `customArgs`) flow through the existing `containerConfig` JSONB and 3-layer `ConfigMerger`.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 17, Spring Boot 3.4, PostgreSQL (Flyway), JUnit 5, React/TypeScript (Vite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Create | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java` | Enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE |
|
||||||
|
| Create | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java` | Probes JAR files and returns detected RuntimeType + mainClass |
|
||||||
|
| Create | `cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java` | Unit tests for detection logic |
|
||||||
|
| Create | `cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql` | Adds detected_runtime_type and detected_main_class to app_versions |
|
||||||
|
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java` | Add detectedRuntimeType, detectedMainClass fields |
|
||||||
|
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java` | Add updateDetectedRuntime method |
|
||||||
|
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java` | Persist and read new columns |
|
||||||
|
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java` | Run detection after upload |
|
||||||
|
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java` | Add runtimeType, customArgs fields |
|
||||||
|
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java` | Resolve runtimeType, customArgs |
|
||||||
|
| Modify | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java` | Add runtimeType, customArgs, mainClass fields |
|
||||||
|
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java` | Build entrypoint per runtime type |
|
||||||
|
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java` | Resolve AUTO, pass runtime fields to ContainerRequest |
|
||||||
|
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java` | Validate customArgs on config save |
|
||||||
|
| Modify | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java` | Validate customArgs on config save |
|
||||||
|
| Modify | `ui/src/api/queries/admin/apps.ts` | Add new fields to AppVersion type |
|
||||||
|
| Modify | `ui/src/pages/AppsTab/AppsTab.tsx` | Runtime Type select, Custom Arguments input, detection hint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: RuntimeType Enum and RuntimeDetector
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java`
|
||||||
|
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java`
|
||||||
|
- Create: `cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the RuntimeType enum**
|
||||||
|
|
||||||
|
```java
|
||||||
|
// cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java
|
||||||
|
package com.cameleer3.server.core.runtime;
|
||||||
|
|
||||||
|
public enum RuntimeType {
|
||||||
|
AUTO,
|
||||||
|
SPRING_BOOT,
|
||||||
|
QUARKUS,
|
||||||
|
PLAIN_JAVA,
|
||||||
|
NATIVE;
|
||||||
|
|
||||||
|
/** Parse from containerConfig string value, case-insensitive. Returns null if unrecognized. */
|
||||||
|
public static RuntimeType fromString(String value) {
|
||||||
|
if (value == null || value.isBlank()) return AUTO;
|
||||||
|
return switch (value.toLowerCase().replace("-", "_")) {
|
||||||
|
case "auto" -> AUTO;
|
||||||
|
case "spring_boot" -> SPRING_BOOT;
|
||||||
|
case "quarkus" -> QUARKUS;
|
||||||
|
case "plain_java" -> PLAIN_JAVA;
|
||||||
|
case "native" -> NATIVE;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase kebab-case for JSON serialization. */
|
||||||
|
public String toConfigValue() {
|
||||||
|
return name().toLowerCase().replace("_", "-");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write RuntimeDetector tests**
|
||||||
|
|
||||||
|
```java
|
||||||
|
// cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java
|
||||||
|
package com.cameleer3.server.core.runtime;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.jar.Attributes;
|
||||||
|
import java.util.jar.JarOutputStream;
|
||||||
|
import java.util.jar.Manifest;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RuntimeDetectorTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private Path createJarWithMainClass(String mainClass) throws IOException {
|
||||||
|
Path jar = tempDir.resolve("app.jar");
|
||||||
|
Manifest manifest = new Manifest();
|
||||||
|
manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||||
|
manifest.getMainAttributes().put(Attributes.Name.MAIN_CLASS, mainClass);
|
||||||
|
try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar.toFile()), manifest)) {
|
||||||
|
// empty JAR with manifest
|
||||||
|
}
|
||||||
|
return jar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createJarWithoutMainClass() throws IOException {
|
||||||
|
Path jar = tempDir.resolve("no-main.jar");
|
||||||
|
Manifest manifest = new Manifest();
|
||||||
|
manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
|
||||||
|
try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar.toFile()), manifest)) {
|
||||||
|
// empty JAR, no Main-Class
|
||||||
|
}
|
||||||
|
return jar;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsSpringBootJarLauncher() throws IOException {
|
||||||
|
Path jar = createJarWithMainClass("org.springframework.boot.loader.launch.JarLauncher");
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar);
|
||||||
|
assertEquals(RuntimeType.SPRING_BOOT, result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsSpringBootPropertiesLauncher() throws IOException {
|
||||||
|
Path jar = createJarWithMainClass("org.springframework.boot.loader.launch.PropertiesLauncher");
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar);
|
||||||
|
assertEquals(RuntimeType.SPRING_BOOT, result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsSpringBootLegacyLauncher() throws IOException {
|
||||||
|
Path jar = createJarWithMainClass("org.springframework.boot.loader.JarLauncher");
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar);
|
||||||
|
assertEquals(RuntimeType.SPRING_BOOT, result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsQuarkus() throws IOException {
|
||||||
|
Path jar = createJarWithMainClass("io.quarkus.bootstrap.runner.QuarkusEntryPoint");
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar);
|
||||||
|
assertEquals(RuntimeType.QUARKUS, result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsPlainJava() throws IOException {
|
||||||
|
Path jar = createJarWithMainClass("com.example.MyApp");
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar);
|
||||||
|
assertEquals(RuntimeType.PLAIN_JAVA, result.runtimeType());
|
||||||
|
assertEquals("com.example.MyApp", result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsNativeBinary() throws IOException {
|
||||||
|
Path binary = tempDir.resolve("app");
|
||||||
|
// ELF header (not a ZIP/JAR)
|
||||||
|
Files.write(binary, new byte[]{0x7f, 'E', 'L', 'F', 0, 0, 0, 0});
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(binary);
|
||||||
|
assertEquals(RuntimeType.NATIVE, result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNullForJarWithoutMainClass() throws IOException {
|
||||||
|
Path jar = createJarWithoutMainClass();
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(jar);
|
||||||
|
assertNull(result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsNullForEmptyFile() throws IOException {
|
||||||
|
Path empty = tempDir.resolve("empty");
|
||||||
|
Files.write(empty, new byte[0]);
|
||||||
|
RuntimeDetector.DetectionResult result = RuntimeDetector.detect(empty);
|
||||||
|
assertNull(result.runtimeType());
|
||||||
|
assertNull(result.mainClass());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `mvn test -pl cameleer3-server-core -Dtest=RuntimeDetectorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||||
|
Expected: FAIL — `RuntimeDetector` class not found
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement RuntimeDetector**
|
||||||
|
|
||||||
|
```java
|
||||||
|
// cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java
|
||||||
|
package com.cameleer3.server.core.runtime;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.util.jar.Manifest;
|
||||||
|
|
||||||
|
public final class RuntimeDetector {
|
||||||
|
|
||||||
|
private RuntimeDetector() {}
|
||||||
|
|
||||||
|
public record DetectionResult(RuntimeType runtimeType, String mainClass) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the runtime type of a file (JAR or native binary).
|
||||||
|
* Returns a result with null runtimeType if detection fails.
|
||||||
|
*/
|
||||||
|
public static DetectionResult detect(Path file) {
|
||||||
|
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||||
|
return new DetectionResult(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a ZIP/JAR (starts with PK magic bytes)
|
||||||
|
if (!isZipFile(file)) {
|
||||||
|
// Non-ZIP file with content = native binary
|
||||||
|
try {
|
||||||
|
if (Files.size(file) > 0) {
|
||||||
|
return new DetectionResult(RuntimeType.NATIVE, null);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return new DetectionResult(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a JAR — read the manifest
|
||||||
|
try (JarFile jar = new JarFile(file.toFile())) {
|
||||||
|
Manifest manifest = jar.getManifest();
|
||||||
|
if (manifest == null) {
|
||||||
|
return new DetectionResult(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String mainClass = manifest.getMainAttributes().getValue("Main-Class");
|
||||||
|
if (mainClass == null || mainClass.isBlank()) {
|
||||||
|
return new DetectionResult(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spring Boot: any launcher in org.springframework.boot.loader
|
||||||
|
if (mainClass.startsWith("org.springframework.boot.loader")) {
|
||||||
|
return new DetectionResult(RuntimeType.SPRING_BOOT, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quarkus
|
||||||
|
if (mainClass.equals("io.quarkus.bootstrap.runner.QuarkusEntryPoint")) {
|
||||||
|
return new DetectionResult(RuntimeType.QUARKUS, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain Java: has a Main-Class, not a known framework
|
||||||
|
return new DetectionResult(RuntimeType.PLAIN_JAVA, mainClass);
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return new DetectionResult(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isZipFile(Path file) {
|
||||||
|
try (InputStream is = Files.newInputStream(file)) {
|
||||||
|
byte[] magic = new byte[2];
|
||||||
|
int read = is.read(magic);
|
||||||
|
return read == 2 && magic[0] == 'P' && magic[1] == 'K';
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn test -pl cameleer3-server-core -Dtest=RuntimeDetectorTest`
|
||||||
|
Expected: All 8 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeType.java \
|
||||||
|
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/RuntimeDetector.java \
|
||||||
|
cameleer3-server-core/src/test/java/com/cameleer3/server/core/runtime/RuntimeDetectorTest.java
|
||||||
|
git commit -m "feat: add RuntimeType enum and RuntimeDetector for JAR probing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: AppVersion + Migration + Repository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql`
|
||||||
|
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java`
|
||||||
|
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java`
|
||||||
|
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create Flyway migration**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql
|
||||||
|
ALTER TABLE app_versions ADD COLUMN detected_runtime_type VARCHAR;
|
||||||
|
ALTER TABLE app_versions ADD COLUMN detected_main_class VARCHAR;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update AppVersion record**
|
||||||
|
|
||||||
|
Replace the entire content of `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.cameleer3.server.core.runtime;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
|
||||||
|
String jarFilename, Long jarSizeBytes, String detectedRuntimeType,
|
||||||
|
String detectedMainClass, Instant uploadedAt) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add updateDetectedRuntime to AppVersionRepository**
|
||||||
|
|
||||||
|
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java`, add after the `delete` method (line 12):
|
||||||
|
|
||||||
|
```java
|
||||||
|
void updateDetectedRuntime(UUID id, String detectedRuntimeType, String detectedMainClass);
|
||||||
|
```
|
||||||
|
|
||||||
|
Full file becomes:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.cameleer3.server.core.runtime;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface AppVersionRepository {
|
||||||
|
List<AppVersion> findByAppId(UUID appId);
|
||||||
|
Optional<AppVersion> findById(UUID id);
|
||||||
|
int findMaxVersion(UUID appId);
|
||||||
|
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
|
||||||
|
void delete(UUID id);
|
||||||
|
void updateDetectedRuntime(UUID id, String detectedRuntimeType, String detectedMainClass);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update PostgresAppVersionRepository**
|
||||||
|
|
||||||
|
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java`:
|
||||||
|
|
||||||
|
Update the SQL SELECT queries in `findByAppId` (line 24) and `findById` (line 31) to include the new columns:
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```
|
||||||
|
SELECT id, app_id, version, jar_path, jar_checksum, jar_filename, jar_size_bytes, uploaded_at
|
||||||
|
```
|
||||||
|
With:
|
||||||
|
```
|
||||||
|
SELECT id, app_id, version, jar_path, jar_checksum, jar_filename, jar_size_bytes, detected_runtime_type, detected_main_class, uploaded_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `mapRow` to read the new columns — replace the full method:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private AppVersion mapRow(ResultSet rs) throws SQLException {
|
||||||
|
Long sizeBytes = rs.getLong("jar_size_bytes");
|
||||||
|
if (rs.wasNull()) sizeBytes = null;
|
||||||
|
return new AppVersion(
|
||||||
|
UUID.fromString(rs.getString("id")),
|
||||||
|
UUID.fromString(rs.getString("app_id")),
|
||||||
|
rs.getInt("version"),
|
||||||
|
rs.getString("jar_path"),
|
||||||
|
rs.getString("jar_checksum"),
|
||||||
|
rs.getString("jar_filename"),
|
||||||
|
sizeBytes,
|
||||||
|
rs.getString("detected_runtime_type"),
|
||||||
|
rs.getString("detected_main_class"),
|
||||||
|
rs.getTimestamp("uploaded_at").toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the `updateDetectedRuntime` method after `delete`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public void updateDetectedRuntime(UUID id, String detectedRuntimeType, String detectedMainClass) {
|
||||||
|
jdbc.update("UPDATE app_versions SET detected_runtime_type = ?, detected_main_class = ? WHERE id = ?",
|
||||||
|
detectedRuntimeType, detectedMainClass, id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn clean compile`
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cameleer3-server-app/src/main/resources/db/migration/V10__app_version_runtime_detection.sql \
|
||||||
|
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersion.java \
|
||||||
|
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java \
|
||||||
|
cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java
|
||||||
|
git commit -m "feat: add detected_runtime_type and detected_main_class to app_versions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Run Detection on Upload
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update AppService.uploadJar to run detection after saving**
|
||||||
|
|
||||||
|
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java`, add an import at the top:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.nio.file.Path;
|
||||||
|
```
|
||||||
|
|
||||||
|
(Note: `Path` is already imported — just verify it's there.)
|
||||||
|
|
||||||
|
Replace the `uploadJar` method (lines 47-76) with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
|
||||||
|
getById(appId); // verify app exists
|
||||||
|
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
|
||||||
|
|
||||||
|
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
|
||||||
|
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
|
||||||
|
Files.createDirectories(versionDir);
|
||||||
|
Path jarFile = versionDir.resolve("app.jar");
|
||||||
|
|
||||||
|
MessageDigest digest;
|
||||||
|
try { digest = MessageDigest.getInstance("SHA-256"); }
|
||||||
|
catch (Exception e) { throw new RuntimeException(e); }
|
||||||
|
|
||||||
|
try (InputStream in = jarData) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int bytesRead;
|
||||||
|
try (var out = Files.newOutputStream(jarFile)) {
|
||||||
|
while ((bytesRead = in.read(buffer)) != -1) {
|
||||||
|
out.write(buffer, 0, bytesRead);
|
||||||
|
digest.update(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String checksum = HexFormat.of().formatHex(digest.digest());
|
||||||
|
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
|
||||||
|
|
||||||
|
// Detect runtime type from the saved JAR
|
||||||
|
RuntimeDetector.DetectionResult detection = RuntimeDetector.detect(jarFile);
|
||||||
|
if (detection.runtimeType() != null) {
|
||||||
|
versionRepo.updateDetectedRuntime(versionId, detection.runtimeType().toConfigValue(), detection.mainClass());
|
||||||
|
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected={}",
|
||||||
|
appId, nextVersion, size, checksum, detection.runtimeType().toConfigValue());
|
||||||
|
} else {
|
||||||
|
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}, detected=unknown",
|
||||||
|
appId, nextVersion, size, checksum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionRepo.findById(versionId).orElseThrow();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn clean compile`
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java
|
||||||
|
git commit -m "feat: run runtime detection on JAR upload"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: ConfigMerger + ResolvedContainerConfig
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java`
|
||||||
|
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add runtimeType and customArgs to ResolvedContainerConfig**
|
||||||
|
|
||||||
|
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java`, add two new fields after `replayEnabled` (line 22):
|
||||||
|
|
||||||
|
```java
|
||||||
|
boolean replayEnabled,
|
||||||
|
String runtimeType,
|
||||||
|
String customArgs
|
||||||
|
```
|
||||||
|
|
||||||
|
Full record signature becomes:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record ResolvedContainerConfig(
|
||||||
|
int memoryLimitMb,
|
||||||
|
Integer memoryReserveMb,
|
||||||
|
int cpuRequest,
|
||||||
|
Integer cpuLimit,
|
||||||
|
int appPort,
|
||||||
|
List<Integer> exposedPorts,
|
||||||
|
Map<String, String> customEnvVars,
|
||||||
|
boolean stripPathPrefix,
|
||||||
|
boolean sslOffloading,
|
||||||
|
String routingMode,
|
||||||
|
String routingDomain,
|
||||||
|
String serverUrl,
|
||||||
|
int replicas,
|
||||||
|
String deploymentStrategy,
|
||||||
|
boolean routeControlEnabled,
|
||||||
|
boolean replayEnabled,
|
||||||
|
String runtimeType,
|
||||||
|
String customArgs
|
||||||
|
) {
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing helper methods (`memoryLimitBytes`, `memoryReserveBytes`, `dockerCpuShares`, `dockerCpuQuota`) remain unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update ConfigMerger.resolve() to include new fields**
|
||||||
|
|
||||||
|
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java`, add two new lines at the end of the `resolve()` return statement (after line 33, the `replayEnabled` line):
|
||||||
|
|
||||||
|
```java
|
||||||
|
boolVal(appConfig, envConfig, "routeControlEnabled", true),
|
||||||
|
boolVal(appConfig, envConfig, "replayEnabled", true),
|
||||||
|
stringVal(appConfig, envConfig, "runtimeType", "auto"),
|
||||||
|
stringVal(appConfig, envConfig, "customArgs", "")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn clean compile`
|
||||||
|
Expected: FAIL — `DeploymentExecutor.resolvedConfigToMap()` creates `ResolvedContainerConfig` with old arity. Fix in next task.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit (with compile fix deferred to Task 5)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java \
|
||||||
|
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java
|
||||||
|
git commit -m "feat: add runtimeType and customArgs to ResolvedContainerConfig and ConfigMerger"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: ContainerRequest + DeploymentExecutor + Entrypoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java`
|
||||||
|
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
|
||||||
|
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add runtimeType, customArgs, mainClass to ContainerRequest**
|
||||||
|
|
||||||
|
Replace the full content of `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.cameleer3.server.core.runtime;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record ContainerRequest(
|
||||||
|
String containerName,
|
||||||
|
String baseImage,
|
||||||
|
String jarPath,
|
||||||
|
String jarVolumeName,
|
||||||
|
String jarVolumeMountPath,
|
||||||
|
String network,
|
||||||
|
List<String> additionalNetworks,
|
||||||
|
Map<String, String> envVars,
|
||||||
|
Map<String, String> labels,
|
||||||
|
long memoryLimitBytes,
|
||||||
|
Long memoryReserveBytes,
|
||||||
|
int cpuShares,
|
||||||
|
Long cpuQuota,
|
||||||
|
List<Integer> exposedPorts,
|
||||||
|
int healthCheckPort,
|
||||||
|
String restartPolicyName,
|
||||||
|
int restartPolicyMaxRetries,
|
||||||
|
String runtimeType,
|
||||||
|
String customArgs,
|
||||||
|
String mainClass
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update DeploymentExecutor — resolve AUTO, pass runtime fields, update resolvedConfigToMap**
|
||||||
|
|
||||||
|
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`:
|
||||||
|
|
||||||
|
Add import at top:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import com.cameleer3.server.core.runtime.RuntimeType;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `executeAsync()`, after `preFlightChecks(jarPath, config);` (after the PRE_FLIGHT stage comment block), add runtime type resolution:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// === PRE-FLIGHT ===
|
||||||
|
updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
|
||||||
|
preFlightChecks(jarPath, config);
|
||||||
|
|
||||||
|
// Resolve runtime type
|
||||||
|
String resolvedRuntimeType = config.runtimeType();
|
||||||
|
String mainClass = null;
|
||||||
|
if ("auto".equalsIgnoreCase(resolvedRuntimeType)) {
|
||||||
|
AppVersion appVersion = appService.getVersion(deployment.appVersionId());
|
||||||
|
if (appVersion.detectedRuntimeType() == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Could not detect runtime type for JAR '" + appVersion.jarFilename() +
|
||||||
|
"'. Set runtimeType explicitly in app configuration.");
|
||||||
|
}
|
||||||
|
resolvedRuntimeType = appVersion.detectedRuntimeType();
|
||||||
|
mainClass = appVersion.detectedMainClass();
|
||||||
|
} else if ("plain-java".equals(resolvedRuntimeType)) {
|
||||||
|
AppVersion appVersion = appService.getVersion(deployment.appVersionId());
|
||||||
|
mainClass = appVersion.detectedMainClass();
|
||||||
|
if (mainClass == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Runtime type 'plain-java' requires a Main-Class in the JAR manifest, " +
|
||||||
|
"but none was detected for '" + appVersion.jarFilename() + "'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `ContainerRequest` constructor call in the replica loop to include the three new fields at the end:
|
||||||
|
|
||||||
|
```java
|
||||||
|
ContainerRequest request = new ContainerRequest(
|
||||||
|
containerName, baseImage, jarPath,
|
||||||
|
volumeName, jarStoragePath,
|
||||||
|
primaryNetwork,
|
||||||
|
additionalNets,
|
||||||
|
baseEnvVars, labels,
|
||||||
|
config.memoryLimitBytes(), config.memoryReserveBytes(),
|
||||||
|
config.dockerCpuShares(), config.dockerCpuQuota(),
|
||||||
|
config.exposedPorts(), agentHealthPort,
|
||||||
|
"on-failure", 3,
|
||||||
|
resolvedRuntimeType, config.customArgs(), mainClass
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `resolvedConfigToMap()` to include new fields — add at the end before the return:
|
||||||
|
|
||||||
|
```java
|
||||||
|
map.put("deploymentStrategy", config.deploymentStrategy());
|
||||||
|
map.put("runtimeType", config.runtimeType());
|
||||||
|
map.put("customArgs", config.customArgs());
|
||||||
|
return map;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add getVersion method to AppService**
|
||||||
|
|
||||||
|
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java`, add after `listVersions` (line 33):
|
||||||
|
|
||||||
|
```java
|
||||||
|
public AppVersion getVersion(UUID versionId) {
|
||||||
|
return versionRepo.findById(versionId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + versionId));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update DockerRuntimeOrchestrator entrypoint construction**
|
||||||
|
|
||||||
|
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`, replace the entrypoint block (the section starting with `// Resolve the JAR path for the entrypoint` through the `createCmd.withEntrypoint` call) with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Resolve the JAR path for the entrypoint
|
||||||
|
String appJarPath;
|
||||||
|
if (request.jarVolumeName() != null && !request.jarVolumeName().isBlank()) {
|
||||||
|
appJarPath = request.jarPath();
|
||||||
|
} else {
|
||||||
|
appJarPath = "/app/app.jar";
|
||||||
|
}
|
||||||
|
|
||||||
|
var createCmd = dockerClient.createContainerCmd(request.baseImage())
|
||||||
|
.withName(request.containerName())
|
||||||
|
.withEnv(envList)
|
||||||
|
.withLabels(request.labels() != null ? request.labels() : Map.of())
|
||||||
|
.withHostConfig(hostConfig)
|
||||||
|
.withHealthcheck(new HealthCheck()
|
||||||
|
.withTest(List.of("CMD-SHELL",
|
||||||
|
"wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
|
||||||
|
.withInterval(10_000_000_000L)
|
||||||
|
.withTimeout(5_000_000_000L)
|
||||||
|
.withRetries(3)
|
||||||
|
.withStartPeriod(30_000_000_000L));
|
||||||
|
|
||||||
|
// Build entrypoint based on runtime type
|
||||||
|
String customArgs = request.customArgs() != null && !request.customArgs().isBlank()
|
||||||
|
? " " + request.customArgs() : "";
|
||||||
|
String entrypoint = switch (request.runtimeType()) {
|
||||||
|
case "quarkus" -> "exec java -javaagent:/app/agent.jar" + customArgs + " -jar " + appJarPath;
|
||||||
|
case "plain-java" -> "exec java -javaagent:/app/agent.jar -cp " + appJarPath +
|
||||||
|
":/app/cameleer3-log-appender.jar" + customArgs + " " + request.mainClass();
|
||||||
|
case "native" -> "exec " + appJarPath + customArgs;
|
||||||
|
default -> // spring-boot (default)
|
||||||
|
"exec java -javaagent:/app/agent.jar -Dloader.path=/app/cameleer3-log-appender.jar" +
|
||||||
|
customArgs + " -cp " + appJarPath + " org.springframework.boot.loader.launch.PropertiesLauncher";
|
||||||
|
};
|
||||||
|
createCmd.withEntrypoint("sh", "-c", entrypoint);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn clean compile`
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java \
|
||||||
|
cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppService.java \
|
||||||
|
cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java \
|
||||||
|
cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java
|
||||||
|
git commit -m "feat: build Docker entrypoint per runtime type with custom args support"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Input Validation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java`
|
||||||
|
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add validation to AppController.updateContainerConfig**
|
||||||
|
|
||||||
|
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java`, add a validation constant and helper method at the bottom of the class, before the `CreateAppRequest` record:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
|
||||||
|
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
|
||||||
|
|
||||||
|
private void validateContainerConfig(Map<String, Object> config) {
|
||||||
|
Object customArgs = config.get("customArgs");
|
||||||
|
if (customArgs instanceof String s && !s.isBlank() && !CUSTOM_ARGS_PATTERN.matcher(s).matches()) {
|
||||||
|
throw new IllegalArgumentException("customArgs contains invalid characters. Only JVM-style arguments are allowed.");
|
||||||
|
}
|
||||||
|
Object runtimeType = config.get("runtimeType");
|
||||||
|
if (runtimeType instanceof String s && !s.isBlank() && RuntimeType.fromString(s) == null) {
|
||||||
|
throw new IllegalArgumentException("Invalid runtimeType: " + s +
|
||||||
|
". Must be one of: auto, spring-boot, quarkus, plain-java, native");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add import at top:
|
||||||
|
|
||||||
|
```java
|
||||||
|
import com.cameleer3.server.core.runtime.RuntimeType;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `updateContainerConfig` method to call validation:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@PutMapping("/{appSlug}/container-config")
|
||||||
|
@Operation(summary = "Update container config for an app")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Container config updated")
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid configuration")
|
||||||
|
@ApiResponse(responseCode = "404", description = "App not found")
|
||||||
|
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
|
||||||
|
@RequestBody Map<String, Object> containerConfig) {
|
||||||
|
try {
|
||||||
|
validateContainerConfig(containerConfig);
|
||||||
|
App app = appService.getBySlug(appSlug);
|
||||||
|
appService.updateContainerConfig(app.id(), containerConfig);
|
||||||
|
return ResponseEntity.ok(appService.getById(app.id()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
if (e.getMessage().contains("not found")) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add same validation to EnvironmentAdminController.updateDefaultContainerConfig**
|
||||||
|
|
||||||
|
In `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java`, add the same constant, helper, and import. Then call `validateContainerConfig(defaultContainerConfig)` as the first line in the try block of `updateDefaultContainerConfig`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn clean compile`
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java \
|
||||||
|
cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
|
||||||
|
git commit -m "feat: validate runtimeType and customArgs on container config save"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: UI — AppVersion Type + Runtime Type Select + Custom Arguments Input
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `ui/src/api/queries/admin/apps.ts`
|
||||||
|
- Modify: `ui/src/pages/AppsTab/AppsTab.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update AppVersion TypeScript interface**
|
||||||
|
|
||||||
|
In `ui/src/api/queries/admin/apps.ts`, add to the `AppVersion` interface (after `uploadedAt`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AppVersion {
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
version: number;
|
||||||
|
jarPath: string;
|
||||||
|
jarChecksum: string;
|
||||||
|
jarFilename: string;
|
||||||
|
jarSizeBytes: number;
|
||||||
|
detectedRuntimeType: string | null;
|
||||||
|
detectedMainClass: string | null;
|
||||||
|
uploadedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add state variables for runtime type and custom args**
|
||||||
|
|
||||||
|
In `ui/src/pages/AppsTab/AppsTab.tsx`, add two new state variables after `sslOffloading` (line 211):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [sslOffloading, setSslOffloading] = useState(true);
|
||||||
|
const [runtimeType, setRuntimeType] = useState(String(defaults.runtimeType ?? 'auto'));
|
||||||
|
const [customArgs, setCustomArgs] = useState(String(defaults.customArgs ?? ''));
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `useEffect` that resets resource defaults when environment changes (lines 218-225), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setRuntimeType(String(d.runtimeType ?? 'auto'));
|
||||||
|
setCustomArgs(String(d.customArgs ?? ''));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add runtimeType and customArgs to the save handler**
|
||||||
|
|
||||||
|
In the container config save handler (lines 888-900), add the two new fields to the `containerConfig` object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const containerConfig: Record<string, unknown> = {
|
||||||
|
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
|
||||||
|
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
|
||||||
|
cpuRequest: cpuRequest ? parseInt(cpuRequest) : null,
|
||||||
|
cpuLimit: cpuLimit ? parseInt(cpuLimit) : null,
|
||||||
|
exposedPorts: ports,
|
||||||
|
customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])),
|
||||||
|
appPort: appPort ? parseInt(appPort) : 8080,
|
||||||
|
replicas: replicas ? parseInt(replicas) : 1,
|
||||||
|
deploymentStrategy: deployStrategy,
|
||||||
|
stripPathPrefix: stripPrefix,
|
||||||
|
sslOffloading: sslOffloading,
|
||||||
|
runtimeType: runtimeType,
|
||||||
|
customArgs: customArgs || null,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add Runtime Type and Custom Arguments fields to the Resources tab UI**
|
||||||
|
|
||||||
|
In the Resources tab rendering (after `{configTab === 'resources' && (`, line 1091), add the two new fields at the top of the `configGrid`, before the Memory Limit row:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{configTab === 'resources' && (
|
||||||
|
<div className={sectionStyles.section}>
|
||||||
|
<SectionHeader>Container Resources</SectionHeader>
|
||||||
|
<div className={styles.configGrid}>
|
||||||
|
<span className={styles.configLabel}>Runtime Type</span>
|
||||||
|
<div>
|
||||||
|
<Select disabled={!editing} value={runtimeType} onChange={(e) => setRuntimeType(e.target.value)}
|
||||||
|
options={[
|
||||||
|
{ value: 'auto', label: 'Auto (detect from JAR)' },
|
||||||
|
{ value: 'spring-boot', label: 'Spring Boot' },
|
||||||
|
{ value: 'quarkus', label: 'Quarkus' },
|
||||||
|
{ value: 'plain-java', label: 'Plain Java' },
|
||||||
|
{ value: 'native', label: 'Native' },
|
||||||
|
]} />
|
||||||
|
{latestVersion?.detectedRuntimeType && (
|
||||||
|
<span className={styles.configHint} style={{ color: 'var(--success)' }}>
|
||||||
|
Detected: {latestVersion.detectedRuntimeType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{latestVersion && !latestVersion.detectedRuntimeType && (
|
||||||
|
<span className={styles.configHint} style={{ color: 'var(--amber)' }}>
|
||||||
|
Detection failed — select runtime type manually
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Custom Arguments</span>
|
||||||
|
<div>
|
||||||
|
<Input disabled={!editing} value={customArgs} onChange={(e) => setCustomArgs(e.target.value)}
|
||||||
|
placeholder="-Xmx256m -Dfoo=bar" className={styles.inputLg} />
|
||||||
|
<span className={styles.configHint}>
|
||||||
|
{runtimeType === 'native' ? 'Arguments passed to the native binary' : 'Additional JVM arguments appended to the start command'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Memory Limit</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `latestVersion` variable, add it near the top of the `ConfigSubTab` function, after the existing hooks. Use the `useAppVersions` hook:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { data: versions } = useAppVersions(app?.slug);
|
||||||
|
const latestVersion = versions?.[0] ?? null; // versions are ordered DESC by version
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `useAppVersions` is already imported/available from `apps.ts`. If `ConfigSubTab` doesn't have `app` in scope, thread it from the parent. Check the component props — `app` is likely already available as a prop or from the parent component's state. The versions query uses `app.slug` (the app slug) as the parameter.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the UI builds**
|
||||||
|
|
||||||
|
Run: `cd ui && npm run build`
|
||||||
|
Expected: BUILD SUCCESS (no TypeScript errors)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add ui/src/api/queries/admin/apps.ts \
|
||||||
|
ui/src/pages/AppsTab/AppsTab.tsx
|
||||||
|
git commit -m "feat: add Runtime Type and Custom Arguments fields to deployment Resources tab"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Full Build Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full Maven build**
|
||||||
|
|
||||||
|
Run: `mvn clean verify`
|
||||||
|
Expected: BUILD SUCCESS with all tests passing
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run UI build**
|
||||||
|
|
||||||
|
Run: `cd ui && npm run build`
|
||||||
|
Expected: BUILD SUCCESS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix any compilation or test failures from record field arity changes**
|
||||||
|
|
||||||
|
The `AppVersion` record gained 2 new fields and `ResolvedContainerConfig` gained 2 new fields. Any existing test that constructs these records directly will need the new arguments added. Common fix pattern:
|
||||||
|
|
||||||
|
- `AppVersion` constructor: add `null, null` before `uploadedAt` (for `detectedRuntimeType`, `detectedMainClass`)
|
||||||
|
- `ResolvedContainerConfig` constructor: add `"auto", ""` at the end (for `runtimeType`, `customArgs`)
|
||||||
|
- `ContainerRequest` constructor: add `"spring-boot", "", null` at the end (for `runtimeType`, `customArgs`, `mainClass`)
|
||||||
|
|
||||||
|
Search for these with:
|
||||||
|
```bash
|
||||||
|
grep -rn "new AppVersion\|new ResolvedContainerConfig\|new ContainerRequest" --include="*.java" cameleer3-server-*/src/test/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit fixes if any**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: update test constructor calls for new record fields"
|
||||||
|
```
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Runtime Type Detection and Custom Arguments
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The server constructs Docker container entrypoints for deployed apps, but currently hardcodes a single start command. Different app frameworks require different start commands:
|
||||||
|
|
||||||
|
- **Spring Boot**: `-cp app.jar org.springframework.boot.loader.launch.PropertiesLauncher` with `-Dloader.path` for the log appender
|
||||||
|
- **Quarkus JVM**: `-jar app.jar` (appender is a compiled-in Maven dependency)
|
||||||
|
- **Plain Java**: `-cp app.jar:/app/cameleer3-log-appender.jar <Main-Class>`
|
||||||
|
- **Native** (Quarkus native): no JVM, just run the binary directly (agent compiled in at build time)
|
||||||
|
|
||||||
|
Users also need a way to pass custom command-line arguments (JVM flags, system properties, or native binary args).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Runtime Type Enum
|
||||||
|
|
||||||
|
New enum `RuntimeType` in the core module:
|
||||||
|
|
||||||
|
```
|
||||||
|
AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
Two new keys in the existing `containerConfig` JSONB, participating in the 3-layer merge (app > env > global):
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|-----|------|---------|-------------|
|
||||||
|
| `runtimeType` | `string` | `"auto"` | One of: `auto`, `spring-boot`, `quarkus`, `plain-java`, `native` |
|
||||||
|
| `customArgs` | `string` | `""` | Freeform arguments appended to the start command |
|
||||||
|
|
||||||
|
No database migration needed for these — they're new keys in existing JSONB columns.
|
||||||
|
|
||||||
|
**AppVersion additions** (requires Flyway migration `V10`):
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `detected_runtime_type` | `VARCHAR` (nullable) | Runtime type detected from JAR on upload |
|
||||||
|
| `detected_main_class` | `VARCHAR` (nullable) | Main-Class from manifest (needed for `PLAIN_JAVA` entrypoint) |
|
||||||
|
|
||||||
|
### Server-Side Runtime Detection
|
||||||
|
|
||||||
|
New class `RuntimeDetector` in the core module. Pure function: takes a file path, returns a detection result (runtime type + main class, both nullable).
|
||||||
|
|
||||||
|
**Detection logic** (checked in order):
|
||||||
|
|
||||||
|
1. **Not a JAR**: file doesn't start with ZIP magic bytes (`PK`) → `NATIVE`
|
||||||
|
2. **Read `META-INF/MANIFEST.MF`** from the JAR:
|
||||||
|
- `Main-Class` contains `org.springframework.boot.loader` → `SPRING_BOOT`
|
||||||
|
- `Main-Class` is `io.quarkus.bootstrap.runner.QuarkusEntryPoint` → `QUARKUS`
|
||||||
|
- `Main-Class` exists (anything else) → `PLAIN_JAVA` (also extract the Main-Class value)
|
||||||
|
3. **No Main-Class in manifest** → detection fails, return null for both fields
|
||||||
|
|
||||||
|
**When it runs**: immediately after JAR upload in `AppController.uploadJar()`. The result is stored on `AppVersion.detectedRuntimeType` and `AppVersion.detectedMainClass`. The upload API response and version listing include these fields so the UI can show the detection result immediately.
|
||||||
|
|
||||||
|
**At deploy time** (PRE_FLIGHT in `DeploymentExecutor`): if `runtimeType` is `AUTO`, use `AppVersion.detectedRuntimeType`. If that is also null, fail the deployment with:
|
||||||
|
|
||||||
|
> "Could not detect runtime type for JAR '{filename}'. Set runtimeType explicitly in app configuration."
|
||||||
|
|
||||||
|
### Entrypoint Construction
|
||||||
|
|
||||||
|
`DockerRuntimeOrchestrator.startContainer()` builds the entrypoint based on the resolved runtime type. `ContainerRequest` gains two new fields: `runtimeType` (String) and `customArgs` (String).
|
||||||
|
|
||||||
|
| Type | Entrypoint |
|
||||||
|
|------|-----------|
|
||||||
|
| `SPRING_BOOT` | `exec java -javaagent:/app/agent.jar -Dloader.path=/app/cameleer3-log-appender.jar {customArgs} -cp {jarPath} org.springframework.boot.loader.launch.PropertiesLauncher` |
|
||||||
|
| `QUARKUS` | `exec java -javaagent:/app/agent.jar {customArgs} -jar {jarPath}` |
|
||||||
|
| `PLAIN_JAVA` | `exec java -javaagent:/app/agent.jar -cp {jarPath}:/app/cameleer3-log-appender.jar {customArgs} {mainClass}` |
|
||||||
|
| `NATIVE` | `exec {jarPath} {customArgs}` |
|
||||||
|
|
||||||
|
All entrypoints are wrapped in `sh -c "..."` for consistent execution. `jarPath` is resolved per mount strategy (volume mount uses the original path, bind mount uses `/app/app.jar`).
|
||||||
|
|
||||||
|
For `PLAIN_JAVA`, `mainClass` comes from `AppVersion.detectedMainClass`. If unavailable (user set `plain-java` explicitly but no main class stored), the deployment fails at PRE_FLIGHT.
|
||||||
|
|
||||||
|
All JVM types receive `CAMELEER_AGENT_*` env vars from `buildEnvVars()` — the agent reads them directly.
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
`customArgs` is validated on save (in `AppController` and `EnvironmentAdminController`) to prevent shell injection. The entrypoint uses `sh -c`, so shell metacharacters must be rejected.
|
||||||
|
|
||||||
|
**Allowed pattern**: `^[-a-zA-Z0-9_.=:/\s+"']*$`
|
||||||
|
|
||||||
|
This covers legitimate JVM args (`-Xmx256m`, `-Dfoo=bar`, `-XX:+UseG1GC`, `-Dpath=/some/path`) while blocking shell metacharacters (`;`, `&`, `|`, `` ` ``, `$`, `(`).
|
||||||
|
|
||||||
|
Validation error: *"customArgs contains invalid characters. Only JVM-style arguments are allowed."*
|
||||||
|
|
||||||
|
`runtimeType` is validated as one of the enum values (case-insensitive).
|
||||||
|
|
||||||
|
### UI Changes
|
||||||
|
|
||||||
|
The **Resources tab** in the app config gets two new fields at the top (before Memory Limit):
|
||||||
|
|
||||||
|
**Runtime Type**: `Select` dropdown with options:
|
||||||
|
- `Auto (detect from JAR)` (default)
|
||||||
|
- `Spring Boot`
|
||||||
|
- `Quarkus`
|
||||||
|
- `Plain Java`
|
||||||
|
- `Native`
|
||||||
|
|
||||||
|
Below the dropdown, a hint shows the detection result from the current JAR version:
|
||||||
|
- "Detected: Spring Boot" (green) — when detection succeeded
|
||||||
|
- "Detection failed — select runtime type manually" (amber) — when detection returned null
|
||||||
|
- No hint when no version has been uploaded yet
|
||||||
|
|
||||||
|
**Custom Arguments**: single-line `Input` text field:
|
||||||
|
- Placeholder: `-Xmx256m -Dfoo=bar`
|
||||||
|
- Label: "Custom Arguments"
|
||||||
|
- Sublabel adapts to runtime type: "Additional JVM arguments" for JVM types, "Arguments passed to the native binary" for `Native`
|
||||||
|
- Client-side validation feedback on invalid characters
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
| Area | Files |
|
||||||
|
|------|-------|
|
||||||
|
| New | `RuntimeDetector.java` (core) — detection logic |
|
||||||
|
| New | `RuntimeType.java` (core) — enum |
|
||||||
|
| Modified | `ContainerRequest.java` (core) — add `runtimeType`, `customArgs`, `mainClass` fields |
|
||||||
|
| Modified | `ConfigMerger.java` (core) — resolve `runtimeType`, `customArgs` string fields |
|
||||||
|
| Modified | `ResolvedContainerConfig.java` (core) — add `runtimeType`, `customArgs` fields |
|
||||||
|
| Modified | `AppVersion.java` (core) — add `detectedRuntimeType`, `detectedMainClass` fields |
|
||||||
|
| Modified | `DockerRuntimeOrchestrator.java` (app) — entrypoint construction per runtime type |
|
||||||
|
| Modified | `DeploymentExecutor.java` (app) — resolve AUTO at PRE_FLIGHT, pass fields to ContainerRequest |
|
||||||
|
| Modified | `AppController.java` (app) — run detection on upload, validate customArgs on save |
|
||||||
|
| Modified | `EnvironmentAdminController.java` (app) — validate customArgs on save |
|
||||||
|
| Modified | `AppService.java` (core) — accept and store detection results |
|
||||||
|
| Modified | `PostgresAppVersionRepository.java` (app) — persist new columns |
|
||||||
|
| New | `V10__app_version_runtime_detection.sql` — add columns to `app_versions` |
|
||||||
|
| Modified | `AppsTab.tsx` (UI) — Runtime Type select + Custom Arguments input + detection hint |
|
||||||
|
| Modified | `apps.ts` (UI) — add fields to `AppVersion` type |
|
||||||
|
|
||||||
|
### Not in Scope
|
||||||
|
|
||||||
|
- No changes to the base Docker image — the server constructs the entrypoint fully
|
||||||
|
- No changes to the JAR upload flow (same endpoint, detection is transparent)
|
||||||
|
- No environment variable changes — agent reads `CAMELEER_AGENT_*` as-is
|
||||||
|
- No Quarkus native-specific upload handling — same upload, detected as native binary
|
||||||
Reference in New Issue
Block a user