From b0275bcf64207f829fc519da7ff7317cfcb3fdba Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:53:56 +0200 Subject: [PATCH 1/8] feat: add exposed_port column to apps table --- src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java | 5 +++++ .../db/migration/V010__add_exposed_port_to_apps.sql | 1 + 2 files changed, 6 insertions(+) create mode 100644 src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java b/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java index eeecc7e..cb12277 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java @@ -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; } } diff --git a/src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql b/src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql new file mode 100644 index 0000000..bdb3ac1 --- /dev/null +++ b/src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql @@ -0,0 +1 @@ +ALTER TABLE apps ADD COLUMN exposed_port INTEGER; From d25849d665dd2eeb23af1750f89c257eb26d6ad9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:55:16 +0200 Subject: [PATCH 2/8] feat: add labels support to StartContainerRequest and DockerRuntimeOrchestrator Co-Authored-By: Claude Sonnet 4.6 --- .../siegeln/cameleer/saas/deployment/DeploymentService.java | 3 ++- .../cameleer/saas/runtime/DockerRuntimeOrchestrator.java | 2 ++ .../siegeln/cameleer/saas/runtime/StartContainerRequest.java | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java index f2c34fc..8f06be8 100644 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java @@ -140,7 +140,8 @@ public class DeploymentService { ), runtimeConfig.parseMemoryLimitBytes(), runtimeConfig.getContainerCpuShares(), - runtimeConfig.getAgentHealthPort() + runtimeConfig.getAgentHealthPort(), + Map.of() )); deployment.setOrchestratorMetadata(Map.of("containerId", containerId)); diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java b/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java index af20552..e576aba 100644 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java +++ b/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java @@ -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", diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java b/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java index fe6c85c..26294a8 100644 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java +++ b/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java @@ -9,5 +9,6 @@ public record StartContainerRequest( Map envVars, long memoryLimitBytes, int cpuShares, - int healthCheckPort + int healthCheckPort, + Map labels ) {} From 024780c01eaaccc4c04414997be264b14652f307 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 20:57:37 +0200 Subject: [PATCH 3/8] feat: add exposed port routing and route URL to app API Adds domain config to RuntimeConfig/application.yml, expands AppResponse with exposedPort and computed routeUrl, adds updateRouting to AppService, and adds PATCH /{appId}/routing endpoint to AppController. Co-Authored-By: Claude Sonnet 4.6 --- .../cameleer/saas/app/AppController.java | 61 ++++++++++++++----- .../siegeln/cameleer/saas/app/AppService.java | 7 +++ .../cameleer/saas/app/dto/AppResponse.java | 17 ++++-- .../dto/UpdateRoutingRequest.java | 5 ++ .../cameleer/saas/runtime/RuntimeConfig.java | 4 ++ src/main/resources/application.yml | 1 + 6 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java index 0206658..bbe6264 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java @@ -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 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()); } } diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java index b2dbd77..580f61b 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java @@ -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); } diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java index b0c1c94..43e1cc3 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java @@ -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 ) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java b/src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java new file mode 100644 index 0000000..385e78f --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java @@ -0,0 +1,5 @@ +package net.siegeln.cameleer.saas.observability.dto; + +public record UpdateRoutingRequest( + Integer exposedPort +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java b/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java index 5dbfe0d..dbfd972 100644 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java @@ -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(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 04b48fc..107b0be 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} From 08b87edd6eacf2527e9c8444c7350b14d07af1ce Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:01:43 +0200 Subject: [PATCH 4/8] feat: add agent status and observability status endpoints Implements AgentStatusService (TDD) that proxies cameleer3-server agent registry API and queries ClickHouse for trace counts. Gracefully degrades to UNKNOWN state when server is unreachable or DataSource is absent. Co-Authored-By: Claude Sonnet 4.6 --- .../observability/AgentStatusController.java | 37 +++++ .../observability/AgentStatusService.java | 136 ++++++++++++++++++ .../dto/AgentStatusResponse.java | 13 ++ .../dto/ObservabilityStatusResponse.java | 11 ++ .../observability/AgentStatusServiceTest.java | 92 ++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java new file mode 100644 index 0000000..cb5e732 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java @@ -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 getAgentStatus(@PathVariable UUID appId) { + try { + return ResponseEntity.ok(agentStatusService.getAgentStatus(appId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/observability-status") + public ResponseEntity getObservabilityStatus(@PathVariable UUID appId) { + try { + return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java new file mode 100644 index 0000000..b738057 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java @@ -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> 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 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 routeIds = (List) 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); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java b/src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java new file mode 100644 index 0000000..2749aff --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java @@ -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 routeIds, + String applicationId, + String environmentId +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java b/src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java new file mode 100644 index 0000000..cfbb574 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java @@ -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 +) {} diff --git a/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java new file mode 100644 index 0000000..18fab7e --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java @@ -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()); + } +} From 210da55e7a51ccafd49f0273224a389f99799d18 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:03:04 +0200 Subject: [PATCH 5/8] feat: add Traefik routing labels for customer apps with exposed ports Co-Authored-By: Claude Sonnet 4.6 --- .../cameleer/saas/deployment/DeploymentService.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java index 8f06be8..e33cc33 100644 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java @@ -126,6 +126,17 @@ public class DeploymentService { }); } + // Build Traefik labels for inbound routing + var labels = new java.util.HashMap(); + 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, @@ -141,7 +152,7 @@ public class DeploymentService { runtimeConfig.parseMemoryLimitBytes(), runtimeConfig.getContainerCpuShares(), runtimeConfig.getAgentHealthPort(), - Map.of() + labels )); deployment.setOrchestratorMetadata(Map.of("containerId", containerId)); From 43cd2d012f908c0e262bb061926561bb30b0573b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:03:41 +0200 Subject: [PATCH 6/8] feat: add cameleer3-server startup connectivity check --- .../ConnectivityHealthCheck.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java b/src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java new file mode 100644 index 0000000..e8e109b --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java @@ -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()); + } + } +} From 9f8d0f43ab11f134d37d88f26daaaf695d3b0367 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:04:57 +0200 Subject: [PATCH 7/8] feat: add dashboard Traefik route and CAMELEER_TENANT_ID config Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + .gitea/workflows/ci.yml | 2 +- .gitea/workflows/sonarqube.yml | 2 +- docker-compose.yml | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index c7ac9be..ef88828 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 46a4c32..c31d03d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index cda62f1..9c062c3 100644 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9e6e693..3220989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 From 9b1643c1ee3831d2e9e0d462fc4ce6cef33be331 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:06:05 +0200 Subject: [PATCH 8/8] docs: update HOWTO with observability dashboard, routing, and agent status Co-Authored-By: Claude Opus 4.6 (1M context) --- HOWTO.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/HOWTO.md b/HOWTO.md index 8bf7a80..7398635 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -62,6 +62,7 @@ Edit `.env` and set at minimum: # Change in production POSTGRES_PASSWORD= CAMELEER_AUTH_TOKEN= +CAMELEER_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 | |--------|------|-------------|