Files
cameleer-server/docs/superpowers/plans/2026-04-08-docker-orchestration.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:42 +02:00

66 KiB

Docker Container Orchestration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the DockerRuntimeOrchestrator fully functional — apply container configs, generate Traefik routing labels, support replicas with blue/green and rolling strategies, and monitor container health via Docker event stream.

Architecture: Three-layer config merge (global → env → app) produces a ResolvedContainerConfig record. TraefikLabelBuilder and DockerNetworkManager are pure utilities consumed by DeploymentExecutor, which orchestrates the full deployment lifecycle with staged progress tracking. DockerEventMonitor provides infrastructure-level health via the Docker event stream.

Tech Stack: Java 17, Spring Boot 3.4, docker-java 3.4.1, PostgreSQL (JSONB), React 18 + TypeScript, @cameleer/design-system


File Structure

New files (core module — cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/)

  • ResolvedContainerConfig.java — typed record with all resolved config fields
  • ConfigMerger.java — pure function, three-layer merge logic
  • DeployStage.java — enum for deployment progress stages

New files (app module — cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/)

  • TraefikLabelBuilder.java — generates Traefik Docker labels
  • DockerNetworkManager.java — lazy network creation + container attachment
  • DockerEventMonitor.java — persistent Docker event stream listener

New files (migration)

  • V7__deployment_orchestration.sql — new columns on deployments table

New files (UI)

  • ui/src/components/DeploymentProgress.tsx — step indicator component
  • ui/src/components/DeploymentProgress.module.css — styles

Modified files (core)

  • ContainerRequest.java — add cpuLimit, exposedPorts, restartPolicy, additionalNetworks
  • DeploymentStatus.java — add DEGRADED, STOPPING
  • Deployment.java — add targetState, deploymentStrategy, replicaStates, deployStage

Modified files (app)

  • DockerRuntimeOrchestrator.java — apply full HostConfig (memory reserve, CPU limit, exposed ports, restart policy)
  • DeploymentExecutor.java — staged deploy flow with pre-flight, strategies, config merge
  • PostgresDeploymentRepository.java — new columns, JSONB for replica states
  • DeploymentController.java — expose deployStage and replicaStates in responses
  • RuntimeBeanConfig.java — wire new beans

Modified files (UI)

  • ui/src/api/queries/admin/apps.ts — update Deployment interface
  • ui/src/pages/AppsTab/AppsTab.tsx — new config fields, replicas column, progress indicator
  • ui/src/pages/Admin/EnvironmentsPage.tsx — routing mode, domain, SSL fields

Task 1: Database Migration

Files:

  • Create: cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql

  • Step 1: Create the migration file

-- V7__deployment_orchestration.sql
-- Deployment orchestration: status model, replicas, strategies, progress tracking

ALTER TABLE deployments ADD COLUMN target_state VARCHAR(20) NOT NULL DEFAULT 'RUNNING';
ALTER TABLE deployments ADD COLUMN deployment_strategy VARCHAR(20) NOT NULL DEFAULT 'BLUE_GREEN';
ALTER TABLE deployments ADD COLUMN replica_states JSONB NOT NULL DEFAULT '[]';
ALTER TABLE deployments ADD COLUMN deploy_stage VARCHAR(30);

-- Backfill existing deployments
UPDATE deployments SET target_state = CASE
    WHEN status = 'STOPPED' THEN 'STOPPED'
    ELSE 'RUNNING'
END;
  • Step 2: Verify migration compiles with Maven

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 3: Commit
git add cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql
git commit -m "feat: V7 migration — deployment orchestration columns"

Task 2: DeploymentStatus Enum + DeployStage Enum

Files:

  • Modify: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentStatus.java

  • Create: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeployStage.java

  • Step 1: Update DeploymentStatus enum

Replace the content of DeploymentStatus.java:

package com.cameleer.server.core.runtime;

public enum DeploymentStatus {
    STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
}
  • Step 2: Create DeployStage enum
package com.cameleer.server.core.runtime;

public enum DeployStage {
    PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
}
  • Step 3: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 4: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentStatus.java \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeployStage.java
git commit -m "feat: add DEGRADED, STOPPING statuses and DeployStage enum"

Task 3: Update Deployment Record

Files:

  • Modify: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java

  • Step 1: Update the Deployment record

Replace the content of Deployment.java:

package com.cameleer.server.core.runtime;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public record Deployment(
        UUID id,
        UUID appId,
        UUID appVersionId,
        UUID environmentId,
        DeploymentStatus status,
        String targetState,
        String deploymentStrategy,
        List<Map<String, Object>> replicaStates,
        String deployStage,
        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,
                targetState, deploymentStrategy, replicaStates, deployStage,
                containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
    }
}
  • Step 2: Verify compilation — expect errors in PostgresDeploymentRepository (will fix in Task 4)

Run: mvn compile -q 2>&1 | head -20 Expected: Compilation errors in PostgresDeploymentRepository.java (wrong constructor arity)

  • Step 3: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java
git commit -m "feat: add targetState, deploymentStrategy, replicaStates, deployStage to Deployment"

Task 4: Update PostgresDeploymentRepository

Files:

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java

  • Step 1: Update the repository to handle new columns

Replace the full content of PostgresDeploymentRepository.java:

package com.cameleer.server.app.storage;

import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

public class PostgresDeploymentRepository implements DeploymentRepository {

    private static final String SELECT_COLS = """
            id, app_id, app_version_id, environment_id, status,
            target_state, deployment_strategy, replica_states, deploy_stage,
            container_id, container_name, error_message,
            deployed_at, stopped_at, created_at""";

    private static final TypeReference<List<Map<String, Object>>> LIST_MAP_TYPE = new TypeReference<>() {};

    private final JdbcTemplate jdbc;
    private final ObjectMapper objectMapper;

