feat: Phase 4 — Observability Pipeline + Inbound Routing #34

Merged
hsiegeln merged 8 commits from feat/phase-4-observability-pipeline into main 2026-04-04 21:20:47 +02:00
22 changed files with 498 additions and 22 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 |
|--------|------|-------------|

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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
) {}

View File

@@ -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));

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.observability.dto;
public record UpdateRoutingRequest(
Integer exposedPort
) {}

View File

@@ -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",

View File

@@ -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();

View File

@@ -9,5 +9,6 @@ public record StartContainerRequest(
Map<String, String> envVars,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort
int healthCheckPort,
Map<String, String> labels
) {}

View File

@@ -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}

View File

@@ -0,0 +1 @@
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;

View File

@@ -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());
}
}