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_AUTH_TOKEN=change_me_bootstrap_token
|
||||||
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
|
||||||
CAMELEER_CONTAINER_CPU_SHARES=512
|
CAMELEER_CONTAINER_CPU_SHARES=512
|
||||||
|
CAMELEER_TENANT_SLUG=default
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Build and Test (unit tests only)
|
- name: Build and Test (unit tests only)
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify -B
|
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:
|
docker:
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Build, Test and Analyze
|
- name: Build, Test and Analyze
|
||||||
run: >-
|
run: >-
|
||||||
mvn clean verify sonar:sonar --batch-mode
|
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.host.url=${{ secrets.SONAR_HOST_URL }}
|
||||||
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
|
||||||
-Dsonar.projectKey=cameleer-saas
|
-Dsonar.projectKey=cameleer-saas
|
||||||
|
|||||||
53
HOWTO.md
53
HOWTO.md
@@ -62,6 +62,7 @@ Edit `.env` and set at minimum:
|
|||||||
# Change in production
|
# Change in production
|
||||||
POSTGRES_PASSWORD=<strong-password>
|
POSTGRES_PASSWORD=<strong-password>
|
||||||
CAMELEER_AUTH_TOKEN=<random-string-for-agent-bootstrap>
|
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 credentials (get from Logto admin console after first boot)
|
||||||
LOGTO_M2M_CLIENT_ID=
|
LOGTO_M2M_CLIENT_ID=
|
||||||
@@ -185,6 +186,46 @@ curl -X POST "http://localhost:8080/api/apps/$APP_ID/stop" \
|
|||||||
-H "Authorization: Bearer $TOKEN"
|
-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
|
## API Reference
|
||||||
|
|
||||||
### Tenants
|
### 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` | List apps |
|
||||||
| GET | `/api/environments/{eid}/apps/{aid}` | Get app |
|
| GET | `/api/environments/{eid}/apps/{aid}` | Get app |
|
||||||
| PUT | `/api/environments/{eid}/apps/{aid}/jar` | Re-upload JAR |
|
| 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 |
|
| DELETE | `/api/environments/{eid}/apps/{aid}` | Delete app |
|
||||||
|
|
||||||
### Deployments
|
### 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)
|
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
|
### Health
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ services:
|
|||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
|
||||||
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
|
||||||
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
|
||||||
|
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
|
- 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.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.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
|
||||||
- traefik.http.services.observe.loadbalancer.server.port=8080
|
- 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:
|
networks:
|
||||||
- cameleer
|
- cameleer
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ package net.siegeln.cameleer.saas.app;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
||||||
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
|
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.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestPart;
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -26,10 +31,19 @@ public class AppController {
|
|||||||
|
|
||||||
private final AppService appService;
|
private final AppService appService;
|
||||||
private final ObjectMapper objectMapper;
|
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.appService = appService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.environmentService = environmentService;
|
||||||
|
this.runtimeConfig = runtimeConfig;
|
||||||
|
this.tenantRepository = tenantRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = "multipart/form-data")
|
@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) {
|
private UUID resolveActorId(Authentication authentication) {
|
||||||
String sub = authentication.getName();
|
String sub = authentication.getName();
|
||||||
try {
|
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(
|
return new AppResponse(
|
||||||
entity.getId(),
|
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
||||||
entity.getEnvironmentId(),
|
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
||||||
entity.getSlug(),
|
app.getExposedPort(), routeUrl,
|
||||||
entity.getDisplayName(),
|
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
||||||
entity.getJarOriginalFilename(),
|
app.getCreatedAt(), app.getUpdatedAt());
|
||||||
entity.getJarSizeBytes(),
|
|
||||||
entity.getJarChecksum(),
|
|
||||||
entity.getCurrentDeploymentId(),
|
|
||||||
entity.getPreviousDeploymentId(),
|
|
||||||
entity.getCreatedAt(),
|
|
||||||
entity.getUpdatedAt()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class AppEntity {
|
|||||||
@Column(name = "previous_deployment_id")
|
@Column(name = "previous_deployment_id")
|
||||||
private UUID previousDeploymentId;
|
private UUID previousDeploymentId;
|
||||||
|
|
||||||
|
@Column(name = "exposed_port")
|
||||||
|
private Integer exposedPort;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -76,6 +79,8 @@ public class AppEntity {
|
|||||||
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
|
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
|
||||||
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
|
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
|
||||||
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = 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 getCreatedAt() { return createdAt; }
|
||||||
public Instant getUpdatedAt() { return updatedAt; }
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ public class AppService {
|
|||||||
null, null, "SUCCESS", null);
|
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) {
|
public Path resolveJarPath(String relativePath) {
|
||||||
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
|
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import java.time.Instant;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record AppResponse(
|
public record AppResponse(
|
||||||
UUID id, UUID environmentId, String slug, String displayName,
|
UUID id,
|
||||||
String jarOriginalFilename, Long jarSizeBytes, String jarChecksum,
|
UUID environmentId,
|
||||||
UUID currentDeploymentId, UUID previousDeploymentId,
|
String slug,
|
||||||
Instant createdAt, Instant updatedAt
|
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(
|
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
||||||
deployment.getImageRef(),
|
deployment.getImageRef(),
|
||||||
containerName,
|
containerName,
|
||||||
@@ -140,7 +151,8 @@ public class DeploymentService {
|
|||||||
),
|
),
|
||||||
runtimeConfig.parseMemoryLimitBytes(),
|
runtimeConfig.parseMemoryLimitBytes(),
|
||||||
runtimeConfig.getContainerCpuShares(),
|
runtimeConfig.getContainerCpuShares(),
|
||||||
runtimeConfig.getAgentHealthPort()
|
runtimeConfig.getAgentHealthPort(),
|
||||||
|
labels
|
||||||
));
|
));
|
||||||
|
|
||||||
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
|
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.nio.file.StandardCopyOption;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -87,6 +88,7 @@ public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
|
|||||||
var container = dockerClient.createContainerCmd(request.imageRef())
|
var container = dockerClient.createContainerCmd(request.imageRef())
|
||||||
.withName(request.containerName())
|
.withName(request.containerName())
|
||||||
.withEnv(envList)
|
.withEnv(envList)
|
||||||
|
.withLabels(request.labels() != null ? request.labels() : Map.of())
|
||||||
.withHostConfig(hostConfig)
|
.withHostConfig(hostConfig)
|
||||||
.withHealthcheck(new HealthCheck()
|
.withHealthcheck(new HealthCheck()
|
||||||
.withTest(List.of("CMD-SHELL",
|
.withTest(List.of("CMD-SHELL",
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class RuntimeConfig {
|
|||||||
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
|
||||||
private String cameleer3ServerEndpoint;
|
private String cameleer3ServerEndpoint;
|
||||||
|
|
||||||
|
@Value("${cameleer.runtime.domain:localhost}")
|
||||||
|
private String domain;
|
||||||
|
|
||||||
public long getMaxJarSize() { return maxJarSize; }
|
public long getMaxJarSize() { return maxJarSize; }
|
||||||
public String getJarStoragePath() { return jarStoragePath; }
|
public String getJarStoragePath() { return jarStoragePath; }
|
||||||
public String getBaseImage() { return baseImage; }
|
public String getBaseImage() { return baseImage; }
|
||||||
@@ -50,6 +53,7 @@ public class RuntimeConfig {
|
|||||||
public int getContainerCpuShares() { return containerCpuShares; }
|
public int getContainerCpuShares() { return containerCpuShares; }
|
||||||
public String getBootstrapToken() { return bootstrapToken; }
|
public String getBootstrapToken() { return bootstrapToken; }
|
||||||
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
|
||||||
public long parseMemoryLimitBytes() {
|
public long parseMemoryLimitBytes() {
|
||||||
var limit = containerMemoryLimit.trim().toLowerCase();
|
var limit = containerMemoryLimit.trim().toLowerCase();
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ public record StartContainerRequest(
|
|||||||
Map<String, String> envVars,
|
Map<String, String> envVars,
|
||||||
long memoryLimitBytes,
|
long memoryLimitBytes,
|
||||||
int cpuShares,
|
int cpuShares,
|
||||||
int healthCheckPort
|
int healthCheckPort,
|
||||||
|
Map<String, String> labels
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -45,5 +45,6 @@ cameleer:
|
|||||||
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||||||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||||||
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
|
||||||
|
domain: ${DOMAIN:localhost}
|
||||||
clickhouse:
|
clickhouse:
|
||||||
url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer}
|
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