diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java index f558435a..9b83780c 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java @@ -104,6 +104,24 @@ public class EnvironmentAdminController { } } + @PutMapping("/{id}/jar-retention") + @Operation(summary = "Update JAR retention policy for an environment") + @ApiResponse(responseCode = "200", description = "Retention policy updated") + @ApiResponse(responseCode = "404", description = "Environment not found") + public ResponseEntity updateJarRetention(@PathVariable UUID id, + @RequestBody JarRetentionRequest request) { + try { + environmentService.updateJarRetentionCount(id, request.jarRetentionCount()); + return ResponseEntity.ok(environmentService.getById(id)); + } catch (IllegalArgumentException e) { + if (e.getMessage().contains("not found")) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + public record CreateEnvironmentRequest(String slug, String displayName, boolean production) {} public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {} + public record JarRetentionRequest(Integer jarRetentionCount) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/retention/JarRetentionJob.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/retention/JarRetentionJob.java new file mode 100644 index 00000000..d39d9744 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/retention/JarRetentionJob.java @@ -0,0 +1,112 @@ +package com.cameleer3.server.app.retention; + +import com.cameleer3.server.core.runtime.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Nightly job that enforces JAR retention policies per environment. + * For each app, keeps the N most recent versions (configured per environment) + * and deletes older ones — unless they are currently deployed. + */ +@Component +public class JarRetentionJob { + + private static final Logger log = LoggerFactory.getLogger(JarRetentionJob.class); + + private final EnvironmentService environmentService; + private final AppService appService; + private final AppVersionRepository versionRepo; + private final DeploymentRepository deploymentRepo; + + public JarRetentionJob(EnvironmentService environmentService, + AppService appService, + AppVersionRepository versionRepo, + DeploymentRepository deploymentRepo) { + this.environmentService = environmentService; + this.appService = appService; + this.versionRepo = versionRepo; + this.deploymentRepo = deploymentRepo; + } + + @Scheduled(cron = "0 0 3 * * *") // 03:00 every day + public void cleanupOldVersions() { + log.info("JAR retention job started"); + int totalDeleted = 0; + + for (Environment env : environmentService.listAll()) { + Integer retentionCount = env.jarRetentionCount(); + if (retentionCount == null) { + log.debug("Environment {} has unlimited retention, skipping", env.slug()); + continue; + } + + for (App app : appService.listByEnvironment(env.id())) { + totalDeleted += cleanupApp(app, retentionCount); + } + } + + log.info("JAR retention job completed — deleted {} versions", totalDeleted); + } + + private int cleanupApp(App app, int retentionCount) { + List versions = versionRepo.findByAppId(app.id()); // ordered DESC by version + if (versions.size() <= retentionCount) return 0; + + // Find version IDs that are currently deployed (any status) + Set deployedVersionIds = deploymentRepo.findByAppId(app.id()).stream() + .map(Deployment::appVersionId) + .collect(Collectors.toSet()); + + int deleted = 0; + // versions is sorted DESC — skip the first retentionCount, delete the rest + for (int i = retentionCount; i < versions.size(); i++) { + AppVersion version = versions.get(i); + if (deployedVersionIds.contains(version.id())) { + log.debug("Skipping deployed version v{} of app {} ({})", version.version(), app.slug(), version.id()); + continue; + } + + // Delete JAR from disk + deleteJarFile(version); + + // Delete DB record + versionRepo.delete(version.id()); + deleted++; + log.info("Deleted version v{} of app {} ({}) — JAR: {}", version.version(), app.slug(), version.id(), version.jarPath()); + } + + return deleted; + } + + private void deleteJarFile(AppVersion version) { + try { + Path jarPath = Path.of(version.jarPath()); + if (Files.exists(jarPath)) { + Files.delete(jarPath); + // Try to remove the empty version directory + Path versionDir = jarPath.getParent(); + if (versionDir != null && Files.isDirectory(versionDir)) { + try (var entries = Files.list(versionDir)) { + if (entries.findFirst().isEmpty()) { + Files.delete(versionDir); + } + } + } + } + } catch (IOException e) { + log.warn("Failed to delete JAR file for version {}: {}", version.id(), e.getMessage()); + } + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java index 1ca89d17..3e947f70 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAppVersionRepository.java @@ -49,6 +49,11 @@ public class PostgresAppVersionRepository implements AppVersionRepository { return id; } + @Override + public void delete(UUID id) { + jdbc.update("DELETE FROM app_versions WHERE id = ?", id); + } + private AppVersion mapRow(ResultSet rs) throws SQLException { Long sizeBytes = rs.getLong("jar_size_bytes"); if (rs.wasNull()) sizeBytes = null; diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java index ca28003d..e2cbb042 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresEnvironmentRepository.java @@ -24,17 +24,19 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { this.objectMapper = objectMapper; } + private static final String SELECT_COLS = "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, created_at"; + @Override public List findAll() { return jdbc.query( - "SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments ORDER BY created_at", + "SELECT " + SELECT_COLS + " FROM environments ORDER BY created_at", (rs, rowNum) -> mapRow(rs)); } @Override public Optional findById(UUID id) { var results = jdbc.query( - "SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments WHERE id = ?", + "SELECT " + SELECT_COLS + " FROM environments WHERE id = ?", (rs, rowNum) -> mapRow(rs), id); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @@ -42,7 +44,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { @Override public Optional findBySlug(String slug) { var results = jdbc.query( - "SELECT id, slug, display_name, production, enabled, default_container_config, created_at FROM environments WHERE slug = ?", + "SELECT " + SELECT_COLS + " FROM environments WHERE slug = ?", (rs, rowNum) -> mapRow(rs), slug); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @@ -61,6 +63,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { displayName, production, enabled, id); } + @Override + public void updateJarRetentionCount(UUID id, Integer jarRetentionCount) { + jdbc.update("UPDATE environments SET jar_retention_count = ? WHERE id = ?", jarRetentionCount, id); + } + @Override public void delete(UUID id) { jdbc.update("DELETE FROM environments WHERE id = ?", id); @@ -84,6 +91,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { config = objectMapper.readValue(json, MAP_TYPE); } } catch (Exception e) { /* use empty default */ } + int retentionRaw = rs.getInt("jar_retention_count"); + Integer jarRetentionCount = rs.wasNull() ? null : retentionRaw; return new Environment( UUID.fromString(rs.getString("id")), rs.getString("slug"), @@ -91,6 +100,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { rs.getBoolean("production"), rs.getBoolean("enabled"), config, + jarRetentionCount, rs.getTimestamp("created_at").toInstant() ); } diff --git a/cameleer3-server-app/src/main/resources/db/migration/V6__jar_retention_policy.sql b/cameleer3-server-app/src/main/resources/db/migration/V6__jar_retention_policy.sql new file mode 100644 index 00000000..09deb468 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V6__jar_retention_policy.sql @@ -0,0 +1 @@ +ALTER TABLE environments ADD COLUMN jar_retention_count INTEGER DEFAULT 5; diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java index 290f7a33..acb98ca4 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/AppVersionRepository.java @@ -9,4 +9,5 @@ public interface AppVersionRepository { Optional findById(UUID id); int findMaxVersion(UUID appId); UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes); + void delete(UUID id); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java index e2563ece..52dde023 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Environment.java @@ -11,5 +11,6 @@ public record Environment( boolean production, boolean enabled, Map defaultContainerConfig, + Integer jarRetentionCount, Instant createdAt ) {} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java index c4bba5d9..b4b205a4 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentRepository.java @@ -12,5 +12,6 @@ public interface EnvironmentRepository { UUID create(String slug, String displayName, boolean production); void update(UUID id, String displayName, boolean production, boolean enabled); void updateDefaultContainerConfig(UUID id, Map defaultContainerConfig); + void updateJarRetentionCount(UUID id, Integer jarRetentionCount); void delete(UUID id); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java index adc329e2..d03d17d2 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/EnvironmentService.java @@ -38,6 +38,14 @@ public class EnvironmentService { repo.updateDefaultContainerConfig(id, defaultContainerConfig); } + public void updateJarRetentionCount(UUID id, Integer jarRetentionCount) { + getById(id); // verify exists + if (jarRetentionCount != null && jarRetentionCount < 1) { + throw new IllegalArgumentException("Retention count must be at least 1 or null for unlimited"); + } + repo.updateJarRetentionCount(id, jarRetentionCount); + } + public void delete(UUID id) { Environment env = getById(id); if ("default".equals(env.slug())) { diff --git a/ui/src/api/queries/admin/environments.ts b/ui/src/api/queries/admin/environments.ts index 6949bb5f..aefeb425 100644 --- a/ui/src/api/queries/admin/environments.ts +++ b/ui/src/api/queries/admin/environments.ts @@ -8,6 +8,7 @@ export interface Environment { production: boolean; enabled: boolean; defaultContainerConfig: Record; + jarRetentionCount: number | null; createdAt: string; } @@ -66,6 +67,18 @@ export function useUpdateDefaultContainerConfig() { }); } +export function useUpdateJarRetention() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, jarRetentionCount }: { id: string; jarRetentionCount: number | null }) => + adminFetch(`/environments/${id}/jar-retention`, { + method: 'PUT', + body: JSON.stringify({ jarRetentionCount }), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }), + }); +} + export function useDeleteEnvironment() { const qc = useQueryClient(); return useMutation({ diff --git a/ui/src/pages/Admin/EnvironmentsPage.tsx b/ui/src/pages/Admin/EnvironmentsPage.tsx index 2b5206e4..281f2340 100644 --- a/ui/src/pages/Admin/EnvironmentsPage.tsx +++ b/ui/src/pages/Admin/EnvironmentsPage.tsx @@ -21,6 +21,7 @@ import { useUpdateEnvironment, useDeleteEnvironment, useUpdateDefaultContainerConfig, + useUpdateJarRetention, } from '../../api/queries/admin/environments'; import type { Environment } from '../../api/queries/admin/environments'; import styles from './UserManagement.module.css'; @@ -44,6 +45,7 @@ export default function EnvironmentsPage() { const updateEnv = useUpdateEnvironment(); const deleteEnv = useDeleteEnvironment(); const updateDefaults = useUpdateDefaultContainerConfig(); + const updateRetention = useUpdateJarRetention(); const selected = useMemo( () => environments.find((e) => e.id === selectedId) ?? null, @@ -291,6 +293,15 @@ export default function EnvironmentsPage() { toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 }); } }} saving={updateDefaults.isPending} /> + + { + try { + await updateRetention.mutateAsync({ id: selected.id, jarRetentionCount: count }); + toast({ title: 'Retention policy updated', variant: 'success' }); + } catch { + toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 }); + } + }} saving={updateRetention.isPending} /> ) : null } @@ -389,3 +400,71 @@ function DefaultResourcesSection({ environment, onSave, saving }: { ); } + +// ── JAR Retention Policy ──────────────────────────────────────────── + +function JarRetentionSection({ environment, onSave, saving }: { + environment: Environment; + onSave: (count: number | null) => Promise; + saving: boolean; +}) { + const current = environment.jarRetentionCount; + const [editing, setEditing] = useState(false); + const [unlimited, setUnlimited] = useState(current === null); + const [count, setCount] = useState(String(current ?? 5)); + + useEffect(() => { + setUnlimited(environment.jarRetentionCount === null); + setCount(String(environment.jarRetentionCount ?? 5)); + setEditing(false); + }, [environment.id]); + + function handleCancel() { + setUnlimited(current === null); + setCount(String(current ?? 5)); + setEditing(false); + } + + async function handleSave() { + await onSave(unlimited ? null : Math.max(1, parseInt(count) || 5)); + setEditing(false); + } + + return ( + <> + JAR Retention +

+ Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted. +

+
+ Policy + {editing ? ( +
+ setUnlimited(!unlimited)} /> + {unlimited + ? Keep all versions (unlimited) + : <> + Keep last + setCount(e.target.value)} style={{ width: 60 }} /> + versions + } +
+ ) : ( + + {current === null ? 'Unlimited (no cleanup)' : `Keep last ${current} versions`} + + )} +
+
+ {editing ? ( + <> + + + + ) : ( + + )} +
+ + ); +}