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