Merge pull request 'feat: Phase 4 — Observability Pipeline + Inbound Routing' (#34) from feat/phase-4-observability-pipeline into main
Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
@@ -27,3 +27,4 @@ DOMAIN=localhost
|
||||
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
|
||||
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
||||
CAMELEER_CONTAINER_CPU_SHARES=512
|
||||
CAMELEER_TENANT_SLUG=default
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Build and Test (unit tests only)
|
||||
run: >-
|
||||
mvn clean verify -B
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Build, Test and Analyze
|
||||
run: >-
|
||||
mvn clean verify sonar:sonar --batch-mode
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
|
||||
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
|
||||
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
||||
-Dsonar.projectKey=cameleer-saas
|
||||
|
||||
53
HOWTO.md
53
HOWTO.md
@@ -62,6 +62,7 @@ Edit `.env` and set at minimum:
|
||||
# Change in production
|
||||
POSTGRES_PASSWORD=<strong-password>
|
||||
CAMELEER_AUTH_TOKEN=<random-string-for-agent-bootstrap>
|
||||
CAMELEER_TENANT_SLUG=<your-tenant-slug> # e.g., "acme" — tags all observability data
|
||||
|
||||
# Logto M2M credentials (get from Logto admin console after first boot)
|
||||
LOGTO_M2M_CLIENT_ID=
|
||||
@@ -185,6 +186,46 @@ curl -X POST "http://localhost:8080/api/apps/$APP_ID/stop" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
### Enable Inbound HTTP Routing
|
||||
|
||||
If your Camel app exposes a REST endpoint, you can make it reachable from outside the stack:
|
||||
|
||||
```bash
|
||||
# Set the port your app listens on (e.g., 8080 for Spring Boot)
|
||||
curl -X PATCH "http://localhost:8080/api/environments/$ENV_ID/apps/$APP_ID/routing" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"exposedPort": 8080}'
|
||||
```
|
||||
|
||||
Your app is now reachable at `http://{app-slug}.{env-slug}.{tenant-slug}.{domain}` (e.g., `http://order-service.default.my-company.localhost`). Traefik routes traffic automatically.
|
||||
|
||||
To disable routing, set `exposedPort` to `null`.
|
||||
|
||||
### View the Observability Dashboard
|
||||
|
||||
The cameleer3-server React SPA dashboard is available at:
|
||||
|
||||
```
|
||||
http://localhost/dashboard
|
||||
```
|
||||
|
||||
This shows execution traces, route topology graphs, metrics, and logs for all deployed apps. Authentication is required (Logto OIDC token via forward-auth).
|
||||
|
||||
### Check Agent & Observability Status
|
||||
|
||||
```bash
|
||||
# Is the agent registered with cameleer3-server?
|
||||
curl "http://localhost:8080/api/apps/$APP_ID/agent-status" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Returns: registered, state (ACTIVE/STALE/DEAD/UNKNOWN), routeIds
|
||||
|
||||
# Is the app producing observability data?
|
||||
curl "http://localhost:8080/api/apps/$APP_ID/observability-status" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Returns: hasTraces, lastTraceAt, traceCount24h
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Tenants
|
||||
@@ -216,6 +257,7 @@ curl -X POST "http://localhost:8080/api/apps/$APP_ID/stop" \
|
||||
| GET | `/api/environments/{eid}/apps` | List apps |
|
||||
| GET | `/api/environments/{eid}/apps/{aid}` | Get app |
|
||||
| PUT | `/api/environments/{eid}/apps/{aid}/jar` | Re-upload JAR |
|
||||
| PATCH | `/api/environments/{eid}/apps/{aid}/routing` | Set/clear exposed port |
|
||||
| DELETE | `/api/environments/{eid}/apps/{aid}` | Delete app |
|
||||
|
||||
### Deployments
|
||||
@@ -234,6 +276,17 @@ curl -X POST "http://localhost:8080/api/apps/$APP_ID/stop" \
|
||||
|
||||
Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream` (stdout/stderr/both)
|
||||
|
||||
### Observability
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/apps/{aid}/agent-status` | Agent registration status |
|
||||
| GET | `/api/apps/{aid}/observability-status` | Trace/metrics data health |
|
||||
|
||||
### Dashboard
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/dashboard` | cameleer3-server observability dashboard (forward-auth protected) |
|
||||
|
||||
### Health
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
|
||||
@@ -94,6 +94,7 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
||||
@@ -101,6 +102,10 @@ services:
|
||||
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
|
||||
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
|
||||
- traefik.http.services.observe.loadbalancer.server.port=8080
|
||||
- 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
|
||||
networks:
|
||||
- cameleer
|
||||
|
||||
|
||||
@@ -3,14 +3,19 @@ package net.siegeln.cameleer.saas.app;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
||||
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
|
||||
import net.siegeln.cameleer.saas.environment.EnvironmentService;
|
||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -26,10 +31,19 @@ public class AppController {
|
||||
|
||||
private final AppService appService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final EnvironmentService environmentService;
|
||||
private final RuntimeConfig runtimeConfig;
|
||||
private final TenantRepository tenantRepository;
|
||||
|
||||
public AppController(AppService appService, ObjectMapper objectMapper) {
|
||||
public AppController(AppService appService, ObjectMapper objectMapper,
|
||||
EnvironmentService environmentService,
|
||||
RuntimeConfig runtimeConfig,
|
||||
TenantRepository tenantRepository) {
|
||||
this.appService = appService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.environmentService = environmentService;
|
||||
this.runtimeConfig = runtimeConfig;
|
||||
this.tenantRepository = tenantRepository;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data")
|
||||
@@ -103,6 +117,21 @@ public class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
@PatchMapping("/{appId}/routing")
|
||||
public ResponseEntity<AppResponse> updateRouting(
|
||||
@PathVariable UUID environmentId,
|
||||
@PathVariable UUID appId,
|
||||
@RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
var actorId = resolveActorId(authentication);
|
||||
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
|
||||
return ResponseEntity.ok(toResponse(app));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
private UUID resolveActorId(Authentication authentication) {
|
||||
String sub = authentication.getName();
|
||||
try {
|
||||
@@ -112,19 +141,23 @@ public class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
private AppResponse toResponse(AppEntity entity) {
|
||||
private AppResponse toResponse(AppEntity app) {
|
||||
String routeUrl = null;
|
||||
if (app.getExposedPort() != null) {
|
||||
var env = environmentService.getById(app.getEnvironmentId()).orElse(null);
|
||||
if (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(
|
||||
entity.getId(),
|
||||
entity.getEnvironmentId(),
|
||||
entity.getSlug(),
|
||||
entity.getDisplayName(),
|
||||
entity.getJarOriginalFilename(),
|
||||
entity.getJarSizeBytes(),
|
||||
entity.getJarChecksum(),
|
||||
entity.getCurrentDeploymentId(),
|
||||
entity.getPreviousDeploymentId(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt()
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public class AppEntity {
|
||||
@Column(name = "previous_deployment_id")
|
||||
private UUID previousDeploymentId;
|
||||
|
||||
@Column(name = "exposed_port")
|
||||
private Integer exposedPort;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -76,6 +79,8 @@ public class AppEntity {
|
||||
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
|
||||
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
|
||||
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
||||
public Integer getExposedPort() { return exposedPort; }
|
||||
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
|
||||
@@ -112,6 +112,13 @@ public class AppService {
|
||||
null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public Path resolveJarPath(String relativePath) {
|
||||
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,17 @@ 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,
|
||||
UUID currentDeploymentId, UUID previousDeploymentId,
|
||||
Instant createdAt, Instant updatedAt
|
||||
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
|
||||
) {}
|
||||
|
||||
@@ -126,6 +126,17 @@ public class DeploymentService {
|
||||
});
|
||||
}
|
||||
|
||||
// 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() + "."
|
||||
+ tenantSlug + "." + runtimeConfig.getDomain() + "`)");
|
||||
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
|
||||
String.valueOf(app.getExposedPort()));
|
||||
}
|
||||
|
||||
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
||||
deployment.getImageRef(),
|
||||
containerName,
|
||||
@@ -140,7 +151,8 @@ public class DeploymentService {
|
||||
),
|
||||
runtimeConfig.parseMemoryLimitBytes(),
|
||||
runtimeConfig.getContainerCpuShares(),
|
||||
runtimeConfig.getAgentHealthPort()
|
||||
runtimeConfig.getAgentHealthPort(),
|
||||
labels
|
||||
));
|
||||
|
||||
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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 {
|
||||
return ResponseEntity.ok(agentStatusService.getAgentStatus(appId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/observability-status")
|
||||
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
||||
try {
|
||||
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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: " + appId));
|
||||
|
||||
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> agents = restClient.get()
|
||||
.uri("/api/v1/agents")
|
||||
.header("Authorization", "Bearer " + runtimeConfig.getBootstrapToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
|
||||
if (agents == null) {
|
||||
return unknownStatus(app.getSlug(), env.getSlug());
|
||||
}
|
||||
|
||||
for (Map<String, Object> agent : agents) {
|
||||
String agentAppId = (String) agent.get("applicationId");
|
||||
String agentEnvId = (String) agent.get("environmentId");
|
||||
if (app.getSlug().equals(agentAppId) && env.getSlug().equals(agentEnvId)) {
|
||||
String state = (String) agent.getOrDefault("state", "UNKNOWN");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> routeIds = (List<String>) agent.getOrDefault("routeIds", Collections.emptyList());
|
||||
return new AgentStatusResponse(
|
||||
true,
|
||||
state,
|
||||
null,
|
||||
routeIds,
|
||||
app.getSlug(),
|
||||
env.getSlug()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return unknownStatus(app.getSlug(), env.getSlug());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch agent status from cameleer3-server: {}", e.getMessage());
|
||||
return unknownStatus(app.getSlug(), env.getSlug());
|
||||
}
|
||||
}
|
||||
|
||||
public ObservabilityStatusResponse getObservabilityStatus(UUID appId) {
|
||||
var app = appRepository.findById(appId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||
|
||||
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||
|
||||
if (clickHouseDataSource == null) {
|
||||
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||
}
|
||||
|
||||
try (var conn = clickHouseDataSource.getConnection()) {
|
||||
String sql = "SELECT count() as cnt, max(start_time) as last_trace " +
|
||||
"FROM executions " +
|
||||
"WHERE application_id = ? AND environment_id = ? " +
|
||||
"AND start_time >= now() - INTERVAL 24 HOUR";
|
||||
try (var stmt = conn.prepareStatement(sql)) {
|
||||
stmt.setString(1, app.getSlug());
|
||||
stmt.setString(2, env.getSlug());
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long count = rs.getLong("cnt");
|
||||
Timestamp lastTrace = rs.getTimestamp("last_trace");
|
||||
boolean hasTraces = count > 0;
|
||||
return new ObservabilityStatusResponse(
|
||||
hasTraces,
|
||||
false,
|
||||
false,
|
||||
hasTraces && lastTrace != null ? lastTrace.toInstant() : null,
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to query ClickHouse for observability status: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return new ObservabilityStatusResponse(false, false, false, null, 0);
|
||||
}
|
||||
|
||||
private AgentStatusResponse unknownStatus(String applicationId, String environmentId) {
|
||||
return new AgentStatusResponse(false, "UNKNOWN", null, Collections.emptyList(), applicationId, environmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.siegeln.cameleer.saas.observability.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ObservabilityStatusResponse(
|
||||
boolean hasTraces,
|
||||
boolean hasMetrics,
|
||||
boolean hasDiagrams,
|
||||
Instant lastTraceAt,
|
||||
long traceCount24h
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.siegeln.cameleer.saas.observability.dto;
|
||||
|
||||
public record UpdateRoutingRequest(
|
||||
Integer exposedPort
|
||||
) {}
|
||||
@@ -21,6 +21,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
@@ -87,6 +88,7 @@ public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
|
||||
var container = dockerClient.createContainerCmd(request.imageRef())
|
||||
.withName(request.containerName())
|
||||
.withEnv(envList)
|
||||
.withLabels(request.labels() != null ? request.labels() : Map.of())
|
||||
.withHostConfig(hostConfig)
|
||||
.withHealthcheck(new HealthCheck()
|
||||
.withTest(List.of("CMD-SHELL",
|
||||
|
||||
@@ -39,6 +39,9 @@ public class RuntimeConfig {
|
||||
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
||||
private String cameleer3ServerEndpoint;
|
||||
|
||||
@Value("${cameleer.runtime.domain:localhost}")
|
||||
private String domain;
|
||||
|
||||
public long getMaxJarSize() { return maxJarSize; }
|
||||
public String getJarStoragePath() { return jarStoragePath; }
|
||||
public String getBaseImage() { return baseImage; }
|
||||
@@ -50,6 +53,7 @@ public class RuntimeConfig {
|
||||
public int getContainerCpuShares() { return containerCpuShares; }
|
||||
public String getBootstrapToken() { return bootstrapToken; }
|
||||
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
||||
public String getDomain() { return domain; }
|
||||
|
||||
public long parseMemoryLimitBytes() {
|
||||
var limit = containerMemoryLimit.trim().toLowerCase();
|
||||
|
||||
@@ -9,5 +9,6 @@ public record StartContainerRequest(
|
||||
Map<String, String> envVars,
|
||||
long memoryLimitBytes,
|
||||
int cpuShares,
|
||||
int healthCheckPort
|
||||
int healthCheckPort,
|
||||
Map<String, String> labels
|
||||
) {}
|
||||
|
||||
@@ -45,5 +45,6 @@ cameleer:
|
||||
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||||
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
||||
domain: ${DOMAIN:localhost}
|
||||
clickhouse:
|
||||
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
|
||||
@@ -0,0 +1,92 @@
|
||||
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.ArgumentMatchers.any;
|
||||
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://localhost:9999");
|
||||
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));
|
||||
|
||||
// Server at localhost:9999 won't be running — should return UNKNOWN gracefully
|
||||
var result = agentStatusService.getAgentStatus(appId);
|
||||
|
||||
assertNotNull(result);
|
||||
assertFalse(result.registered());
|
||||
assertEquals("UNKNOWN", result.state());
|
||||
assertEquals("my-app", result.applicationId());
|
||||
assertEquals("default", result.environmentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getObservabilityStatus_shouldReturnEmptyWhenClickHouseUnavailable() {
|
||||
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));
|
||||
|
||||
// No ClickHouse DataSource injected — should return empty status
|
||||
var result = agentStatusService.getObservabilityStatus(appId);
|
||||
|
||||
assertNotNull(result);
|
||||
assertFalse(result.hasTraces());
|
||||
assertEquals(0, result.traceCount24h());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user