feat: remove migrated environment/app/deployment/runtime code from SaaS

- Delete environment/, app/, deployment/, runtime/ packages (source + tests)
- Delete apikey/ package (tied to environments, table will be dropped)
- Strip AsyncConfig to empty @EnableAsync (no more deploymentExecutor bean)
- Remove EnvironmentService dependency from TenantService
- Remove environment/app isolation from TenantIsolationInterceptor
- Remove environment seeding from BootstrapDataSeeder
- Refactor ServerApiClient to use LogtoConfig instead of RuntimeConfig
- Add server-endpoint property to LogtoConfig (was in RuntimeConfig)
- Remove runtime config section and multipart config from application.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 23:59:53 +02:00
parent 160a989f9f
commit c254fbf723
48 changed files with 55 additions and 3054 deletions

View File

@@ -1,51 +0,0 @@
package net.siegeln.cameleer.saas.apikey;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "api_keys")
public class ApiKeyEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "environment_id", nullable = false)
private UUID environmentId;
@Column(name = "key_hash", nullable = false, length = 64)
private String keyHash;
@Column(name = "key_prefix", nullable = false, length = 12)
private String keyPrefix;
@Column(name = "status", nullable = false, length = 20)
private String status = "ACTIVE";
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "revoked_at")
private Instant revokedAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getEnvironmentId() { return environmentId; }
public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
public String getKeyHash() { return keyHash; }
public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
public String getKeyPrefix() { return keyPrefix; }
public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public Instant getRevokedAt() { return revokedAt; }
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
}

View File

@@ -1,12 +0,0 @@
package net.siegeln.cameleer.saas.apikey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {
Optional<ApiKeyEntity> findByKeyHashAndStatus(String keyHash, String status);
List<ApiKeyEntity> findByEnvironmentId(UUID environmentId);
List<ApiKeyEntity> findByEnvironmentIdAndStatus(UUID environmentId, String status);
}

View File

@@ -1,84 +0,0 @@
package net.siegeln.cameleer.saas.apikey;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class ApiKeyService {
public record GeneratedKey(String plaintext, String keyHash, String prefix) {}
private final ApiKeyRepository repository;
public ApiKeyService(ApiKeyRepository repository) {
this.repository = repository;
}
public GeneratedKey generate() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
String plaintext = "cmk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
String hash = sha256Hex(plaintext);
String prefix = plaintext.substring(0, 12);
return new GeneratedKey(plaintext, hash, prefix);
}
public GeneratedKey createForEnvironment(UUID environmentId) {
var key = generate();
var entity = new ApiKeyEntity();
entity.setEnvironmentId(environmentId);
entity.setKeyHash(key.keyHash());
entity.setKeyPrefix(key.prefix());
repository.save(entity);
return key;
}
public Optional<ApiKeyEntity> validate(String plaintext) {
String hash = sha256Hex(plaintext);
return repository.findByKeyHashAndStatus(hash, "ACTIVE");
}
public GeneratedKey rotate(UUID environmentId) {
List<ApiKeyEntity> active = repository.findByEnvironmentIdAndStatus(environmentId, "ACTIVE");
for (var k : active) {
k.setStatus("ROTATED");
}
repository.saveAll(active);
return createForEnvironment(environmentId);
}
public void revoke(UUID keyId) {
repository.findById(keyId).ifPresent(k -> {
k.setStatus("REVOKED");
k.setRevokedAt(Instant.now());
repository.save(k);
});
}
public List<ApiKeyEntity> listByEnvironment(UUID environmentId) {
return repository.findByEnvironmentId(environmentId);
}
public static String sha256Hex(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(64);
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
}

View File