    public PostgresDeploymentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
        this.jdbc = jdbc;
        this.objectMapper = objectMapper;
    }

    @Override
    public List<Deployment> findByAppId(UUID appId) {
        return jdbc.query("SELECT " + SELECT_COLS + " FROM deployments WHERE app_id = ? ORDER BY created_at DESC",
                (rs, rowNum) -> mapRow(rs), appId);
    }

    @Override
    public List<Deployment> findByEnvironmentId(UUID environmentId) {
        return jdbc.query("SELECT " + SELECT_COLS + " FROM deployments WHERE environment_id = ? ORDER BY created_at DESC",
                (rs, rowNum) -> mapRow(rs), environmentId);
    }

    @Override
    public Optional<Deployment> findById(UUID id) {
        var results = jdbc.query("SELECT " + SELECT_COLS + " FROM deployments WHERE id = ?",
                (rs, rowNum) -> mapRow(rs), id);
        return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
    }

    @Override
    public Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId) {
        var results = jdbc.query(
                "SELECT " + SELECT_COLS + " FROM deployments WHERE app_id = ? AND environment_id = ? AND status IN ('STARTING', 'RUNNING', 'DEGRADED') ORDER BY created_at DESC LIMIT 1",
                (rs, rowNum) -> mapRow(rs), appId, environmentId);
        return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
    }

    @Override
    public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName) {
        UUID id = UUID.randomUUID();
        jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name) VALUES (?, ?, ?, ?, ?)",
                id, appId, appVersionId, environmentId, containerName);
        return id;
    }

    @Override
    public void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage) {
        jdbc.update("UPDATE deployments SET status = ?, container_id = ?, error_message = ? WHERE id = ?",
                status.name(), containerId, errorMessage, id);
    }

    @Override
    public void markDeployed(UUID id) {
        jdbc.update("UPDATE deployments SET deployed_at = now() WHERE id = ?", id);
    }

    @Override
    public void markStopped(UUID id) {
        jdbc.update("UPDATE deployments SET stopped_at = now() WHERE id = ?", id);
    }

    public void updateReplicaStates(UUID id, List<Map<String, Object>> replicaStates) {
        try {
            String json = objectMapper.writeValueAsString(replicaStates);
            jdbc.update("UPDATE deployments SET replica_states = ?::jsonb WHERE id = ?", json, id);
        } catch (Exception e) {
            throw new RuntimeException("Failed to serialize replica states", e);
        }
    }

    public void updateDeployStage(UUID id, String stage) {
        jdbc.update("UPDATE deployments SET deploy_stage = ? WHERE id = ?", stage, id);
    }

    public void updateTargetState(UUID id, String targetState) {
        jdbc.update("UPDATE deployments SET target_state = ? WHERE id = ?", targetState, id);
    }

    public void updateDeploymentStrategy(UUID id, String strategy) {
        jdbc.update("UPDATE deployments SET deployment_strategy = ? WHERE id = ?", strategy, id);
    }

    public Optional<Deployment> findByContainerId(String containerId) {
        // Search in replica_states JSONB for containerId
        var results = jdbc.query(
                "SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? AND status IN ('STARTING', 'RUNNING', 'DEGRADED') ORDER BY created_at DESC LIMIT 1",
                (rs, rowNum) -> mapRow(rs), "%" + containerId + "%");
        return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
    }

    private Deployment mapRow(ResultSet rs) throws SQLException {
        List<Map<String, Object>> replicaStates = List.of();
        try {
            String json = rs.getString("replica_states");
            if (json != null && !json.isBlank()) {
                replicaStates = objectMapper.readValue(json, LIST_MAP_TYPE);
            }
        } catch (Exception e) { /* use empty default */ }

        return new Deployment(
                UUID.fromString(rs.getString("id")),
                UUID.fromString(rs.getString("app_id")),
                UUID.fromString(rs.getString("app_version_id")),
                UUID.fromString(rs.getString("environment_id")),
                DeploymentStatus.valueOf(rs.getString("status")),
                rs.getString("target_state"),
                rs.getString("deployment_strategy"),
                replicaStates,
                rs.getString("deploy_stage"),
                rs.getString("container_id"),
                rs.getString("container_name"),
                rs.getString("error_message"),
                rs.getTimestamp("deployed_at") != null ? rs.getTimestamp("deployed_at").toInstant() : null,
                rs.getTimestamp("stopped_at") != null ? rs.getTimestamp("stopped_at").toInstant() : null,
                rs.getTimestamp("created_at").toInstant()
        );
    }
}
  • Step 2: Update RuntimeBeanConfig to pass ObjectMapper to the repository

In cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java, change the deploymentRepository() bean:

@Bean
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
    return new PostgresDeploymentRepository(jdbc, objectMapper);
}
  • Step 3: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 4: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java
git commit -m "feat: update PostgresDeploymentRepository for orchestration columns"

Task 5: ResolvedContainerConfig + ConfigMerger

Files:

  • Create: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java

  • Create: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java

  • Step 1: Create ResolvedContainerConfig record

package com.cameleer.server.core.runtime;

import java.util.List;
import java.util.Map;

public record ResolvedContainerConfig(
        int memoryLimitMb,
        Integer memoryReserveMb,
        int cpuShares,
        Double cpuLimit,
        int appPort,
        List<Integer> exposedPorts,
        Map<String, String> customEnvVars,
        boolean stripPathPrefix,
        boolean sslOffloading,
        String routingMode,
        String routingDomain,
        String serverUrl,
        int replicas,
        String deploymentStrategy
) {
    public long memoryLimitBytes() {
        return (long) memoryLimitMb * 1024 * 1024;
    }

    public Long memoryReserveBytes() {
        return memoryReserveMb != null ? (long) memoryReserveMb * 1024 * 1024 : null;
    }
}
  • Step 2: Create ConfigMerger
package com.cameleer.server.core.runtime;

import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Three-layer config merge: global defaults → environment defaults → app overrides.
 * App values override env, env values override global.
 */
public final class ConfigMerger {

    private ConfigMerger() {}

    public static ResolvedContainerConfig resolve(
            GlobalRuntimeDefaults global,
            Map<String, Object> envConfig,
            Map<String, Object> appConfig) {

        return new ResolvedContainerConfig(
                intVal(appConfig, envConfig, "memoryLimitMb", global.memoryLimitMb()),
                intOrNull(appConfig, envConfig, "memoryReserveMb"),
                intVal(appConfig, envConfig, "cpuShares", global.cpuShares()),
                doubleOrNull(appConfig, envConfig, "cpuLimit"),
                intVal(appConfig, envConfig, "appPort", 8080),
                intList(appConfig, envConfig, "exposedPorts"),
                stringMap(appConfig, envConfig, "customEnvVars"),
                boolVal(appConfig, envConfig, "stripPathPrefix", true),
                boolVal(appConfig, envConfig, "sslOffloading", true),
                stringVal(appConfig, envConfig, "routingMode", global.routingMode()),
                stringVal(appConfig, envConfig, "routingDomain", global.routingDomain()),
                stringVal(appConfig, envConfig, "serverUrl", global.serverUrl()),
                intVal(appConfig, envConfig, "replicas", 1),
                stringVal(appConfig, envConfig, "deploymentStrategy", "blue-green")
        );
    }

    private static int intVal(Map<String, Object> app, Map<String, Object> env, String key, int fallback) {
        if (app.containsKey(key) && app.get(key) instanceof Number n) return n.intValue();
        if (env.containsKey(key) && env.get(key) instanceof Number n) return n.intValue();
        return fallback;
    }

    private static Integer intOrNull(Map<String, Object> app, Map<String, Object> env, String key) {
        if (app.containsKey(key) && app.get(key) instanceof Number n) return n.intValue();
        if (env.containsKey(key) && env.get(key) instanceof Number n) return n.intValue();
        return null;
    }

