diff --git a/docs/superpowers/plans/2026-04-08-docker-orchestration.md b/docs/superpowers/plans/2026-04-08-docker-orchestration.md new file mode 100644 index 00000000..f06edad0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-docker-orchestration.md @@ -0,0 +1,1906 @@ +# 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 — `cameleer3-server-core/src/main/java/com/cameleer3/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 — `cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql` + +- [ ] **Step 1: Create the migration file** + +```sql +-- 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** + +```bash +git add cameleer3-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: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/DeploymentStatus.java` +- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/DeployStage.java` + +- [ ] **Step 1: Update DeploymentStatus enum** + +Replace the content of `DeploymentStatus.java`: + +```java +package com.cameleer3.server.core.runtime; + +public enum DeploymentStatus { + STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED +} +``` + +- [ ] **Step 2: Create DeployStage enum** + +```java +package com.cameleer3.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** + +```bash +git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/DeploymentStatus.java \ + cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/DeployStage.java +git commit -m "feat: add DEGRADED, STOPPING statuses and DeployStage enum" +``` + +--- + +### Task 3: Update Deployment Record + +**Files:** +- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Deployment.java` + +- [ ] **Step 1: Update the Deployment record** + +Replace the content of `Deployment.java`: + +```java +package com.cameleer3.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> 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** + +```bash +git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Deployment.java +git commit -m "feat: add targetState, deploymentStrategy, replicaStates, deployStage to Deployment" +``` + +--- + +### Task 4: Update PostgresDeploymentRepository + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresDeploymentRepository.java` + +- [ ] **Step 1: Update the repository to handle new columns** + +Replace the full content of `PostgresDeploymentRepository.java`: + +```java +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.runtime.Deployment; +import com.cameleer3.server.core.runtime.DeploymentRepository; +import com.cameleer3.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_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 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 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 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 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> 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 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> 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 `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java`, change the `deploymentRepository()` bean: + +```java +@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** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresDeploymentRepository.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java +git commit -m "feat: update PostgresDeploymentRepository for orchestration columns" +``` + +--- + +### Task 5: ResolvedContainerConfig + ConfigMerger + +**Files:** +- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java` +- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java` + +- [ ] **Step 1: Create ResolvedContainerConfig record** + +```java +package com.cameleer3.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 exposedPorts, + Map 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** + +```java +package com.cameleer3.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 envConfig, + Map 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 app, Map 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 app, Map 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 app, Map 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 app, Map 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 app, Map 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 intList(Map app, Map 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 stringMap(Map app, Map env, String key) { + Object val = app.containsKey(key) ? app.get(key) : env.get(key); + if (val instanceof Map map) { + Map 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** + +```bash +git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ResolvedContainerConfig.java \ + cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ConfigMerger.java +git commit -m "feat: ResolvedContainerConfig record and three-layer ConfigMerger" +``` + +--- + +### Task 6: Update ContainerRequest + +**Files:** +- Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java` + +- [ ] **Step 1: Expand ContainerRequest with new fields** + +Replace `ContainerRequest.java`: + +```java +package com.cameleer3.server.core.runtime; + +import java.util.List; +import java.util.Map; + +public record ContainerRequest( + String containerName, + String baseImage, + String jarPath, + String network, + List additionalNetworks, + Map envVars, + Map labels, + long memoryLimitBytes, + Long memoryReserveBytes, + int cpuShares, + Long cpuQuota, + List 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()`: + +```java +@Override +public String startContainer(ContainerRequest request) { + List envList = request.envVars().entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()).toList(); + + // Volume bind: mount JAR into container + Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro); + + HostConfig hostConfig = HostConfig.newHostConfig() + .withMemory(request.memoryLimitBytes()) + .withMemorySwap(request.memoryLimitBytes()) + .withCpuShares(request.cpuShares()) + .withNetworkMode(request.network()) + .withBinds(jarBind) + .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`: +```java +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: + +```java +ContainerRequest request = new ContainerRequest( + deployment.containerName(), + baseImage, + jarPath, + dockerNetwork, + envVars, + labels, + parseMemoryLimitBytes(containerMemoryLimit), + containerCpuShares, + agentHealthPort); +``` + +To: + +```java +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** + +```bash +git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/ContainerRequest.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java +git commit -m "feat: expand ContainerRequest with cpuLimit, ports, restart policy, additional networks" +``` + +--- + +### Task 7: TraefikLabelBuilder + +**Files:** +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/TraefikLabelBuilder.java` + +- [ ] **Step 1: Create TraefikLabelBuilder** + +```java +package com.cameleer3.server.app.runtime; + +import com.cameleer3.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 build(String appSlug, String envSlug, ResolvedContainerConfig config) { + String svc = envSlug + "-" + appSlug; + Map labels = new LinkedHashMap<>(); + + // Core labels + labels.put("traefik.enable", "true"); + labels.put("managed-by", "cameleer3-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** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/TraefikLabelBuilder.java +git commit -m "feat: TraefikLabelBuilder with path-based and subdomain routing" +``` + +--- + +### Task 8: DockerNetworkManager + +**Files:** +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerNetworkManager.java` + +- [ ] **Step 1: Create DockerNetworkManager** + +```java +package com.cameleer3.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 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: + +```java +public DockerClient getDockerClient() { + return dockerClient; +} +``` + +- [ ] **Step 3: Wire DockerNetworkManager in RuntimeOrchestratorAutoConfig** + +In `RuntimeOrchestratorAutoConfig.java`, add a bean for the network manager: + +```java +@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: + +```java +@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** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerNetworkManager.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java +git commit -m "feat: DockerNetworkManager with lazy network creation and container attachment" +``` + +--- + +### Task 9: DockerEventMonitor + +**Files:** +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerEventMonitor.java` + +- [ ] **Step 1: Create DockerEventMonitor** + +```java +package com.cameleer3.server.app.runtime; + +import com.cameleer3.server.core.runtime.Deployment; +import com.cameleer3.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() { + @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 labels = event.getActor() != null ? event.getActor().getAttributes() : null; + if (labels == null || !"cameleer3-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 deploymentOpt = deploymentRepository.findByContainerId(containerId); + if (deploymentOpt.isEmpty()) return; + + Deployment deployment = deploymentOpt.get(); + List> replicas = new java.util.ArrayList<>(deployment.replicaStates()); + + boolean changed = false; + for (int i = 0; i < replicas.size(); i++) { + Map replica = replicas.get(i); + if (containerId.equals(replica.get("containerId"))) { + Map 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** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerEventMonitor.java +git commit -m "feat: DockerEventMonitor — persistent event stream for container lifecycle" +``` + +--- + +### Task 10: Rewrite DeploymentExecutor + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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`: + +```java +package com.cameleer3.server.app.runtime; + +import com.cameleer3.server.app.storage.PostgresDeploymentRepository; +import com.cameleer3.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://cameleer3-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 baseEnvVars = buildEnvVars(app, env, config); + Map labels = TraefikLabelBuilder.build(app.slug(), env.slug(), config); + + List> replicaStates = new ArrayList<>(); + List 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 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 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 buildEnvVars(App app, Environment env, ResolvedContainerConfig config) { + Map 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 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> updateReplicaHealth(List> replicas, + List containerIds) { + List> updated = new ArrayList<>(); + for (Map replica : replicas) { + String cid = (String) replica.get("containerId"); + ContainerStatus status = orchestrator.getContainerStatus(cid); + Map 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** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java +git commit -m "feat: rewrite DeploymentExecutor with staged deploy, config merge, replicas" +``` + +--- + +### Task 11: Update DeploymentController + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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: + +```java +@PostMapping("/{deploymentId}/stop") +@Operation(summary = "Stop a running deployment") +public ResponseEntity 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** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/main/resources/application.yml` + +- [ ] **Step 1: Add the server-url property** + +In the `cameleer.runtime` section, add: + +```yaml + 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** + +```bash +git add cameleer3-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** + +```typescript +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** + +```bash +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** + +```css +.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** + +```tsx +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 ( +
+ {STAGES.map((stage, i) => { + const isCompleted = i < currentIndex; + const isActive = i === currentIndex && !failed; + const isFailed = i === currentIndex && failed; + + return ( +
+ {i > 0 && ( +
+ )} +
+
+ + {stage.label} + +
+
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 3: Type-check** + +Run: `cd ui && npx tsc --noEmit -p tsconfig.app.json` +Expected: No new errors + +- [ ] **Step 4: Commit** + +```bash +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: + +```typescript +const STATUS_COLORS: Record = { + 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: + +```html +Replicas +``` + +In the deployments table body, add the replicas cell after the status badge: + +```tsx + + {d.replicaStates.length > 0 ? ( + + {d.replicaStates.filter((r) => r.status === 'RUNNING').length}/{d.replicaStates.length} + + ) : '—'} + +``` + +Add the `DeploymentProgress` component import and show it below the deployments table when a deployment is actively deploying: + +```tsx +import { DeploymentProgress } from '../../components/DeploymentProgress'; +``` + +After the deployments table, add: + +```tsx +{deployments.filter((d) => d.deployStage).map((d) => ( +
+ {d.containerName} + +
+))} +``` + +- [ ] **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: + +```tsx +App Port + setAppPort(e.target.value)} style={{ width: 80 }} /> + +Replicas + setReplicas(e.target.value)} style={{ width: 60 }} type="number" /> + +Deploy Strategy + setRoutingMode(e.target.value)} className={styles.metaValue}> + + + + : {routingMode === 'subdomain' ? 'Subdomain' : 'Path-based'}} + +Routing Domain +{editing + ? setRoutingDomain(e.target.value)} placeholder="e.g. apps.example.com" style={{ width: 200 }} /> + : {defaults.routingDomain || '—'}} + +Server URL +{editing + ? setServerUrl(e.target.value)} placeholder="auto-detect" style={{ width: 200 }} /> + : {defaults.serverUrl || '(global default)'}} + +SSL Offloading +{editing + ? setSslOffloading(!sslOffloading)} /> + : {sslOffloading ? 'Enabled' : 'Disabled'}} +``` + +- [ ] **Step 2: Type-check** + +Run: `cd ui && npx tsc --noEmit -p tsconfig.app.json` +Expected: No new errors + +- [ ] **Step 3: Commit** + +```bash +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** + +```bash +git push +``` + +- [ ] **Step 4: Verify CI passes** + +Check the Gitea CI pipeline for the push.