# 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**

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

```java
package com.cameleer.server.core.runtime;

public enum DeploymentStatus {
    STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
}
```

- [ ] **Step 2: Create DeployStage enum**

```java
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**

```bash
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`:

```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**

```bash
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`:

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

```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 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**

```java
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**

```java
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**

```bash
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`:

```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()`:

```java
@Override
public String startContainer(ContainerRequest request) {
    List<String> envList = request.envVars().entrySet().stream()
            .map(e -> e.getKey() + "=" + e.getValue()).toList();

    // Volume bind: mount JAR into container
    Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);

    HostConfig hostConfig = HostConfig.newHostConfig()
            .withMemory(request.memoryLimitBytes())
            .withMemorySwap(request.memoryLimitBytes())
            .withCpuShares(request.cpuShares())
            .withNetworkMode(request.network())
            .withBinds(jarBind)
            .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 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**

```java
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**

```bash
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**

```java
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:

```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 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**

```java
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**

```bash
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`:

```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**

```bash
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:

```java
@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**

```bash
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:

```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 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**

```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 (
    <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**

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

```html
<th>Replicas</th>
```

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

```tsx
<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:

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

After the deployments table, add:

```tsx
{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:

```tsx
<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:

```typescript
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**

```bash
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:

```typescript
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:

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

Add to `handleCancel`:

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

Include in `handleSave`:

```typescript
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`:

```tsx
<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**

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