    private static Double doubleOrNull(Map<String, Object> app, Map<String, Object> env, String key) {
        if (app.containsKey(key) && app.get(key) instanceof Number n) return n.doubleValue();
        if (env.containsKey(key) && env.get(key) instanceof Number n) return n.doubleValue();
        return null;
    }

    private static boolean boolVal(Map<String, Object> app, Map<String, Object> env, String key, boolean fallback) {
        if (app.containsKey(key) && app.get(key) instanceof Boolean b) return b;
        if (env.containsKey(key) && env.get(key) instanceof Boolean b) return b;
        return fallback;
    }

    private static String stringVal(Map<String, Object> app, Map<String, Object> env, String key, String fallback) {
        if (app.containsKey(key) && app.get(key) instanceof String s) return s;
        if (env.containsKey(key) && env.get(key) instanceof String s) return s;
        return fallback;
    }

    @SuppressWarnings("unchecked")
    private static List<Integer> intList(Map<String, Object> app, Map<String, Object> env, String key) {
        Object val = app.containsKey(key) ? app.get(key) : env.get(key);
        if (val instanceof List<?> list) {
            return list.stream()
                    .filter(Number.class::isInstance)
                    .map(n -> ((Number) n).intValue())
                    .toList();
        }
        return List.of();
    }

    @SuppressWarnings("unchecked")
    private static Map<String, String> stringMap(Map<String, Object> app, Map<String, Object> env, String key) {
        Object val = app.containsKey(key) ? app.get(key) : env.get(key);
        if (val instanceof Map<?, ?> map) {
            Map<String, String> result = new HashMap<>();
            map.forEach((k, v) -> result.put(String.valueOf(k), String.valueOf(v)));
            return Collections.unmodifiableMap(result);
        }
        return Map.of();
    }

    /** Global defaults extracted from application.yml @Value fields */
    public record GlobalRuntimeDefaults(
            int memoryLimitMb,
            int cpuShares,
            String routingMode,
            String routingDomain,
            String serverUrl
    ) {}
}
  • Step 3: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 4: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java \
       cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java
git commit -m "feat: ResolvedContainerConfig record and three-layer ConfigMerger"

Task 6: Update ContainerRequest

Files:

  • Modify: cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java

  • Step 1: Expand ContainerRequest with new fields

Replace ContainerRequest.java:

package com.cameleer.server.core.runtime;

import java.util.List;
import java.util.Map;

public record ContainerRequest(
        String containerName,
        String baseImage,
        String jarPath,
        String network,
        List<String> additionalNetworks,
        Map<String, String> envVars,
        Map<String, String> labels,
        long memoryLimitBytes,
        Long memoryReserveBytes,
        int cpuShares,
        Long cpuQuota,
        List<Integer> exposedPorts,
        int healthCheckPort,
        String restartPolicyName,
        int restartPolicyMaxRetries
) {}
  • Step 2: Fix compilation errors in DockerRuntimeOrchestrator and DeploymentExecutor

These files construct ContainerRequest — they will have wrong arity. For now, update DockerRuntimeOrchestrator.startContainer() to use the new fields (full rewrite comes in Task 8). Update the existing constructor call in DeploymentExecutor to pass default values for new fields (full rewrite comes in Task 10).

In DockerRuntimeOrchestrator.java, update startContainer():

