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