@@ -1,176 +0,0 @@
package net.siegeln.cameleer.saas.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.app.dto.AppResponse;
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
import net.siegeln.cameleer.saas.environment.EnvironmentService;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/environments/{environmentId}/apps")
public class AppController {
private final AppService appService;
private final ObjectMapper objectMapper;
private final EnvironmentService environmentService;
private final RuntimeConfig runtimeConfig;
private final TenantRepository tenantRepository;
public AppController(AppService appService, ObjectMapper objectMapper,
EnvironmentService environmentService,
RuntimeConfig runtimeConfig,
TenantRepository tenantRepository) {
this.appService = appService;
this.objectMapper = objectMapper;
this.environmentService = environmentService;
this.runtimeConfig = runtimeConfig;
this.tenantRepository = tenantRepository;
}
@PostMapping(consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<AppResponse> create(
@PathVariable UUID environmentId,
@RequestPart("metadata") String metadataJson,
@RequestPart(value = "file", required = false) MultipartFile file,
Authentication authentication) {
try {
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
UUID actorId = resolveActorId(authentication);
var entity = appService.create(environmentId, request.slug(), request.displayName(),
file, request.memoryLimit(), request.cpuShares(), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
var msg = e.getMessage();
if (msg != null && (msg.contains("already exists") || msg.contains("slug"))) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} catch (IOException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
var apps = appService.listByEnvironmentId(environmentId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(apps);
}
@GetMapping("/{appId}")
public ResponseEntity<AppResponse> getById(
@PathVariable UUID environmentId,
@PathVariable UUID appId) {
return appService.getById(appId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<AppResponse> reuploadJar(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
@RequestPart("file") MultipartFile file,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = appService.reuploadJar(appId, file, actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{appId}")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<Void> delete(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
appService.delete(appId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PatchMapping("/{appId}/routing")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
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) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
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(
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
app.getExposedPort(), routeUrl,
app.getMemoryLimit(), app.getCpuShares(),
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
app.getCreatedAt(), app.getUpdatedAt());
}
}

View File

@@ -1,96 +0,0 @@
package net.siegeln.cameleer.saas.app;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "apps")
public class AppEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "environment_id", nullable = false)
private UUID environmentId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", nullable = false)
private String displayName;
@Column(name = "jar_storage_path", length = 500)
private String jarStoragePath;
@Column(name = "jar_checksum", length = 64)
private String jarChecksum;
@Column(name = "jar_original_filename")
private String jarOriginalFilename;
@Column(name = "jar_size_bytes")
private Long jarSizeBytes;
@Column(name = "current_deployment_id")
private UUID currentDeploymentId;
@Column(name = "previous_deployment_id")
private UUID previousDeploymentId;
@Column(name = "exposed_port")
private Integer exposedPort;
@Column(name = "memory_limit", length = 20)
private String memoryLimit;
@Column(name = "cpu_shares")
private Integer cpuShares;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getEnvironmentId() { return environmentId; }
public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getJarStoragePath() { return jarStoragePath; }
public void setJarStoragePath(String jarStoragePath) { this.jarStoragePath = jarStoragePath; }
public String getJarChecksum() { return jarChecksum; }
public void setJarChecksum(String jarChecksum) { this.jarChecksum = jarChecksum; }
public String getJarOriginalFilename() { return jarOriginalFilename; }
public void setJarOriginalFilename(String jarOriginalFilename) { this.jarOriginalFilename = jarOriginalFilename; }
public Long getJarSizeBytes() { return jarSizeBytes; }
public void setJarSizeBytes(Long jarSizeBytes) { this.jarSizeBytes = jarSizeBytes; }
public UUID getCurrentDeploymentId() { return currentDeploymentId; }
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
public Integer getExposedPort() { return exposedPort; }
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
public String getMemoryLimit() { return memoryLimit; }
public void setMemoryLimit(String memoryLimit) { this.memoryLimit = memoryLimit; }
public Integer getCpuShares() { return cpuShares; }
public void setCpuShares(Integer cpuShares) { this.cpuShares = cpuShares; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -1,24 +0,0 @@
package net.siegeln.cameleer.saas.app;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface AppRepository extends JpaRepository<AppEntity, UUID> {
List<AppEntity> findByEnvironmentId(UUID environmentId);
Optional<AppEntity> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
boolean existsByEnvironmentIdAndSlug(UUID environmentId, String slug);
@Query("SELECT COUNT(a) FROM AppEntity a JOIN EnvironmentEntity e ON a.environmentId = e.id WHERE e.tenantId = :tenantId")
long countByTenantId(UUID tenantId);
long countByEnvironmentId(UUID environmentId);
}

View File

@@ -1,175 +0,0 @@
package net.siegeln.cameleer.saas.app;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class AppService {
private final AppRepository appRepository;
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final AuditService auditService;
private final RuntimeConfig runtimeConfig;
public AppService(AppRepository appRepository,
EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository,
AuditService auditService,
RuntimeConfig runtimeConfig) {
this.appRepository = appRepository;
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
this.auditService = auditService;
this.runtimeConfig = runtimeConfig;
}
public AppEntity create(UUID envId, String slug, String displayName,
MultipartFile jarFile, String memoryLimit, Integer cpuShares, UUID actorId) {
if (jarFile != null && !jarFile.isEmpty()) {
validateJarFile(jarFile);
}
var env = environmentRepository.findById(envId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
if (appRepository.existsByEnvironmentIdAndSlug(envId, slug)) {
throw new IllegalArgumentException("App slug already exists in this environment: " + slug);
}
var tenantId = env.getTenantId();
enforceTierLimit(tenantId);
var entity = new AppEntity();
entity.setEnvironmentId(envId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setMemoryLimit(memoryLimit);
entity.setCpuShares(cpuShares);
if (jarFile != null && !jarFile.isEmpty()) {
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
var checksum = storeJar(jarFile, relativePath);
entity.setJarStoragePath(relativePath);
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
}
var saved = appRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.APP_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public AppEntity reuploadJar(UUID appId, MultipartFile jarFile, UUID actorId) {
validateJarFile(jarFile);
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
var checksum = storeJar(jarFile, entity.getJarStoragePath());
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
return appRepository.save(entity);
}
public List<AppEntity> listByEnvironmentId(UUID envId) {
return appRepository.findByEnvironmentId(envId);
}
public Optional<AppEntity> getById(UUID id) {
return appRepository.findById(id);
}
public void delete(UUID appId, UUID actorId) {
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
appRepository.delete(entity);
var env = environmentRepository.findById(entity.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_DELETE, entity.getSlug(),
null, null, "SUCCESS", null);
}
public AppEntity updateRouting(UUID appId, Integer exposedPort, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found"));
app.setExposedPort(exposedPort);
return appRepository.save(app);
}
public Path resolveJarPath(String relativePath) {
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
}
private void validateJarFile(MultipartFile jarFile) {
var filename = jarFile.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".jar")) {
throw new IllegalArgumentException("File must be a .jar file");
}
if (jarFile.getSize() > runtimeConfig.getMaxJarSize()) {
throw new IllegalArgumentException("JAR file exceeds maximum allowed size");
}
}
private String storeJar(MultipartFile file, String relativePath) {
try {
var targetPath = resolveJarPath(relativePath);
Files.createDirectories(targetPath.getParent());
Files.copy(file.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return computeSha256(file);
} catch (IOException e) {
throw new IllegalStateException("Failed to store JAR file", e);
}
}
private String computeSha256(MultipartFile file) {
try {
var digest = MessageDigest.getInstance("SHA-256");
var hash = digest.digest(file.getBytes());
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException | IOException e) {
throw new IllegalStateException("Failed to compute SHA-256 checksum", e);
}
}
private void enforceTierLimit(UUID tenantId) {
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
if (license.isEmpty()) {
throw new IllegalStateException("No active license");
}
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
var maxApps = (int) limits.getOrDefault("max_agents", 3);
if (maxApps != -1 && appRepository.countByTenantId(tenantId) >= maxApps) {
throw new IllegalStateException("App limit reached for current tier");
}
}
}

View File

@@ -1,22 +0,0 @@
package net.siegeln.cameleer.saas.app.dto;
import java.time.Instant;
import java.util.UUID;
public record AppResponse(
UUID id,
UUID environmentId,
String slug,
String displayName,
String jarOriginalFilename,
Long jarSizeBytes,
String jarChecksum,
Integer exposedPort,
String routeUrl,
String memoryLimit,
Integer cpuShares,
UUID currentDeploymentId,
UUID previousDeploymentId,
Instant createdAt,
Instant updatedAt
) {}

View File

@@ -1,17 +0,0 @@
package net.siegeln.cameleer.saas.app.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateAppRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName,
@Size(max = 20)
@Pattern(regexp = "^(\\d+[mgMG])?$", message = "Memory limit must be like 256m, 512m, 1g")
String memoryLimit,
Integer cpuShares
) {}

View File

@@ -1,32 +1,9 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
private final RuntimeConfig runtimeConfig;
public AsyncConfig(RuntimeConfig runtimeConfig) {
this.runtimeConfig = runtimeConfig;
}
@Bean(name = "deploymentTaskExecutor")
public Executor deploymentTaskExecutor() {
var executor = new ThreadPoolTaskExecutor();
// Core == max: no burst threads. Deployments beyond pool size queue (up to 25).
executor.setCorePoolSize(runtimeConfig.getDeploymentThreadPoolSize());
executor.setMaxPoolSize(runtimeConfig.getDeploymentThreadPoolSize());
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("deploy-");
executor.initialize();
return executor;
}
}

View File

@@ -6,9 +6,6 @@ import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.environment.EnvironmentStatus;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import org.slf4j.Logger;
@@ -29,15 +26,12 @@ public class BootstrapDataSeeder implements ApplicationRunner {
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
private final TenantRepository tenantRepository;
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
public BootstrapDataSeeder(TenantRepository tenantRepository,
EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository) {
this.tenantRepository = tenantRepository;
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
}
@@ -78,15 +72,6 @@ public class BootstrapDataSeeder implements ApplicationRunner {
tenant = tenantRepository.save(tenant);
log.info("Created tenant: {} ({})", tenant.getSlug(), tenant.getId());
// Create default environment
EnvironmentEntity env = new EnvironmentEntity();
env.setTenantId(tenant.getId());
env.setSlug("default");
env.setDisplayName("Default");
env.setStatus(EnvironmentStatus.ACTIVE);
environmentRepository.save(env);
log.info("Created default environment for tenant '{}'", tenantSlug);
// Create license
LicenseEntity license = new LicenseEntity();
license.setTenantId(tenant.getId());

View File

@@ -2,39 +2,30 @@ package net.siegeln.cameleer.saas.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Map;
import java.util.UUID;
/**
* Single interceptor handling both tenant resolution (JWT org_id TenantContext)
* Interceptor handling tenant resolution (JWT org_id to TenantContext)
* and tenant isolation (path variable validation). Fail-closed: any endpoint with
* {tenantId}, {environmentId}, or {appId} in its path is automatically isolated.
* {tenantId} in its path is automatically isolated.
* Platform admins (SCOPE_platform:admin) bypass all isolation checks.
*/
@Component
public class TenantIsolationInterceptor implements HandlerInterceptor {
private final TenantService tenantService;
private final EnvironmentRepository environmentRepository;
private final AppRepository appRepository;
public TenantIsolationInterceptor(TenantService tenantService,
EnvironmentRepository environmentRepository,
AppRepository appRepository) {
public TenantIsolationInterceptor(TenantService tenantService) {
this.tenantService = tenantService;
this.environmentRepository = environmentRepository;
this.appRepository = appRepository;
}
@Override
@@ -43,7 +34,7 @@ public class TenantIsolationInterceptor implements HandlerInterceptor {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) return true;
// 1. Resolve: JWT organization_id TenantContext
// 1. Resolve: JWT organization_id -> TenantContext
Jwt jwt = jwtAuth.getToken();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
@@ -63,7 +54,7 @@ public class TenantIsolationInterceptor implements HandlerInterceptor {
if (isPlatformAdmin) return true;
// Check tenantId in path (e.g., /api/tenants/{tenantId}/environments)
// Check tenantId in path (e.g., /api/tenants/{tenantId}/license)
if (pathVars.containsKey("tenantId")) {
if (resolvedTenantId == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context");
@@ -76,28 +67,6 @@ public class TenantIsolationInterceptor implements HandlerInterceptor {
}
}
// Check environmentId in path (e.g., /api/environments/{environmentId}/apps)
if (pathVars.containsKey("environmentId") && resolvedTenantId != null) {
UUID envId = UUID.fromString(pathVars.get("environmentId"));
environmentRepository.findById(envId).ifPresent(env -> {
if (!env.getTenantId().equals(resolvedTenantId)) {
throw new AccessDeniedException("Environment does not belong to tenant");
}
});
}
// Check appId in path (e.g., /api/apps/{appId}/deploy)
if (pathVars.containsKey("appId") && resolvedTenantId != null) {
UUID appId = UUID.fromString(pathVars.get("appId"));
appRepository.findById(appId).ifPresent(app ->
environmentRepository.findById(app.getEnvironmentId()).ifPresent(env -> {
if (!env.getTenantId().equals(resolvedTenantId)) {
throw new AccessDeniedException("App does not belong to tenant");
}
})
);
}
return true;
}

View File

@@ -1,122 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/apps/{appId}")
public class DeploymentController {
private final DeploymentService deploymentService;
public DeploymentController(DeploymentService deploymentService) {
this.deploymentService = deploymentService;
}
@PostMapping("/deploy")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<DeploymentResponse> deploy(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.deploy(appId, actorId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/deployments")
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
var deployments = deploymentService.listByAppId(appId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(deployments);
}
@GetMapping("/deployments/{deploymentId}")
public ResponseEntity<DeploymentResponse> getDeployment(
@PathVariable UUID appId,
@PathVariable UUID deploymentId) {
return deploymentService.getById(deploymentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/stop")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<DeploymentResponse> stop(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.stop(appId, actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/restart")
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
public ResponseEntity<DeploymentResponse> restart(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.restart(appId, actorId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private DeploymentResponse toResponse(DeploymentEntity entity) {
return new DeploymentResponse(
entity.getId(),
entity.getAppId(),
entity.getVersion(),
entity.getImageRef(),
entity.getDesiredStatus().name(),
entity.getObservedStatus().name(),
entity.getErrorMessage(),
entity.getOrchestratorMetadata(),
entity.getDeployedAt(),
entity.getStoppedAt(),
entity.getCreatedAt()
);
}
}

View File

@@ -1,88 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "deployments")
public class DeploymentEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "app_id", nullable = false)
private UUID appId;
@Column(nullable = false)
private int version;
@Column(name = "image_ref", nullable = false, length = 500)
private String imageRef;
@Enumerated(EnumType.STRING)
@Column(name = "desired_status", nullable = false, length = 20)
private DesiredStatus desiredStatus = DesiredStatus.RUNNING;
@Enumerated(EnumType.STRING)
@Column(name = "observed_status", nullable = false, length = 20)
private ObservedStatus observedStatus = ObservedStatus.BUILDING;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "orchestrator_metadata")
private Map<String, Object> orchestratorMetadata = Map.of();
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "deployed_at")
private Instant deployedAt;
@Column(name = "stopped_at")
private Instant stoppedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getAppId() { return appId; }
public void setAppId(UUID appId) { this.appId = appId; }
public int getVersion() { return version; }
public void setVersion(int version) { this.version = version; }
public String getImageRef() { return imageRef; }
public void setImageRef(String imageRef) { this.imageRef = imageRef; }
public DesiredStatus getDesiredStatus() { return desiredStatus; }
public void setDesiredStatus(DesiredStatus desiredStatus) { this.desiredStatus = desiredStatus; }
public ObservedStatus getObservedStatus() { return observedStatus; }
public void setObservedStatus(ObservedStatus observedStatus) { this.observedStatus = observedStatus; }
public Map<String, Object> getOrchestratorMetadata() { return orchestratorMetadata; }
public void setOrchestratorMetadata(Map<String, Object> orchestratorMetadata) { this.orchestratorMetadata = orchestratorMetadata; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Instant getDeployedAt() { return deployedAt; }
public void setDeployedAt(Instant deployedAt) { this.deployedAt = deployedAt; }
public Instant getStoppedAt() { return stoppedAt; }
public void setStoppedAt(Instant stoppedAt) { this.stoppedAt = stoppedAt; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -1,191 +0,0 @@
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("deploymentTaskExecutor")
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);
}
}

View File

@@ -1,19 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface DeploymentRepository extends JpaRepository<DeploymentEntity, UUID> {
List<DeploymentEntity> findByAppIdOrderByVersionDesc(UUID appId);
@Query("SELECT COALESCE(MAX(d.version), 0) FROM DeploymentEntity d WHERE d.appId = :appId")
int findMaxVersionByAppId(UUID appId);
Optional<DeploymentEntity> findByAppIdAndVersion(UUID appId, int version);
}

View File

@@ -1,121 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class DeploymentService {
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
private final DeploymentRepository deploymentRepository;
private final AppRepository appRepository;
private final EnvironmentRepository environmentRepository;
private final RuntimeOrchestrator runtimeOrchestrator;
private final AuditService auditService;
private final DeploymentExecutor deploymentExecutor;
public DeploymentService(DeploymentRepository deploymentRepository,
AppRepository appRepository,
EnvironmentRepository environmentRepository,
RuntimeOrchestrator runtimeOrchestrator,
AuditService auditService,
DeploymentExecutor deploymentExecutor) {
this.deploymentRepository = deploymentRepository;
this.appRepository = appRepository;
this.environmentRepository = environmentRepository;
this.runtimeOrchestrator = runtimeOrchestrator;
this.auditService = auditService;
this.deploymentExecutor = deploymentExecutor;
}
public DeploymentEntity deploy(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getJarStoragePath() == null) {
throw new IllegalStateException("App has no JAR uploaded: " + appId);
}
var env = environmentRepository.findById(app.getEnvironmentId())
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
int nextVersion = deploymentRepository.findMaxVersionByAppId(appId) + 1;
var imageRef = "cameleer-runtime-" + env.getSlug() + "-" + app.getSlug() + ":v" + nextVersion;
var deployment = new DeploymentEntity();
deployment.setAppId(appId);
deployment.setVersion(nextVersion);
deployment.setImageRef(imageRef);
deployment.setObservedStatus(ObservedStatus.BUILDING);
deployment.setDesiredStatus(DesiredStatus.RUNNING);
var saved = deploymentRepository.save(deployment);
auditService.log(actorId, null, env.getTenantId(),
AuditAction.APP_DEPLOY, app.getSlug(),
env.getSlug(), null, "SUCCESS", null);
// Delegate to separate service so Spring's @Async proxy works
deploymentExecutor.executeAsync(saved, app, env);
return saved;
}
public DeploymentEntity stop(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getCurrentDeploymentId() == null) {
throw new IllegalStateException("App has no active deployment: " + appId);
}
var deployment = deploymentRepository.findById(app.getCurrentDeploymentId())
.orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + app.getCurrentDeploymentId()));
var metadata = deployment.getOrchestratorMetadata();
if (metadata != null && metadata.containsKey("containerId")) {
var containerId = (String) metadata.get("containerId");
runtimeOrchestrator.stopContainer(containerId);
}
deployment.setDesiredStatus(DesiredStatus.STOPPED);
deployment.setObservedStatus(ObservedStatus.STOPPED);
deployment.setStoppedAt(Instant.now());
var saved = deploymentRepository.save(deployment);
var env = environmentRepository.findById(app.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_STOP, app.getSlug(),
env != null ? env.getSlug() : null, null, "SUCCESS", null);
return saved;
}
public DeploymentEntity restart(UUID appId, UUID actorId) {
stop(appId, actorId);
return deploy(appId, actorId);
}
public List<DeploymentEntity> listByAppId(UUID appId) {
return deploymentRepository.findByAppIdOrderByVersionDesc(appId);
}
public Optional<DeploymentEntity> getById(UUID deploymentId) {
return deploymentRepository.findById(deploymentId);
}
}

View File

@@ -1,5 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
public enum DesiredStatus {
RUNNING, STOPPED
}

View File

@@ -1,5 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
public enum ObservedStatus {
BUILDING, STARTING, RUNNING, FAILED, STOPPED
}

View File

@@ -1,12 +0,0 @@
package net.siegeln.cameleer.saas.deployment.dto;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record DeploymentResponse(
UUID id, UUID appId, int version, String imageRef,
String desiredStatus, String observedStatus, String errorMessage,
Map<String, Object> orchestratorMetadata,
Instant deployedAt, Instant stoppedAt, Instant createdAt
) {}

View File

@@ -1,124 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants/{tenantId}/environments")
public class EnvironmentController {
private final EnvironmentService environmentService;
public EnvironmentController(EnvironmentService environmentService) {
this.environmentService = environmentService;
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<EnvironmentResponse> create(
@PathVariable UUID tenantId,
@Valid @RequestBody CreateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.create(tenantId, request.slug(), request.displayName(), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@GetMapping
public ResponseEntity<List<EnvironmentResponse>> list(@PathVariable UUID tenantId) {
var environments = environmentService.listByTenantId(tenantId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(environments);
}
@GetMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> getById(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId) {
return environmentService.getById(environmentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PatchMapping("/{environmentId}")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<EnvironmentResponse> update(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
@Valid @RequestBody UpdateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{environmentId}")
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<Void> delete(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
environmentService.delete(environmentId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private EnvironmentResponse toResponse(EnvironmentEntity entity) {
return new EnvironmentResponse(
entity.getId(),
entity.getTenantId(),
entity.getSlug(),
entity.getDisplayName(),
entity.getStatus().name(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -1,57 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "environments")
public class EnvironmentEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", nullable = false)
private String displayName;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EnvironmentStatus status = EnvironmentStatus.ACTIVE;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public EnvironmentStatus getStatus() { return status; }
public void setStatus(EnvironmentStatus status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -1,20 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface EnvironmentRepository extends JpaRepository<EnvironmentEntity, UUID> {
List<EnvironmentEntity> findByTenantId(UUID tenantId);
Optional<EnvironmentEntity> findByTenantIdAndSlug(UUID tenantId, String slug);
long countByTenantId(UUID tenantId);
boolean existsByTenantIdAndSlug(UUID tenantId, String slug);
}

View File

@@ -1,116 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class EnvironmentService {
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final AuditService auditService;
public EnvironmentService(EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository,
AuditService auditService) {
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
this.auditService = auditService;
}
public EnvironmentEntity create(UUID tenantId, String slug, String displayName, UUID actorId) {
if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) {
throw new IllegalArgumentException("Slug already exists for this tenant: " + slug);
}
enforceTierLimit(tenantId);
var entity = new EnvironmentEntity();
entity.setTenantId(tenantId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
var saved = environmentRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.ENVIRONMENT_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public EnvironmentEntity createDefaultForTenant(UUID tenantId) {
return environmentRepository.findByTenantIdAndSlug(tenantId, "default")
.orElseGet(() -> createInternal(tenantId, "default", "Default"));
}
/** Creates an environment without license enforcement — used for bootstrapping (e.g., tenant provisioning). */
private EnvironmentEntity createInternal(UUID tenantId, String slug, String displayName) {
if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) {
throw new IllegalArgumentException("Slug already exists for this tenant: " + slug);
}
var entity = new EnvironmentEntity();
entity.setTenantId(tenantId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
return environmentRepository.save(entity);
}
public List<EnvironmentEntity> listByTenantId(UUID tenantId) {
return environmentRepository.findByTenantId(tenantId);
}
public Optional<EnvironmentEntity> getById(UUID id) {
return environmentRepository.findById(id);
}
public EnvironmentEntity updateDisplayName(UUID environmentId, String displayName, UUID actorId) {
var entity = environmentRepository.findById(environmentId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
entity.setDisplayName(displayName);
var saved = environmentRepository.save(entity);
auditService.log(actorId, null, entity.getTenantId(),
AuditAction.ENVIRONMENT_UPDATE, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public void delete(UUID environmentId, UUID actorId) {
var entity = environmentRepository.findById(environmentId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
if ("default".equals(entity.getSlug())) {
throw new IllegalStateException("Cannot delete the default environment");
}
environmentRepository.delete(entity);
auditService.log(actorId, null, entity.getTenantId(),
AuditAction.ENVIRONMENT_DELETE, entity.getSlug(),
null, null, "SUCCESS", null);
}
private void enforceTierLimit(UUID tenantId) {
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
if (license.isEmpty()) {
throw new IllegalStateException("No active license");
}
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
var maxEnvs = (int) limits.getOrDefault("max_environments", 1);
var currentCount = environmentRepository.countByTenantId(tenantId);
if (maxEnvs != -1 && currentCount >= maxEnvs) {
throw new IllegalStateException("Environment limit reached for current tier");
}
}
}

View File

@@ -1,5 +0,0 @@
package net.siegeln.cameleer.saas.environment;
public enum EnvironmentStatus {
ACTIVE, SUSPENDED
}

View File

@@ -1,13 +0,0 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateEnvironmentRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -1,14 +0,0 @@
package net.siegeln.cameleer.saas.environment.dto;
import java.time.Instant;
import java.util.UUID;
public record EnvironmentResponse(
UUID id,
UUID tenantId,
String slug,
String displayName,
String status,
Instant createdAt,
Instant updatedAt
) {}

View File

@@ -1,9 +0,0 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateEnvironmentRequest(
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -25,6 +25,9 @@ public class LogtoConfig {
@Value("${cameleer.identity.m2m-client-secret:}")
private String m2mClientSecret;
@Value("${cameleer.identity.server-endpoint:http://cameleer3-server:8081}")
private String serverEndpoint;
@PostConstruct
public void init() {
if (isConfigured()) return;
@@ -50,6 +53,7 @@ public class LogtoConfig {
public String getLogtoEndpoint() { return logtoEndpoint; }
public String getM2mClientId() { return m2mClientId; }
public String getM2mClientSecret() { return m2mClientSecret; }
public String getServerEndpoint() { return serverEndpoint; }
public boolean isConfigured() {
return logtoEndpoint != null && !logtoEndpoint.isEmpty()

View File

@@ -1,7 +1,6 @@
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;
@@ -9,6 +8,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Map;
/**
* Authenticated client for cameleer3-server API calls.
@@ -21,15 +21,13 @@ public class ServerApiClient {
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) {
public ServerApiClient(LogtoConfig config) {
this.config = config;
this.runtimeConfig = runtimeConfig;
this.tokenClient = RestClient.builder().build();
}
@@ -38,16 +36,54 @@ public class ServerApiClient {
}
/**
* Returns a RestClient pre-configured with server base URL and auth headers.
* Returns a RestClient pre-configured with server base URL and auth headers for GET requests.
*/
public RestClient.RequestHeadersSpec<?> get(String uri) {
return RestClient.create(runtimeConfig.getCameleer3ServerEndpoint())
return RestClient.create(config.getServerEndpoint())
.get()
.uri(uri)
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1");
}
/**
* Returns a RestClient pre-configured with server base URL and auth headers for POST requests.
*/
public RestClient.RequestBodySpec post(String uri) {
return RestClient.create(config.getServerEndpoint())
.post()
.uri(uri)
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1");
}
/**
* Push a license token to a server instance.
*/
public void pushLicense(String serverEndpoint, String licenseToken) {
RestClient.create(serverEndpoint)
.post()
.uri("/api/v1/admin/license")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("token", licenseToken))
.retrieve()
.toBodilessEntity();
}
/**
* Check server health.
*/
@SuppressWarnings("unchecked")
public Map<String, Object> getHealth(String serverEndpoint) {
return RestClient.create(serverEndpoint)
.get()
.uri("/api/v1/health")
.retrieve()
.body(Map.class);
}
private synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;

View File

@@ -1,9 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import java.nio.file.Path;
public record BuildImageRequest(
String baseImage,
Path jarPath,
String imageTag
) {}

View File

@@ -1,8 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
public record ContainerStatus(
String state,
boolean running,
int exitCode,
String error
) {}

View File

@@ -1,175 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.command.BuildImageResultCallback;
import com.github.dockerjava.api.model.*;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);
private DockerClient dockerClient;
@PostConstruct
public void init() {
var config = DefaultDockerClientConfig.createDefaultConfigBuilder()
.withDockerHost("unix:///var/run/docker.sock")
.build();
var httpClient = new ZerodepDockerHttpClient.Builder()
.dockerHost(config.getDockerHost())
.build();
dockerClient = DockerClientImpl.getInstance(config, httpClient);
log.info("Docker client initialized, host: {}", config.getDockerHost());
}
@PreDestroy
public void close() throws IOException {
if (dockerClient != null) {
dockerClient.close();
}
}
@Override
public String buildImage(BuildImageRequest request) {
Path buildDir = null;
try {
buildDir = Files.createTempDirectory("cameleer-build-");
var dockerfile = buildDir.resolve("Dockerfile");
Files.writeString(dockerfile,
"FROM " + request.baseImage() + "\nCOPY app.jar /app/app.jar\n");
Files.copy(request.jarPath(), buildDir.resolve("app.jar"), StandardCopyOption.REPLACE_EXISTING);
var imageId = dockerClient.buildImageCmd(buildDir.toFile())
.withTags(Set.of(request.imageTag()))
.exec(new BuildImageResultCallback())
.awaitImageId();
log.info("Built image {} -> {}", request.imageTag(), imageId);
return imageId;
} catch (IOException e) {
throw new RuntimeException("Failed to build image: " + e.getMessage(), e);
} finally {
if (buildDir != null) {
deleteDirectory(buildDir);
}
}
}
@Override
public String startContainer(StartContainerRequest request) {
var envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.toList();
var hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network());
var container = dockerClient.createContainerCmd(request.imageRef())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels() != null ? request.labels() : Map.of())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL",
"wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
.withInterval(10_000_000_000L) // 10s
.withTimeout(5_000_000_000L) // 5s
.withRetries(3)
.withStartPeriod(30_000_000_000L)) // 30s
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
log.info("Started container {} ({})", request.containerName(), container.getId());
return container.getId();
}
@Override
public void stopContainer(String containerId) {
try {
dockerClient.stopContainerCmd(containerId).withTimeout(30).exec();
log.info("Stopped container {}", containerId);
} catch (Exception e) {
log.warn("Failed to stop container {}: {}", containerId, e.getMessage());
}
}
@Override
public void removeContainer(String containerId) {
try {
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
log.info("Removed container {}", containerId);
} catch (Exception e) {
log.warn("Failed to remove container {}: {}", containerId, e.getMessage());
}
}
@Override
public ContainerStatus getContainerStatus(String containerId) {
try {
var inspection = dockerClient.inspectContainerCmd(containerId).exec();
var state = inspection.getState();
var health = state.getHealth();
var healthStatus = health != null ? health.getStatus() : null;
// Use health status if available, otherwise fall back to container state
var effectiveState = healthStatus != null ? healthStatus : state.getStatus();
return new ContainerStatus(
effectiveState,
Boolean.TRUE.equals(state.getRunning()),
state.getExitCodeLong() != null ? state.getExitCodeLong().intValue() : 0,
state.getError());
} catch (Exception e) {
return new ContainerStatus("not_found", false, -1, e.getMessage());
}
}
@Override
public void streamLogs(String containerId, LogConsumer consumer) {
dockerClient.logContainerCmd(containerId)
.withStdOut(true)
.withStdErr(true)
.withFollowStream(true)
.withTimestamps(true)
.exec(new ResultCallback.Adapter<Frame>() {
@Override
public void onNext(Frame frame) {
var stream = frame.getStreamType() == StreamType.STDERR ? "stderr" : "stdout";
consumer.accept(stream, new String(frame.getPayload()).trim(),
System.currentTimeMillis());
}
});
}
private void deleteDirectory(Path dir) {
try {
Files.walk(dir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch (IOException e) {
log.warn("Failed to clean up build directory: {}", dir, e);
}
}
}

View File

@@ -1,6 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
@FunctionalInterface
public interface LogConsumer {
void accept(String stream, String message, long timestampMillis);
}

View File

@@ -1,67 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class RuntimeConfig {
@Value("${cameleer.runtime.max-jar-size:209715200}")
private long maxJarSize;
@Value("${cameleer.runtime.jar-storage-path:/data/jars}")
private String jarStoragePath;
@Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
private String baseImage;
@Value("${cameleer.runtime.docker-network:cameleer}")
private String dockerNetwork;
@Value("${cameleer.runtime.agent-health-port:9464}")
private int agentHealthPort;
@Value("${cameleer.runtime.health-check-timeout:60}")
private int healthCheckTimeout;
@Value("${cameleer.runtime.deployment-thread-pool-size:4}")
private int deploymentThreadPoolSize;
@Value("${cameleer.runtime.container-memory-limit:512m}")
private String containerMemoryLimit;
@Value("${cameleer.runtime.container-cpu-shares:512}")
private int containerCpuShares;
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
private String bootstrapToken;
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
private String cameleer3ServerEndpoint;
@Value("${cameleer.runtime.domain:localhost}")
private String domain;
public long getMaxJarSize() { return maxJarSize; }
public String getJarStoragePath() { return jarStoragePath; }
public String getBaseImage() { return baseImage; }
public String getDockerNetwork() { return dockerNetwork; }
public int getAgentHealthPort() { return agentHealthPort; }
public int getHealthCheckTimeout() { return healthCheckTimeout; }
public int getDeploymentThreadPoolSize() { return deploymentThreadPoolSize; }
public String getContainerMemoryLimit() { return containerMemoryLimit; }
public int getContainerCpuShares() { return containerCpuShares; }
public String getBootstrapToken() { return bootstrapToken; }
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
public String getDomain() { return domain; }
public long parseMemoryLimitBytes() {
var limit = containerMemoryLimit.trim().toLowerCase();
if (limit.endsWith("g")) {
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024 * 1024;
} else if (limit.endsWith("m")) {
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(limit);
}
}

View File

@@ -1,10 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
public interface RuntimeOrchestrator {
String buildImage(BuildImageRequest request);
String startContainer(StartContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
void streamLogs(String containerId, LogConsumer consumer);
}

View File

@@ -1,14 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import java.util.Map;
public record StartContainerRequest(
String imageRef,
String containerName,
String network,
Map<String, String> envVars,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort,
Map<String, String> labels
) {}

View File

@@ -2,7 +2,6 @@ package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.springframework.stereotype.Service;
@@ -17,13 +16,11 @@ public class TenantService {
private final TenantRepository tenantRepository;
private final AuditService auditService;
private final LogtoManagementClient logtoClient;
private final EnvironmentService environmentService;
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient, EnvironmentService environmentService) {
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) {
this.tenantRepository = tenantRepository;
this.auditService = auditService;
this.logtoClient = logtoClient;
this.environmentService = environmentService;
}
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
@@ -47,8 +44,6 @@ public class TenantService {
}
}
environmentService.createDefaultForTenant(saved.getId());
auditService.log(actorId, null, saved.getId(),
AuditAction.TENANT_CREATE, saved.getSlug(),
null, null, "SUCCESS", null);

View File

@@ -5,10 +5,6 @@ server:
spring:
application:
name: cameleer-saas
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://postgres:5432/cameleer_saas}
username: ${SPRING_DATASOURCE_USERNAME:cameleer}
@@ -44,15 +40,4 @@ cameleer:
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
spa-client-id: ${LOGTO_SPA_CLIENT_ID:}
audience: ${CAMELEER_OIDC_AUDIENCE:https://api.cameleer.local}
runtime:
max-jar-size: 209715200
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer}
agent-health-port: 9464
health-check-timeout: 60
deployment-thread-pool-size: 4
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
domain: ${DOMAIN:localhost}
server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}

View File

@@ -1,34 +0,0 @@
package net.siegeln.cameleer.saas.apikey;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ApiKeyServiceTest {
@Test
void generatedKeyShouldHaveCmkPrefix() {
var service = new ApiKeyService(null);
var key = service.generate();
assertThat(key.plaintext()).startsWith("cmk_");
assertThat(key.prefix()).hasSize(12);
assertThat(key.keyHash()).hasSize(64);
}
@Test
void generatedKeyHashShouldBeConsistent() {
var service = new ApiKeyService(null);
var key = service.generate();
String rehash = ApiKeyService.sha256Hex(key.plaintext());
assertThat(rehash).isEqualTo(key.keyHash());
}
@Test
void twoGeneratedKeysShouldDiffer() {
var service = new ApiKeyService(null);
var key1 = service.generate();
var key2 = service.generate();
assertThat(key1.plaintext()).isNotEqualTo(key2.plaintext());
assertThat(key1.keyHash()).isNotEqualTo(key2.keyHash());
}
}

View File

@@ -1,175 +0,0 @@
package net.siegeln.cameleer.saas.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class AppControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private AppRepository appRepository;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID environmentId;
@BeforeEach
void setUp() {
appRepository.deleteAll();
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
var tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
var env = new net.siegeln.cameleer.saas.environment.EnvironmentEntity();
env.setTenantId(tenantId);
env.setSlug("default");
env.setDisplayName("Default");
var savedEnv = environmentRepository.save(env);
environmentId = savedEnv.getId();
}
@Test
void createApp_shouldReturn201() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "order-svc", "displayName": "Order Service"}
""".getBytes());
var jar = new MockMultipartFile("file", "order-service.jar",
"application/java-archive", "fake-jar".getBytes());
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.slug").value("order-svc"))
.andExpect(jsonPath("$.displayName").value("Order Service"));
}
@Test
void createApp_nonJarFile_shouldReturn400() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "order-svc", "displayName": "Order Service"}
""".getBytes());
var txt = new MockMultipartFile("file", "readme.txt",
"text/plain", "hello".getBytes());
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(txt)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isBadRequest());
}
@Test
void listApps_shouldReturnAll() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "billing-svc", "displayName": "Billing Service"}
""".getBytes());
var jar = new MockMultipartFile("file", "billing-service.jar",
"application/java-archive", "fake-jar".getBytes());
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/environments/" + environmentId + "/apps")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].slug").value("billing-svc"));
}
@Test
void deleteApp_shouldReturn204() throws Exception {
var metadata = new MockMultipartFile("metadata", "", "application/json",
"""
{"slug": "payment-svc", "displayName": "Payment Service"}
""".getBytes());
var jar = new MockMultipartFile("file", "payment-service.jar",
"application/java-archive", "fake-jar".getBytes());
var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
.file(jar)
.file(metadata)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isCreated())
.andReturn();
String appId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"))))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,167 +0,0 @@
package net.siegeln.cameleer.saas.app;
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.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
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.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AppServiceTest {
@TempDir
Path tempDir;
@Mock
private AppRepository appRepository;
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private LicenseRepository licenseRepository;
@Mock
private AuditService auditService;
@Mock
private RuntimeConfig runtimeConfig;
private AppService appService;
@BeforeEach
void setUp() {
when(runtimeConfig.getJarStoragePath()).thenReturn(tempDir.toString());
when(runtimeConfig.getMaxJarSize()).thenReturn(209715200L);
appService = new AppService(appRepository, environmentRepository, licenseRepository, auditService, runtimeConfig);
}
@Test
void create_shouldStoreJarAndCreateApp() throws Exception {
var envId = UUID.randomUUID();
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("default");
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
var jarBytes = "fake-jar-content".getBytes();
var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", jarBytes);
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(false);
when(appRepository.countByTenantId(tenantId)).thenReturn(0L);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = appService.create(envId, "myapp", "My App", jarFile, null, null, actorId);
assertThat(result.getSlug()).isEqualTo("myapp");
assertThat(result.getDisplayName()).isEqualTo("My App");
assertThat(result.getEnvironmentId()).isEqualTo(envId);
assertThat(result.getJarOriginalFilename()).isEqualTo("myapp.jar");
assertThat(result.getJarSizeBytes()).isEqualTo((long) jarBytes.length);
assertThat(result.getJarChecksum()).isNotBlank();
assertThat(result.getJarStoragePath()).contains("tenants")
.contains("envs")
.contains("apps")
.endsWith("app.jar");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_CREATE);
}
@Test
void create_shouldRejectNonJarFile() {
var envId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes());
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, null, null, actorId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(".jar");
}
@Test
void create_shouldRejectDuplicateSlug() {
var envId = UUID.randomUUID();
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("default");
var jarFile = new MockMultipartFile("file", "myapp.jar", "application/java-archive", "fake-jar".getBytes());
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true);
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, null, null, actorId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("myapp");
}
@Test
void reuploadJar_shouldUpdateChecksumAndPath() throws Exception {
var appId = UUID.randomUUID();
var envId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var existingApp = new AppEntity();
existingApp.setId(appId);
existingApp.setEnvironmentId(envId);
existingApp.setSlug("myapp");
existingApp.setDisplayName("My App");
existingApp.setJarStoragePath("tenants/some-tenant/envs/default/apps/myapp/app.jar");
existingApp.setJarChecksum("oldchecksum");
existingApp.setJarOriginalFilename("old.jar");
existingApp.setJarSizeBytes(100L);
var newJarBytes = "new-jar-content".getBytes();
var newJarFile = new MockMultipartFile("file", "new-myapp.jar", "application/java-archive", newJarBytes);
when(appRepository.findById(appId)).thenReturn(Optional.of(existingApp));
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = appService.reuploadJar(appId, newJarFile, actorId);
assertThat(result.getJarOriginalFilename()).isEqualTo("new-myapp.jar");
assertThat(result.getJarSizeBytes()).isEqualTo((long) newJarBytes.length);
assertThat(result.getJarChecksum()).isNotBlank();
assertThat(result.getJarChecksum()).isNotEqualTo("oldchecksum");
}
}

View File

@@ -1,120 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.TestcontainersConfig;
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.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class DeploymentControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private DeploymentRepository deploymentRepository;
@Autowired
private AppRepository appRepository;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID appId;
@BeforeEach
void setUp() {
deploymentRepository.deleteAll();
appRepository.deleteAll();
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
var tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
var env = new EnvironmentEntity();
env.setTenantId(tenantId);
env.setSlug("default");
env.setDisplayName("Default");
var savedEnv = environmentRepository.save(env);
var app = new AppEntity();
app.setEnvironmentId(savedEnv.getId());
app.setSlug("test-app");
app.setDisplayName("Test App");
app.setJarStoragePath("tenants/test-org/envs/default/apps/test-app/app.jar");
app.setJarChecksum("abc123def456");
var savedApp = appRepository.save(app);
appId = savedApp.getId();
}
@Test
void listDeployments_shouldReturnEmpty() throws Exception {
mockMvc.perform(get("/api/apps/" + appId + "/deployments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
@Test
void getDeployment_notFound_shouldReturn404() throws Exception {
mockMvc.perform(get("/api/apps/" + appId + "/deployments/" + UUID.randomUUID())
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isNotFound());
}
@Test
void deploy_noAuth_shouldReturn401() throws Exception {
mockMvc.perform(post("/api/apps/" + appId + "/deploy"))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -1,179 +0,0 @@
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.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeploymentServiceTest {
@Mock
private DeploymentRepository deploymentRepository;
@Mock
private AppRepository appRepository;
@Mock
private AppService appService;
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private TenantRepository tenantRepository;
@Mock
private RuntimeOrchestrator runtimeOrchestrator;
@Mock
private RuntimeConfig runtimeConfig;
@Mock
private AuditService auditService;
@Mock
private DeploymentExecutor deploymentExecutor;
private DeploymentService deploymentService;
private UUID appId;
private UUID envId;
private UUID tenantId;
private UUID actorId;
private AppEntity app;
private EnvironmentEntity env;
private TenantEntity tenant;
@BeforeEach
void setUp() {
deploymentService = new DeploymentService(
deploymentRepository,
appRepository,
environmentRepository,
runtimeOrchestrator,
auditService,
deploymentExecutor
);
appId = UUID.randomUUID();
envId = UUID.randomUUID();
tenantId = UUID.randomUUID();
actorId = UUID.randomUUID();
env = new EnvironmentEntity();
env.setId(envId);
env.setTenantId(tenantId);
env.setSlug("prod");
tenant = new TenantEntity();
tenant.setSlug("acme");
app = new AppEntity();
app.setId(appId);
app.setEnvironmentId(envId);
app.setSlug("myapp");
app.setDisplayName("My App");
app.setJarStoragePath("tenants/acme/envs/prod/apps/myapp/app.jar");
when(runtimeConfig.getBaseImage()).thenReturn("cameleer-runtime-base:latest");
when(runtimeConfig.getDockerNetwork()).thenReturn("cameleer");
when(runtimeConfig.getAgentHealthPort()).thenReturn(9464);
when(runtimeConfig.getHealthCheckTimeout()).thenReturn(60);
when(runtimeConfig.parseMemoryLimitBytes()).thenReturn(536870912L);
when(runtimeConfig.getContainerCpuShares()).thenReturn(512);
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(tenantRepository.findById(tenantId)).thenReturn(Optional.of(tenant));
when(deploymentRepository.findMaxVersionByAppId(appId)).thenReturn(0);
when(deploymentRepository.save(any(DeploymentEntity.class))).thenAnswer(inv -> {
var d = (DeploymentEntity) inv.getArgument(0);
if (d.getId() == null) {
d.setId(UUID.randomUUID());
}
return d;
});
when(appService.resolveJarPath(any())).thenReturn(Path.of("/data/jars/tenants/acme/envs/prod/apps/myapp/app.jar"));
when(runtimeOrchestrator.buildImage(any(BuildImageRequest.class))).thenReturn("sha256:abc123");
when(runtimeOrchestrator.startContainer(any())).thenReturn("container-id-123");
}
@Test
void deploy_shouldCreateDeploymentWithBuildingStatus() {
var result = deploymentService.deploy(appId, actorId);
assertThat(result).isNotNull();
assertThat(result.getAppId()).isEqualTo(appId);
assertThat(result.getVersion()).isEqualTo(1);
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.BUILDING);
assertThat(result.getImageRef()).contains("myapp").contains("v1");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_DEPLOY);
}
@Test
void deploy_shouldRejectAppWithNoJar() {
app.setJarStoragePath(null);
assertThatThrownBy(() -> deploymentService.deploy(appId, actorId))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("JAR");
}
@Test
void stop_shouldUpdateDesiredStatus() {
var deploymentId = UUID.randomUUID();
app.setCurrentDeploymentId(deploymentId);
var deployment = new DeploymentEntity();
deployment.setId(deploymentId);
deployment.setAppId(appId);
deployment.setVersion(1);
deployment.setImageRef("cameleer-runtime-prod-myapp:v1");
deployment.setObservedStatus(ObservedStatus.RUNNING);
deployment.setOrchestratorMetadata(Map.of("containerId", "container-id-123"));
when(deploymentRepository.findById(deploymentId)).thenReturn(Optional.of(deployment));
var result = deploymentService.stop(appId, actorId);
assertThat(result.getDesiredStatus()).isEqualTo(DesiredStatus.STOPPED);
assertThat(result.getObservedStatus()).isEqualTo(ObservedStatus.STOPPED);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.APP_STOP);
}
}

View File

@@ -1,199 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class EnvironmentControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private EnvironmentRepository environmentRepository;
@Autowired
private LicenseRepository licenseRepository;
@Autowired
private TenantRepository tenantRepository;
private UUID tenantId;
@BeforeEach
void setUp() {
environmentRepository.deleteAll();
licenseRepository.deleteAll();
tenantRepository.deleteAll();
var tenant = new TenantEntity();
tenant.setName("Test Org");
tenant.setSlug("test-org-" + System.nanoTime());
var savedTenant = tenantRepository.save(tenant);
tenantId = savedTenant.getId();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("MID");
license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID));
license.setLimits(LicenseDefaults.limitsForTier(Tier.MID));
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("test-token");
licenseRepository.save(license);
}
@Test
void createEnvironment_shouldReturn201() throws Exception {
var request = new CreateEnvironmentRequest("prod", "Production");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.slug").value("prod"))
.andExpect(jsonPath("$.displayName").value("Production"))
.andExpect(jsonPath("$.status").value("ACTIVE"));
}
@Test
void createEnvironment_duplicateSlug_shouldReturn409() throws Exception {
var request = new CreateEnvironmentRequest("staging", "Staging");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void listEnvironments_shouldReturnAll() throws Exception {
var request = new CreateEnvironmentRequest("dev", "Development");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].slug").value("dev"));
}
@Test
void updateEnvironment_shouldReturn200() throws Exception {
var createRequest = new CreateEnvironmentRequest("qa", "QA");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createRequest)))
.andExpect(status().isCreated())
.andReturn();
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.displayName").value("QA Updated"));
}
@Test
void deleteDefaultEnvironment_shouldReturn403() throws Exception {
var request = new CreateEnvironmentRequest("default", "Default");
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String environmentId = objectMapper.readTree(createResult.getResponse().getContentAsString())
.get("id").asText();
mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))
.authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"),
new SimpleGrantedAuthority("SCOPE_platform:admin"))))
.andExpect(status().isForbidden());
}
@Test
void createEnvironment_noAuth_shouldReturn401() throws Exception {
var request = new CreateEnvironmentRequest("no-auth", "No Auth");
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -1,173 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class EnvironmentServiceTest {
@Mock
private EnvironmentRepository environmentRepository;
@Mock
private LicenseRepository licenseRepository;
@Mock
private AuditService auditService;
private EnvironmentService environmentService;
@BeforeEach
void setUp() {
environmentService = new EnvironmentService(environmentRepository, licenseRepository, auditService);
}
@Test
void create_shouldCreateEnvironmentAndLogAudit() {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("HIGH");
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(false);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = environmentService.create(tenantId, "prod", "Production", actorId);
assertThat(result.getSlug()).isEqualTo("prod");
assertThat(result.getDisplayName()).isEqualTo("Production");
assertThat(result.getTenantId()).isEqualTo(tenantId);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_CREATE);
}
@Test
void create_shouldRejectDuplicateSlug() {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "prod")).thenReturn(true);
assertThatThrownBy(() -> environmentService.create(tenantId, "prod", "Production", actorId))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void create_shouldEnforceTierLimit() {
var tenantId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var license = new LicenseEntity();
license.setTenantId(tenantId);
license.setTier("LOW");
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "staging")).thenReturn(false);
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
.thenReturn(Optional.of(license));
when(environmentRepository.countByTenantId(tenantId)).thenReturn(1L);
assertThatThrownBy(() -> environmentService.create(tenantId, "staging", "Staging", actorId))
.isInstanceOf(IllegalStateException.class);
}
@Test
void listByTenantId_shouldReturnEnvironments() {
var tenantId = UUID.randomUUID();
var env1 = new EnvironmentEntity();
env1.setSlug("default");
var env2 = new EnvironmentEntity();
env2.setSlug("prod");
when(environmentRepository.findByTenantId(tenantId)).thenReturn(List.of(env1, env2));
var result = environmentService.listByTenantId(tenantId);
assertThat(result).hasSize(2);
assertThat(result).extracting(EnvironmentEntity::getSlug).containsExactly("default", "prod");
}
@Test
void getById_shouldReturnEnvironment() {
var id = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setSlug("prod");
when(environmentRepository.findById(id)).thenReturn(Optional.of(env));
var result = environmentService.getById(id);
assertThat(result).isPresent();
assertThat(result.get().getSlug()).isEqualTo("prod");
}
@Test
void updateDisplayName_shouldUpdateAndLogAudit() {
var environmentId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setSlug("prod");
env.setDisplayName("Old Name");
env.setTenantId(UUID.randomUUID());
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = environmentService.updateDisplayName(environmentId, "New Name", actorId);
assertThat(result.getDisplayName()).isEqualTo("New Name");
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.ENVIRONMENT_UPDATE);
}
@Test
void delete_shouldRejectDefaultEnvironment() {
var environmentId = UUID.randomUUID();
var actorId = UUID.randomUUID();
var env = new EnvironmentEntity();
env.setSlug("default");
when(environmentRepository.findById(environmentId)).thenReturn(Optional.of(env));
assertThatThrownBy(() -> environmentService.delete(environmentId, actorId))
.isInstanceOf(IllegalStateException.class);
}
@Test
void createDefaultForTenant_shouldCreateWithDefaultSlug() {
var tenantId = UUID.randomUUID();
when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty());
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false);
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = environmentService.createDefaultForTenant(tenantId);
assertThat(result.getSlug()).isEqualTo("default");
assertThat(result.getDisplayName()).isEqualTo("Default");
}
}

View File

@@ -1,32 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DockerRuntimeOrchestratorTest {
@Test
void runtimeConfig_parseMemoryLimitBytes_megabytes() {
assertEquals(512 * 1024 * 1024L, parseMemoryLimit("512m"));
}
@Test
void runtimeConfig_parseMemoryLimitBytes_gigabytes() {
assertEquals(1024L * 1024 * 1024, parseMemoryLimit("1g"));
}
@Test
void runtimeConfig_parseMemoryLimitBytes_bytes() {
assertEquals(536870912L, parseMemoryLimit("536870912"));
}
private long parseMemoryLimit(String limit) {
var l = limit.trim().toLowerCase();
if (l.endsWith("g")) {
return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024 * 1024;
} else if (l.endsWith("m")) {
return Long.parseLong(l.substring(0, l.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(l);
}
}

View File

@@ -2,7 +2,6 @@ package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.BeforeEach;
@@ -33,14 +32,11 @@ class TenantServiceTest {
@Mock
private LogtoManagementClient logtoClient;
@Mock
private EnvironmentService environmentService;
private TenantService tenantService;
@BeforeEach
void setUp() {
tenantService = new TenantService(tenantRepository, auditService, logtoClient, environmentService);
tenantService = new TenantService(tenantRepository, auditService, logtoClient);
}
@Test