feat: JAR retention policy with nightly cleanup job
Per-environment "keep last N versions" setting (default 5, null for unlimited). Nightly scheduled job at 03:00 deletes old versions from both database and disk, skipping any version that is currently deployed. Full stack: - V6 migration: adds jar_retention_count column to environments - Environment record, repository, service, admin controller endpoint - JarRetentionJob: @Scheduled nightly, iterates environments and apps - UI: retention policy editor on admin Environments page with toggle between limited/unlimited and version count input - AppVersionRepository.delete() for version cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 CreateEnvironmentRequest(String slug, String displayName, boolean production) {}
|
||||||
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
|
public record UpdateEnvironmentRequest(String displayName, boolean production, boolean enabled) {}
|
||||||
|
public record JarRetentionRequest(Integer jarRetentionCount) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AppVersion> 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<UUID> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,11 @@ public class PostgresAppVersionRepository implements AppVersionRepository {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(UUID id) {
|
||||||
|
jdbc.update("DELETE FROM app_versions WHERE id = ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
private AppVersion mapRow(ResultSet rs) throws SQLException {
|
private AppVersion mapRow(ResultSet rs) throws SQLException {
|
||||||
Long sizeBytes = rs.getLong("jar_size_bytes");
|
Long sizeBytes = rs.getLong("jar_size_bytes");
|
||||||
if (rs.wasNull()) sizeBytes = null;
|
if (rs.wasNull()) sizeBytes = null;
|
||||||
|
|||||||
@@ -24,17 +24,19 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final String SELECT_COLS = "id, slug, display_name, production, enabled, default_container_config, jar_retention_count, created_at";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Environment> findAll() {
|
public List<Environment> findAll() {
|
||||||
return jdbc.query(
|
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));
|
(rs, rowNum) -> mapRow(rs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Environment> findById(UUID id) {
|
public Optional<Environment> findById(UUID id) {
|
||||||
var results = jdbc.query(
|
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);
|
(rs, rowNum) -> mapRow(rs), id);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<Environment> findBySlug(String slug) {
|
public Optional<Environment> findBySlug(String slug) {
|
||||||
var results = jdbc.query(
|
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);
|
(rs, rowNum) -> mapRow(rs), slug);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
displayName, production, enabled, id);
|
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
|
@Override
|
||||||
public void delete(UUID id) {
|
public void delete(UUID id) {
|
||||||
jdbc.update("DELETE FROM environments WHERE id = ?", id);
|
jdbc.update("DELETE FROM environments WHERE id = ?", id);
|
||||||
@@ -84,6 +91,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
config = objectMapper.readValue(json, MAP_TYPE);
|
config = objectMapper.readValue(json, MAP_TYPE);
|
||||||
}
|
}
|
||||||
} catch (Exception e) { /* use empty default */ }
|
} catch (Exception e) { /* use empty default */ }
|
||||||
|
int retentionRaw = rs.getInt("jar_retention_count");
|
||||||
|
Integer jarRetentionCount = rs.wasNull() ? null : retentionRaw;
|
||||||
return new Environment(
|
return new Environment(
|
||||||
UUID.fromString(rs.getString("id")),
|
UUID.fromString(rs.getString("id")),
|
||||||
rs.getString("slug"),
|
rs.getString("slug"),
|
||||||
@@ -91,6 +100,7 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
rs.getBoolean("production"),
|
rs.getBoolean("production"),
|
||||||
rs.getBoolean("enabled"),
|
rs.getBoolean("enabled"),
|
||||||
config,
|
config,
|
||||||
|
jarRetentionCount,
|
||||||
rs.getTimestamp("created_at").toInstant()
|
rs.getTimestamp("created_at").toInstant()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE environments ADD COLUMN jar_retention_count INTEGER DEFAULT 5;
|
||||||
@@ -9,4 +9,5 @@ public interface AppVersionRepository {
|
|||||||
Optional<AppVersion> findById(UUID id);
|
Optional<AppVersion> findById(UUID id);
|
||||||
int findMaxVersion(UUID appId);
|
int findMaxVersion(UUID appId);
|
||||||
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
|
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
|
||||||
|
void delete(UUID id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public record Environment(
|
|||||||
boolean production,
|
boolean production,
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
Map<String, Object> defaultContainerConfig,
|
Map<String, Object> defaultContainerConfig,
|
||||||
|
Integer jarRetentionCount,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ public interface EnvironmentRepository {
|
|||||||
UUID create(String slug, String displayName, boolean production);
|
UUID create(String slug, String displayName, boolean production);
|
||||||
void update(UUID id, String displayName, boolean production, boolean enabled);
|
void update(UUID id, String displayName, boolean production, boolean enabled);
|
||||||
void updateDefaultContainerConfig(UUID id, Map<String, Object> defaultContainerConfig);
|
void updateDefaultContainerConfig(UUID id, Map<String, Object> defaultContainerConfig);
|
||||||
|
void updateJarRetentionCount(UUID id, Integer jarRetentionCount);
|
||||||
void delete(UUID id);
|
void delete(UUID id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ public class EnvironmentService {
|
|||||||
repo.updateDefaultContainerConfig(id, defaultContainerConfig);
|
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) {
|
public void delete(UUID id) {
|
||||||
Environment env = getById(id);
|
Environment env = getById(id);
|
||||||
if ("default".equals(env.slug())) {
|
if ("default".equals(env.slug())) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Environment {
|
|||||||
production: boolean;
|
production: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
defaultContainerConfig: Record<string, unknown>;
|
defaultContainerConfig: Record<string, unknown>;
|
||||||
|
jarRetentionCount: number | null;
|
||||||
createdAt: string;
|
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<Environment>(`/environments/${id}/jar-retention`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ jarRetentionCount }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDeleteEnvironment() {
|
export function useDeleteEnvironment() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
useUpdateEnvironment,
|
useUpdateEnvironment,
|
||||||
useDeleteEnvironment,
|
useDeleteEnvironment,
|
||||||
useUpdateDefaultContainerConfig,
|
useUpdateDefaultContainerConfig,
|
||||||
|
useUpdateJarRetention,
|
||||||
} from '../../api/queries/admin/environments';
|
} from '../../api/queries/admin/environments';
|
||||||
import type { Environment } from '../../api/queries/admin/environments';
|
import type { Environment } from '../../api/queries/admin/environments';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
@@ -44,6 +45,7 @@ export default function EnvironmentsPage() {
|
|||||||
const updateEnv = useUpdateEnvironment();
|
const updateEnv = useUpdateEnvironment();
|
||||||
const deleteEnv = useDeleteEnvironment();
|
const deleteEnv = useDeleteEnvironment();
|
||||||
const updateDefaults = useUpdateDefaultContainerConfig();
|
const updateDefaults = useUpdateDefaultContainerConfig();
|
||||||
|
const updateRetention = useUpdateJarRetention();
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => environments.find((e) => e.id === selectedId) ?? null,
|
() => 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 });
|
toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 });
|
||||||
}
|
}
|
||||||
}} saving={updateDefaults.isPending} />
|
}} saving={updateDefaults.isPending} />
|
||||||
|
|
||||||
|
<JarRetentionSection environment={selected} onSave={async (count) => {
|
||||||
|
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
|
) : 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<void>;
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<SectionHeader>JAR Retention</SectionHeader>
|
||||||
|
<p className={styles.inheritedNote}>
|
||||||
|
Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted.
|
||||||
|
</p>
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>Policy</span>
|
||||||
|
{editing ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Toggle checked={!unlimited} onChange={() => setUnlimited(!unlimited)} />
|
||||||
|
{unlimited
|
||||||
|
? <span>Keep all versions (unlimited)</span>
|
||||||
|
: <>
|
||||||
|
<span>Keep last</span>
|
||||||
|
<Input value={count} onChange={(e) => setCount(e.target.value)} style={{ width: 60 }} />
|
||||||
|
<span>versions</span>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{current === null ? 'Unlimited (no cleanup)' : `Keep last ${current} versions`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleCancel}>Cancel</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleSave} loading={saving}>Save</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Policy</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user