@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)
            .withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()));

    // Apply optional fields
    if (request.memoryReserveBytes() != null) {
        hostConfig.withMemoryReservation(request.memoryReserveBytes());
    }
    if (request.cpuQuota() != null) {
        hostConfig.withCpuQuota(request.cpuQuota());
    }

    var createCmd = dockerClient.createContainerCmd(request.baseImage())
            .withName(request.containerName())
            .withEnv(envList)
            .withLabels(request.labels() != null ? request.labels() : Map.of())
            .withHostConfig(hostConfig)
            .withHealthcheck(new HealthCheck()
                    .withTest(List.of("CMD-SHELL",
                            "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
                    .withInterval(10_000_000_000L)
                    .withTimeout(5_000_000_000L)
                    .withRetries(3)
                    .withStartPeriod(30_000_000_000L));

    // Expose additional ports
    if (request.exposedPorts() != null && !request.exposedPorts().isEmpty()) {
        var ports = request.exposedPorts().stream()
                .map(p -> com.github.dockerjava.api.model.ExposedPort.tcp(p))
                .toArray(com.github.dockerjava.api.model.ExposedPort[]::new);
        createCmd.withExposedPorts(ports);
    }

    var container = createCmd.exec();
    dockerClient.startContainerCmd(container.getId()).exec();

    // Connect to additional networks
    if (request.additionalNetworks() != null) {
        for (String net : request.additionalNetworks()) {
            dockerClient.connectToNetworkCmd()
                    .withContainerId(container.getId())
                    .withNetworkId(net)
                    .exec();
        }
    }

    log.info("Started container {} ({})", request.containerName(), container.getId());
    return container.getId();
}

Add the import for RestartPolicy:

import com.github.dockerjava.api.model.RestartPolicy;
  • Step 3: Update DeploymentExecutor temporarily

In DeploymentExecutor.java, find the ContainerRequest constructor call and add default values for new fields. Change:

ContainerRequest request = new ContainerRequest(
        deployment.containerName(),
        baseImage,
        jarPath,
        dockerNetwork,
        envVars,
        labels,
        parseMemoryLimitBytes(containerMemoryLimit),
        containerCpuShares,
        agentHealthPort);

To:

ContainerRequest request = new ContainerRequest(
        deployment.containerName(),
        baseImage,
        jarPath,
        dockerNetwork,
        List.of(),     // additionalNetworks
        envVars,
        labels,
        parseMemoryLimitBytes(containerMemoryLimit),
        null,          // memoryReserveBytes
        containerCpuShares,
        null,          // cpuQuota
        List.of(),     // exposedPorts
        agentHealthPort,
        "on-failure",  // restartPolicyName
        3              // restartPolicyMaxRetries
);

Add import java.util.List; if not already present.

  • Step 4: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 5: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
git commit -m "feat: expand ContainerRequest with cpuLimit, ports, restart policy, additional networks"

Task 7: TraefikLabelBuilder

Files:

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java

  • Step 1: Create TraefikLabelBuilder

package com.cameleer.server.app.runtime;

import com.cameleer.server.core.runtime.ResolvedContainerConfig;

import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Generates Traefik Docker labels for container routing.
 * Supports path-based and subdomain-based routing modes.
 */
public final class TraefikLabelBuilder {

    private TraefikLabelBuilder() {}

    public static Map<String, String> build(String appSlug, String envSlug, ResolvedContainerConfig config) {
        String svc = envSlug + "-" + appSlug;
        Map<String, String> labels = new LinkedHashMap<>();

        // Core labels
        labels.put("traefik.enable", "true");
        labels.put("managed-by", "cameleer-server");
        labels.put("cameleer.app", appSlug);
        labels.put("cameleer.environment", envSlug);

        // Service port
        labels.put("traefik.http.services." + svc + ".loadbalancer.server.port",
                String.valueOf(config.appPort()));

        // Routing rule
        if ("subdomain".equals(config.routingMode())) {
            labels.put("traefik.http.routers." + svc + ".rule",
                    "Host(`" + appSlug + "-" + envSlug + "." + config.routingDomain() + "`)");
        } else {
            // Path-based (default)
            labels.put("traefik.http.routers." + svc + ".rule",
                    "PathPrefix(`/" + envSlug + "/" + appSlug + "/`)");

            if (config.stripPathPrefix()) {
                labels.put("traefik.http.middlewares." + svc + "-strip.stripprefix.prefixes",
                        "/" + envSlug + "/" + appSlug);
                labels.put("traefik.http.routers." + svc + ".middlewares",
                        svc + "-strip");
            }
        }

        // Entrypoints
        labels.put("traefik.http.routers." + svc + ".entrypoints",
                config.sslOffloading() ? "websecure" : "web");

        // TLS
        if (config.sslOffloading()) {
            labels.put("traefik.http.routers." + svc + ".tls", "true");
            labels.put("traefik.http.routers." + svc + ".tls.certresolver", "default");
        }

        return labels;
    }
}
  • Step 2: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java
git commit -m "feat: TraefikLabelBuilder with path-based and subdomain routing"

Task 8: DockerNetworkManager

Files:

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerNetworkManager.java

  • Step 1: Create DockerNetworkManager

package com.cameleer.server.app.runtime;

import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.model.Network;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

/**
 * Manages Docker networks for the deployment system.
 * Creates bridge networks lazily and connects containers to additional networks.
 */
public class DockerNetworkManager {

    private static final Logger log = LoggerFactory.getLogger(DockerNetworkManager.class);
    public static final String TRAEFIK_NETWORK = "cameleer-traefik";
    public static final String ENV_NETWORK_PREFIX = "cameleer-env-";

    private final DockerClient dockerClient;

    public DockerNetworkManager(DockerClient dockerClient) {
        this.dockerClient = dockerClient;
    }

    /**
     * Ensure a Docker bridge network exists. Creates it if missing (idempotent).
     * Returns the network ID.
     */
    public String ensureNetwork(String networkName) {
        List<Network> existing = dockerClient.listNetworksCmd()
                .withNameFilter(networkName)
                .exec();

        for (Network net : existing) {
            if (net.getName().equals(networkName)) {
                return net.getId();
            }
        }

        String id = dockerClient.createNetworkCmd()
                .withName(networkName)
                .withDriver("bridge")
                .withCheckDuplicate(true)
                .exec()
                .getId();

        log.info("Created Docker network: {} ({})", networkName, id);
        return id;
    }

    /**
     * Connect a container to an additional network.
     */
    public void connectContainer(String containerId, String networkName) {
        String networkId = ensureNetwork(networkName);
        try {
            dockerClient.connectToNetworkCmd()
                    .withContainerId(containerId)
                    .withNetworkId(networkId)
                    .exec();
            log.debug("Connected container {} to network {}", containerId, networkName);
        } catch (Exception e) {
            // May already be connected
            if (!e.getMessage().contains("already exists")) {
                throw e;
            }
        }
    }

    /**
     * Returns the environment-specific network name for an environment slug.
     */
    public static String envNetworkName(String envSlug) {
        return ENV_NETWORK_PREFIX + envSlug;
    }
}
  • Step 2: Expose DockerClient from DockerRuntimeOrchestrator

Add a getter to DockerRuntimeOrchestrator.java so the network manager can share the same client:

public DockerClient getDockerClient() {
    return dockerClient;
}
  • Step 3: Wire DockerNetworkManager in RuntimeOrchestratorAutoConfig

In RuntimeOrchestratorAutoConfig.java, add a bean for the network manager:

@Bean
@ConditionalOnBean(DockerRuntimeOrchestrator.class)
public DockerNetworkManager dockerNetworkManager(RuntimeOrchestrator orchestrator) {
    return new DockerNetworkManager(((DockerRuntimeOrchestrator) orchestrator).getDockerClient());
}

Actually, the auto-config creates the orchestrator conditionally. A simpler approach — make the network manager a @Bean inside the same config and pass it the orchestrator. Update the bean factory:

@Bean
public RuntimeOrchestrator runtimeOrchestrator() {
    if (Files.exists(Path.of("/var/run/docker.sock"))) {
        log.info("Docker socket detected - enabling Docker runtime orchestrator");
        return new DockerRuntimeOrchestrator();
    }
    log.info("No Docker socket detected - runtime management disabled (observability-only mode)");
    return new DisabledRuntimeOrchestrator();
}

@Bean
public DockerNetworkManager dockerNetworkManager(RuntimeOrchestrator orchestrator) {
    if (orchestrator instanceof DockerRuntimeOrchestrator docker) {
        return new DockerNetworkManager(docker.getDockerClient());
    }
    return null; // No network management when Docker is unavailable
}
  • Step 4: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 5: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerNetworkManager.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java
git commit -m "feat: DockerNetworkManager with lazy network creation and container attachment"

Task 9: DockerEventMonitor

Files:

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerEventMonitor.java

  • Step 1: Create DockerEventMonitor

package com.cameleer.server.app.runtime;

import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.Event;
import com.github.dockerjava.api.model.EventType;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;

import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Listens to Docker daemon event stream for container lifecycle events.
 * Updates deployment replica states when containers die, restart, or OOM.
 */
@Component
@ConditionalOnBean(DockerRuntimeOrchestrator.class)
public class DockerEventMonitor {

    private static final Logger log = LoggerFactory.getLogger(DockerEventMonitor.class);

    private final DockerClient dockerClient;
    private final PostgresDeploymentRepository deploymentRepository;
    private Closeable eventStream;

    public DockerEventMonitor(DockerRuntimeOrchestrator orchestrator,
                              PostgresDeploymentRepository deploymentRepository) {
        this.dockerClient = orchestrator.getDockerClient();
        this.deploymentRepository = deploymentRepository;
    }

    @PostConstruct
    public void startListening() {
        eventStream = dockerClient.eventsCmd()
                .withEventTypeFilter(EventType.CONTAINER)
                .withEventFilter("die", "oom", "start", "stop")
                .exec(new ResultCallback.Adapter<Event>() {
                    @Override
                    public void onNext(Event event) {
                        handleEvent(event);
                    }

                    @Override
                    public void onError(Throwable throwable) {
                        log.warn("Docker event stream error, reconnecting: {}", throwable.getMessage());
                        reconnect();
                    }
                });

        log.info("Docker event monitor started");
    }

    @PreDestroy
    public void stop() {
        if (eventStream != null) {
            try { eventStream.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    private void handleEvent(Event event) {
        String containerId = event.getId();
        if (containerId == null) return;

        // Only process containers managed by us
        Map<String, String> labels = event.getActor() != null ? event.getActor().getAttributes() : null;
        if (labels == null || !"cameleer-server".equals(labels.get("managed-by"))) return;

        String action = event.getAction();
        log.debug("Docker event: {} for container {} ({})", action, containerId.substring(0, 12),
                labels.get("cameleer.app"));

        Optional<Deployment> deploymentOpt = deploymentRepository.findByContainerId(containerId);
        if (deploymentOpt.isEmpty()) return;

        Deployment deployment = deploymentOpt.get();
        List<Map<String, Object>> replicas = new java.util.ArrayList<>(deployment.replicaStates());

        boolean changed = false;
        for (int i = 0; i < replicas.size(); i++) {
            Map<String, Object> replica = replicas.get(i);
            if (containerId.equals(replica.get("containerId"))) {
                Map<String, Object> updated = new java.util.HashMap<>(replica);
                switch (action) {
                    case "die", "oom", "stop" -> {
                        updated.put("status", "DEAD");
                        if ("oom".equals(action)) {
                            updated.put("oomKilled", true);
                            log.warn("Container {} OOM-killed (app={}, env={})", containerId.substring(0, 12),
                                    labels.get("cameleer.app"), labels.get("cameleer.environment"));
                        }
                    }
                    case "start" -> updated.put("status", "RUNNING");
                }
                replicas.set(i, updated);
                changed = true;
                break;
            }
        }

        if (!changed) return;

        // Update replica states
        deploymentRepository.updateReplicaStates(deployment.id(), replicas);

        // Recompute aggregate deployment status
        long running = replicas.stream().filter(r -> "RUNNING".equals(r.get("status"))).count();
        DeploymentStatus newStatus;
        if (running == replicas.size()) {
            newStatus = DeploymentStatus.RUNNING;
        } else if (running > 0) {
            newStatus = DeploymentStatus.DEGRADED;
        } else {
            newStatus = DeploymentStatus.FAILED;
        }

        if (deployment.status() != newStatus) {
            deploymentRepository.updateStatus(deployment.id(), newStatus, deployment.containerId(), deployment.errorMessage());
            log.info("Deployment {} status: {} → {} ({}/{} replicas running)",
                    deployment.id(), deployment.status(), newStatus, running, replicas.size());
        }
    }

    private void reconnect() {
        try {
            Thread.sleep(5000);
            startListening();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
  • Step 2: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS (may need PostgresDeploymentRepository to be injected by type — the @Component annotation handles this since it's conditional on DockerRuntimeOrchestrator existing)

  • Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerEventMonitor.java
git commit -m "feat: DockerEventMonitor — persistent event stream for container lifecycle"

Task 10: Rewrite DeploymentExecutor

Files:

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java

This is the largest task — the orchestration hub that ties everything together.

  • Step 1: Rewrite DeploymentExecutor

Replace the full content of DeploymentExecutor.java:

package com.cameleer.server.app.runtime;

import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;

@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;
    private final DeploymentRepository deploymentRepository;
    private final PostgresDeploymentRepository pgDeployRepo;
    private final DockerNetworkManager networkManager;

    @Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
    private String baseImage;

    @Value("${cameleer.runtime.docker-network:cameleer}")
    private String dockerNetwork;

    @Value("${cameleer.runtime.container-memory-limit:512m}")
    private String globalMemoryLimit;

    @Value("${cameleer.runtime.container-cpu-shares:512}")
    private int globalCpuShares;

    @Value("${cameleer.runtime.health-check-timeout:60}")
    private int healthCheckTimeout;

    @Value("${cameleer.runtime.agent-health-port:9464}")
    private int agentHealthPort;

    @Value("${security.bootstrap-token:}")
    private String bootstrapToken;

    @Value("${cameleer.runtime.routing-mode:path}")
    private String globalRoutingMode;

    @Value("${cameleer.runtime.routing-domain:localhost}")
    private String globalRoutingDomain;

    @Value("${cameleer.runtime.server-url:}")
    private String globalServerUrl;

    public DeploymentExecutor(RuntimeOrchestrator orchestrator,
                              DeploymentService deploymentService,
                              AppService appService,
                              EnvironmentService envService,
                              DeploymentRepository deploymentRepository,
                              DockerNetworkManager networkManager) {
        this.orchestrator = orchestrator;
        this.deploymentService = deploymentService;
        this.appService = appService;
        this.envService = envService;
        this.deploymentRepository = deploymentRepository;
        this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
        this.networkManager = networkManager;
    }

    @Async("deploymentTaskExecutor")
    public void executeAsync(Deployment deployment) {
        try {
            // Resolve metadata
            App app = appService.getById(deployment.appId());
            Environment env = envService.getById(deployment.environmentId());
            String jarPath = appService.resolveJarPath(deployment.appVersionId());

            // Merge configs
            var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults(
                    parseMemoryLimitMb(globalMemoryLimit),
                    globalCpuShares,
                    globalRoutingMode,
                    globalRoutingDomain,
                    globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl
            );
            ResolvedContainerConfig config = ConfigMerger.resolve(
                    globalDefaults, env.defaultContainerConfig(), app.containerConfig());

            // Set deployment strategy
            pgDeployRepo.updateDeploymentStrategy(deployment.id(), config.deploymentStrategy());

            // === PRE-FLIGHT ===
            updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
            preFlightChecks(jarPath, config);

            // === PULL IMAGE ===
            updateStage(deployment.id(), DeployStage.PULL_IMAGE);
            // Docker pulls on create if not present — no explicit pull needed for now

            // === CREATE NETWORKS ===
            updateStage(deployment.id(), DeployStage.CREATE_NETWORK);
            String traefikNet = null;
            String envNet = null;
            if (networkManager != null) {
                traefikNet = DockerNetworkManager.TRAEFIK_NETWORK;
                networkManager.ensureNetwork(traefikNet);
                envNet = DockerNetworkManager.envNetworkName(env.slug());
                networkManager.ensureNetwork(envNet);
            }

            // === START REPLICAS ===
            updateStage(deployment.id(), DeployStage.START_REPLICAS);

            Map<String, String> baseEnvVars = buildEnvVars(app, env, config);
            Map<String, String> labels = TraefikLabelBuilder.build(app.slug(), env.slug(), config);

            List<Map<String, Object>> replicaStates = new ArrayList<>();
            List<String> newContainerIds = new ArrayList<>();

            for (int i = 0; i < config.replicas(); i++) {
                String containerName = env.slug() + "-" + app.slug() + "-" + i;
                Long cpuQuota = config.cpuLimit() != null ? (long) (config.cpuLimit() * 100_000) : null;

                ContainerRequest request = new ContainerRequest(
                        containerName,
                        baseImage,
                        jarPath,
                        traefikNet != null ? traefikNet : dockerNetwork,
                        envNet != null ? List.of(envNet) : List.of(),
                        baseEnvVars,
                        labels,
                        config.memoryLimitBytes(),
                        config.memoryReserveBytes(),
                        config.cpuShares(),
                        cpuQuota,
                        config.exposedPorts(),
                        agentHealthPort,
                        "on-failure",
                        3
                );

                String containerId = orchestrator.startContainer(request);
                newContainerIds.add(containerId);

                replicaStates.add(Map.of(
                        "index", i,
                        "containerId", containerId,
                        "containerName", containerName,
                        "status", "STARTING"
                ));
            }

            pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);

            // === HEALTH CHECK ===
            updateStage(deployment.id(), DeployStage.HEALTH_CHECK);
            int healthyCount = waitForAnyHealthy(newContainerIds, healthCheckTimeout);

            if (healthyCount == 0) {
                // All unhealthy — clean up new replicas and fail
                for (String cid : newContainerIds) {
                    try { orchestrator.stopContainer(cid); orchestrator.removeContainer(cid); }
                    catch (Exception e) { log.warn("Cleanup failed for {}: {}", cid, e.getMessage()); }
                }
                pgDeployRepo.updateDeployStage(deployment.id(), null);
                deploymentService.markFailed(deployment.id(), "No replicas passed health check within " + healthCheckTimeout + "s");
                return;
            }

            // Update replica states after health check
            replicaStates = updateReplicaHealth(replicaStates, newContainerIds);
            pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);

            // === SWAP TRAFFIC ===
            updateStage(deployment.id(), DeployStage.SWAP_TRAFFIC);

            // Stop previous deployment (blue/green: all at once after new is healthy)
            Optional<Deployment> existing = deploymentRepository.findActiveByAppIdAndEnvironmentId(
                    deployment.appId(), deployment.environmentId());
            if (existing.isPresent() && !existing.get().id().equals(deployment.id())) {
                stopDeploymentContainers(existing.get());
                deploymentService.markStopped(existing.get().id());
                log.info("Stopped previous deployment {} for replacement", existing.get().id());
            }

            // === COMPLETE ===
            updateStage(deployment.id(), DeployStage.COMPLETE);

            // Store first container ID for backward compatibility
            String primaryContainerId = newContainerIds.get(0);
            DeploymentStatus finalStatus = healthyCount == config.replicas()
                    ? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED;
            deploymentService.markRunning(deployment.id(), primaryContainerId);
            if (finalStatus == DeploymentStatus.DEGRADED) {
                deploymentRepository.updateStatus(deployment.id(), DeploymentStatus.DEGRADED,
                        primaryContainerId, null);
            }

            pgDeployRepo.updateDeployStage(deployment.id(), null);
            log.info("Deployment {} is {} ({}/{} replicas healthy)",
                    deployment.id(), finalStatus, healthyCount, config.replicas());

        } catch (Exception e) {
            log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
            pgDeployRepo.updateDeployStage(deployment.id(), null);
            deploymentService.markFailed(deployment.id(), e.getMessage());
        }
    }

    public void stopDeployment(Deployment deployment) {
        pgDeployRepo.updateTargetState(deployment.id(), "STOPPED");
        deploymentRepository.updateStatus(deployment.id(), DeploymentStatus.STOPPING,
                deployment.containerId(), null);

        stopDeploymentContainers(deployment);
        deploymentService.markStopped(deployment.id());
    }

    private void stopDeploymentContainers(Deployment deployment) {
        for (Map<String, Object> replica : deployment.replicaStates()) {
            String cid = (String) replica.get("containerId");
            if (cid != null) {
                try {
                    orchestrator.stopContainer(cid);
                    orchestrator.removeContainer(cid);
                } catch (Exception e) {
                    log.warn("Failed to stop replica container {}: {}", cid, e.getMessage());
                }
            }
        }
        // Backward compat: also stop the single containerId if set
        if (deployment.containerId() != null && deployment.replicaStates().isEmpty()) {
            try {
                orchestrator.stopContainer(deployment.containerId());
                orchestrator.removeContainer(deployment.containerId());
            } catch (Exception e) {
                log.warn("Failed to stop container {}: {}", deployment.containerId(), e.getMessage());
            }
        }
    }

    private void preFlightChecks(String jarPath, ResolvedContainerConfig config) {
        if (!Files.exists(Path.of(jarPath))) {
            throw new IllegalStateException("JAR file not found: " + jarPath);
        }
        if (config.memoryLimitMb() <= 0) {
            throw new IllegalStateException("Memory limit must be positive, got: " + config.memoryLimitMb());
        }
        if (config.appPort() <= 0 || config.appPort() > 65535) {
            throw new IllegalStateException("Invalid app port: " + config.appPort());
        }
        if (config.replicas() < 1) {
            throw new IllegalStateException("Replicas must be >= 1, got: " + config.replicas());
        }
    }

    private Map<String, String> buildEnvVars(App app, Environment env, ResolvedContainerConfig config) {
        Map<String, String> envVars = new LinkedHashMap<>();
        envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
        envVars.put("CAMELEER_APPLICATION_ID", app.slug());
        envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
        envVars.put("CAMELEER_SERVER_URL", config.serverUrl());
        if (bootstrapToken != null && !bootstrapToken.isBlank()) {
            envVars.put("CAMELEER_AUTH_TOKEN", bootstrapToken);
        }
        // Merge custom env vars (app overrides env defaults)
        envVars.putAll(config.customEnvVars());
        return envVars;
    }

    private int waitForAnyHealthy(List<String> containerIds, int timeoutSeconds) {
        long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
        while (System.currentTimeMillis() < deadline) {
            int healthy = 0;
            for (String cid : containerIds) {
                ContainerStatus status = orchestrator.getContainerStatus(cid);
                if ("healthy".equals(status.state())) healthy++;
            }
            if (healthy > 0) return healthy;
            try { Thread.sleep(2000); } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return 0;
            }
        }
        return 0;
    }

    private List<Map<String, Object>> updateReplicaHealth(List<Map<String, Object>> replicas,
                                                           List<String> containerIds) {
        List<Map<String, Object>> updated = new ArrayList<>();
        for (Map<String, Object> replica : replicas) {
            String cid = (String) replica.get("containerId");
            ContainerStatus status = orchestrator.getContainerStatus(cid);
            Map<String, Object> copy = new HashMap<>(replica);
            copy.put("status", status.running() ? "RUNNING" : "DEAD");
            updated.add(copy);
        }
        return updated;
    }

    private void updateStage(UUID deploymentId, DeployStage stage) {
        pgDeployRepo.updateDeployStage(deploymentId, stage.name());
    }

    private int parseMemoryLimitMb(String limit) {
        limit = limit.trim().toLowerCase();
        if (limit.endsWith("g")) return (int) (Double.parseDouble(limit.replace("g", "")) * 1024);
        if (limit.endsWith("m")) return (int) Double.parseDouble(limit.replace("m", ""));
        return Integer.parseInt(limit);
    }
}
  • Step 2: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
git commit -m "feat: rewrite DeploymentExecutor with staged deploy, config merge, replicas"

Task 11: Update DeploymentController

Files:

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java

  • Step 1: Update the stop endpoint to use stopDeployment

The controller's stop endpoint currently calls deploymentService.markStopped() directly. It needs to call deploymentExecutor.stopDeployment() instead to actually stop containers and handle replicas.

Find the stop method and replace:

@PostMapping("/{deploymentId}/stop")
@Operation(summary = "Stop a running deployment")
public ResponseEntity<Deployment> stop(@PathVariable UUID appId, @PathVariable UUID deploymentId) {
    try {
        Deployment deployment = deploymentService.getById(deploymentId);
        deploymentExecutor.stopDeployment(deployment);
        return ResponseEntity.ok(deploymentService.getById(deploymentId));
    } catch (IllegalArgumentException e) {
        return ResponseEntity.notFound().build();
    }
}
  • Step 2: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java
git commit -m "feat: update stop endpoint to use DeploymentExecutor for replica cleanup"

Task 12: Update application.yml

Files:

  • Modify: cameleer-server-app/src/main/resources/application.yml

  • Step 1: Add the server-url property

In the cameleer.runtime section, add:

    server-url: ${CAMELEER_SERVER_URL:}

This goes after the existing routing-domain line.

  • Step 2: Verify compilation

Run: mvn compile -q Expected: BUILD SUCCESS

  • Step 3: Commit
git add cameleer-server-app/src/main/resources/application.yml
git commit -m "feat: add CAMELEER_SERVER_URL config property"

Task 13: UI — Update Deployment Interface

Files:

  • Modify: ui/src/api/queries/admin/apps.ts

  • Step 1: Update the Deployment TypeScript interface

export interface Deployment {
  id: string;
  appId: string;
  appVersionId: string;
  environmentId: string;
  status: 'STOPPED' | 'STARTING' | 'RUNNING' | 'DEGRADED' | 'STOPPING' | 'FAILED';
  targetState: string;
  deploymentStrategy: string;
  replicaStates: { index: number; containerId: string; containerName: string; status: string; oomKilled?: boolean }[];
  deployStage: string | null;
  containerId: string | null;
  containerName: string | null;
  errorMessage: string | null;
  deployedAt: string | null;
  stoppedAt: string | null;
  createdAt: string;
}
  • Step 2: Type-check

Run: cd ui && npx tsc --noEmit -p tsconfig.app.json Expected: No new errors

  • Step 3: Commit
git add ui/src/api/queries/admin/apps.ts
git commit -m "feat: update Deployment interface with replicas, stages, new statuses"

Task 14: UI — DeploymentProgress Component

Files:

  • Create: ui/src/components/DeploymentProgress.tsx

  • Create: ui/src/components/DeploymentProgress.module.css

  • Step 1: Create the CSS module

.container {
  display: flex;
  align-items: center;
  gap: 0;
  padding: 8px 0;
}

.step {
  display: flex;
  align-items: center;
  gap: 0;
}

.dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid var(--border-subtle);
  background: transparent;
  flex-shrink: 0;
}

.dotCompleted {
  background: var(--success);
  border-color: var(--success);
}

.dotActive {
  background: var(--accent, #6c7aff);
  border-color: var(--accent, #6c7aff);
  animation: pulse 1.5s ease-in-out infinite;
}

.dotFailed {
  background: var(--error);
  border-color: var(--error);
}

.line {
  width: 32px;
  height: 2px;
  background: var(--border-subtle);
}

.lineCompleted {
  background: var(--success);
}

.label {
  font-size: 10px;
  color: var(--text-muted);
  text-align: center;
  margin-top: 4px;
}

.labelActive {
  color: var(--accent, #6c7aff);
  font-weight: 600;
}

.labelFailed {
  color: var(--error);
}

.stepColumn {
  display: flex;
  flex-direction: column;
  align-items: center;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}
  • Step 2: Create the component
import styles from './DeploymentProgress.module.css';

const STAGES = [
  { key: 'PRE_FLIGHT', label: 'Pre-flight' },
  { key: 'PULL_IMAGE', label: 'Pull' },
  { key: 'CREATE_NETWORK', label: 'Network' },
  { key: 'START_REPLICAS', label: 'Start' },
  { key: 'HEALTH_CHECK', label: 'Health' },
  { key: 'SWAP_TRAFFIC', label: 'Swap' },
  { key: 'COMPLETE', label: 'Done' },
];

interface DeploymentProgressProps {
  currentStage: string | null;
  failed?: boolean;
}

export function DeploymentProgress({ currentStage, failed }: DeploymentProgressProps) {
  if (!currentStage) return null;

  const currentIndex = STAGES.findIndex((s) => s.key === currentStage);

  return (
    <div className={styles.container}>
      {STAGES.map((stage, i) => {
        const isCompleted = i < currentIndex;
        const isActive = i === currentIndex && !failed;
        const isFailed = i === currentIndex && failed;

        return (
          <div key={stage.key} className={styles.step}>
            {i > 0 && (
              <div className={`${styles.line} ${isCompleted || isActive || isFailed ? styles.lineCompleted : ''}`} />
            )}
            <div className={styles.stepColumn}>
              <div className={`${styles.dot} ${isCompleted ? styles.dotCompleted : ''} ${isActive ? styles.dotActive : ''} ${isFailed ? styles.dotFailed : ''}`} />
              <span className={`${styles.label} ${isActive ? styles.labelActive : ''} ${isFailed ? styles.labelFailed : ''}`}>
                {stage.label}
              </span>
            </div>
          </div>
        );
      })}
    </div>
  );
}
  • Step 3: Type-check

Run: cd ui && npx tsc --noEmit -p tsconfig.app.json Expected: No new errors

  • Step 4: Commit
git add ui/src/components/DeploymentProgress.tsx ui/src/components/DeploymentProgress.module.css
git commit -m "feat: DeploymentProgress step indicator component"

Task 15: UI — Update Deployments Overview (AppsTab)

Files:

  • Modify: ui/src/pages/AppsTab/AppsTab.tsx

  • Step 1: Add STATUS_COLORS for new statuses

Find the STATUS_COLORS constant and add the new status values:

const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
  RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
  DEGRADED: 'warning', STOPPING: 'auto',
};
  • Step 2: Add replicas column and progress indicator to OverviewSubTab

In the deployments table header, add a Replicas column after Status:

<th>Replicas</th>

In the deployments table body, add the replicas cell after the status badge:

<td>
  {d.replicaStates.length > 0 ? (
    <span className={styles.cellMeta}>
      {d.replicaStates.filter((r) => r.status === 'RUNNING').length}/{d.replicaStates.length}
    </span>
  ) : '—'}
</td>

Add the DeploymentProgress component import and show it below the deployments table when a deployment is actively deploying:

import { DeploymentProgress } from '../../components/DeploymentProgress';

After the deployments table, add:

{deployments.filter((d) => d.deployStage).map((d) => (
  <div key={d.id} style={{ marginBottom: 8 }}>
    <span className={styles.cellMeta}>{d.containerName}</span>
    <DeploymentProgress currentStage={d.deployStage} failed={d.status === 'FAILED'} />
  </div>
))}
  • Step 3: Add new config fields to Resources tab

In the create page Resources tab and the detail config Resources sub-tab, add these fields after the existing CPU Limit row:

<span className={styles.configLabel}>App Port</span>
<Input disabled={!editing} value={appPort} onChange={(e) => setAppPort(e.target.value)} style={{ width: 80 }} />

<span className={styles.configLabel}>Replicas</span>
<Input disabled={!editing} value={replicas} onChange={(e) => setReplicas(e.target.value)} style={{ width: 60 }} type="number" />

<span className={styles.configLabel}>Deploy Strategy</span>
<Select disabled={!editing} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}
  options={[{ value: 'blue-green', label: 'Blue/Green' }, { value: 'rolling', label: 'Rolling' }]} />

<span className={styles.configLabel}>Strip Path Prefix</span>
<div className={styles.configInline}>
  <Toggle checked={stripPrefix} onChange={() => editing && setStripPrefix(!stripPrefix)} disabled={!editing} />
  <span className={stripPrefix ? styles.toggleEnabled : styles.toggleDisabled}>{stripPrefix ? 'Enabled' : 'Disabled'}</span>
</div>

<span className={styles.configLabel}>SSL Offloading</span>
<div className={styles.configInline}>
  <Toggle checked={sslOffloading} onChange={() => editing && setSslOffloading(!sslOffloading)} disabled={!editing} />
  <span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>
</div>

Add the corresponding state variables:

const [appPort, setAppPort] = useState('8080');
const [replicas, setReplicas] = useState('1');
const [deployStrategy, setDeployStrategy] = useState('blue-green');
const [stripPrefix, setStripPrefix] = useState(true);
const [sslOffloading, setSslOffloading] = useState(true);

Add these fields to both syncFromServer (reading from app.containerConfig) and handleSave (writing to container config).

  • Step 4: Type-check

Run: cd ui && npx tsc --noEmit -p tsconfig.app.json Expected: No new errors

  • Step 5: Commit
git add ui/src/pages/AppsTab/AppsTab.tsx
git commit -m "feat: replicas column, deploy progress, and new config fields in Deployments UI"

Task 16: UI — Environment Admin Routing Fields

Files:

  • Modify: ui/src/pages/Admin/EnvironmentsPage.tsx

  • Step 1: Add routing config fields to DefaultResourcesSection

In the DefaultResourcesSection component, add state variables:

const [routingMode, setRoutingMode] = useState(String(defaults.routingMode ?? 'path'));
const [routingDomain, setRoutingDomain] = useState(String(defaults.routingDomain ?? ''));
const [serverUrl, setServerUrl] = useState(String(defaults.serverUrl ?? ''));
const [sslOffloading, setSslOffloading] = useState(defaults.sslOffloading !== false);

Add to the useEffect reset:

setRoutingMode(String(environment.defaultContainerConfig.routingMode ?? 'path'));
setRoutingDomain(String(environment.defaultContainerConfig.routingDomain ?? ''));
setServerUrl(String(environment.defaultContainerConfig.serverUrl ?? ''));
setSslOffloading(environment.defaultContainerConfig.sslOffloading !== false);

Add to handleCancel:

setRoutingMode(String(defaults.routingMode ?? 'path'));
setRoutingDomain(String(defaults.routingDomain ?? ''));
setServerUrl(String(defaults.serverUrl ?? ''));
setSslOffloading(defaults.sslOffloading !== false);

Include in handleSave:

await onSave({
    memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
    memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
    cpuShares: cpuShares ? parseInt(cpuShares) : null,
    cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
    routingMode,
    routingDomain: routingDomain || null,
    serverUrl: serverUrl || null,
    sslOffloading,
});

Add the UI fields in the metaGrid:

<span className={styles.metaLabel}>Routing Mode</span>
{editing
  ? <select value={routingMode} onChange={(e) => setRoutingMode(e.target.value)} className={styles.metaValue}>
      <option value="path">Path-based</option>
      <option value="subdomain">Subdomain</option>
    </select>
  : <span className={styles.metaValue}>{routingMode === 'subdomain' ? 'Subdomain' : 'Path-based'}</span>}

<span className={styles.metaLabel}>Routing Domain</span>
{editing
  ? <Input value={routingDomain} onChange={(e) => setRoutingDomain(e.target.value)} placeholder="e.g. apps.example.com" style={{ width: 200 }} />
  : <span className={styles.metaValue}>{defaults.routingDomain || '—'}</span>}

<span className={styles.metaLabel}>Server URL</span>
{editing
  ? <Input value={serverUrl} onChange={(e) => setServerUrl(e.target.value)} placeholder="auto-detect" style={{ width: 200 }} />
  : <span className={styles.metaValue}>{defaults.serverUrl || '(global default)'}</span>}

<span className={styles.metaLabel}>SSL Offloading</span>
{editing
  ? <Toggle checked={sslOffloading} onChange={() => setSslOffloading(!sslOffloading)} />
  : <span className={styles.metaValue}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>}
  • Step 2: Type-check

Run: cd ui && npx tsc --noEmit -p tsconfig.app.json Expected: No new errors

  • Step 3: Commit
git add ui/src/pages/Admin/EnvironmentsPage.tsx
git commit -m "feat: routing mode, domain, server URL, SSL offloading on Environments page"

Task 17: Final Build Verification

  • Step 1: Full Maven build

Run: mvn clean compile -q Expected: BUILD SUCCESS

  • Step 2: Full UI type-check

Run: cd ui && npx tsc --noEmit -p tsconfig.app.json Expected: EXIT 0

  • Step 3: Push all commits
git push
  • Step 4: Verify CI passes

Check the Gitea CI pipeline for the push.