feat: add app service with JAR upload and tier enforcement

Implements AppService with JAR file storage, SHA-256 checksum computation,
tier-based app limit enforcement via LicenseDefaults, and audit logging.
Four TDD tests all pass covering creation, JAR validation, duplicate slug
rejection, and JAR re-upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 17:47:05 +02:00
parent 2151801d40
commit 51f5822364
2 changed files with 328 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
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, UUID actorId) {
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 relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
var checksum = storeJar(jarFile, relativePath);
var entity = new AppEntity();
entity.setEnvironmentId(envId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setJarStoragePath(relativePath);
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
var saved = appRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.APP_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public AppEntity reuploadJar(UUID appId, MultipartFile jarFile, UUID actorId) {
validateJarFile(jarFile);
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
var checksum = storeJar(jarFile, entity.getJarStoragePath());
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
return appRepository.save(entity);
}
public List<AppEntity> listByEnvironmentId(UUID envId) {
return appRepository.findByEnvironmentId(envId);
}
public Optional<AppEntity> getById(UUID id) {
return appRepository.findById(id);
}
public void delete(UUID appId, UUID actorId) {
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
appRepository.delete(entity);
var env = environmentRepository.findById(entity.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_DELETE, entity.getSlug(),
null, null, "SUCCESS", null);
}
public 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");
}
}
}