Files
cameleer-saas/docs/superpowers/plans/2026-04-07-plan3-runtime-management.md
hsiegeln 3fa062b92c
Some checks failed
CI / build (push) Failing after 25s
CI / docker (push) Has been skipped
docs: add architecture review spec and implementation plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:53:22 +02:00

37 KiB

Plan 3: Runtime Management in the Server

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: 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\cameleer3-server

Source reference: Code ported from C:\Users\Hendrik\Documents\projects\cameleer-saas (environment, app, deployment, runtime packages)


File Map

New Files — Core Module (cameleer3-server-core)

src/main/java/com/cameleer3/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 (cameleer3-server-app)

src/main/java/com/cameleer3/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/cameleer3/server/app/storage/
├── PostgresEnvironmentRepository.java
├── PostgresAppRepository.java
├── PostgresAppVersionRepository.java
└── PostgresDeploymentRepository.java

src/main/java/com/cameleer3/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
  • cameleer3-server-app/pom.xml — add docker-java dependency
  • application.yml — add runtime config properties

Task 1: Add docker-java Dependency

Files:

  • Modify: cameleer3-server-app/pom.xml

  • Step 1: Add docker-java dependency

<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>
  • Step 2: Verify build

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app Expected: BUILD SUCCESS.

  • Step 3: Commit
git add cameleer3-server-app/pom.xml
git commit -m "chore: add docker-java dependency for runtime orchestration"

Task 2: Database Migration — Runtime Management Tables

Files:

  • Create: cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql

  • Step 1: Write migration

-- 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');
  • Step 2: Commit
git add cameleer3-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 cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/

  • Step 1: Create all domain records

// Environment.java
package com.cameleer3.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.cameleer3.server.core.runtime;
public enum EnvironmentStatus { ACTIVE, SUSPENDED }

// App.java
package com.cameleer3.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.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, Instant uploadedAt) {}

// Deployment.java
package com.cameleer3.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.cameleer3.server.core.runtime;
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }

// RoutingMode.java
package com.cameleer3.server.core.runtime;
public enum RoutingMode { path, subdomain }
  • Step 2: Commit
git add cameleer3-server-core/src/main/java/com/cameleer3/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/

  • Step 1: Create repository interfaces

// EnvironmentRepository.java
package com.cameleer3.server.core.runtime;
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
package com.cameleer3.server.core.runtime;
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
package com.cameleer3.server.core.runtime;
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
package com.cameleer3.server.core.runtime;
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);
}
  • Step 2: Create RuntimeOrchestrator interface
// RuntimeOrchestrator.java
package com.cameleer3.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<String> getLogs(String containerId, int tailLines);
}

// ContainerRequest.java
package com.cameleer3.server.core.runtime;
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
package com.cameleer3.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");
    }
}
  • Step 3: Commit
git add cameleer3-server-core/src/main/java/com/cameleer3/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/

  • Step 1: Create EnvironmentService

package com.cameleer3.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<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);
    }
}
  • Step 2: Create AppService
package com.cameleer3.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<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);
    }
}
  • Step 3: Create DeploymentService
package com.cameleer3.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<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);
    }
}
  • Step 4: Commit
git add cameleer3-server-core/src/main/java/com/cameleer3/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/

  • Step 1: Implement all four repositories

Follow the pattern from PostgresUserRepository.javaJdbcTemplate 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

  • Step 2: Wire beans

Create RuntimeBeanConfig.java in app/config/:

@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);
    }
}
  • Step 3: Run tests

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app Expected: PASS (Flyway applies V3 migration, context loads).

  • Step 4: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
git commit -m "feat: implement PostgreSQL repositories for runtime management"

Task 7: Docker Runtime Orchestrator

Files:

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java

  • Step 1: Implement DisabledRuntimeOrchestrator

package com.cameleer3.server.app.runtime;

import com.cameleer3.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<String> getLogs(String id, int tail) { return Stream.empty(); }
}
  • 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:

@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();
}
  • Step 3: Implement RuntimeOrchestratorAutoConfig
package com.cameleer3.server.app.runtime;

import com.cameleer3.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();
    }
}
  • Step 4: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"

Task 8: DeploymentExecutor — Async Deployment Pipeline

Files:

  • Create: cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java

  • Step 1: Implement async deployment pipeline

package com.cameleer3.server.app.runtime;

import com.cameleer3.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<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");
    }
}
  • Step 2: Add async config

Add to RuntimeBeanConfig.java or create AsyncConfig.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;
}
  • Step 3: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/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)

  • Step 1: Implement EnvironmentAdminController

CRUD for environments. Path: /api/v1/admin/environments. Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity).

  • 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:

@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);
}
  • Step 3: Implement DeploymentController

Deploy, stop, restart, promote, logs. Path: /api/v1/apps/{appId}/deployments. Requires OPERATOR role.

Key endpoints:

@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);
}
  • Step 4: Add security rules to SecurityConfig

Add to SecurityConfig.filterChain():

// Runtime management (OPERATOR+)
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
  • Step 5: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
git add cameleer3-server-app/src/main/java/com/cameleer3/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: cameleer3-server-app/src/main/resources/application.yml

  • Step 1: Add runtime config properties

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}
  • Step 2: Run full test suite

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify Expected: PASS.

  • Step 3: Commit
git add cameleer3-server-app/src/main/resources/application.yml
git commit -m "feat: add runtime management configuration properties"

Task 11: Integration Tests

  • Step 1: Write EnvironmentAdminController integration test

Test CRUD operations for environments. Follows existing pattern from AgentRegistrationControllerIT.

  • Step 2: Write AppController integration test

Test app creation, JAR upload, version listing.

  • 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.

  • Step 4: Commit
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
git commit -m "test: add integration tests for runtime management API"

Task 12: Final Verification

  • Step 1: Run full build

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify Expected: All tests PASS.

  • Step 2: Verify schema applies cleanly

Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors.

  • Step 3: Commit any remaining fixes
git add -A
git commit -m "chore: finalize runtime management — all tests passing"