docs: add Phase 4 Observability Pipeline implementation plan
8 tasks: migration, labels support, routing API, agent/observability status endpoints, Traefik routing labels, connectivity check, Docker Compose + env, HOWTO update. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,789 @@
|
|||||||
|
# Phase 4: Observability Pipeline + Inbound Routing — 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:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer3-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
|
||||||
|
|
||||||
|
**Architecture:** Wiring phase — cameleer3-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.4.3, docker-java 3.4.1, ClickHouse JDBC, Traefik v3 labels, Spring RestClient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer3-server for agent registration
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java` — Agent status + observability status endpoints
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java` — Response DTO
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java` — Response DTO
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java` — Request DTO for PATCH routing
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java` — Startup connectivity verification
|
||||||
|
- `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java` — Unit tests
|
||||||
|
- `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusControllerTest.java` — Integration tests
|
||||||
|
- `src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql` — Migration
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java` — Add `labels` field
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java` — Apply labels on container create
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java` — Add `domain` property
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java` — Add `exposedPort` field
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/AppService.java` — Add `updateRouting` method
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/AppController.java` — Add PATCH routing endpoint
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java` — Add `exposedPort` + `routeUrl` fields
|
||||||
|
- `src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java` — Build labels for Traefik routing
|
||||||
|
- `src/main/resources/application.yml` — Add `domain` property
|
||||||
|
- `docker-compose.yml` — Add dashboard Traefik route, `CAMELEER_TENANT_ID`
|
||||||
|
- `.env.example` — Add `CAMELEER_TENANT_SLUG`
|
||||||
|
- `HOWTO.md` — Update with observability + routing docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Database Migration + Entity Changes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create migration V010**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add exposedPort field to AppEntity**
|
||||||
|
|
||||||
|
Add after `previousDeploymentId` field:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Column(name = "exposed_port")
|
||||||
|
private Integer exposedPort;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getter and setter:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public Integer getExposedPort() { return exposedPort; }
|
||||||
|
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn compile -B -q`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java
|
||||||
|
git commit -m "feat: add exposed_port column to apps table"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: StartContainerRequest Labels + DockerRuntimeOrchestrator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add labels field to StartContainerRequest**
|
||||||
|
|
||||||
|
Replace the current record with:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.runtime;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record StartContainerRequest(
|
||||||
|
String imageRef,
|
||||||
|
String containerName,
|
||||||
|
String network,
|
||||||
|
Map<String, String> envVars,
|
||||||
|
long memoryLimitBytes,
|
||||||
|
int cpuShares,
|
||||||
|
int healthCheckPort,
|
||||||
|
Map<String, String> labels
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply labels in DockerRuntimeOrchestrator.startContainer**
|
||||||
|
|
||||||
|
In the `startContainer` method, after `.withHostConfig(hostConfig)` and before `.withHealthcheck(...)`, add:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.withLabels(request.labels() != null ? request.labels() : Map.of())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix all existing callers of StartContainerRequest**
|
||||||
|
|
||||||
|
The `DeploymentService.executeDeploymentAsync` method creates a `StartContainerRequest`. Add `Map.of()` as the labels argument (empty labels for now — routing labels come in Task 5):
|
||||||
|
|
||||||
|
Find the existing `new StartContainerRequest(...)` call and add `Map.of()` as the last argument.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify compilation and run unit tests**
|
||||||
|
|
||||||
|
Run: `mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java" -q`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java
|
||||||
|
git commit -m "feat: add labels support to StartContainerRequest and DockerRuntimeOrchestrator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: RuntimeConfig Domain + AppResponse + AppService Routing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppService.java`
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppController.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java`
|
||||||
|
- Modify: `src/main/resources/application.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add domain property to RuntimeConfig**
|
||||||
|
|
||||||
|
Add field and getter:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Value("${cameleer.runtime.domain:localhost}")
|
||||||
|
private String domain;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add domain to application.yml**
|
||||||
|
|
||||||
|
In the `cameleer.runtime` section, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
domain: ${DOMAIN:localhost}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update AppResponse to include exposedPort and routeUrl**
|
||||||
|
|
||||||
|
Replace the record:
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.app.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record AppResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID environmentId,
|
||||||
|
String slug,
|
||||||
|
String displayName,
|
||||||
|
String jarOriginalFilename,
|
||||||
|
Long jarSizeBytes,
|
||||||
|
String jarChecksum,
|
||||||
|
Integer exposedPort,
|
||||||
|
String routeUrl,
|
||||||
|
UUID currentDeploymentId,
|
||||||
|
UUID previousDeploymentId,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create UpdateRoutingRequest**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
public record UpdateRoutingRequest(
|
||||||
|
Integer exposedPort
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add updateRouting method to AppService**
|
||||||
|
|
||||||
|
```java
|
||||||
|
public AppEntity updateRouting(UUID appId, Integer exposedPort, UUID actorId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
app.setExposedPort(exposedPort);
|
||||||
|
return appRepository.save(app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update AppController — add PATCH routing endpoint and update toResponse**
|
||||||
|
|
||||||
|
Add the endpoint:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@PatchMapping("/{appId}/routing")
|
||||||
|
public ResponseEntity<AppResponse> updateRouting(
|
||||||
|
@PathVariable UUID environmentId,
|
||||||
|
@PathVariable UUID appId,
|
||||||
|
@RequestBody UpdateRoutingRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
try {
|
||||||
|
var actorId = resolveActorId(authentication);
|
||||||
|
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
|
||||||
|
var env = environmentService.getById(app.getEnvironmentId()).orElse(null);
|
||||||
|
return ResponseEntity.ok(toResponse(app, env));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires adding `EnvironmentService` and `RuntimeConfig` as constructor dependencies to `AppController`. Update the constructor.
|
||||||
|
|
||||||
|
Update `toResponse` to accept the environment and compute the route URL:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private AppResponse toResponse(AppEntity app, EnvironmentEntity env) {
|
||||||
|
String routeUrl = null;
|
||||||
|
if (app.getExposedPort() != null && env != null) {
|
||||||
|
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
|
||||||
|
if (tenant != null) {
|
||||||
|
routeUrl = "http://" + app.getSlug() + "." + env.getSlug() + "."
|
||||||
|
+ tenant.getSlug() + "." + runtimeConfig.getDomain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AppResponse(
|
||||||
|
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
||||||
|
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
||||||
|
app.getExposedPort(), routeUrl,
|
||||||
|
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
||||||
|
app.getCreatedAt(), app.getUpdatedAt());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires adding `TenantRepository` as a constructor dependency too. Update the existing `toResponse(AppEntity)` calls in other methods to pass the environment — look up the environment from the `environmentId` path variable or from `environmentService`.
|
||||||
|
|
||||||
|
For the list/get/create endpoints that already have `environmentId` in the path, look up the environment once and pass it.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn compile -B -q`
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/AppService.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/app/AppController.java \
|
||||||
|
src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java \
|
||||||
|
src/main/resources/application.yml
|
||||||
|
git commit -m "feat: add exposed port routing and route URL to app API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Agent Status + Observability Status Endpoints (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java`
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java`
|
||||||
|
- Create: `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create DTOs**
|
||||||
|
|
||||||
|
`AgentStatusResponse.java`:
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record AgentStatusResponse(
|
||||||
|
boolean registered,
|
||||||
|
String state,
|
||||||
|
Instant lastHeartbeat,
|
||||||
|
List<String> routeIds,
|
||||||
|
String applicationId,
|
||||||
|
String environmentId
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ObservabilityStatusResponse.java`:
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record ObservabilityStatusResponse(
|
||||||
|
boolean hasTraces,
|
||||||
|
boolean hasMetrics,
|
||||||
|
boolean hasDiagrams,
|
||||||
|
Instant lastTraceAt,
|
||||||
|
long traceCount24h
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing tests**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.app.AppEntity;
|
||||||
|
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AgentStatusServiceTest {
|
||||||
|
|
||||||
|
@Mock private AppRepository appRepository;
|
||||||
|
@Mock private EnvironmentRepository environmentRepository;
|
||||||
|
@Mock private RuntimeConfig runtimeConfig;
|
||||||
|
|
||||||
|
private AgentStatusService agentStatusService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
|
||||||
|
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAgentStatus_appNotFound_shouldThrow() {
|
||||||
|
when(appRepository.findById(any())).thenReturn(Optional.empty());
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> agentStatusService.getAgentStatus(UUID.randomUUID()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAgentStatus_shouldReturnUnknownWhenServerUnreachable() {
|
||||||
|
var appId = UUID.randomUUID();
|
||||||
|
var envId = UUID.randomUUID();
|
||||||
|
|
||||||
|
var app = new AppEntity();
|
||||||
|
app.setId(appId);
|
||||||
|
app.setEnvironmentId(envId);
|
||||||
|
app.setSlug("my-app");
|
||||||
|
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
|
||||||
|
|
||||||
|
var env = new EnvironmentEntity();
|
||||||
|
env.setId(envId);
|
||||||
|
env.setSlug("default");
|
||||||
|
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||||
|
|
||||||
|
var result = agentStatusService.getAgentStatus(appId);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertFalse(result.registered());
|
||||||
|
assertEquals("UNKNOWN", result.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement AgentStatusService**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.app.AppRepository;
|
||||||
|
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AgentStatusService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentStatusService.class);
|
||||||
|
|
||||||
|
private final AppRepository appRepository;
|
||||||
|
private final EnvironmentRepository environmentRepository;
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
@Qualifier("clickHouseDataSource")
|
||||||
|
private DataSource clickHouseDataSource;
|
||||||
|
|
||||||
|
public AgentStatusService(AppRepository appRepository,
|
||||||
|
EnvironmentRepository environmentRepository,
|
||||||
|
RuntimeConfig runtimeConfig) {
|
||||||
|
this.appRepository = appRepository;
|
||||||
|
this.environmentRepository = environmentRepository;
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
this.restClient = RestClient.builder()
|
||||||
|
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentStatusResponse getAgentStatus(UUID appId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Environment not found"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = restClient.get()
|
||||||
|
.uri("/api/v1/agents")
|
||||||
|
.header("Authorization", "Bearer " + runtimeConfig.getBootstrapToken())
|
||||||
|
.retrieve()
|
||||||
|
.body(List.class);
|
||||||
|
|
||||||
|
if (response != null) {
|
||||||
|
for (var agentObj : response) {
|
||||||
|
if (agentObj instanceof java.util.Map<?, ?> agent) {
|
||||||
|
var agentAppId = String.valueOf(agent.get("applicationId"));
|
||||||
|
var agentEnvId = String.valueOf(agent.get("environmentId"));
|
||||||
|
if (app.getSlug().equals(agentAppId) && env.getSlug().equals(agentEnvId)) {
|
||||||
|
var state = String.valueOf(agent.getOrDefault("state", "UNKNOWN"));
|
||||||
|
var routeIds = agent.get("routeIds");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var routes = routeIds instanceof List<?> r ? (List<String>) r : List.<String>of();
|
||||||
|
return new AgentStatusResponse(true, state, null, routes,
|
||||||
|
agentAppId, agentEnvId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AgentStatusResponse(false, "NOT_REGISTERED", null,
|
||||||
|
List.of(), app.getSlug(), env.getSlug());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query agent status from cameleer3-server: {}", e.getMessage());
|
||||||
|
return new AgentStatusResponse(false, "UNKNOWN", null,
|
||||||
|
List.of(), app.getSlug(), env.getSlug());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservabilityStatusResponse getObservabilityStatus(UUID appId) {
|
||||||
|
var app = appRepository.findById(appId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("App not found"));
|
||||||
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Environment not found"));
|
||||||
|
|
||||||
|
if (clickHouseDataSource == null) {
|
||||||
|
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var conn = clickHouseDataSource.getConnection();
|
||||||
|
var ps = conn.prepareStatement("""
|
||||||
|
SELECT
|
||||||
|
count() as trace_count,
|
||||||
|
max(start_time) as last_trace
|
||||||
|
FROM executions
|
||||||
|
WHERE application_id = ? AND environment = ?
|
||||||
|
AND start_time > now() - INTERVAL 24 HOUR
|
||||||
|
""")) {
|
||||||
|
ps.setString(1, app.getSlug());
|
||||||
|
ps.setString(2, env.getSlug());
|
||||||
|
|
||||||
|
try (var rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
var count = rs.getLong("trace_count");
|
||||||
|
var lastTrace = rs.getTimestamp("last_trace");
|
||||||
|
return new ObservabilityStatusResponse(
|
||||||
|
count > 0, false, false,
|
||||||
|
lastTrace != null ? lastTrace.toInstant() : null,
|
||||||
|
count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query observability status from ClickHouse: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create AgentStatusController**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
|
||||||
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/apps/{appId}")
|
||||||
|
public class AgentStatusController {
|
||||||
|
|
||||||
|
private final AgentStatusService agentStatusService;
|
||||||
|
|
||||||
|
public AgentStatusController(AgentStatusService agentStatusService) {
|
||||||
|
this.agentStatusService = agentStatusService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/agent-status")
|
||||||
|
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
|
||||||
|
try {
|
||||||
|
var status = agentStatusService.getAgentStatus(appId);
|
||||||
|
return ResponseEntity.ok(status);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/observability-status")
|
||||||
|
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
||||||
|
try {
|
||||||
|
var status = agentStatusService.getObservabilityStatus(appId);
|
||||||
|
return ResponseEntity.ok(status);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn test -pl . -Dtest=AgentStatusServiceTest -B`
|
||||||
|
Expected: 2 tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/observability/ \
|
||||||
|
src/test/java/net/siegeln/cameleer/saas/observability/
|
||||||
|
git commit -m "feat: add agent status and observability status endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Traefik Routing Labels in DeploymentService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build Traefik labels when app has exposedPort**
|
||||||
|
|
||||||
|
In `executeDeploymentAsync`, after building the `envVars` map and before creating `startRequest`, add label computation:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Build Traefik labels for inbound routing
|
||||||
|
var labels = new java.util.HashMap<String, String>();
|
||||||
|
if (app.getExposedPort() != null) {
|
||||||
|
labels.put("traefik.enable", "true");
|
||||||
|
labels.put("traefik.http.routers." + containerName + ".rule",
|
||||||
|
"Host(`" + app.getSlug() + "." + env.getSlug() + "."
|
||||||
|
+ tenant.getSlug() + "." + runtimeConfig.getDomain() + "`)");
|
||||||
|
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
|
||||||
|
String.valueOf(app.getExposedPort()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pass `labels` to the `StartContainerRequest` constructor (replacing the `Map.of()` added in Task 2).
|
||||||
|
|
||||||
|
Note: The `tenant` variable is already looked up earlier in the method for container naming.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run unit tests**
|
||||||
|
|
||||||
|
Run: `mvn test -pl . -Dtest=DeploymentServiceTest -B`
|
||||||
|
Expected: All tests PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java
|
||||||
|
git commit -m "feat: add Traefik routing labels for customer apps with exposed ports"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Connectivity Health Check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create startup connectivity check**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package net.siegeln.cameleer.saas.observability;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ConnectivityHealthCheck {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConnectivityHealthCheck.class);
|
||||||
|
|
||||||
|
private final RuntimeConfig runtimeConfig;
|
||||||
|
|
||||||
|
public ConnectivityHealthCheck(RuntimeConfig runtimeConfig) {
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void verifyConnectivity() {
|
||||||
|
checkCameleer3Server();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkCameleer3Server() {
|
||||||
|
try {
|
||||||
|
var client = RestClient.builder()
|
||||||
|
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
||||||
|
.build();
|
||||||
|
var response = client.get()
|
||||||
|
.uri("/actuator/health")
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
if (response.getStatusCode().is2xxSuccessful()) {
|
||||||
|
log.info("cameleer3-server connectivity: OK ({})",
|
||||||
|
runtimeConfig.getCameleer3ServerEndpoint());
|
||||||
|
} else {
|
||||||
|
log.warn("cameleer3-server connectivity: HTTP {} ({})",
|
||||||
|
response.getStatusCode(), runtimeConfig.getCameleer3ServerEndpoint());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("cameleer3-server connectivity: FAILED ({}) - {}",
|
||||||
|
runtimeConfig.getCameleer3ServerEndpoint(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify compilation**
|
||||||
|
|
||||||
|
Run: `mvn compile -B -q`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java
|
||||||
|
git commit -m "feat: add cameleer3-server startup connectivity check"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Docker Compose + .env + CI Updates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
- Modify: `.env.example`
|
||||||
|
- Modify: `.gitea/workflows/ci.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update docker-compose.yml — add dashboard route and CAMELEER_TENANT_ID**
|
||||||
|
|
||||||
|
In the `cameleer3-server` service:
|
||||||
|
|
||||||
|
Add to environment section:
|
||||||
|
```yaml
|
||||||
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new Traefik labels (after existing ones):
|
||||||
|
```yaml
|
||||||
|
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
|
||||||
|
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
|
||||||
|
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
|
||||||
|
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update .env.example**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```
|
||||||
|
CAMELEER_TENANT_SLUG=default
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update CI excludes**
|
||||||
|
|
||||||
|
In `.gitea/workflows/ci.yml`, add `**/AgentStatusControllerTest.java` to the Surefire excludes (if integration test exists).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all unit tests**
|
||||||
|
|
||||||
|
Run: `mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java" -q`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml .env.example .gitea/workflows/ci.yml
|
||||||
|
git commit -m "feat: add dashboard Traefik route and CAMELEER_TENANT_ID config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Update HOWTO.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `HOWTO.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add observability and routing sections**
|
||||||
|
|
||||||
|
After the "Deploy a Camel Application" section, add:
|
||||||
|
|
||||||
|
**Observability Dashboard section** — explains how to access the dashboard at `/dashboard`, what data is visible.
|
||||||
|
|
||||||
|
**Inbound HTTP Routing section** — explains how to set `exposedPort` on an app and what URL to use.
|
||||||
|
|
||||||
|
**Agent Status section** — explains the agent-status and observability-status endpoints.
|
||||||
|
|
||||||
|
Update the API Reference table with the new endpoints:
|
||||||
|
- `GET /api/apps/{aid}/agent-status`
|
||||||
|
- `GET /api/apps/{aid}/observability-status`
|
||||||
|
- `PATCH /api/environments/{eid}/apps/{aid}/routing`
|
||||||
|
|
||||||
|
Update the .env table to include `CAMELEER_TENANT_SLUG`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add HOWTO.md
|
||||||
|
git commit -m "docs: update HOWTO with observability dashboard, routing, and agent status"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Spec Coverage
|
||||||
|
|
||||||
|
| Spec Requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Serve cameleer3-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
|
||||||
|
| CAMELEER_TENANT_ID configuration | Task 7 (docker-compose env) |
|
||||||
|
| Agent connectivity verification endpoint | Task 4 (AgentStatusService + Controller) |
|
||||||
|
| Observability data health endpoint | Task 4 (ObservabilityStatusResponse) |
|
||||||
|
| Inbound HTTP routing (exposedPort + Traefik labels) | Tasks 1, 2, 3, 5 |
|
||||||
|
| StartContainerRequest labels support | Task 2 |
|
||||||
|
| AppResponse with routeUrl | Task 3 |
|
||||||
|
| PATCH routing API | Task 3 |
|
||||||
|
| Startup connectivity check | Task 6 |
|
||||||
|
| Docker Compose changes | Task 7 |
|
||||||
|
| .env.example updates | Task 7 |
|
||||||
|
| HOWTO.md updates | Task 8 |
|
||||||
|
| V010 migration | Task 1 |
|
||||||
Reference in New Issue
Block a user