Rename Java packages from com.cameleer3 to com.cameleer, module directories from cameleer3-* to cameleer-*, and all references throughout workflows, Dockerfiles, docs, migrations, and pom.xml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
66 KiB
Docker Container Orchestration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the DockerRuntimeOrchestrator fully functional — apply container configs, generate Traefik routing labels, support replicas with blue/green and rolling strategies, and monitor container health via Docker event stream.
Architecture: Three-layer config merge (global → env → app) produces a ResolvedContainerConfig record. TraefikLabelBuilder and DockerNetworkManager are pure utilities consumed by DeploymentExecutor, which orchestrates the full deployment lifecycle with staged progress tracking. DockerEventMonitor provides infrastructure-level health via the Docker event stream.
Tech Stack: Java 17, Spring Boot 3.4, docker-java 3.4.1, PostgreSQL (JSONB), React 18 + TypeScript, @cameleer/design-system
File Structure
New files (core module — cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/)
ResolvedContainerConfig.java— typed record with all resolved config fieldsConfigMerger.java— pure function, three-layer merge logicDeployStage.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 labelsDockerNetworkManager.java— lazy network creation + container attachmentDockerEventMonitor.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 componentui/src/components/DeploymentProgress.module.css— styles
Modified files (core)
ContainerRequest.java— add cpuLimit, exposedPorts, restartPolicy, additionalNetworksDeploymentStatus.java— add DEGRADED, STOPPINGDeployment.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 mergePostgresDeploymentRepository.java— new columns, JSONB for replica statesDeploymentController.java— expose deployStage and replicaStates in responsesRuntimeBeanConfig.java— wire new beans
Modified files (UI)
ui/src/api/queries/admin/apps.ts— update Deployment interfaceui/src/pages/AppsTab/AppsTab.tsx— new config fields, replicas column, progress indicatorui/src/pages/Admin/EnvironmentsPage.tsx— routing mode, domain, SSL fields
Task 1: Database Migration
Files:
-
Create:
cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql -
Step 1: Create the migration file
-- V7__deployment_orchestration.sql
-- Deployment orchestration: status model, replicas, strategies, progress tracking
ALTER TABLE deployments ADD COLUMN target_state VARCHAR(20) NOT NULL DEFAULT 'RUNNING';
ALTER TABLE deployments ADD COLUMN deployment_strategy VARCHAR(20) NOT NULL DEFAULT 'BLUE_GREEN';
ALTER TABLE deployments ADD COLUMN replica_states JSONB NOT NULL DEFAULT '[]';
ALTER TABLE deployments ADD COLUMN deploy_stage VARCHAR(30);
-- Backfill existing deployments
UPDATE deployments SET target_state = CASE
WHEN status = 'STOPPED' THEN 'STOPPED'
ELSE 'RUNNING'
END;
- Step 2: Verify migration compiles with Maven
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 3: Commit
git add cameleer-server-app/src/main/resources/db/migration/V7__deployment_orchestration.sql
git commit -m "feat: V7 migration — deployment orchestration columns"
Task 2: DeploymentStatus Enum + DeployStage Enum
Files:
-
Modify:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentStatus.java -
Create:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeployStage.java -
Step 1: Update DeploymentStatus enum
Replace the content of DeploymentStatus.java:
package com.cameleer.server.core.runtime;
public enum DeploymentStatus {
STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
}
- Step 2: Create DeployStage enum
package com.cameleer.server.core.runtime;
public enum DeployStage {
PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
}
- Step 3: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 4: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentStatus.java \
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeployStage.java
git commit -m "feat: add DEGRADED, STOPPING statuses and DeployStage enum"
Task 3: Update Deployment Record
Files:
-
Modify:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java -
Step 1: Update the Deployment record
Replace the content of Deployment.java:
package com.cameleer.server.core.runtime;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record Deployment(
UUID id,
UUID appId,
UUID appVersionId,
UUID environmentId,
DeploymentStatus status,
String targetState,
String deploymentStrategy,
List<Map<String, Object>> replicaStates,
String deployStage,
String containerId,
String containerName,
String errorMessage,
Instant deployedAt,
Instant stoppedAt,
Instant createdAt
) {
public Deployment withStatus(DeploymentStatus newStatus) {
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
targetState, deploymentStrategy, replicaStates, deployStage,
containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
}
}
- Step 2: Verify compilation — expect errors in PostgresDeploymentRepository (will fix in Task 4)
Run: mvn compile -q 2>&1 | head -20
Expected: Compilation errors in PostgresDeploymentRepository.java (wrong constructor arity)
- Step 3: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java
git commit -m "feat: add targetState, deploymentStrategy, replicaStates, deployStage to Deployment"
Task 4: Update PostgresDeploymentRepository
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java -
Step 1: Update the repository to handle new columns
Replace the full content of PostgresDeploymentRepository.java:
package com.cameleer.server.app.storage;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public class PostgresDeploymentRepository implements DeploymentRepository {
private static final String SELECT_COLS = """
id, app_id, app_version_id, environment_id, status,
target_state, deployment_strategy, replica_states, deploy_stage,
container_id, container_name, error_message,
deployed_at, stopped_at, created_at""";
private static final TypeReference<List<Map<String, Object>>> LIST_MAP_TYPE = new TypeReference<>() {};
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public PostgresDeploymentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
@Override
public List<Deployment> findByAppId(UUID appId) {
return jdbc.query("SELECT " + SELECT_COLS + " FROM deployments WHERE app_id = ? ORDER BY created_at DESC",
(rs, rowNum) -> mapRow(rs), appId);
}
@Override
public List<Deployment> findByEnvironmentId(UUID environmentId) {
return jdbc.query("SELECT " + SELECT_COLS + " FROM deployments WHERE environment_id = ? ORDER BY created_at DESC",
(rs, rowNum) -> mapRow(rs), environmentId);
}
@Override
public Optional<Deployment> findById(UUID id) {
var results = jdbc.query("SELECT " + SELECT_COLS + " FROM deployments WHERE id = ?",
(rs, rowNum) -> mapRow(rs), id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE app_id = ? AND environment_id = ? AND status IN ('STARTING', 'RUNNING', 'DEGRADED') ORDER BY created_at DESC LIMIT 1",
(rs, rowNum) -> mapRow(rs), appId, environmentId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name) VALUES (?, ?, ?, ?, ?)",
id, appId, appVersionId, environmentId, containerName);
return id;
}
@Override
public void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage) {
jdbc.update("UPDATE deployments SET status = ?, container_id = ?, error_message = ? WHERE id = ?",
status.name(), containerId, errorMessage, id);
}
@Override
public void markDeployed(UUID id) {
jdbc.update("UPDATE deployments SET deployed_at = now() WHERE id = ?", id);
}
@Override
public void markStopped(UUID id) {
jdbc.update("UPDATE deployments SET stopped_at = now() WHERE id = ?", id);
}
public void updateReplicaStates(UUID id, List<Map<String, Object>> replicaStates) {
try {
String json = objectMapper.writeValueAsString(replicaStates);
jdbc.update("UPDATE deployments SET replica_states = ?::jsonb WHERE id = ?", json, id);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize replica states", e);
}
}
public void updateDeployStage(UUID id, String stage) {
jdbc.update("UPDATE deployments SET deploy_stage = ? WHERE id = ?", stage, id);
}
public void updateTargetState(UUID id, String targetState) {
jdbc.update("UPDATE deployments SET target_state = ? WHERE id = ?", targetState, id);
}
public void updateDeploymentStrategy(UUID id, String strategy) {
jdbc.update("UPDATE deployments SET deployment_strategy = ? WHERE id = ?", strategy, id);
}
public Optional<Deployment> findByContainerId(String containerId) {
// Search in replica_states JSONB for containerId
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? AND status IN ('STARTING', 'RUNNING', 'DEGRADED') ORDER BY created_at DESC LIMIT 1",
(rs, rowNum) -> mapRow(rs), "%" + containerId + "%");
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
private Deployment mapRow(ResultSet rs) throws SQLException {
List<Map<String, Object>> replicaStates = List.of();
try {
String json = rs.getString("replica_states");
if (json != null && !json.isBlank()) {
replicaStates = objectMapper.readValue(json, LIST_MAP_TYPE);
}
} catch (Exception e) { /* use empty default */ }
return new Deployment(
UUID.fromString(rs.getString("id")),
UUID.fromString(rs.getString("app_id")),
UUID.fromString(rs.getString("app_version_id")),
UUID.fromString(rs.getString("environment_id")),
DeploymentStatus.valueOf(rs.getString("status")),
rs.getString("target_state"),
rs.getString("deployment_strategy"),
replicaStates,
rs.getString("deploy_stage"),
rs.getString("container_id"),
rs.getString("container_name"),
rs.getString("error_message"),
rs.getTimestamp("deployed_at") != null ? rs.getTimestamp("deployed_at").toInstant() : null,
rs.getTimestamp("stopped_at") != null ? rs.getTimestamp("stopped_at").toInstant() : null,
rs.getTimestamp("created_at").toInstant()
);
}
}
- Step 2: Update RuntimeBeanConfig to pass ObjectMapper to the repository
In cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java, change the deploymentRepository() bean:
@Bean
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
return new PostgresDeploymentRepository(jdbc, objectMapper);
}
- Step 3: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 4: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java \
cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java
git commit -m "feat: update PostgresDeploymentRepository for orchestration columns"
Task 5: ResolvedContainerConfig + ConfigMerger
Files:
-
Create:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java -
Create:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java -
Step 1: Create ResolvedContainerConfig record
package com.cameleer.server.core.runtime;
import java.util.List;
import java.util.Map;
public record ResolvedContainerConfig(
int memoryLimitMb,
Integer memoryReserveMb,
int cpuShares,
Double cpuLimit,
int appPort,
List<Integer> exposedPorts,
Map<String, String> customEnvVars,
boolean stripPathPrefix,
boolean sslOffloading,
String routingMode,
String routingDomain,
String serverUrl,
int replicas,
String deploymentStrategy
) {
public long memoryLimitBytes() {
return (long) memoryLimitMb * 1024 * 1024;
}
public Long memoryReserveBytes() {
return memoryReserveMb != null ? (long) memoryReserveMb * 1024 * 1024 : null;
}
}
- Step 2: Create ConfigMerger
package com.cameleer.server.core.runtime;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Three-layer config merge: global defaults → environment defaults → app overrides.
* App values override env, env values override global.
*/
public final class ConfigMerger {
private ConfigMerger() {}
public static ResolvedContainerConfig resolve(
GlobalRuntimeDefaults global,
Map<String, Object> envConfig,
Map<String, Object> appConfig) {
return new ResolvedContainerConfig(
intVal(appConfig, envConfig, "memoryLimitMb", global.memoryLimitMb()),
intOrNull(appConfig, envConfig, "memoryReserveMb"),
intVal(appConfig, envConfig, "cpuShares", global.cpuShares()),
doubleOrNull(appConfig, envConfig, "cpuLimit"),
intVal(appConfig, envConfig, "appPort", 8080),
intList(appConfig, envConfig, "exposedPorts"),
stringMap(appConfig, envConfig, "customEnvVars"),
boolVal(appConfig, envConfig, "stripPathPrefix", true),
boolVal(appConfig, envConfig, "sslOffloading", true),
stringVal(appConfig, envConfig, "routingMode", global.routingMode()),
stringVal(appConfig, envConfig, "routingDomain", global.routingDomain()),
stringVal(appConfig, envConfig, "serverUrl", global.serverUrl()),
intVal(appConfig, envConfig, "replicas", 1),
stringVal(appConfig, envConfig, "deploymentStrategy", "blue-green")
);
}
private static int intVal(Map<String, Object> app, Map<String, Object> env, String key, int fallback) {
if (app.containsKey(key) && app.get(key) instanceof Number n) return n.intValue();
if (env.containsKey(key) && env.get(key) instanceof Number n) return n.intValue();
return fallback;
}
private static Integer intOrNull(Map<String, Object> app, Map<String, Object> env, String key) {
if (app.containsKey(key) && app.get(key) instanceof Number n) return n.intValue();
if (env.containsKey(key) && env.get(key) instanceof Number n) return n.intValue();
return null;
}
private static Double doubleOrNull(Map<String, Object> app, Map<String, Object> env, String key) {
if (app.containsKey(key) && app.get(key) instanceof Number n) return n.doubleValue();
if (env.containsKey(key) && env.get(key) instanceof Number n) return n.doubleValue();
return null;
}
private static boolean boolVal(Map<String, Object> app, Map<String, Object> env, String key, boolean fallback) {
if (app.containsKey(key) && app.get(key) instanceof Boolean b) return b;
if (env.containsKey(key) && env.get(key) instanceof Boolean b) return b;
return fallback;
}
private static String stringVal(Map<String, Object> app, Map<String, Object> env, String key, String fallback) {
if (app.containsKey(key) && app.get(key) instanceof String s) return s;
if (env.containsKey(key) && env.get(key) instanceof String s) return s;
return fallback;
}
@SuppressWarnings("unchecked")
private static List<Integer> intList(Map<String, Object> app, Map<String, Object> env, String key) {
Object val = app.containsKey(key) ? app.get(key) : env.get(key);
if (val instanceof List<?> list) {
return list.stream()
.filter(Number.class::isInstance)
.map(n -> ((Number) n).intValue())
.toList();
}
return List.of();
}
@SuppressWarnings("unchecked")
private static Map<String, String> stringMap(Map<String, Object> app, Map<String, Object> env, String key) {
Object val = app.containsKey(key) ? app.get(key) : env.get(key);
if (val instanceof Map<?, ?> map) {
Map<String, String> result = new HashMap<>();
map.forEach((k, v) -> result.put(String.valueOf(k), String.valueOf(v)));
return Collections.unmodifiableMap(result);
}
return Map.of();
}
/** Global defaults extracted from application.yml @Value fields */
public record GlobalRuntimeDefaults(
int memoryLimitMb,
int cpuShares,
String routingMode,
String routingDomain,
String serverUrl
) {}
}
- Step 3: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 4: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ResolvedContainerConfig.java \
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ConfigMerger.java
git commit -m "feat: ResolvedContainerConfig record and three-layer ConfigMerger"
Task 6: Update ContainerRequest
Files:
-
Modify:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java -
Step 1: Expand ContainerRequest with new fields
Replace ContainerRequest.java:
package com.cameleer.server.core.runtime;
import java.util.List;
import java.util.Map;
public record ContainerRequest(
String containerName,
String baseImage,
String jarPath,
String network,
List<String> additionalNetworks,
Map<String, String> envVars,
Map<String, String> labels,
long memoryLimitBytes,
Long memoryReserveBytes,
int cpuShares,
Long cpuQuota,
List<Integer> exposedPorts,
int healthCheckPort,
String restartPolicyName,
int restartPolicyMaxRetries
) {}
- Step 2: Fix compilation errors in DockerRuntimeOrchestrator and DeploymentExecutor
These files construct ContainerRequest — they will have wrong arity. For now, update DockerRuntimeOrchestrator.startContainer() to use the new fields (full rewrite comes in Task 8). Update the existing constructor call in DeploymentExecutor to pass default values for new fields (full rewrite comes in Task 10).
In DockerRuntimeOrchestrator.java, update startContainer():
@Override
public String startContainer(ContainerRequest request) {
List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList();
// Volume bind: mount JAR into container
Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);
HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network())
.withBinds(jarBind)
.withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()));
// Apply optional fields
if (request.memoryReserveBytes() != null) {
hostConfig.withMemoryReservation(request.memoryReserveBytes());
}
if (request.cpuQuota() != null) {
hostConfig.withCpuQuota(request.cpuQuota());
}
var createCmd = dockerClient.createContainerCmd(request.baseImage())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels() != null ? request.labels() : Map.of())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL",
"wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
.withInterval(10_000_000_000L)
.withTimeout(5_000_000_000L)
.withRetries(3)
.withStartPeriod(30_000_000_000L));
// Expose additional ports
if (request.exposedPorts() != null && !request.exposedPorts().isEmpty()) {
var ports = request.exposedPorts().stream()
.map(p -> com.github.dockerjava.api.model.ExposedPort.tcp(p))
.toArray(com.github.dockerjava.api.model.ExposedPort[]::new);
createCmd.withExposedPorts(ports);
}
var container = createCmd.exec();
dockerClient.startContainerCmd(container.getId()).exec();
// Connect to additional networks
if (request.additionalNetworks() != null) {
for (String net : request.additionalNetworks()) {
dockerClient.connectToNetworkCmd()
.withContainerId(container.getId())
.withNetworkId(net)
.exec();
}
}
log.info("Started container {} ({})", request.containerName(), container.getId());
return container.getId();
}
Add the import for RestartPolicy:
import com.github.dockerjava.api.model.RestartPolicy;
- Step 3: Update DeploymentExecutor temporarily
In DeploymentExecutor.java, find the ContainerRequest constructor call and add default values for new fields. Change:
ContainerRequest request = new ContainerRequest(
deployment.containerName(),
baseImage,
jarPath,
dockerNetwork,
envVars,
labels,
parseMemoryLimitBytes(containerMemoryLimit),
containerCpuShares,
agentHealthPort);
To:
ContainerRequest request = new ContainerRequest(
deployment.containerName(),
baseImage,
jarPath,
dockerNetwork,
List.of(), // additionalNetworks
envVars,
labels,
parseMemoryLimitBytes(containerMemoryLimit),
null, // memoryReserveBytes
containerCpuShares,
null, // cpuQuota
List.of(), // exposedPorts
agentHealthPort,
"on-failure", // restartPolicyName
3 // restartPolicyMaxRetries
);
Add import java.util.List; if not already present.
- Step 4: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 5: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/ContainerRequest.java \
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java \
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
git commit -m "feat: expand ContainerRequest with cpuLimit, ports, restart policy, additional networks"
Task 7: TraefikLabelBuilder
Files:
-
Create:
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java -
Step 1: Create TraefikLabelBuilder
package com.cameleer.server.app.runtime;
import com.cameleer.server.core.runtime.ResolvedContainerConfig;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Generates Traefik Docker labels for container routing.
* Supports path-based and subdomain-based routing modes.
*/
public final class TraefikLabelBuilder {
private TraefikLabelBuilder() {}
public static Map<String, String> build(String appSlug, String envSlug, ResolvedContainerConfig config) {
String svc = envSlug + "-" + appSlug;
Map<String, String> labels = new LinkedHashMap<>();
// Core labels
labels.put("traefik.enable", "true");
labels.put("managed-by", "cameleer-server");
labels.put("cameleer.app", appSlug);
labels.put("cameleer.environment", envSlug);
// Service port
labels.put("traefik.http.services." + svc + ".loadbalancer.server.port",
String.valueOf(config.appPort()));
// Routing rule
if ("subdomain".equals(config.routingMode())) {
labels.put("traefik.http.routers." + svc + ".rule",
"Host(`" + appSlug + "-" + envSlug + "." + config.routingDomain() + "`)");
} else {
// Path-based (default)
labels.put("traefik.http.routers." + svc + ".rule",
"PathPrefix(`/" + envSlug + "/" + appSlug + "/`)");
if (config.stripPathPrefix()) {
labels.put("traefik.http.middlewares." + svc + "-strip.stripprefix.prefixes",
"/" + envSlug + "/" + appSlug);
labels.put("traefik.http.routers." + svc + ".middlewares",
svc + "-strip");
}
}
// Entrypoints
labels.put("traefik.http.routers." + svc + ".entrypoints",
config.sslOffloading() ? "websecure" : "web");
// TLS
if (config.sslOffloading()) {
labels.put("traefik.http.routers." + svc + ".tls", "true");
labels.put("traefik.http.routers." + svc + ".tls.certresolver", "default");
}
return labels;
}
}
- Step 2: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/TraefikLabelBuilder.java
git commit -m "feat: TraefikLabelBuilder with path-based and subdomain routing"
Task 8: DockerNetworkManager
Files:
-
Create:
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerNetworkManager.java -
Step 1: Create DockerNetworkManager
package com.cameleer.server.app.runtime;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.model.Network;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
/**
* Manages Docker networks for the deployment system.
* Creates bridge networks lazily and connects containers to additional networks.
*/
public class DockerNetworkManager {
private static final Logger log = LoggerFactory.getLogger(DockerNetworkManager.class);
public static final String TRAEFIK_NETWORK = "cameleer-traefik";
public static final String ENV_NETWORK_PREFIX = "cameleer-env-";
private final DockerClient dockerClient;
public DockerNetworkManager(DockerClient dockerClient) {
this.dockerClient = dockerClient;
}
/**
* Ensure a Docker bridge network exists. Creates it if missing (idempotent).
* Returns the network ID.
*/
public String ensureNetwork(String networkName) {
List<Network> existing = dockerClient.listNetworksCmd()
.withNameFilter(networkName)
.exec();
for (Network net : existing) {
if (net.getName().equals(networkName)) {
return net.getId();
}
}
String id = dockerClient.createNetworkCmd()
.withName(networkName)
.withDriver("bridge")
.withCheckDuplicate(true)
.exec()
.getId();
log.info("Created Docker network: {} ({})", networkName, id);
return id;
}
/**
* Connect a container to an additional network.
*/
public void connectContainer(String containerId, String networkName) {
String networkId = ensureNetwork(networkName);
try {
dockerClient.connectToNetworkCmd()
.withContainerId(containerId)
.withNetworkId(networkId)
.exec();
log.debug("Connected container {} to network {}", containerId, networkName);
} catch (Exception e) {
// May already be connected
if (!e.getMessage().contains("already exists")) {
throw e;
}
}
}
/**
* Returns the environment-specific network name for an environment slug.
*/
public static String envNetworkName(String envSlug) {
return ENV_NETWORK_PREFIX + envSlug;
}
}
- Step 2: Expose DockerClient from DockerRuntimeOrchestrator
Add a getter to DockerRuntimeOrchestrator.java so the network manager can share the same client:
public DockerClient getDockerClient() {
return dockerClient;
}
- Step 3: Wire DockerNetworkManager in RuntimeOrchestratorAutoConfig
In RuntimeOrchestratorAutoConfig.java, add a bean for the network manager:
@Bean
@ConditionalOnBean(DockerRuntimeOrchestrator.class)
public DockerNetworkManager dockerNetworkManager(RuntimeOrchestrator orchestrator) {
return new DockerNetworkManager(((DockerRuntimeOrchestrator) orchestrator).getDockerClient());
}
Actually, the auto-config creates the orchestrator conditionally. A simpler approach — make the network manager a @Bean inside the same config and pass it the orchestrator. Update the bean factory:
@Bean
public RuntimeOrchestrator runtimeOrchestrator() {
if (Files.exists(Path.of("/var/run/docker.sock"))) {
log.info("Docker socket detected - enabling Docker runtime orchestrator");
return new DockerRuntimeOrchestrator();
}
log.info("No Docker socket detected - runtime management disabled (observability-only mode)");
return new DisabledRuntimeOrchestrator();
}
@Bean
public DockerNetworkManager dockerNetworkManager(RuntimeOrchestrator orchestrator) {
if (orchestrator instanceof DockerRuntimeOrchestrator docker) {
return new DockerNetworkManager(docker.getDockerClient());
}
return null; // No network management when Docker is unavailable
}
- Step 4: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 5: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerNetworkManager.java \
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerRuntimeOrchestrator.java \
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/RuntimeOrchestratorAutoConfig.java
git commit -m "feat: DockerNetworkManager with lazy network creation and container attachment"
Task 9: DockerEventMonitor
Files:
-
Create:
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerEventMonitor.java -
Step 1: Create DockerEventMonitor
package com.cameleer.server.app.runtime;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.Event;
import com.github.dockerjava.api.model.EventType;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Listens to Docker daemon event stream for container lifecycle events.
* Updates deployment replica states when containers die, restart, or OOM.
*/
@Component
@ConditionalOnBean(DockerRuntimeOrchestrator.class)
public class DockerEventMonitor {
private static final Logger log = LoggerFactory.getLogger(DockerEventMonitor.class);
private final DockerClient dockerClient;
private final PostgresDeploymentRepository deploymentRepository;
private Closeable eventStream;
public DockerEventMonitor(DockerRuntimeOrchestrator orchestrator,
PostgresDeploymentRepository deploymentRepository) {
this.dockerClient = orchestrator.getDockerClient();
this.deploymentRepository = deploymentRepository;
}
@PostConstruct
public void startListening() {
eventStream = dockerClient.eventsCmd()
.withEventTypeFilter(EventType.CONTAINER)
.withEventFilter("die", "oom", "start", "stop")
.exec(new ResultCallback.Adapter<Event>() {
@Override
public void onNext(Event event) {
handleEvent(event);
}
@Override
public void onError(Throwable throwable) {
log.warn("Docker event stream error, reconnecting: {}", throwable.getMessage());
reconnect();
}
});
log.info("Docker event monitor started");
}
@PreDestroy
public void stop() {
if (eventStream != null) {
try { eventStream.close(); } catch (IOException e) { /* ignore */ }
}
}
private void handleEvent(Event event) {
String containerId = event.getId();
if (containerId == null) return;
// Only process containers managed by us
Map<String, String> labels = event.getActor() != null ? event.getActor().getAttributes() : null;
if (labels == null || !"cameleer-server".equals(labels.get("managed-by"))) return;
String action = event.getAction();
log.debug("Docker event: {} for container {} ({})", action, containerId.substring(0, 12),
labels.get("cameleer.app"));
Optional<Deployment> deploymentOpt = deploymentRepository.findByContainerId(containerId);
if (deploymentOpt.isEmpty()) return;
Deployment deployment = deploymentOpt.get();
List<Map<String, Object>> replicas = new java.util.ArrayList<>(deployment.replicaStates());
boolean changed = false;
for (int i = 0; i < replicas.size(); i++) {
Map<String, Object> replica = replicas.get(i);
if (containerId.equals(replica.get("containerId"))) {
Map<String, Object> updated = new java.util.HashMap<>(replica);
switch (action) {
case "die", "oom", "stop" -> {
updated.put("status", "DEAD");
if ("oom".equals(action)) {
updated.put("oomKilled", true);
log.warn("Container {} OOM-killed (app={}, env={})", containerId.substring(0, 12),
labels.get("cameleer.app"), labels.get("cameleer.environment"));
}
}
case "start" -> updated.put("status", "RUNNING");
}
replicas.set(i, updated);
changed = true;
break;
}
}
if (!changed) return;
// Update replica states
deploymentRepository.updateReplicaStates(deployment.id(), replicas);
// Recompute aggregate deployment status
long running = replicas.stream().filter(r -> "RUNNING".equals(r.get("status"))).count();
DeploymentStatus newStatus;
if (running == replicas.size()) {
newStatus = DeploymentStatus.RUNNING;
} else if (running > 0) {
newStatus = DeploymentStatus.DEGRADED;
} else {
newStatus = DeploymentStatus.FAILED;
}
if (deployment.status() != newStatus) {
deploymentRepository.updateStatus(deployment.id(), newStatus, deployment.containerId(), deployment.errorMessage());
log.info("Deployment {} status: {} → {} ({}/{} replicas running)",
deployment.id(), deployment.status(), newStatus, running, replicas.size());
}
}
private void reconnect() {
try {
Thread.sleep(5000);
startListening();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
- Step 2: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS (may need PostgresDeploymentRepository to be injected by type — the @Component annotation handles this since it's conditional on DockerRuntimeOrchestrator existing)
- Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DockerEventMonitor.java
git commit -m "feat: DockerEventMonitor — persistent event stream for container lifecycle"
Task 10: Rewrite DeploymentExecutor
Files:
- Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
This is the largest task — the orchestration hub that ties everything together.
- Step 1: Rewrite DeploymentExecutor
Replace the full content of DeploymentExecutor.java:
package com.cameleer.server.app.runtime;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
@Service
public class DeploymentExecutor {
private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class);
private final RuntimeOrchestrator orchestrator;
private final DeploymentService deploymentService;
private final AppService appService;
private final EnvironmentService envService;
private final DeploymentRepository deploymentRepository;
private final PostgresDeploymentRepository pgDeployRepo;
private final DockerNetworkManager networkManager;
@Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
private String baseImage;
@Value("${cameleer.runtime.docker-network:cameleer}")
private String dockerNetwork;
@Value("${cameleer.runtime.container-memory-limit:512m}")
private String globalMemoryLimit;
@Value("${cameleer.runtime.container-cpu-shares:512}")
private int globalCpuShares;
@Value("${cameleer.runtime.health-check-timeout:60}")
private int healthCheckTimeout;
@Value("${cameleer.runtime.agent-health-port:9464}")
private int agentHealthPort;
@Value("${security.bootstrap-token:}")
private String bootstrapToken;
@Value("${cameleer.runtime.routing-mode:path}")
private String globalRoutingMode;
@Value("${cameleer.runtime.routing-domain:localhost}")
private String globalRoutingDomain;
@Value("${cameleer.runtime.server-url:}")
private String globalServerUrl;
public DeploymentExecutor(RuntimeOrchestrator orchestrator,
DeploymentService deploymentService,
AppService appService,
EnvironmentService envService,
DeploymentRepository deploymentRepository,
DockerNetworkManager networkManager) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.appService = appService;
this.envService = envService;
this.deploymentRepository = deploymentRepository;
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
this.networkManager = networkManager;
}
@Async("deploymentTaskExecutor")
public void executeAsync(Deployment deployment) {
try {
// Resolve metadata
App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId());
String jarPath = appService.resolveJarPath(deployment.appVersionId());
// Merge configs
var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults(
parseMemoryLimitMb(globalMemoryLimit),
globalCpuShares,
globalRoutingMode,
globalRoutingDomain,
globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl
);
ResolvedContainerConfig config = ConfigMerger.resolve(
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
// Set deployment strategy
pgDeployRepo.updateDeploymentStrategy(deployment.id(), config.deploymentStrategy());
// === PRE-FLIGHT ===
updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
preFlightChecks(jarPath, config);
// === PULL IMAGE ===
updateStage(deployment.id(), DeployStage.PULL_IMAGE);
// Docker pulls on create if not present — no explicit pull needed for now
// === CREATE NETWORKS ===
updateStage(deployment.id(), DeployStage.CREATE_NETWORK);
String traefikNet = null;
String envNet = null;
if (networkManager != null) {
traefikNet = DockerNetworkManager.TRAEFIK_NETWORK;
networkManager.ensureNetwork(traefikNet);
envNet = DockerNetworkManager.envNetworkName(env.slug());
networkManager.ensureNetwork(envNet);
}
// === START REPLICAS ===
updateStage(deployment.id(), DeployStage.START_REPLICAS);
Map<String, String> baseEnvVars = buildEnvVars(app, env, config);
Map<String, String> labels = TraefikLabelBuilder.build(app.slug(), env.slug(), config);
List<Map<String, Object>> replicaStates = new ArrayList<>();
List<String> newContainerIds = new ArrayList<>();
for (int i = 0; i < config.replicas(); i++) {
String containerName = env.slug() + "-" + app.slug() + "-" + i;
Long cpuQuota = config.cpuLimit() != null ? (long) (config.cpuLimit() * 100_000) : null;
ContainerRequest request = new ContainerRequest(
containerName,
baseImage,
jarPath,
traefikNet != null ? traefikNet : dockerNetwork,
envNet != null ? List.of(envNet) : List.of(),
baseEnvVars,
labels,
config.memoryLimitBytes(),
config.memoryReserveBytes(),
config.cpuShares(),
cpuQuota,
config.exposedPorts(),
agentHealthPort,
"on-failure",
3
);
String containerId = orchestrator.startContainer(request);
newContainerIds.add(containerId);
replicaStates.add(Map.of(
"index", i,
"containerId", containerId,
"containerName", containerName,
"status", "STARTING"
));
}
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === HEALTH CHECK ===
updateStage(deployment.id(), DeployStage.HEALTH_CHECK);
int healthyCount = waitForAnyHealthy(newContainerIds, healthCheckTimeout);
if (healthyCount == 0) {
// All unhealthy — clean up new replicas and fail
for (String cid : newContainerIds) {
try { orchestrator.stopContainer(cid); orchestrator.removeContainer(cid); }
catch (Exception e) { log.warn("Cleanup failed for {}: {}", cid, e.getMessage()); }
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
deploymentService.markFailed(deployment.id(), "No replicas passed health check within " + healthCheckTimeout + "s");
return;
}
// Update replica states after health check
replicaStates = updateReplicaHealth(replicaStates, newContainerIds);
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === SWAP TRAFFIC ===
updateStage(deployment.id(), DeployStage.SWAP_TRAFFIC);
// Stop previous deployment (blue/green: all at once after new is healthy)
Optional<Deployment> existing = deploymentRepository.findActiveByAppIdAndEnvironmentId(
deployment.appId(), deployment.environmentId());
if (existing.isPresent() && !existing.get().id().equals(deployment.id())) {
stopDeploymentContainers(existing.get());
deploymentService.markStopped(existing.get().id());
log.info("Stopped previous deployment {} for replacement", existing.get().id());
}
// === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE);
// Store first container ID for backward compatibility
String primaryContainerId = newContainerIds.get(0);
DeploymentStatus finalStatus = healthyCount == config.replicas()
? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED;
deploymentService.markRunning(deployment.id(), primaryContainerId);
if (finalStatus == DeploymentStatus.DEGRADED) {
deploymentRepository.updateStatus(deployment.id(), DeploymentStatus.DEGRADED,
primaryContainerId, null);
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
log.info("Deployment {} is {} ({}/{} replicas healthy)",
deployment.id(), finalStatus, healthyCount, config.replicas());
} catch (Exception e) {
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
pgDeployRepo.updateDeployStage(deployment.id(), null);
deploymentService.markFailed(deployment.id(), e.getMessage());
}
}
public void stopDeployment(Deployment deployment) {
pgDeployRepo.updateTargetState(deployment.id(), "STOPPED");
deploymentRepository.updateStatus(deployment.id(), DeploymentStatus.STOPPING,
deployment.containerId(), null);
stopDeploymentContainers(deployment);
deploymentService.markStopped(deployment.id());
}
private void stopDeploymentContainers(Deployment deployment) {
for (Map<String, Object> replica : deployment.replicaStates()) {
String cid = (String) replica.get("containerId");
if (cid != null) {
try {
orchestrator.stopContainer(cid);
orchestrator.removeContainer(cid);
} catch (Exception e) {
log.warn("Failed to stop replica container {}: {}", cid, e.getMessage());
}
}
}
// Backward compat: also stop the single containerId if set
if (deployment.containerId() != null && deployment.replicaStates().isEmpty()) {
try {
orchestrator.stopContainer(deployment.containerId());
orchestrator.removeContainer(deployment.containerId());
} catch (Exception e) {
log.warn("Failed to stop container {}: {}", deployment.containerId(), e.getMessage());
}
}
}
private void preFlightChecks(String jarPath, ResolvedContainerConfig config) {
if (!Files.exists(Path.of(jarPath))) {
throw new IllegalStateException("JAR file not found: " + jarPath);
}
if (config.memoryLimitMb() <= 0) {
throw new IllegalStateException("Memory limit must be positive, got: " + config.memoryLimitMb());
}
if (config.appPort() <= 0 || config.appPort() > 65535) {
throw new IllegalStateException("Invalid app port: " + config.appPort());
}
if (config.replicas() < 1) {
throw new IllegalStateException("Replicas must be >= 1, got: " + config.replicas());
}
}
private Map<String, String> buildEnvVars(App app, Environment env, ResolvedContainerConfig config) {
Map<String, String> envVars = new LinkedHashMap<>();
envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
envVars.put("CAMELEER_APPLICATION_ID", app.slug());
envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
envVars.put("CAMELEER_SERVER_URL", config.serverUrl());
if (bootstrapToken != null && !bootstrapToken.isBlank()) {
envVars.put("CAMELEER_AUTH_TOKEN", bootstrapToken);
}
// Merge custom env vars (app overrides env defaults)
envVars.putAll(config.customEnvVars());
return envVars;
}
private int waitForAnyHealthy(List<String> containerIds, int timeoutSeconds) {
long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
while (System.currentTimeMillis() < deadline) {
int healthy = 0;
for (String cid : containerIds) {
ContainerStatus status = orchestrator.getContainerStatus(cid);
if ("healthy".equals(status.state())) healthy++;
}
if (healthy > 0) return healthy;
try { Thread.sleep(2000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
return 0;
}
}
return 0;
}
private List<Map<String, Object>> updateReplicaHealth(List<Map<String, Object>> replicas,
List<String> containerIds) {
List<Map<String, Object>> updated = new ArrayList<>();
for (Map<String, Object> replica : replicas) {
String cid = (String) replica.get("containerId");
ContainerStatus status = orchestrator.getContainerStatus(cid);
Map<String, Object> copy = new HashMap<>(replica);
copy.put("status", status.running() ? "RUNNING" : "DEAD");
updated.add(copy);
}
return updated;
}
private void updateStage(UUID deploymentId, DeployStage stage) {
pgDeployRepo.updateDeployStage(deploymentId, stage.name());
}
private int parseMemoryLimitMb(String limit) {
limit = limit.trim().toLowerCase();
if (limit.endsWith("g")) return (int) (Double.parseDouble(limit.replace("g", "")) * 1024);
if (limit.endsWith("m")) return (int) Double.parseDouble(limit.replace("m", ""));
return Integer.parseInt(limit);
}
}
- Step 2: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java
git commit -m "feat: rewrite DeploymentExecutor with staged deploy, config merge, replicas"
Task 11: Update DeploymentController
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java -
Step 1: Update the stop endpoint to use stopDeployment
The controller's stop endpoint currently calls deploymentService.markStopped() directly. It needs to call deploymentExecutor.stopDeployment() instead to actually stop containers and handle replicas.
Find the stop method and replace:
@PostMapping("/{deploymentId}/stop")
@Operation(summary = "Stop a running deployment")
public ResponseEntity<Deployment> stop(@PathVariable UUID appId, @PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment);
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
- Step 2: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 3: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java
git commit -m "feat: update stop endpoint to use DeploymentExecutor for replica cleanup"
Task 12: Update application.yml
Files:
-
Modify:
cameleer-server-app/src/main/resources/application.yml -
Step 1: Add the server-url property
In the cameleer.runtime section, add:
server-url: ${CAMELEER_SERVER_URL:}
This goes after the existing routing-domain line.
- Step 2: Verify compilation
Run: mvn compile -q
Expected: BUILD SUCCESS
- Step 3: Commit
git add cameleer-server-app/src/main/resources/application.yml
git commit -m "feat: add CAMELEER_SERVER_URL config property"
Task 13: UI — Update Deployment Interface
Files:
-
Modify:
ui/src/api/queries/admin/apps.ts -
Step 1: Update the Deployment TypeScript interface
export interface Deployment {
id: string;
appId: string;
appVersionId: string;
environmentId: string;
status: 'STOPPED' | 'STARTING' | 'RUNNING' | 'DEGRADED' | 'STOPPING' | 'FAILED';
targetState: string;
deploymentStrategy: string;
replicaStates: { index: number; containerId: string; containerName: string; status: string; oomKilled?: boolean }[];
deployStage: string | null;
containerId: string | null;
containerName: string | null;
errorMessage: string | null;
deployedAt: string | null;
stoppedAt: string | null;
createdAt: string;
}
- Step 2: Type-check
Run: cd ui && npx tsc --noEmit -p tsconfig.app.json
Expected: No new errors
- Step 3: Commit
git add ui/src/api/queries/admin/apps.ts
git commit -m "feat: update Deployment interface with replicas, stages, new statuses"
Task 14: UI — DeploymentProgress Component
Files:
-
Create:
ui/src/components/DeploymentProgress.tsx -
Create:
ui/src/components/DeploymentProgress.module.css -
Step 1: Create the CSS module
.container {
display: flex;
align-items: center;
gap: 0;
padding: 8px 0;
}
.step {
display: flex;
align-items: center;
gap: 0;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--border-subtle);
background: transparent;
flex-shrink: 0;
}
.dotCompleted {
background: var(--success);
border-color: var(--success);
}
.dotActive {
background: var(--accent, #6c7aff);
border-color: var(--accent, #6c7aff);
animation: pulse 1.5s ease-in-out infinite;
}
.dotFailed {
background: var(--error);
border-color: var(--error);
}
.line {
width: 32px;
height: 2px;
background: var(--border-subtle);
}
.lineCompleted {
background: var(--success);
}
.label {
font-size: 10px;
color: var(--text-muted);
text-align: center;
margin-top: 4px;
}
.labelActive {
color: var(--accent, #6c7aff);
font-weight: 600;
}
.labelFailed {
color: var(--error);
}
.stepColumn {
display: flex;
flex-direction: column;
align-items: center;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
- Step 2: Create the component
import styles from './DeploymentProgress.module.css';
const STAGES = [
{ key: 'PRE_FLIGHT', label: 'Pre-flight' },
{ key: 'PULL_IMAGE', label: 'Pull' },
{ key: 'CREATE_NETWORK', label: 'Network' },
{ key: 'START_REPLICAS', label: 'Start' },
{ key: 'HEALTH_CHECK', label: 'Health' },
{ key: 'SWAP_TRAFFIC', label: 'Swap' },
{ key: 'COMPLETE', label: 'Done' },
];
interface DeploymentProgressProps {
currentStage: string | null;
failed?: boolean;
}
export function DeploymentProgress({ currentStage, failed }: DeploymentProgressProps) {
if (!currentStage) return null;
const currentIndex = STAGES.findIndex((s) => s.key === currentStage);
return (
<div className={styles.container}>
{STAGES.map((stage, i) => {
const isCompleted = i < currentIndex;
const isActive = i === currentIndex && !failed;
const isFailed = i === currentIndex && failed;
return (
<div key={stage.key} className={styles.step}>
{i > 0 && (
<div className={`${styles.line} ${isCompleted || isActive || isFailed ? styles.lineCompleted : ''}`} />
)}
<div className={styles.stepColumn}>
<div className={`${styles.dot} ${isCompleted ? styles.dotCompleted : ''} ${isActive ? styles.dotActive : ''} ${isFailed ? styles.dotFailed : ''}`} />
<span className={`${styles.label} ${isActive ? styles.labelActive : ''} ${isFailed ? styles.labelFailed : ''}`}>
{stage.label}
</span>
</div>
</div>
);
})}
</div>
);
}
- Step 3: Type-check
Run: cd ui && npx tsc --noEmit -p tsconfig.app.json
Expected: No new errors
- Step 4: Commit
git add ui/src/components/DeploymentProgress.tsx ui/src/components/DeploymentProgress.module.css
git commit -m "feat: DeploymentProgress step indicator component"
Task 15: UI — Update Deployments Overview (AppsTab)
Files:
-
Modify:
ui/src/pages/AppsTab/AppsTab.tsx -
Step 1: Add STATUS_COLORS for new statuses
Find the STATUS_COLORS constant and add the new status values:
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
DEGRADED: 'warning', STOPPING: 'auto',
};
- Step 2: Add replicas column and progress indicator to OverviewSubTab
In the deployments table header, add a Replicas column after Status:
<th>Replicas</th>
In the deployments table body, add the replicas cell after the status badge:
<td>
{d.replicaStates.length > 0 ? (
<span className={styles.cellMeta}>
{d.replicaStates.filter((r) => r.status === 'RUNNING').length}/{d.replicaStates.length}
</span>
) : '—'}
</td>
Add the DeploymentProgress component import and show it below the deployments table when a deployment is actively deploying:
import { DeploymentProgress } from '../../components/DeploymentProgress';
After the deployments table, add:
{deployments.filter((d) => d.deployStage).map((d) => (
<div key={d.id} style={{ marginBottom: 8 }}>
<span className={styles.cellMeta}>{d.containerName}</span>
<DeploymentProgress currentStage={d.deployStage} failed={d.status === 'FAILED'} />
</div>
))}
- Step 3: Add new config fields to Resources tab
In the create page Resources tab and the detail config Resources sub-tab, add these fields after the existing CPU Limit row:
<span className={styles.configLabel}>App Port</span>
<Input disabled={!editing} value={appPort} onChange={(e) => setAppPort(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>Replicas</span>
<Input disabled={!editing} value={replicas} onChange={(e) => setReplicas(e.target.value)} style={{ width: 60 }} type="number" />
<span className={styles.configLabel}>Deploy Strategy</span>
<Select disabled={!editing} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}
options={[{ value: 'blue-green', label: 'Blue/Green' }, { value: 'rolling', label: 'Rolling' }]} />
<span className={styles.configLabel}>Strip Path Prefix</span>
<div className={styles.configInline}>
<Toggle checked={stripPrefix} onChange={() => editing && setStripPrefix(!stripPrefix)} disabled={!editing} />
<span className={stripPrefix ? styles.toggleEnabled : styles.toggleDisabled}>{stripPrefix ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>SSL Offloading</span>
<div className={styles.configInline}>
<Toggle checked={sslOffloading} onChange={() => editing && setSslOffloading(!sslOffloading)} disabled={!editing} />
<span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>
</div>
Add the corresponding state variables:
const [appPort, setAppPort] = useState('8080');
const [replicas, setReplicas] = useState('1');
const [deployStrategy, setDeployStrategy] = useState('blue-green');
const [stripPrefix, setStripPrefix] = useState(true);
const [sslOffloading, setSslOffloading] = useState(true);
Add these fields to both syncFromServer (reading from app.containerConfig) and handleSave (writing to container config).
- Step 4: Type-check
Run: cd ui && npx tsc --noEmit -p tsconfig.app.json
Expected: No new errors
- Step 5: Commit
git add ui/src/pages/AppsTab/AppsTab.tsx
git commit -m "feat: replicas column, deploy progress, and new config fields in Deployments UI"
Task 16: UI — Environment Admin Routing Fields
Files:
-
Modify:
ui/src/pages/Admin/EnvironmentsPage.tsx -
Step 1: Add routing config fields to DefaultResourcesSection
In the DefaultResourcesSection component, add state variables:
const [routingMode, setRoutingMode] = useState(String(defaults.routingMode ?? 'path'));
const [routingDomain, setRoutingDomain] = useState(String(defaults.routingDomain ?? ''));
const [serverUrl, setServerUrl] = useState(String(defaults.serverUrl ?? ''));
const [sslOffloading, setSslOffloading] = useState(defaults.sslOffloading !== false);
Add to the useEffect reset:
setRoutingMode(String(environment.defaultContainerConfig.routingMode ?? 'path'));
setRoutingDomain(String(environment.defaultContainerConfig.routingDomain ?? ''));
setServerUrl(String(environment.defaultContainerConfig.serverUrl ?? ''));
setSslOffloading(environment.defaultContainerConfig.sslOffloading !== false);
Add to handleCancel:
setRoutingMode(String(defaults.routingMode ?? 'path'));
setRoutingDomain(String(defaults.routingDomain ?? ''));
setServerUrl(String(defaults.serverUrl ?? ''));
setSslOffloading(defaults.sslOffloading !== false);
Include in handleSave:
await onSave({
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
cpuShares: cpuShares ? parseInt(cpuShares) : null,
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
routingMode,
routingDomain: routingDomain || null,
serverUrl: serverUrl || null,
sslOffloading,
});
Add the UI fields in the metaGrid:
<span className={styles.metaLabel}>Routing Mode</span>
{editing
? <select value={routingMode} onChange={(e) => setRoutingMode(e.target.value)} className={styles.metaValue}>
<option value="path">Path-based</option>
<option value="subdomain">Subdomain</option>
</select>
: <span className={styles.metaValue}>{routingMode === 'subdomain' ? 'Subdomain' : 'Path-based'}</span>}
<span className={styles.metaLabel}>Routing Domain</span>
{editing
? <Input value={routingDomain} onChange={(e) => setRoutingDomain(e.target.value)} placeholder="e.g. apps.example.com" style={{ width: 200 }} />
: <span className={styles.metaValue}>{defaults.routingDomain || '—'}</span>}
<span className={styles.metaLabel}>Server URL</span>
{editing
? <Input value={serverUrl} onChange={(e) => setServerUrl(e.target.value)} placeholder="auto-detect" style={{ width: 200 }} />
: <span className={styles.metaValue}>{defaults.serverUrl || '(global default)'}</span>}
<span className={styles.metaLabel}>SSL Offloading</span>
{editing
? <Toggle checked={sslOffloading} onChange={() => setSslOffloading(!sslOffloading)} />
: <span className={styles.metaValue}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>}
- Step 2: Type-check
Run: cd ui && npx tsc --noEmit -p tsconfig.app.json
Expected: No new errors
- Step 3: Commit
git add ui/src/pages/Admin/EnvironmentsPage.tsx
git commit -m "feat: routing mode, domain, server URL, SSL offloading on Environments page"
Task 17: Final Build Verification
- Step 1: Full Maven build
Run: mvn clean compile -q
Expected: BUILD SUCCESS
- Step 2: Full UI type-check
Run: cd ui && npx tsc --noEmit -p tsconfig.app.json
Expected: EXIT 0
- Step 3: Push all commits
git push
- Step 4: Verify CI passes
Check the Gitea CI pipeline for the push.