diff --git a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java deleted file mode 100644 index 2fb08cb..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java +++ /dev/null @@ -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; } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java deleted file mode 100644 index f6f9fd7..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java +++ /dev/null @@ -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 { - Optional findByKeyHashAndStatus(String keyHash, String status); - List findByEnvironmentId(UUID environmentId); - List findByEnvironmentIdAndStatus(UUID environmentId, String status); -} diff --git a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java b/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java deleted file mode 100644 index 8a82f2d..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java +++ /dev/null @@ -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 validate(String plaintext) { - String hash = sha256Hex(plaintext); - return repository.findByKeyHashAndStatus(hash, "ACTIVE"); - } - - public GeneratedKey rotate(UUID environmentId) { - List 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 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); - } - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java deleted file mode 100644 index ccba3b7..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java +++ /dev/null @@ -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 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(@PathVariable UUID environmentId) { - - var apps = appService.listByEnvironmentId(environmentId) - .stream() - .map(this::toResponse) - .toList(); - return ResponseEntity.ok(apps); - } - - @GetMapping("/{appId}") - public ResponseEntity 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 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 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 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()); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java b/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java deleted file mode 100644 index 50c54c3..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java +++ /dev/null @@ -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; } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppRepository.java b/src/main/java/net/siegeln/cameleer/saas/app/AppRepository.java deleted file mode 100644 index c10f379..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppRepository.java +++ /dev/null @@ -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 { - - List findByEnvironmentId(UUID environmentId); - - Optional 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); -} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java deleted file mode 100644 index 4606019..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java +++ /dev/null @@ -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 listByEnvironmentId(UUID envId) { - return appRepository.findByEnvironmentId(envId); - } - - public Optional 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"); - } - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java deleted file mode 100644 index b2c4ddc..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java +++ /dev/null @@ -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 -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java deleted file mode 100644 index c80b4c4..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java +++ /dev/null @@ -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 -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java index c830ac4..390fc42 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java @@ -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; - } } diff --git a/src/main/java/net/siegeln/cameleer/saas/config/BootstrapDataSeeder.java b/src/main/java/net/siegeln/cameleer/saas/config/BootstrapDataSeeder.java index 4892346..96341d2 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/BootstrapDataSeeder.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/BootstrapDataSeeder.java @@ -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()); diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java index 916e53b..8808781 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java @@ -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; } diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java deleted file mode 100644 index bc33901..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java +++ /dev/null @@ -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 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> listDeployments(@PathVariable UUID appId) { - - var deployments = deploymentService.listByAppId(appId) - .stream() - .map(this::toResponse) - .toList(); - return ResponseEntity.ok(deployments); - } - - @GetMapping("/deployments/{deploymentId}") - public ResponseEntity 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 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 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() - ); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentEntity.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentEntity.java deleted file mode 100644 index d1a1286..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentEntity.java +++ /dev/null @@ -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 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 getOrchestratorMetadata() { return orchestratorMetadata; } - public void setOrchestratorMetadata(Map 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; } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java deleted file mode 100644 index fd4a9a0..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentExecutor.java +++ /dev/null @@ -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(); - if (app.getExposedPort() != null) { - labels.put("traefik.enable", "true"); - labels.put("traefik.http.routers." + containerName + ".rule", - "Host(`" + app.getSlug() + "." + env.getSlug() + "." - + tenantSlug + "." + runtimeConfig.getDomain() + "`)"); - labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port", - String.valueOf(app.getExposedPort())); - } - - long memoryBytes = app.getMemoryLimit() != null - ? parseMemoryBytes(app.getMemoryLimit()) - : runtimeConfig.parseMemoryLimitBytes(); - int cpuShares = app.getCpuShares() != null - ? app.getCpuShares() - : runtimeConfig.getContainerCpuShares(); - - var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest( - deployment.getImageRef(), - containerName, - runtimeConfig.getDockerNetwork(), - Map.of( - "CAMELEER_AUTH_TOKEN", runtimeConfig.getBootstrapToken(), - "CAMELEER_EXPORT_TYPE", "HTTP", - "CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(), - "CAMELEER_APPLICATION_ID", app.getSlug(), - "CAMELEER_ENVIRONMENT_ID", env.getSlug(), - "CAMELEER_DISPLAY_NAME", containerName - ), - memoryBytes, - cpuShares, - runtimeConfig.getAgentHealthPort(), - labels - )); - - deployment.setOrchestratorMetadata(Map.of("containerId", containerId)); - deploymentRepository.save(deployment); - - boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout()); - - var previousDeploymentId = app.getCurrentDeploymentId(); - - if (healthy) { - deployment.setObservedStatus(ObservedStatus.RUNNING); - deployment.setDeployedAt(Instant.now()); - deploymentRepository.save(deployment); - - app.setPreviousDeploymentId(previousDeploymentId); - app.setCurrentDeploymentId(deployment.getId()); - appRepository.save(app); - } else { - deployment.setObservedStatus(ObservedStatus.FAILED); - deployment.setErrorMessage("Container did not become healthy within timeout"); - deploymentRepository.save(deployment); - - app.setCurrentDeploymentId(deployment.getId()); - appRepository.save(app); - } - - } catch (Exception e) { - log.error("Deployment {} failed: {}", deployment.getId(), e.getMessage(), e); - deployment.setObservedStatus(ObservedStatus.FAILED); - deployment.setErrorMessage(e.getMessage()); - deploymentRepository.save(deployment); - } - } - - boolean waitForHealthy(String containerId, int timeoutSeconds) { - var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L); - while (System.currentTimeMillis() < deadline) { - var status = runtimeOrchestrator.getContainerStatus(containerId); - if (!status.running()) { - return false; - } - if ("healthy".equals(status.state())) { - return true; - } - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - return false; - } - - static long parseMemoryBytes(String limit) { - var s = limit.trim().toLowerCase(); - if (s.endsWith("g")) { - return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024 * 1024; - } else if (s.endsWith("m")) { - return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024; - } - return Long.parseLong(s); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentRepository.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentRepository.java deleted file mode 100644 index 166df60..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentRepository.java +++ /dev/null @@ -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 { - List findByAppIdOrderByVersionDesc(UUID appId); - - @Query("SELECT COALESCE(MAX(d.version), 0) FROM DeploymentEntity d WHERE d.appId = :appId") - int findMaxVersionByAppId(UUID appId); - - Optional findByAppIdAndVersion(UUID appId, int version); -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java deleted file mode 100644 index 3b1d5dc..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java +++ /dev/null @@ -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 listByAppId(UUID appId) { - return deploymentRepository.findByAppIdOrderByVersionDesc(appId); - } - - public Optional getById(UUID deploymentId) { - return deploymentRepository.findById(deploymentId); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DesiredStatus.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DesiredStatus.java deleted file mode 100644 index b489bca..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DesiredStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package net.siegeln.cameleer.saas.deployment; - -public enum DesiredStatus { - RUNNING, STOPPED -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/ObservedStatus.java b/src/main/java/net/siegeln/cameleer/saas/deployment/ObservedStatus.java deleted file mode 100644 index c1add1d..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/ObservedStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package net.siegeln.cameleer.saas.deployment; - -public enum ObservedStatus { - BUILDING, STARTING, RUNNING, FAILED, STOPPED -} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/dto/DeploymentResponse.java b/src/main/java/net/siegeln/cameleer/saas/deployment/dto/DeploymentResponse.java deleted file mode 100644 index 9a87334..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/dto/DeploymentResponse.java +++ /dev/null @@ -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 orchestratorMetadata, - Instant deployedAt, Instant stoppedAt, Instant createdAt -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java deleted file mode 100644 index c64124d..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java +++ /dev/null @@ -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 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(@PathVariable UUID tenantId) { - var environments = environmentService.listByTenantId(tenantId) - .stream() - .map(this::toResponse) - .toList(); - return ResponseEntity.ok(environments); - } - - @GetMapping("/{environmentId}") - public ResponseEntity 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 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 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() - ); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentEntity.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentEntity.java deleted file mode 100644 index 1e2b9c8..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentEntity.java +++ /dev/null @@ -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; } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentRepository.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentRepository.java deleted file mode 100644 index 5d659c1..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentRepository.java +++ /dev/null @@ -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 { - - List findByTenantId(UUID tenantId); - - Optional findByTenantIdAndSlug(UUID tenantId, String slug); - - long countByTenantId(UUID tenantId); - - boolean existsByTenantIdAndSlug(UUID tenantId, String slug); -} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java deleted file mode 100644 index 1dc24a7..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java +++ /dev/null @@ -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 listByTenantId(UUID tenantId) { - return environmentRepository.findByTenantId(tenantId); - } - - public Optional 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"); - } - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentStatus.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentStatus.java deleted file mode 100644 index e3a0611..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package net.siegeln.cameleer.saas.environment; - -public enum EnvironmentStatus { - ACTIVE, SUSPENDED -} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/dto/CreateEnvironmentRequest.java b/src/main/java/net/siegeln/cameleer/saas/environment/dto/CreateEnvironmentRequest.java deleted file mode 100644 index ddff789..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/dto/CreateEnvironmentRequest.java +++ /dev/null @@ -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 -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/dto/EnvironmentResponse.java b/src/main/java/net/siegeln/cameleer/saas/environment/dto/EnvironmentResponse.java deleted file mode 100644 index 50efec6..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/dto/EnvironmentResponse.java +++ /dev/null @@ -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 -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/dto/UpdateEnvironmentRequest.java b/src/main/java/net/siegeln/cameleer/saas/environment/dto/UpdateEnvironmentRequest.java deleted file mode 100644 index 19f0873..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/environment/dto/UpdateEnvironmentRequest.java +++ /dev/null @@ -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 -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java index f323422..9de49d2 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java @@ -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() diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java index bf27b51..c755321 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java @@ -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 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; diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/BuildImageRequest.java b/src/main/java/net/siegeln/cameleer/saas/runtime/BuildImageRequest.java deleted file mode 100644 index da92c06..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/BuildImageRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package net.siegeln.cameleer.saas.runtime; - -import java.nio.file.Path; - -public record BuildImageRequest( - String baseImage, - Path jarPath, - String imageTag -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/ContainerStatus.java b/src/main/java/net/siegeln/cameleer/saas/runtime/ContainerStatus.java deleted file mode 100644 index cdd1954..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/ContainerStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package net.siegeln.cameleer.saas.runtime; - -public record ContainerStatus( - String state, - boolean running, - int exitCode, - String error -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java b/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java deleted file mode 100644 index 6569047..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java +++ /dev/null @@ -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() { - @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); - } - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/LogConsumer.java b/src/main/java/net/siegeln/cameleer/saas/runtime/LogConsumer.java deleted file mode 100644 index c1adc0d..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/LogConsumer.java +++ /dev/null @@ -1,6 +0,0 @@ -package net.siegeln.cameleer.saas.runtime; - -@FunctionalInterface -public interface LogConsumer { - void accept(String stream, String message, long timestampMillis); -} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java b/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java deleted file mode 100644 index dbfd972..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeOrchestrator.java b/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeOrchestrator.java deleted file mode 100644 index 4ec24fa..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeOrchestrator.java +++ /dev/null @@ -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); -} diff --git a/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java b/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java deleted file mode 100644 index 26294a8..0000000 --- a/src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package net.siegeln.cameleer.saas.runtime; - -import java.util.Map; - -public record StartContainerRequest( - String imageRef, - String containerName, - String network, - Map envVars, - long memoryLimitBytes, - int cpuShares, - int healthCheckPort, - Map labels -) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java index 030998f..133c295 100644 --- a/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java @@ -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); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e742392..61b3351 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} diff --git a/src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java deleted file mode 100644 index 108d7f2..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java +++ /dev/null @@ -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()); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java deleted file mode 100644 index 2b0cf84..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java +++ /dev/null @@ -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()); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java deleted file mode 100644 index 6868c74..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java +++ /dev/null @@ -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"); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentControllerTest.java deleted file mode 100644 index 26b859f..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentControllerTest.java +++ /dev/null @@ -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()); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java deleted file mode 100644 index d4c9515..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/deployment/DeploymentServiceTest.java +++ /dev/null @@ -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); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java deleted file mode 100644 index 21e7960..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java +++ /dev/null @@ -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()); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java deleted file mode 100644 index f8bb833..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentServiceTest.java +++ /dev/null @@ -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"); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java b/src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java deleted file mode 100644 index 1914710..0000000 --- a/src/test/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestratorTest.java +++ /dev/null @@ -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); - } -} diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java index 0890b6a..7be62df 100644 --- a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java @@ -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