From 5d04a154f950cd9fe686184cac7bdea444a50c2e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:08:37 +0200 Subject: [PATCH] refactor: deployment infrastructure cleanup (4 fixes) 1. Docker socket security: remove root group from Dockerfile, use group_add in docker-compose.yml for runtime-only socket access 2. M2M server communication: create ServerApiClient using Logto client_credentials grant with API resource scope. Add M2M server role in bootstrap. Replace hacky admin/admin login in AgentStatusService. 3. Async deployment: extract DeploymentExecutor as separate @Service so Spring's @Async proxy works (self-invocation bypasses proxy). Deploy now returns immediately, health check runs in background. 4. Bootstrap: M2M server role (cameleer-m2m-server) with server:admin scope, idempotent creation outside the M2M app creation block. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 1 - docker-compose.yml | 2 + docker/logto-bootstrap.sh | 18 ++ .../saas/deployment/DeploymentExecutor.java | 191 ++++++++++++++++++ .../saas/deployment/DeploymentService.java | 173 +--------------- .../saas/identity/ServerApiClient.java | 79 ++++++++ .../observability/AgentStatusService.java | 21 +- .../deployment/DeploymentServiceTest.java | 9 +- .../observability/AgentStatusServiceTest.java | 8 +- 9 files changed, 314 insertions(+), 188 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java diff --git a/Dockerfile b/Dockerfile index c8d6066..7edf931 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,6 @@ RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B FROM eclipse-temurin:21-jre-alpine WORKDIR /app RUN addgroup -S cameleer && adduser -S cameleer -G cameleer \ - && addgroup cameleer root \ && mkdir -p /data/jars && chown -R cameleer:cameleer /data COPY --from=build /build/target/*.jar app.jar USER cameleer diff --git a/docker-compose.yml b/docker-compose.yml index 4921cb5..8cc3cd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -133,6 +133,8 @@ services: cameleer-saas: image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} restart: unless-stopped + group_add: + - "0" depends_on: postgres: condition: service_healthy diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh index 730515b..f2bcd38 100644 --- a/docker/logto-bootstrap.sh +++ b/docker/logto-bootstrap.sh @@ -305,6 +305,24 @@ else fi fi fi + +fi + +# Create M2M role for the Cameleer API resource (server:admin access) — idempotent +EXISTING_M2M_SERVER_ROLE=$(api_get "/api/roles" | jq -r '.[] | select(.name == "cameleer-m2m-server") | .id') +if [ -z "$EXISTING_M2M_SERVER_ROLE" ]; then + log "Creating M2M server access role..." + SERVER_M2M_ROLE_RESPONSE=$(api_post "/api/roles" "{ + \"name\": \"cameleer-m2m-server\", + \"description\": \"Server API access for SaaS backend (M2M)\", + \"type\": \"MachineToMachine\", + \"scopeIds\": [\"$SCOPE_SERVER_ADMIN\"] + }") + EXISTING_M2M_SERVER_ROLE=$(echo "$SERVER_M2M_ROLE_RESPONSE" | jq -r '.id') +fi +if [ -n "$EXISTING_M2M_SERVER_ROLE" ] && [ "$EXISTING_M2M_SERVER_ROLE" != "null" ] && [ -n "$M2M_ID" ]; then + api_post "/api/roles/$EXISTING_M2M_SERVER_ROLE/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null 2>&1 + log "Assigned server API role to M2M app: $EXISTING_M2M_SERVER_ROLE" fi # ============================================================ diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java new file mode 100644 index 0000000..bfbce6e --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java @@ -0,0 +1,191 @@ +package net.siegeln.cameleer.saas.deployment; + +import net.siegeln.cameleer.saas.app.AppEntity; +import net.siegeln.cameleer.saas.app.AppRepository; +import net.siegeln.cameleer.saas.app.AppService; +import net.siegeln.cameleer.saas.environment.EnvironmentEntity; +import net.siegeln.cameleer.saas.runtime.BuildImageRequest; +import net.siegeln.cameleer.saas.runtime.RuntimeConfig; +import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator; +import net.siegeln.cameleer.saas.runtime.StartContainerRequest; +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Map; + +/** + * Executes deployments asynchronously. Separated from DeploymentService + * so that Spring's @Async proxy works (self-invocation bypasses the proxy). + */ +@Service +public class DeploymentExecutor { + + private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class); + + private final DeploymentRepository deploymentRepository; + private final AppRepository appRepository; + private final AppService appService; + private final TenantRepository tenantRepository; + private final RuntimeOrchestrator runtimeOrchestrator; + private final RuntimeConfig runtimeConfig; + + public DeploymentExecutor(DeploymentRepository deploymentRepository, + AppRepository appRepository, + AppService appService, + TenantRepository tenantRepository, + RuntimeOrchestrator runtimeOrchestrator, + RuntimeConfig runtimeConfig) { + this.deploymentRepository = deploymentRepository; + this.appRepository = appRepository; + this.appService = appService; + this.tenantRepository = tenantRepository; + this.runtimeOrchestrator = runtimeOrchestrator; + this.runtimeConfig = runtimeConfig; + } + + @Async("deploymentExecutor") + public void executeAsync(DeploymentEntity deployment, AppEntity app, EnvironmentEntity env) { + try { + var jarPath = appService.resolveJarPath(app.getJarStoragePath()); + + runtimeOrchestrator.buildImage(new BuildImageRequest( + runtimeConfig.getBaseImage(), + jarPath, + deployment.getImageRef() + )); + + deployment.setObservedStatus(ObservedStatus.STARTING); + deploymentRepository.save(deployment); + + var tenant = tenantRepository.findById(env.getTenantId()).orElse(null); + var tenantSlug = tenant != null ? tenant.getSlug() : env.getTenantId().toString(); + var containerName = tenantSlug + "-" + env.getSlug() + "-" + app.getSlug(); + + // Stop and remove old container by deployment metadata + if (app.getCurrentDeploymentId() != null) { + deploymentRepository.findById(app.getCurrentDeploymentId()).ifPresent(oldDeployment -> { + var oldMetadata = oldDeployment.getOrchestratorMetadata(); + if (oldMetadata != null && oldMetadata.containsKey("containerId")) { + var oldContainerId = (String) oldMetadata.get("containerId"); + try { + runtimeOrchestrator.stopContainer(oldContainerId); + runtimeOrchestrator.removeContainer(oldContainerId); + } catch (Exception e) { + log.warn("Failed to stop/remove old container {}: {}", oldContainerId, e.getMessage()); + } + } + }); + } + // Also remove any orphaned container with the same name + try { + var existing = runtimeOrchestrator.getContainerStatus(containerName); + if (!"not_found".equals(existing.state())) { + runtimeOrchestrator.stopContainer(containerName); + runtimeOrchestrator.removeContainer(containerName); + } + } catch (Exception e) { + // Container doesn't exist — expected for fresh deploys + } + + // 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())); + } + + long memoryBytes = app.getMemoryLimit() != null + ? parseMemoryBytes(app.getMemoryLimit()) + : runtimeConfig.parseMemoryLimitBytes(); + int cpuShares = app.getCpuShares() != null + ? app.getCpuShares() + : runtimeConfig.getContainerCpuShares(); + + var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest( + deployment.getImageRef(), + containerName, + runtimeConfig.getDockerNetwork(), + Map.of( + "CAMELEER_AUTH_TOKEN", runtimeConfig.getBootstrapToken(), + "CAMELEER_EXPORT_TYPE", "HTTP", + "CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(), + "CAMELEER_APPLICATION_ID", app.getSlug(), + "CAMELEER_ENVIRONMENT_ID", env.getSlug(), + "CAMELEER_DISPLAY_NAME", containerName + ), + memoryBytes, + cpuShares, + runtimeConfig.getAgentHealthPort(), + labels + )); + + deployment.setOrchestratorMetadata(Map.of("containerId", containerId)); + deploymentRepository.save(deployment); + + boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout()); + + var previousDeploymentId = app.getCurrentDeploymentId(); + + if (healthy) { + deployment.setObservedStatus(ObservedStatus.RUNNING); + deployment.setDeployedAt(Instant.now()); + deploymentRepository.save(deployment); + + app.setPreviousDeploymentId(previousDeploymentId); + app.setCurrentDeploymentId(deployment.getId()); + appRepository.save(app); + } else { + deployment.setObservedStatus(ObservedStatus.FAILED); + deployment.setErrorMessage("Container did not become healthy within timeout"); + deploymentRepository.save(deployment); + + app.setCurrentDeploymentId(deployment.getId()); + appRepository.save(app); + } + + } catch (Exception e) { + log.error("Deployment {} failed: {}", deployment.getId(), e.getMessage(), e); + deployment.setObservedStatus(ObservedStatus.FAILED); + deployment.setErrorMessage(e.getMessage()); + deploymentRepository.save(deployment); + } + } + + boolean waitForHealthy(String containerId, int timeoutSeconds) { + var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L); + while (System.currentTimeMillis() < deadline) { + var status = runtimeOrchestrator.getContainerStatus(containerId); + if (!status.running()) { + return false; + } + if ("healthy".equals(status.state())) { + return true; + } + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return false; + } + + static long parseMemoryBytes(String limit) { + var s = limit.trim().toLowerCase(); + if (s.endsWith("g")) { + return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024 * 1024; + } else if (s.endsWith("m")) { + return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024; + } + return Long.parseLong(s); + } +} 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 68d3773..3b1d5dc 100644 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java @@ -1,25 +1,16 @@ package net.siegeln.cameleer.saas.deployment; -import net.siegeln.cameleer.saas.app.AppEntity; import net.siegeln.cameleer.saas.app.AppRepository; -import net.siegeln.cameleer.saas.app.AppService; import net.siegeln.cameleer.saas.audit.AuditAction; import net.siegeln.cameleer.saas.audit.AuditService; -import net.siegeln.cameleer.saas.environment.EnvironmentEntity; import net.siegeln.cameleer.saas.environment.EnvironmentRepository; -import net.siegeln.cameleer.saas.runtime.BuildImageRequest; -import net.siegeln.cameleer.saas.runtime.RuntimeConfig; import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator; -import net.siegeln.cameleer.saas.runtime.StartContainerRequest; -import net.siegeln.cameleer.saas.tenant.TenantRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -30,29 +21,23 @@ public class DeploymentService { private final DeploymentRepository deploymentRepository; private final AppRepository appRepository; - private final AppService appService; private final EnvironmentRepository environmentRepository; - private final TenantRepository tenantRepository; private final RuntimeOrchestrator runtimeOrchestrator; - private final RuntimeConfig runtimeConfig; private final AuditService auditService; + private final DeploymentExecutor deploymentExecutor; public DeploymentService(DeploymentRepository deploymentRepository, AppRepository appRepository, - AppService appService, EnvironmentRepository environmentRepository, - TenantRepository tenantRepository, RuntimeOrchestrator runtimeOrchestrator, - RuntimeConfig runtimeConfig, - AuditService auditService) { + AuditService auditService, + DeploymentExecutor deploymentExecutor) { this.deploymentRepository = deploymentRepository; this.appRepository = appRepository; - this.appService = appService; this.environmentRepository = environmentRepository; - this.tenantRepository = tenantRepository; this.runtimeOrchestrator = runtimeOrchestrator; - this.runtimeConfig = runtimeConfig; this.auditService = auditService; + this.deploymentExecutor = deploymentExecutor; } public DeploymentEntity deploy(UUID appId, UUID actorId) { @@ -83,128 +68,12 @@ public class DeploymentService { AuditAction.APP_DEPLOY, app.getSlug(), env.getSlug(), null, "SUCCESS", null); - executeDeploymentAsync(saved.getId(), app, env); + // Delegate to separate service so Spring's @Async proxy works + deploymentExecutor.executeAsync(saved, app, env); return saved; } - @Async("deploymentExecutor") - public void executeDeploymentAsync(UUID deploymentId, AppEntity app, EnvironmentEntity env) { - var deployment = deploymentRepository.findById(deploymentId).orElse(null); - if (deployment == null) { - log.error("Deployment not found for async execution: {}", deploymentId); - return; - } - - try { - var jarPath = appService.resolveJarPath(app.getJarStoragePath()); - - runtimeOrchestrator.buildImage(new BuildImageRequest( - runtimeConfig.getBaseImage(), - jarPath, - deployment.getImageRef() - )); - - deployment.setObservedStatus(ObservedStatus.STARTING); - deploymentRepository.save(deployment); - - var tenant = tenantRepository.findById(env.getTenantId()).orElse(null); - var tenantSlug = tenant != null ? tenant.getSlug() : env.getTenantId().toString(); - var containerName = tenantSlug + "-" + env.getSlug() + "-" + app.getSlug(); - - if (app.getCurrentDeploymentId() != null) { - deploymentRepository.findById(app.getCurrentDeploymentId()).ifPresent(oldDeployment -> { - var oldMetadata = oldDeployment.getOrchestratorMetadata(); - if (oldMetadata != null && oldMetadata.containsKey("containerId")) { - var oldContainerId = (String) oldMetadata.get("containerId"); - try { - runtimeOrchestrator.stopContainer(oldContainerId); - runtimeOrchestrator.removeContainer(oldContainerId); - } catch (Exception e) { - log.warn("Failed to stop/remove old container {}: {}", oldContainerId, e.getMessage()); - } - } - }); - } - // Also try removing any container with the same name (handles orphaned containers) - try { - var existing = runtimeOrchestrator.getContainerStatus(containerName); - if (!"not_found".equals(existing.state())) { - runtimeOrchestrator.stopContainer(containerName); - runtimeOrchestrator.removeContainer(containerName); - } - } catch (Exception e) { - // Container doesn't exist — expected for fresh deploys - } - - // 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())); - } - - long memoryBytes = app.getMemoryLimit() != null - ? parseMemoryBytes(app.getMemoryLimit()) - : runtimeConfig.parseMemoryLimitBytes(); - int cpuShares = app.getCpuShares() != null - ? app.getCpuShares() - : runtimeConfig.getContainerCpuShares(); - - var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest( - deployment.getImageRef(), - containerName, - runtimeConfig.getDockerNetwork(), - Map.of( - "CAMELEER_AUTH_TOKEN", runtimeConfig.getBootstrapToken(), - "CAMELEER_EXPORT_TYPE", "HTTP", - "CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(), - "CAMELEER_APPLICATION_ID", app.getSlug(), - "CAMELEER_ENVIRONMENT_ID", env.getSlug(), - "CAMELEER_DISPLAY_NAME", containerName - ), - memoryBytes, - cpuShares, - runtimeConfig.getAgentHealthPort(), - labels - )); - - deployment.setOrchestratorMetadata(Map.of("containerId", containerId)); - deploymentRepository.save(deployment); - - boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout()); - - var previousDeploymentId = app.getCurrentDeploymentId(); - - if (healthy) { - deployment.setObservedStatus(ObservedStatus.RUNNING); - deployment.setDeployedAt(Instant.now()); - deploymentRepository.save(deployment); - - app.setPreviousDeploymentId(previousDeploymentId); - app.setCurrentDeploymentId(deployment.getId()); - appRepository.save(app); - } else { - deployment.setObservedStatus(ObservedStatus.FAILED); - deployment.setErrorMessage("Container did not become healthy within timeout"); - deploymentRepository.save(deployment); - - app.setCurrentDeploymentId(deployment.getId()); - appRepository.save(app); - } - - } catch (Exception e) { - log.error("Deployment {} failed: {}", deploymentId, e.getMessage(), e); - deployment.setObservedStatus(ObservedStatus.FAILED); - deployment.setErrorMessage(e.getMessage()); - deploymentRepository.save(deployment); - } - } - public DeploymentEntity stop(UUID appId, UUID actorId) { var app = appRepository.findById(appId) .orElseThrow(() -> new IllegalArgumentException("App not found: " + appId)); @@ -249,34 +118,4 @@ public class DeploymentService { public Optional getById(UUID deploymentId) { return deploymentRepository.findById(deploymentId); } - - static long parseMemoryBytes(String limit) { - var s = limit.trim().toLowerCase(); - if (s.endsWith("g")) { - return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024 * 1024; - } else if (s.endsWith("m")) { - return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024; - } - return Long.parseLong(s); - } - - boolean waitForHealthy(String containerId, int timeoutSeconds) { - var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L); - while (System.currentTimeMillis() < deadline) { - var status = runtimeOrchestrator.getContainerStatus(containerId); - if (!status.running()) { - return false; - } - if ("healthy".equals(status.state())) { - return true; - } - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - return false; - } } diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java new file mode 100644 index 0000000..5271c44 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java @@ -0,0 +1,79 @@ +package net.siegeln.cameleer.saas.identity; + +import com.fasterxml.jackson.databind.JsonNode; +import net.siegeln.cameleer.saas.runtime.RuntimeConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.time.Instant; + +/** + * Authenticated client for cameleer3-server API calls. + * Uses Logto M2M client_credentials grant with the Cameleer API resource. + */ +@Service +public class ServerApiClient { + + private static final Logger log = LoggerFactory.getLogger(ServerApiClient.class); + private static final String API_RESOURCE = "https://api.cameleer.local"; + + private final LogtoConfig config; + private final RuntimeConfig runtimeConfig; + private final RestClient tokenClient; + + private volatile String cachedToken; + private volatile Instant tokenExpiry = Instant.MIN; + + public ServerApiClient(LogtoConfig config, RuntimeConfig runtimeConfig) { + this.config = config; + this.runtimeConfig = runtimeConfig; + this.tokenClient = RestClient.builder().build(); + } + + public boolean isAvailable() { + return config.isConfigured(); + } + + /** + * Returns a RestClient pre-configured with server base URL and auth headers. + */ + public RestClient.RequestHeadersSpec get(String uri) { + return RestClient.create(runtimeConfig.getCameleer3ServerEndpoint()) + .get() + .uri(uri) + .header("Authorization", "Bearer " + getAccessToken()) + .header("X-Cameleer-Protocol-Version", "2"); + } + + private synchronized String getAccessToken() { + if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) { + return cachedToken; + } + + try { + var response = tokenClient.post() + .uri(config.getLogtoEndpoint() + "/oidc/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("grant_type=client_credentials" + + "&client_id=" + config.getM2mClientId() + + "&client_secret=" + config.getM2mClientSecret() + + "&resource=" + API_RESOURCE + + "&scope=server:admin") + .retrieve() + .body(JsonNode.class); + + cachedToken = response.get("access_token").asText(); + long expiresIn = response.get("expires_in").asLong(); + tokenExpiry = Instant.now().plusSeconds(expiresIn); + log.info("Acquired cameleer3-server M2M access token"); + + return cachedToken; + } catch (Exception e) { + log.error("Failed to get server API M2M token", e); + throw new RuntimeException("Server API authentication failed", e); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java index db05eb2..de760a7 100644 --- a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java @@ -2,15 +2,14 @@ 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.identity.ServerApiClient; 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; @@ -26,8 +25,7 @@ public class AgentStatusService { private final AppRepository appRepository; private final EnvironmentRepository environmentRepository; - private final RuntimeConfig runtimeConfig; - private final RestClient restClient; + private final ServerApiClient serverApiClient; @Autowired(required = false) @Qualifier("clickHouseDataSource") @@ -35,13 +33,10 @@ public class AgentStatusService { public AgentStatusService(AppRepository appRepository, EnvironmentRepository environmentRepository, - RuntimeConfig runtimeConfig) { + ServerApiClient serverApiClient) { this.appRepository = appRepository; this.environmentRepository = environmentRepository; - this.runtimeConfig = runtimeConfig; - this.restClient = RestClient.builder() - .baseUrl(runtimeConfig.getCameleer3ServerEndpoint()) - .build(); + this.serverApiClient = serverApiClient; } public AgentStatusResponse getAgentStatus(UUID appId) { @@ -51,11 +46,13 @@ public class AgentStatusService { var env = environmentRepository.findById(app.getEnvironmentId()) .orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId())); + if (!serverApiClient.isAvailable()) { + return unknownStatus(app.getSlug(), env.getSlug()); + } + try { @SuppressWarnings("unchecked") - List> agents = restClient.get() - .uri("/api/v1/agents") - .header("Authorization", "Bearer " + "TODO-api-key") + List> agents = serverApiClient.get("/api/v1/agents") .retrieve() .body(List.class); diff --git a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java index 4abea39..d4c9515 100644 --- a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java @@ -60,6 +60,9 @@ class DeploymentServiceTest { @Mock private AuditService auditService; + @Mock + private DeploymentExecutor deploymentExecutor; + private DeploymentService deploymentService; private UUID appId; @@ -75,12 +78,10 @@ class DeploymentServiceTest { deploymentService = new DeploymentService( deploymentRepository, appRepository, - appService, environmentRepository, - tenantRepository, runtimeOrchestrator, - runtimeConfig, - auditService + auditService, + deploymentExecutor ); appId = UUID.randomUUID(); diff --git a/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java index 18fab7e..94faaf7 100644 --- a/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java @@ -4,7 +4,7 @@ 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 net.siegeln.cameleer.saas.identity.ServerApiClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,14 +23,14 @@ class AgentStatusServiceTest { @Mock private AppRepository appRepository; @Mock private EnvironmentRepository environmentRepository; - @Mock private RuntimeConfig runtimeConfig; + @Mock private ServerApiClient serverApiClient; private AgentStatusService agentStatusService; @BeforeEach void setUp() { - when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://localhost:9999"); - agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig); + when(serverApiClient.isAvailable()).thenReturn(false); + agentStatusService = new AgentStatusService(appRepository, environmentRepository, serverApiClient); } @Test