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
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer \
|
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer \
|
||||||
&& addgroup cameleer root \
|
|
||||||
&& mkdir -p /data/jars && chown -R cameleer:cameleer /data
|
&& mkdir -p /data/jars && chown -R cameleer:cameleer /data
|
||||||
COPY --from=build /build/target/*.jar app.jar
|
COPY --from=build /build/target/*.jar app.jar
|
||||||
USER cameleer
|
USER cameleer
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ services:
|
|||||||
cameleer-saas:
|
cameleer-saas:
|
||||||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
group_add:
|
||||||
|
- "0"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -305,6 +305,24 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
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
|
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;
|
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.AppRepository;
|
||||||
import net.siegeln.cameleer.saas.app.AppService;
|
|
||||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
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.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.RuntimeOrchestrator;
|
||||||
import net.siegeln.cameleer.saas.runtime.StartContainerRequest;
|
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -30,29 +21,23 @@ public class DeploymentService {
|
|||||||
|
|
||||||
private final DeploymentRepository deploymentRepository;
|
private final DeploymentRepository deploymentRepository;
|
||||||
private final AppRepository appRepository;
|
private final AppRepository appRepository;
|
||||||
private final AppService appService;
|
|
||||||
private final EnvironmentRepository environmentRepository;
|
private final EnvironmentRepository environmentRepository;
|
||||||
private final TenantRepository tenantRepository;
|
|
||||||
private final RuntimeOrchestrator runtimeOrchestrator;
|
private final RuntimeOrchestrator runtimeOrchestrator;
|
||||||
private final RuntimeConfig runtimeConfig;
|
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final DeploymentExecutor deploymentExecutor;
|
||||||
|
|
||||||
public DeploymentService(DeploymentRepository deploymentRepository,
|
public DeploymentService(DeploymentRepository deploymentRepository,
|
||||||
AppRepository appRepository,
|
AppRepository appRepository,
|
||||||
AppService appService,
|
|
||||||
EnvironmentRepository environmentRepository,
|
EnvironmentRepository environmentRepository,
|
||||||
TenantRepository tenantRepository,
|
|
||||||
RuntimeOrchestrator runtimeOrchestrator,
|
RuntimeOrchestrator runtimeOrchestrator,
|
||||||
RuntimeConfig runtimeConfig,
|
AuditService auditService,
|
||||||
AuditService auditService) {
|
DeploymentExecutor deploymentExecutor) {
|
||||||
this.deploymentRepository = deploymentRepository;
|
this.deploymentRepository = deploymentRepository;
|
||||||
this.appRepository = appRepository;
|
this.appRepository = appRepository;
|
||||||
this.appService = appService;
|
|
||||||
this.environmentRepository = environmentRepository;
|
this.environmentRepository = environmentRepository;
|
||||||
this.tenantRepository = tenantRepository;
|
|
||||||
this.runtimeOrchestrator = runtimeOrchestrator;
|
this.runtimeOrchestrator = runtimeOrchestrator;
|
||||||
this.runtimeConfig = runtimeConfig;
|
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.deploymentExecutor = deploymentExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DeploymentEntity deploy(UUID appId, UUID actorId) {
|
public DeploymentEntity deploy(UUID appId, UUID actorId) {
|
||||||
@@ -83,128 +68,12 @@ public class DeploymentService {
|
|||||||
AuditAction.APP_DEPLOY, app.getSlug(),
|
AuditAction.APP_DEPLOY, app.getSlug(),
|
||||||
env.getSlug(), null, "SUCCESS", null);
|
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;
|
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) {
|
public DeploymentEntity stop(UUID appId, UUID actorId) {
|
||||||
var app = appRepository.findById(appId)
|
var app = appRepository.findById(appId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
|
||||||
@@ -249,34 +118,4 @@ public class DeploymentService {
|
|||||||
public Optional<DeploymentEntity> getById(UUID deploymentId) {
|
public Optional<DeploymentEntity> getById(UUID deploymentId) {
|
||||||
return deploymentRepository.findById(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.app.AppRepository;
|
||||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
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.AgentStatusResponse;
|
||||||
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestClient;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
@@ -26,8 +25,7 @@ public class AgentStatusService {
|
|||||||
|
|
||||||
private final AppRepository appRepository;
|
private final AppRepository appRepository;
|
||||||
private final EnvironmentRepository environmentRepository;
|
private final EnvironmentRepository environmentRepository;
|
||||||
private final RuntimeConfig runtimeConfig;
|
private final ServerApiClient serverApiClient;
|
||||||
private final RestClient restClient;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
@Qualifier("clickHouseDataSource")
|
@Qualifier("clickHouseDataSource")
|
||||||
@@ -35,13 +33,10 @@ public class AgentStatusService {
|
|||||||
|
|
||||||
public AgentStatusService(AppRepository appRepository,
|
public AgentStatusService(AppRepository appRepository,
|
||||||
EnvironmentRepository environmentRepository,
|
EnvironmentRepository environmentRepository,
|
||||||
RuntimeConfig runtimeConfig) {
|
ServerApiClient serverApiClient) {
|
||||||
this.appRepository = appRepository;
|
this.appRepository = appRepository;
|
||||||
this.environmentRepository = environmentRepository;
|
this.environmentRepository = environmentRepository;
|
||||||
this.runtimeConfig = runtimeConfig;
|
this.serverApiClient = serverApiClient;
|
||||||
this.restClient = RestClient.builder()
|
|
||||||
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AgentStatusResponse getAgentStatus(UUID appId) {
|
public AgentStatusResponse getAgentStatus(UUID appId) {
|
||||||
@@ -51,11 +46,13 @@ public class AgentStatusService {
|
|||||||
var env = environmentRepository.findById(app.getEnvironmentId())
|
var env = environmentRepository.findById(app.getEnvironmentId())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
|
||||||
|
|
||||||
|
if (!serverApiClient.isAvailable()) {
|
||||||
|
return unknownStatus(app.getSlug(), env.getSlug());
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<Map<String, Object>> agents = restClient.get()
|
List<Map<String, Object>> agents = serverApiClient.get("/api/v1/agents")
|
||||||
.uri("/api/v1/agents")
|
|
||||||
.header("Authorization", "Bearer " + "TODO-api-key")
|
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(List.class);
|
.body(List.class);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class DeploymentServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private AuditService auditService;
|
private AuditService auditService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private DeploymentExecutor deploymentExecutor;
|
||||||
|
|
||||||
private DeploymentService deploymentService;
|
private DeploymentService deploymentService;
|
||||||
|
|
||||||
private UUID appId;
|
private UUID appId;
|
||||||
@@ -75,12 +78,10 @@ class DeploymentServiceTest {
|
|||||||
deploymentService = new DeploymentService(
|
deploymentService = new DeploymentService(
|
||||||
deploymentRepository,
|
deploymentRepository,
|
||||||
appRepository,
|
appRepository,
|
||||||
appService,
|
|
||||||
environmentRepository,
|
environmentRepository,
|
||||||
tenantRepository,
|
|
||||||
runtimeOrchestrator,
|
runtimeOrchestrator,
|
||||||
runtimeConfig,
|
auditService,
|
||||||
auditService
|
deploymentExecutor
|
||||||
);
|
);
|
||||||
|
|
||||||
appId = UUID.randomUUID();
|
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.app.AppRepository;
|
||||||
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
|
||||||
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -23,14 +23,14 @@ class AgentStatusServiceTest {
|
|||||||
|
|
||||||
@Mock private AppRepository appRepository;
|
@Mock private AppRepository appRepository;
|
||||||
@Mock private EnvironmentRepository environmentRepository;
|
@Mock private EnvironmentRepository environmentRepository;
|
||||||
@Mock private RuntimeConfig runtimeConfig;
|
@Mock private ServerApiClient serverApiClient;
|
||||||
|
|
||||||
private AgentStatusService agentStatusService;
|
private AgentStatusService agentStatusService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://localhost:9999");
|
when(serverApiClient.isAvailable()).thenReturn(false);
|
||||||
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
|
agentStatusService = new AgentStatusService(appRepository, environmentRepository, serverApiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user