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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -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<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()));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<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()));
|
||||
}
|
||||
|
||||
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<DeploymentEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> agents = restClient.get()
|
||||
.uri("/api/v1/agents")
|
||||
.header("Authorization", "Bearer " + "TODO-api-key")
|
||||
List<Map<String, Object>> agents = serverApiClient.get("/api/v1/agents")
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user