# Plan 3: Runtime Management in the Server > **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer-server with enhancements beyond the original 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 (`- [x]`) syntax for tracking. **Goal:** Move environment management, app lifecycle, JAR upload, and Docker container orchestration from the SaaS layer into the server, so the server is a self-sufficient product that can deploy and manage Camel applications. **Architecture:** The server gains Environment/App/AppVersion/Deployment entities stored in its PostgreSQL. A `RuntimeOrchestrator` interface abstracts Docker/K8s/disabled modes, auto-detected at startup. The Docker implementation uses a shared base image + volume-mounted JARs (no per-deployment image builds). Apps are promoted between environments by creating new Deployments pointing to the same AppVersion. Routing supports both path-based and subdomain-based modes via Traefik labels. **Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers **Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server` **Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages) --- ## File Map ### New Files — Core Module (`cameleer-server-core`) ``` src/main/java/com/cameleer/server/core/runtime/ ├── Environment.java Record: id, slug, displayName, status, createdAt ├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED ├── EnvironmentRepository.java Interface: CRUD + findBySlug ├── EnvironmentService.java Business logic: create, list, delete, enforce limits ├── App.java Record: id, environmentId, slug, displayName, createdAt ├── AppVersion.java Record: id, appId, version, jarPath, sha256, uploadedAt ├── AppRepository.java Interface: CRUD + findByEnvironmentId ├── AppVersionRepository.java Interface: CRUD + findByAppId ├── AppService.java Business logic: create, upload JAR, list, delete ├── Deployment.java Record: id, appId, appVersionId, environmentId, status, containerId ├── DeploymentStatus.java Enum: STARTING, RUNNING, FAILED, STOPPED ├── DeploymentRepository.java Interface: CRUD + findByAppId + findByEnvironmentId ├── DeploymentService.java Business logic: deploy, stop, restart, promote ├── RuntimeOrchestrator.java Interface: startContainer, stopContainer, getStatus, getLogs ├── RuntimeConfig.java Record: jarStoragePath, baseImage, dockerNetwork, routing, etc. ├── ContainerRequest.java Record: containerName, jarPath, envVars, memoryLimit, cpuShares ├── ContainerStatus.java Record: state, running, exitCode, error └── RoutingMode.java Enum: path, subdomain ``` ### New Files — App Module (`cameleer-server-app`) ``` src/main/java/com/cameleer/server/app/runtime/ ├── DockerRuntimeOrchestrator.java Docker implementation using docker-java ├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode) ├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled ├── DeploymentExecutor.java @Service: async deployment pipeline ├── JarStorageService.java File-system JAR storage with versioning └── ContainerLogCollector.java Collects Docker container stdout/stderr src/main/java/com/cameleer/server/app/storage/ ├── PostgresEnvironmentRepository.java ├── PostgresAppRepository.java ├── PostgresAppVersionRepository.java └── PostgresDeploymentRepository.java src/main/java/com/cameleer/server/app/controller/ ├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments ├── AppController.java App + version CRUD + JAR upload └── DeploymentController.java Deploy, stop, restart, promote, logs src/main/resources/db/migration/ └── V3__runtime_management.sql Environments, apps, app_versions, deployments tables ``` ### Modified Files - `pom.xml` (parent) — add docker-java dependency - `cameleer-server-app/pom.xml` — add docker-java dependency - `application.yml` — add runtime config properties --- ### Task 1: Add docker-java Dependency **Files:** - Modify: `cameleer-server-app/pom.xml` - [x] **Step 1: Add docker-java dependency** ```xml com.github.docker-java docker-java-core 3.4.1 com.github.docker-java docker-java-transport-zerodep 3.4.1 ``` - [x] **Step 2: Verify build** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn compile -pl cameleer-server-app` Expected: BUILD SUCCESS. - [x] **Step 3: Commit** ```bash git add cameleer-server-app/pom.xml git commit -m "chore: add docker-java dependency for runtime orchestration" ``` --- ### Task 2: Database Migration — Runtime Management Tables **Files:** - Create: `cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql` - [x] **Step 1: Write migration** ```sql -- V3__runtime_management.sql -- Runtime management: environments, apps, app versions, deployments CREATE TABLE environments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug VARCHAR(100) NOT NULL UNIQUE, display_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE apps ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE, slug VARCHAR(100) NOT NULL, display_name VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(environment_id, slug) ); CREATE INDEX idx_apps_environment_id ON apps(environment_id); CREATE TABLE app_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, version INTEGER NOT NULL, jar_path VARCHAR(500) NOT NULL, jar_checksum VARCHAR(64) NOT NULL, jar_filename VARCHAR(255), jar_size_bytes BIGINT, uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(app_id, version) ); CREATE INDEX idx_app_versions_app_id ON app_versions(app_id); CREATE TABLE deployments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_version_id UUID NOT NULL REFERENCES app_versions(id), environment_id UUID NOT NULL REFERENCES environments(id), status VARCHAR(20) NOT NULL DEFAULT 'STARTING', container_id VARCHAR(100), container_name VARCHAR(255), error_message TEXT, deployed_at TIMESTAMPTZ, stopped_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_deployments_app_id ON deployments(app_id); CREATE INDEX idx_deployments_env_id ON deployments(environment_id); -- Default environment (standalone mode always has at least one) INSERT INTO environments (slug, display_name) VALUES ('default', 'Default'); ``` - [x] **Step 2: Commit** ```bash git add cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)" ``` --- ### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records **Files:** - Create all records in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/` - [x] **Step 1: Create all domain records** ```java // Environment.java package com.cameleer.server.core.runtime; import java.time.Instant; import java.util.UUID; public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {} // EnvironmentStatus.java package com.cameleer.server.core.runtime; public enum EnvironmentStatus { ACTIVE, SUSPENDED } // App.java package com.cameleer.server.core.runtime; import java.time.Instant; import java.util.UUID; public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {} // AppVersion.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, Instant uploadedAt) {} // Deployment.java package com.cameleer.server.core.runtime; import java.time.Instant; import java.util.UUID; public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId, DeploymentStatus status, String containerId, String containerName, String errorMessage, Instant deployedAt, Instant stoppedAt, Instant createdAt) { public Deployment withStatus(DeploymentStatus newStatus) { return new Deployment(id, appId, appVersionId, environmentId, newStatus, containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt); } } // DeploymentStatus.java package com.cameleer.server.core.runtime; public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED } // RoutingMode.java package com.cameleer.server.core.runtime; public enum RoutingMode { path, subdomain } ``` - [x] **Step 2: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ git commit -m "feat: add runtime management domain records" ``` --- ### Task 4: Core — Repository Interfaces and RuntimeOrchestrator **Files:** - Create repository interfaces and RuntimeOrchestrator in `core/runtime/` - [x] **Step 1: Create repository interfaces** ```java // EnvironmentRepository.java package com.cameleer.server.core.runtime; import java.util.*; public interface EnvironmentRepository { List findAll(); Optional findById(UUID id); Optional findBySlug(String slug); UUID create(String slug, String displayName); void updateDisplayName(UUID id, String displayName); void updateStatus(UUID id, EnvironmentStatus status); void delete(UUID id); } // AppRepository.java package com.cameleer.server.core.runtime; import java.util.*; public interface AppRepository { List findByEnvironmentId(UUID environmentId); Optional findById(UUID id); Optional findByEnvironmentIdAndSlug(UUID environmentId, String slug); UUID create(UUID environmentId, String slug, String displayName); void delete(UUID id); } // AppVersionRepository.java package com.cameleer.server.core.runtime; import java.util.*; public interface AppVersionRepository { List findByAppId(UUID appId); Optional findById(UUID id); int findMaxVersion(UUID appId); UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes); } // DeploymentRepository.java package com.cameleer.server.core.runtime; import java.util.*; public interface DeploymentRepository { List findByAppId(UUID appId); List findByEnvironmentId(UUID environmentId); Optional findById(UUID id); Optional findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId); UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName); void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage); void markDeployed(UUID id); void markStopped(UUID id); } ``` - [x] **Step 2: Create RuntimeOrchestrator interface** ```java // RuntimeOrchestrator.java package com.cameleer.server.core.runtime; import java.util.stream.Stream; public interface RuntimeOrchestrator { boolean isEnabled(); String startContainer(ContainerRequest request); void stopContainer(String containerId); void removeContainer(String containerId); ContainerStatus getContainerStatus(String containerId); Stream getLogs(String containerId, int tailLines); } // ContainerRequest.java package com.cameleer.server.core.runtime; import java.util.Map; public record ContainerRequest( String containerName, String baseImage, String jarPath, String network, Map envVars, Map labels, long memoryLimitBytes, int cpuShares, int healthCheckPort ) {} // ContainerStatus.java package com.cameleer.server.core.runtime; public record ContainerStatus(String state, boolean running, int exitCode, String error) { public static ContainerStatus notFound() { return new ContainerStatus("not_found", false, -1, "Container not found"); } } ``` - [x] **Step 3: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator" ``` --- ### Task 5: Core — EnvironmentService, AppService, DeploymentService **Files:** - Create service classes in `core/runtime/` - [x] **Step 1: Create EnvironmentService** ```java package com.cameleer.server.core.runtime; import java.util.List; import java.util.UUID; public class EnvironmentService { private final EnvironmentRepository repo; public EnvironmentService(EnvironmentRepository repo) { this.repo = repo; } public List listAll() { return repo.findAll(); } public Environment getById(UUID id) { return repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + id)); } public Environment getBySlug(String slug) { return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug)); } public UUID create(String slug, String displayName) { if (repo.findBySlug(slug).isPresent()) { throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists"); } return repo.create(slug, displayName); } public void delete(UUID id) { Environment env = getById(id); if ("default".equals(env.slug())) { throw new IllegalArgumentException("Cannot delete the default environment"); } repo.delete(id); } } ``` - [x] **Step 2: Create AppService** ```java package com.cameleer.server.core.runtime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; import java.nio.file.*; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.UUID; public class AppService { private static final Logger log = LoggerFactory.getLogger(AppService.class); private final AppRepository appRepo; private final AppVersionRepository versionRepo; private final String jarStoragePath; public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) { this.appRepo = appRepo; this.versionRepo = versionRepo; this.jarStoragePath = jarStoragePath; } public List listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); } public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); } public List listVersions(UUID appId) { return versionRepo.findByAppId(appId); } public UUID createApp(UUID environmentId, String slug, String displayName) { if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); } return appRepo.create(environmentId, slug, displayName); } public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException { App app = getById(appId); 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); log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}", appId, nextVersion, size, checksum); return versionRepo.findById(versionId).orElseThrow(); } public String resolveJarPath(UUID appVersionId) { AppVersion version = versionRepo.findById(appVersionId) .orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId)); return version.jarPath(); } public void deleteApp(UUID id) { appRepo.delete(id); } } ``` - [x] **Step 3: Create DeploymentService** ```java package com.cameleer.server.core.runtime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.List; import java.util.UUID; public class DeploymentService { private static final Logger log = LoggerFactory.getLogger(DeploymentService.class); private final DeploymentRepository deployRepo; private final AppService appService; private final EnvironmentService envService; public DeploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) { this.deployRepo = deployRepo; this.appService = appService; this.envService = envService; } public List listByApp(UUID appId) { return deployRepo.findByAppId(appId); } public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); } /** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */ public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) { App app = appService.getById(appId); Environment env = envService.getById(environmentId); String containerName = env.slug() + "-" + app.slug(); UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName); return deployRepo.findById(deploymentId).orElseThrow(); } /** Promote: deploy the same app version to a different environment. */ public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) { return createDeployment(appId, appVersionId, targetEnvironmentId); } public void markRunning(UUID deploymentId, String containerId) { deployRepo.updateStatus(deploymentId, DeploymentStatus.RUNNING, containerId, null); deployRepo.markDeployed(deploymentId); } public void markFailed(UUID deploymentId, String errorMessage) { deployRepo.updateStatus(deploymentId, DeploymentStatus.FAILED, null, errorMessage); } public void markStopped(UUID deploymentId) { deployRepo.updateStatus(deploymentId, DeploymentStatus.STOPPED, null, null); deployRepo.markStopped(deploymentId); } } ``` - [x] **Step 4: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ git commit -m "feat: add EnvironmentService, AppService, DeploymentService" ``` --- ### Task 6: App Module — PostgreSQL Repositories **Files:** - Create all Postgres repositories in `app/storage/` - [x] **Step 1: Implement all four repositories** Follow the pattern from `PostgresUserRepository.java` — `JdbcTemplate` with row mappers. Each repository implements its core interface with standard SQL (INSERT, SELECT, UPDATE, DELETE). Key patterns to follow: - Constructor injection of `JdbcTemplate` - RowMapper lambdas returning records - `UUID.randomUUID()` for ID generation - `Timestamp.from(Instant)` for timestamp parameters - [x] **Step 2: Wire beans** Create `RuntimeBeanConfig.java` in `app/config/`: ```java @Configuration public class RuntimeBeanConfig { @Bean public EnvironmentRepository environmentRepository(JdbcTemplate jdbc) { return new PostgresEnvironmentRepository(jdbc); } @Bean public AppRepository appRepository(JdbcTemplate jdbc) { return new PostgresAppRepository(jdbc); } @Bean public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) { return new PostgresAppVersionRepository(jdbc); } @Bean public DeploymentRepository deploymentRepository(JdbcTemplate jdbc) { return new PostgresDeploymentRepository(jdbc); } @Bean public EnvironmentService environmentService(EnvironmentRepository repo) { return new EnvironmentService(repo); } @Bean public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo, @Value("${cameleer.runtime.jar-storage-path:/data/jars}") String jarStoragePath) { return new AppService(appRepo, versionRepo, jarStoragePath); } @Bean public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) { return new DeploymentService(deployRepo, appService, envService); } } ``` - [x] **Step 3: Run tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app` Expected: PASS (Flyway applies V3 migration, context loads). - [x] **Step 4: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/Postgres*Repository.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java git commit -m "feat: implement PostgreSQL repositories for runtime management" ``` --- ### Task 7: Docker Runtime Orchestrator **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DisabledRuntimeOrchestrator.java` - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java` - [x] **Step 1: Implement DisabledRuntimeOrchestrator** ```java package com.cameleer.server.app.runtime; import com.cameleer.server.core.runtime.*; import java.util.stream.Stream; public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator { @Override public boolean isEnabled() { return false; } @Override public String startContainer(ContainerRequest r) { throw new UnsupportedOperationException("Runtime management disabled"); } @Override public void stopContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); } @Override public void removeContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); } @Override public ContainerStatus getContainerStatus(String id) { return ContainerStatus.notFound(); } @Override public Stream getLogs(String id, int tail) { return Stream.empty(); } } ``` - [x] **Step 2: Implement DockerRuntimeOrchestrator** Port from SaaS `DockerRuntimeOrchestrator.java`, adapted: - Uses docker-java `DockerClientImpl` with zerodep transport - `startContainer()`: creates container from base image with volume mount for JAR (instead of image build), sets env vars, Traefik labels, health check, resource limits - `stopContainer()`: stops with 30s timeout - `removeContainer()`: force remove - `getContainerStatus()`: inspect container state - `getLogs()`: tail container logs Key difference from SaaS version: **no image build**. The base image is pre-built. JAR is volume-mounted: ```java @Override public String startContainer(ContainerRequest request) { List envList = request.envVars().entrySet().stream() .map(e -> e.getKey() + "=" + e.getValue()).toList(); // Volume bind: mount JAR into container Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro); HostConfig hostConfig = HostConfig.newHostConfig() .withMemory(request.memoryLimitBytes()) .withMemorySwap(request.memoryLimitBytes()) .withCpuShares(request.cpuShares()) .withNetworkMode(request.network()) .withBinds(jarBind); CreateContainerResponse container = dockerClient.createContainerCmd(request.baseImage()) .withName(request.containerName()) .withEnv(envList) .withLabels(request.labels()) .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)) .exec(); dockerClient.startContainerCmd(container.getId()).exec(); return container.getId(); } ``` - [x] **Step 3: Implement RuntimeOrchestratorAutoConfig** ```java package com.cameleer.server.app.runtime; import com.cameleer.server.core.runtime.RuntimeOrchestrator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.nio.file.Files; import java.nio.file.Path; @Configuration public class RuntimeOrchestratorAutoConfig { private static final Logger log = LoggerFactory.getLogger(RuntimeOrchestratorAutoConfig.class); @Bean public RuntimeOrchestrator runtimeOrchestrator() { // Auto-detect: Docker socket available? if (Files.exists(Path.of("/var/run/docker.sock"))) { log.info("Docker socket detected — enabling Docker runtime orchestrator"); return new DockerRuntimeOrchestrator(); } // TODO: K8s detection (check for service account token) log.info("No Docker socket or K8s detected — runtime management disabled (observability-only mode)"); return new DisabledRuntimeOrchestrator(); } } ``` - [x] **Step 4: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/ git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment" ``` --- ### Task 8: DeploymentExecutor — Async Deployment Pipeline **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java` - [x] **Step 1: Implement async deployment pipeline** ```java package com.cameleer.server.app.runtime; import com.cameleer.server.core.runtime.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; @Service public class DeploymentExecutor { private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class); private final RuntimeOrchestrator orchestrator; private final DeploymentService deploymentService; private final AppService appService; private final EnvironmentService envService; // Inject runtime config values public DeploymentExecutor(RuntimeOrchestrator orchestrator, DeploymentService deploymentService, AppService appService, EnvironmentService envService) { this.orchestrator = orchestrator; this.deploymentService = deploymentService; this.appService = appService; this.envService = envService; } @Async("deploymentExecutor") public void executeAsync(Deployment deployment) { try { // Stop existing deployment in same environment for same app // ... (find active deployment, stop container) String jarPath = appService.resolveJarPath(deployment.appVersionId()); App app = appService.getById(deployment.appId()); Environment env = envService.getById(deployment.environmentId()); Map envVars = new HashMap<>(); envVars.put("CAMELEER_EXPORT_TYPE", "HTTP"); envVars.put("CAMELEER_EXPORT_ENDPOINT", /* server endpoint */); envVars.put("CAMELEER_AUTH_TOKEN", /* bootstrap token */); envVars.put("CAMELEER_APPLICATION_ID", app.slug()); envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug()); envVars.put("CAMELEER_DISPLAY_NAME", deployment.containerName()); Map labels = buildTraefikLabels(app, env, deployment); ContainerRequest request = new ContainerRequest( deployment.containerName(), /* baseImage */, jarPath, /* network */, envVars, labels, /* memoryLimit */, /* cpuShares */, 9464); String containerId = orchestrator.startContainer(request); waitForHealthy(containerId, 60); deploymentService.markRunning(deployment.id(), containerId); log.info("Deployment {} is RUNNING (container={})", deployment.id(), containerId); } catch (Exception e) { log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e); deploymentService.markFailed(deployment.id(), e.getMessage()); } } private void waitForHealthy(String containerId, int timeoutSeconds) throws InterruptedException { long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L; while (System.currentTimeMillis() < deadline) { ContainerStatus status = orchestrator.getContainerStatus(containerId); if ("healthy".equalsIgnoreCase(status.state()) || (status.running() && "running".equalsIgnoreCase(status.state()))) { return; } if (!status.running()) { throw new RuntimeException("Container stopped unexpectedly: " + status.error()); } Thread.sleep(2000); } throw new RuntimeException("Container health check timed out after " + timeoutSeconds + "s"); } private Map buildTraefikLabels(App app, Environment env, Deployment deployment) { // TODO: implement path-based and subdomain-based Traefik labels based on routing config return Map.of("traefik.enable", "true"); } } ``` - [x] **Step 2: Add async config** Add to `RuntimeBeanConfig.java` or create `AsyncConfig.java`: ```java @Bean(name = "deploymentExecutor") public TaskExecutor deploymentTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(4); executor.setQueueCapacity(25); executor.setThreadNamePrefix("deploy-"); executor.initialize(); return executor; } ``` - [x] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java git commit -m "feat: implement async DeploymentExecutor pipeline" ``` --- ### Task 9: REST Controllers — Environment, App, Deployment **Files:** - Create: `EnvironmentAdminController.java` (under `/api/v1/admin/environments`, ADMIN role) - Create: `AppController.java` (under `/api/v1/apps`, OPERATOR role) - Create: `DeploymentController.java` (under `/api/v1/apps/{appId}/deployments`, OPERATOR role) - [x] **Step 1: Implement EnvironmentAdminController** CRUD for environments. Path: `/api/v1/admin/environments`. Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity). - [x] **Step 2: Implement AppController** App CRUD + JAR upload. Path: `/api/v1/apps`. Requires OPERATOR role. JAR upload via `multipart/form-data`. Returns app versions. Key endpoint for JAR upload: ```java @PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadJar(@PathVariable UUID appId, @RequestParam("file") MultipartFile file) throws IOException { AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize()); return ResponseEntity.status(201).body(version); } ``` - [x] **Step 3: Implement DeploymentController** Deploy, stop, restart, promote, logs. Path: `/api/v1/apps/{appId}/deployments`. Requires OPERATOR role. Key endpoints: ```java @PostMapping public ResponseEntity deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) { // request contains: appVersionId, environmentId Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId()); deploymentExecutor.executeAsync(deployment); return ResponseEntity.accepted().body(deployment); } @PostMapping("/{deploymentId}/promote") public ResponseEntity promote(@PathVariable UUID appId, @PathVariable UUID deploymentId, @RequestBody PromoteRequest request) { Deployment source = deploymentService.getById(deploymentId); Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId()); deploymentExecutor.executeAsync(promoted); return ResponseEntity.accepted().body(promoted); } ``` - [x] **Step 4: Add security rules to SecurityConfig** Add to `SecurityConfig.filterChain()`: ```java // Runtime management (OPERATOR+) .requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN") ``` - [x] **Step 5: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java git commit -m "feat: add REST controllers for environment, app, and deployment management" ``` --- ### Task 10: Configuration and Application Properties **Files:** - Modify: `cameleer-server-app/src/main/resources/application.yml` - [x] **Step 1: Add runtime config properties** ```yaml cameleer: runtime: enabled: ${CAMELEER_RUNTIME_ENABLED:true} jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars} base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:cameleer-runtime-base:latest} docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer} agent-health-port: 9464 health-check-timeout: 60 container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m} container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512} routing-mode: ${CAMELEER_ROUTING_MODE:path} routing-domain: ${CAMELEER_ROUTING_DOMAIN:localhost} ``` - [x] **Step 2: Run full test suite** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify` Expected: PASS. - [x] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/resources/application.yml git commit -m "feat: add runtime management configuration properties" ``` --- ### Task 11: Integration Tests - [x] **Step 1: Write EnvironmentAdminController integration test** Test CRUD operations for environments. Follows existing pattern from `AgentRegistrationControllerIT`. - [x] **Step 2: Write AppController integration test** Test app creation, JAR upload, version listing. - [x] **Step 3: Write DeploymentController integration test** Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the deployment record is created even if Docker is unavailable). Full Docker tests require Docker-in-Docker and are out of scope for CI. - [x] **Step 4: Commit** ```bash git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ git commit -m "test: add integration tests for runtime management API" ``` --- ### Task 12: Final Verification - [x] **Step 1: Run full build** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify` Expected: All tests PASS. - [x] **Step 2: Verify schema applies cleanly** Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors. - [x] **Step 3: Commit any remaining fixes** ```bash git add -A git commit -m "chore: finalize runtime management — all tests passing" ```