2026-04-08 08:53:22 +02:00
# Plan 3: Runtime Management in the Server
2026-04-15 15:28:44 +02:00
> **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer-server with enhancements beyond the original plan.
2026-04-09 08:58:15 +02:00
> **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.
2026-04-08 08:53:22 +02:00
**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
2026-04-15 15:28:44 +02:00
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server`
2026-04-08 08:53:22 +02:00
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
---
## File Map
2026-04-15 15:28:44 +02:00
### New Files — Core Module (`cameleer-server-core`)
2026-04-08 08:53:22 +02:00
```
2026-04-15 15:28:44 +02:00
src/main/java/com/cameleer/server/core/runtime/
2026-04-08 08:53:22 +02:00
├── 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
```
2026-04-15 15:28:44 +02:00
### New Files — App Module (`cameleer-server-app`)
2026-04-08 08:53:22 +02:00
```
2026-04-15 15:28:44 +02:00
src/main/java/com/cameleer/server/app/runtime/
2026-04-08 08:53:22 +02:00
├── 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
2026-04-15 15:28:44 +02:00
src/main/java/com/cameleer/server/app/storage/
2026-04-08 08:53:22 +02:00
├── PostgresEnvironmentRepository.java
├── PostgresAppRepository.java
├── PostgresAppVersionRepository.java
└── PostgresDeploymentRepository.java
2026-04-15 15:28:44 +02:00
src/main/java/com/cameleer/server/app/controller/
2026-04-08 08:53:22 +02:00
├── 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
2026-04-15 15:28:44 +02:00
- `cameleer-server-app/pom.xml` — add docker-java dependency
2026-04-08 08:53:22 +02:00
- `application.yml` — add runtime config properties
---
### Task 1: Add docker-java Dependency
**Files:**
2026-04-15 15:28:44 +02:00
- Modify: `cameleer-server-app/pom.xml`
2026-04-08 08:53:22 +02:00
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Add docker-java dependency **
2026-04-08 08:53:22 +02:00
```xml
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
<version>3.4.1</version>
</dependency>
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Verify build **
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn compile -pl cameleer-server-app`
2026-04-08 08:53:22 +02:00
Expected: BUILD SUCCESS.
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-app/pom.xml
2026-04-08 08:53:22 +02:00
git commit -m "chore: add docker-java dependency for runtime orchestration"
```
---
### Task 2: Database Migration — Runtime Management Tables
**Files:**
2026-04-15 15:28:44 +02:00
- Create: `cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
2026-04-08 08:53:22 +02:00
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Write migration **
2026-04-08 08:53:22 +02:00
```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');
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-app/src/main/resources/db/migration/V3__runtime_management.sql
2026-04-08 08:53:22 +02:00
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
```
---
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
**Files:**
2026-04-15 15:28:44 +02:00
- Create all records in `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/`
2026-04-08 08:53:22 +02:00
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Create all domain records **
2026-04-08 08:53:22 +02:00
```java
// Environment.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.time.Instant;
import java.util.UUID;
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
// EnvironmentStatus.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
// App.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.time.Instant;
import java.util.UUID;
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
// AppVersion.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
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
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
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
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
// RoutingMode.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
public enum RoutingMode { path, subdomain }
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
2026-04-08 08:53:22 +02:00
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/`
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Create repository interfaces **
2026-04-08 08:53:22 +02:00
```java
// EnvironmentRepository.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.util.*;
public interface EnvironmentRepository {
List<Environment> findAll();
Optional<Environment> findById(UUID id);
Optional<Environment> 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
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.util.*;
public interface AppRepository {
List<App> findByEnvironmentId(UUID environmentId);
Optional<App> findById(UUID id);
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
UUID create(UUID environmentId, String slug, String displayName);
void delete(UUID id);
}
// AppVersionRepository.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.util.*;
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);
}
// DeploymentRepository.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.util.*;
public interface DeploymentRepository {
List<Deployment> findByAppId(UUID appId);
List<Deployment> findByEnvironmentId(UUID environmentId);
Optional<Deployment> findById(UUID id);
Optional<Deployment> 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);
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Create RuntimeOrchestrator interface **
2026-04-08 08:53:22 +02:00
```java
// RuntimeOrchestrator.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
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<String> getLogs(String containerId, int tailLines);
}
// ContainerRequest.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.util.Map;
public record ContainerRequest(
String containerName,
String baseImage,
String jarPath,
String network,
Map<String, String> envVars,
Map<String, String> labels,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort
) {}
// ContainerStatus.java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
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");
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
2026-04-08 08:53:22 +02:00
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
```
---
### Task 5: Core — EnvironmentService, AppService, DeploymentService
**Files:**
- Create service classes in `core/runtime/`
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Create EnvironmentService **
2026-04-08 08:53:22 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
import java.util.List;
import java.util.UUID;
public class EnvironmentService {
private final EnvironmentRepository repo;
public EnvironmentService(EnvironmentRepository repo) {
this.repo = repo;
}
public List<Environment> 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);
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Create AppService **
2026-04-08 08:53:22 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
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<App> 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<AppVersion> 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);
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Create DeploymentService **
2026-04-08 08:53:22 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.core.runtime;
2026-04-08 08:53:22 +02:00
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<Deployment> 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);
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 4: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/
2026-04-08 08:53:22 +02:00
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
```
---
### Task 6: App Module — PostgreSQL Repositories
**Files:**
- Create all Postgres repositories in `app/storage/`
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Implement all four repositories **
2026-04-08 08:53:22 +02:00
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
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Wire beans **
2026-04-08 08:53:22 +02:00
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);
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Run tests **
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app`
2026-04-08 08:53:22 +02:00
Expected: PASS (Flyway applies V3 migration, context loads).
2026-04-09 08:58:15 +02:00
- [x] **Step 4: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
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
2026-04-08 08:53:22 +02:00
git commit -m "feat: implement PostgreSQL repositories for runtime management"
```
---
### Task 7: Docker Runtime Orchestrator
**Files:**
2026-04-15 15:28:44 +02:00
- 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`
2026-04-08 08:53:22 +02:00
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Implement DisabledRuntimeOrchestrator **
2026-04-08 08:53:22 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.app.runtime;
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
import com.cameleer.server.core.runtime.*;
2026-04-08 08:53:22 +02:00
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<String> getLogs(String id, int tail) { return Stream.empty(); }
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Implement DockerRuntimeOrchestrator **
2026-04-08 08:53:22 +02:00
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<String> 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();
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Implement RuntimeOrchestratorAutoConfig **
2026-04-08 08:53:22 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.app.runtime;
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
2026-04-08 08:53:22 +02:00
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();
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 4: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/
2026-04-08 08:53:22 +02:00
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
```
---
### Task 8: DeploymentExecutor — Async Deployment Pipeline
**Files:**
2026-04-15 15:28:44 +02:00
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java`
2026-04-08 08:53:22 +02:00
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Implement async deployment pipeline **
2026-04-08 08:53:22 +02:00
```java
2026-04-15 15:28:44 +02:00
package com.cameleer.server.app.runtime;
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
import com.cameleer.server.core.runtime.*;
2026-04-08 08:53:22 +02:00
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<String, String> 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<String, String> 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<String, String> 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");
}
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Add async config **
2026-04-08 08:53:22 +02:00
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;
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
2026-04-08 08:53:22 +02:00
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)
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Implement EnvironmentAdminController **
2026-04-08 08:53:22 +02:00
CRUD for environments. Path: `/api/v1/admin/environments` . Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity).
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Implement AppController **
2026-04-08 08:53:22 +02:00
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<AppVersion> 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);
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Implement DeploymentController **
2026-04-08 08:53:22 +02:00
Deploy, stop, restart, promote, logs. Path: `/api/v1/apps/{appId}/deployments` . Requires OPERATOR role.
Key endpoints:
```java
@PostMapping
public ResponseEntity<Deployment> 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<Deployment> 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);
}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 4: Add security rules to SecurityConfig **
2026-04-08 08:53:22 +02:00
Add to `SecurityConfig.filterChain()` :
```java
// Runtime management (OPERATOR+)
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
```
2026-04-09 08:58:15 +02:00
- [x] **Step 5: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
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
2026-04-08 08:53:22 +02:00
git commit -m "feat: add REST controllers for environment, app, and deployment management"
```
---
### Task 10: Configuration and Application Properties
**Files:**
2026-04-15 15:28:44 +02:00
- Modify: `cameleer-server-app/src/main/resources/application.yml`
2026-04-08 08:53:22 +02:00
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Add runtime config properties **
2026-04-08 08:53:22 +02:00
```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}
```
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Run full test suite **
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
2026-04-08 08:53:22 +02:00
Expected: PASS.
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-app/src/main/resources/application.yml
2026-04-08 08:53:22 +02:00
git commit -m "feat: add runtime management configuration properties"
```
---
### Task 11: Integration Tests
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Write EnvironmentAdminController integration test **
2026-04-08 08:53:22 +02:00
Test CRUD operations for environments. Follows existing pattern from `AgentRegistrationControllerIT` .
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Write AppController integration test **
2026-04-08 08:53:22 +02:00
Test app creation, JAR upload, version listing.
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Write DeploymentController integration test **
2026-04-08 08:53:22 +02:00
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.
2026-04-09 08:58:15 +02:00
- [x] **Step 4: Commit **
2026-04-08 08:53:22 +02:00
```bash
2026-04-15 15:28:44 +02:00
git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/
2026-04-08 08:53:22 +02:00
git commit -m "test: add integration tests for runtime management API"
```
---
### Task 12: Final Verification
2026-04-09 08:58:15 +02:00
- [x] **Step 1: Run full build **
2026-04-08 08:53:22 +02:00
2026-04-15 15:28:44 +02:00
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify`
2026-04-08 08:53:22 +02:00
Expected: All tests PASS.
2026-04-09 08:58:15 +02:00
- [x] **Step 2: Verify schema applies cleanly **
2026-04-08 08:53:22 +02:00
Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors.
2026-04-09 08:58:15 +02:00
- [x] **Step 3: Commit any remaining fixes **
2026-04-08 08:53:22 +02:00
```bash
git add -A
git commit -m "chore: finalize runtime management — all tests passing"
```