Skip to content
Draft
9 changes: 9 additions & 0 deletions apps/temps-cli/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11922,6 +11922,11 @@ export type ProjectResponse = {
*/
gitlab_webhook_id?: number | null;
id: number;
/**
* Hours to retain built Docker images before nightly cleanup. Null = use the
* system-wide default from settings.
*/
image_retention_hours?: number | null;
last_deployment?: number | null;
main_branch: string;
name: string;
Expand Down Expand Up @@ -17194,6 +17199,10 @@ export type UpdateProjectSettingsRequest = {
*/
error_source_root?: string | null;
git_provider_connection_id?: number | null;
/**
* Hours to retain built Docker images. Omit = unchanged; null = use system default.
*/
image_retention_hours?: number | null;
main_branch?: string | null;
preset?: string | null;
preset_config?: null | PresetConfigSchema;
Expand Down
11 changes: 10 additions & 1 deletion apps/temps-cli/src/commands/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,22 @@ export function registerProjectsCommands(program: Command): void {

projects
.command('settings')
.description('Update project settings (slug, attack mode, preview environments)')
.description('Update project settings (slug, attack mode, preview environments, image retention)')
.option('-p, --project <project>', 'Project slug or ID')
.option('--slug <slug>', 'Project URL slug')
.option('--attack-mode', 'Enable attack mode (CAPTCHA protection)')
.option('--no-attack-mode', 'Disable attack mode')
.option('--preview-envs', 'Enable preview environments')
.option('--no-preview-envs', 'Disable preview environments')
.option(
'--image-retention-hours <hours>',
'Hours to keep built images before nightly cleanup removes them (1-8760). ' +
'Images are needed to roll back, so this is the project rollback window'
)
.option(
'--reset-image-retention',
'Clear the per-project image retention override and use the system default'
)
.option('--json', 'Output in JSON format')
.option('-y, --yes', 'Skip prompts (for automation)')
.action(updateSettingsAction)
Expand Down
57 changes: 56 additions & 1 deletion apps/temps-cli/src/commands/projects/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,36 @@ export async function updateSettingsAction(
slug?: string
attackMode?: boolean
previewEnvs?: boolean
imageRetentionHours?: string
resetImageRetention?: boolean
json?: boolean
yes?: boolean
}
): Promise<void> {
// Validate arguments before authenticating or hitting the network, so a
// typo'd flag fails immediately instead of after a project lookup.
//
// Tri-state, matching the PATCH contract: `undefined` leaves the value
// unchanged, `null` clears the override back to the system default, and a
// number sets an explicit per-project window.
let imageRetentionHours: number | null | undefined
if (options.resetImageRetention && options.imageRetentionHours !== undefined) {
throw new Error(
'Pass either --image-retention-hours or --reset-image-retention, not both'
)
}
if (options.resetImageRetention) {
imageRetentionHours = null
} else if (options.imageRetentionHours !== undefined) {
const parsed = Number(options.imageRetentionHours)
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 8760) {
throw new Error(
`--image-retention-hours must be a whole number between 1 and 8760 (got "${options.imageRetentionHours}")`
)
}
imageRetentionHours = parsed
}

await requireAuth()
await setupClient()

Expand Down Expand Up @@ -147,7 +173,13 @@ export async function updateSettingsAction(
let previewEnvs = options.previewEnvs

// Only prompt if no flags provided AND not in automation mode
if (slug === undefined && attackMode === undefined && previewEnvs === undefined && !options.yes) {
if (
slug === undefined &&
attackMode === undefined &&
previewEnvs === undefined &&
imageRetentionHours === undefined &&
!options.yes
) {
newline()
header('Update Project Settings')
info(`Current settings for "${project.name}"`)
Expand Down Expand Up @@ -177,6 +209,12 @@ export async function updateSettingsAction(
slug: slug ?? undefined,
attack_mode: attackMode ?? undefined,
enable_preview_environments: previewEnvs ?? undefined,
// Only include the key when the user actually asked to change it —
// sending `null` unconditionally would silently reset every project
// to the system default.
...(imageRetentionHours !== undefined
? { image_retention_hours: imageRetentionHours }
: {}),
},
})
if (error) {
Expand All @@ -194,6 +232,23 @@ export async function updateSettingsAction(
keyValue('Slug', slug ?? project.slug)
keyValue('Attack Mode', attackMode ? colors.success('Enabled') : colors.muted('Disabled'))
keyValue('Preview Environments', previewEnvs ? colors.success('Enabled') : colors.muted('Disabled'))

const effectiveRetention =
imageRetentionHours !== undefined
? imageRetentionHours
: (updated?.image_retention_hours ?? null)
keyValue(
'Image Retention',
effectiveRetention === null
? colors.muted('System default')
: `${effectiveRetention}h`
)
if (effectiveRetention !== null && effectiveRetention < 48) {
warning(
`Rollback is only possible while a deployment's image still exists. ` +
`At ${effectiveRetention}h, deployments older than that can no longer be rolled back to.`
)
}
}

export async function updateGitAction(
Expand Down
1 change: 1 addition & 0 deletions crates/temps-agents/src/services/config_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ mod tests {
fn make_project(id: i32, has_git: bool) -> projects::Model {
projects::Model {
id,
image_retention_hours: None,
name: "test".into(),
repo_name: "repo".into(),
repo_owner: "owner".into(),
Expand Down
1 change: 1 addition & 0 deletions crates/temps-agents/src/services/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,7 @@ mod tests {
fn make_project(id: i32) -> projects::Model {
projects::Model {
id,
image_retention_hours: None,
name: "test-app".into(),
repo_name: "repo".into(),
repo_owner: "testowner".into(),
Expand Down
1 change: 1 addition & 0 deletions crates/temps-ai-chat/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,7 @@ mod tests {
let now = chrono::Utc::now();
temps_entities::projects::Model {
id,
image_retention_hours: None,
name: "P".to_string(),
repo_name: "r".to_string(),
repo_owner: "o".to_string(),
Expand Down
1 change: 1 addition & 0 deletions crates/temps-ai-chat/src/pending_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ mod tests {
let now = Utc::now();
temps_entities::projects::Model {
id,
image_retention_hours: None,
name: "test-project".to_string(),
repo_name: "repo".to_string(),
repo_owner: "owner".to_string(),
Expand Down
1 change: 1 addition & 0 deletions crates/temps-ai-chat/src/providers/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ mod tests {
let now = chrono::Utc::now();
projects::Model {
id,
image_retention_hours: None,
name: name.to_string(),
repo_name: "repo".to_string(),
repo_owner: "owner".to_string(),
Expand Down
1 change: 1 addition & 0 deletions crates/temps-ai-chat/src/providers/repo_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ mod tests {
let now = chrono::Utc::now();
projects::Model {
id,
image_retention_hours: None,
name: "test".to_string(),
repo_name: "repo".to_string(),
repo_owner: "owner".to_string(),
Expand Down
1 change: 1 addition & 0 deletions crates/temps-ai-chat/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,7 @@ mod tests {
let now = Utc::now();
temps_entities::projects::Model {
id,
image_retention_hours: None,
name: name.to_string(),
repo_name: "r".to_string(),
repo_owner: "o".to_string(),
Expand Down
12 changes: 9 additions & 3 deletions crates/temps-config/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ use temps_core::error_builder::ErrorBuilder;
use temps_core::{
problemdetails::Problem, AiConfigSettings, AppSettings, AuditContext, AuditLogger,
AuditOperation, BuildLimitsSettings, ClusterDnsSettings, ContainerLogSettings,
DiskSpaceAlertSettings, LetsEncryptSettings, MetricsStoreKind, MonitoringSettings,
ObservabilityCompressionSettings, ObservabilityRetentionSettings, PublicHostnameStrategy,
RateLimitSettings, RequestMetadata, ScreenshotSettings, SecurityHeadersSettings,
DiskSpaceAlertSettings, ImageRetentionSettings, LetsEncryptSettings, MetricsStoreKind,
MonitoringSettings, ObservabilityCompressionSettings, ObservabilityRetentionSettings,
PublicHostnameStrategy, RateLimitSettings, RequestMetadata, ScreenshotSettings,
SecurityHeadersSettings,
};
use tracing::{error, info};
use utoipa::{OpenApi, ToSchema};
Expand Down Expand Up @@ -172,6 +173,10 @@ pub struct AppSettingsResponse {
/// Build-time resource limits (control-plane only). No sensitive content,
/// passed through as-is.
pub build_limits: BuildLimitsSettings,

/// Deployment-image retention policy. No sensitive content, passed through
/// as-is so the settings UI can show and edit the system-wide default.
pub image_retention: ImageRetentionSettings,
}

/// Monitoring settings with the ClickHouse DSN masked.
Expand Down Expand Up @@ -383,6 +388,7 @@ impl From<AppSettings> for AppSettingsResponse {
require_mfa_for_admins: settings.require_mfa_for_admins,
cluster_dns: settings.cluster_dns,
build_limits: settings.build_limits,
image_retention: settings.image_retention,
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions crates/temps-core/src/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ pub struct AppSettings {
/// hardware that already has its own per-host headroom).
pub build_limits: BuildLimitsSettings,

/// Retention policy for locally-built deployment images. Modeled as a
/// settings row (not an env var) per CLAUDE.md so an operator can change
/// the system-wide default at runtime without restarting the binary.
/// Individual projects override it via `projects.image_retention_hours`.
pub image_retention: ImageRetentionSettings,

/// Cluster-DNS resolver settings (ADR-024, experimental beta). Off by
/// default — see `ClusterDnsSettings` for the incident background and
/// trade-offs. Must be explicitly enabled by operators who need
Expand Down Expand Up @@ -194,6 +200,48 @@ pub struct BuildLimitsSettings {
pub memory_limit_mb: u32,
}

/// System-wide retention policy for locally-built deployment images.
///
/// The nightly cleanup removes a Temps-built image only once *every*
/// deployment that references it is older than the owning project's retention
/// window. Deleting an image makes rollback/promotion to that deployment
/// impossible, so the default is deliberately generous: it is a rollback
/// window, not a cache TTL.
#[derive(Debug, Clone, Serialize, ToSchema, Deserialize)]
#[serde(default)]
pub struct ImageRetentionSettings {
/// Whether the nightly pass removes expired deployment images at all.
/// Disabling it keeps every built image forever (the pre-0.1 behaviour).
pub enabled: bool,

/// Default hours to keep a built deployment image when the owning project
/// has no `image_retention_hours` override. Valid range 1..=8760.
#[schema(minimum = 1, maximum = 8760, example = 336)]
pub default_hours: i64,
}

impl Default for ImageRetentionSettings {
fn default() -> Self {
Self {
enabled: true,
// 14 days. Long enough that a rollback is still possible after a
// quiet week; short enough to bound disk growth. A 48h default
// would silently destroy the rollback history of any project that
// did not deploy over a long weekend.
default_hours: 336,
}
}
}

impl ImageRetentionSettings {
/// Clamp `default_hours` into the range the projects API accepts, so a
/// hand-edited settings row can never produce a cutoff that deletes images
/// the moment they are built (or one that never expires by accident).
pub fn effective_default_hours(&self) -> i64 {
self.default_hours.clamp(1, 8760)
}
}

impl Default for BuildLimitsSettings {
fn default() -> Self {
Self {
Expand Down Expand Up @@ -825,6 +873,7 @@ impl Default for AppSettings {
security_headers: SecurityHeadersSettings::default(),
rate_limiting: RateLimitSettings::default(),
docker_registry: DockerRegistrySettings::default(),
image_retention: ImageRetentionSettings::default(),
disk_space_alert: DiskSpaceAlertSettings::default(),
container_logs: ContainerLogSettings::default(),
multi_node: MultiNodeSettings::default(),
Expand Down
7 changes: 4 additions & 3 deletions crates/temps-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ pub use anyhow;
pub use app_settings::{
AgentSandboxSettings, AiConfigSettings, AppSettings, BuildLimitsSettings, ClusterDnsSettings,
ContainerLogSettings, DiskSpaceAlertSettings, DnsProviderSettings, DockerRegistrySettings,
LetsEncryptSettings, MetricsStoreKind, MonitoringSettings, MultiNodeSettings,
ObservabilityCompressionSettings, ObservabilityRetentionSettings, PreviewGatewaySettings,
ProviderConfig, RateLimitSettings, ScreenshotSettings, SecurityHeadersSettings,
ImageRetentionSettings, LetsEncryptSettings, MetricsStoreKind, MonitoringSettings,
MultiNodeSettings, ObservabilityCompressionSettings, ObservabilityRetentionSettings,
PreviewGatewaySettings, ProviderConfig, RateLimitSettings, ScreenshotSettings,
SecurityHeadersSettings,
};
pub use async_trait;
pub use chrono;
Expand Down
17 changes: 16 additions & 1 deletion crates/temps-deployments/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,28 @@ impl TempsPlugin for DeploymentsPlugin {
let cas_dir = config_service.data_dir().join("cas");
let cleanup_file_store: Arc<dyn temps_file_store::FileStore> =
Arc::new(temps_file_store::fs_store::FsFileStore::new(cas_dir));
// Operator-configured image retention (settings row, not an env
// var). Falls back to the built-in default when settings cannot be
// read so a transient DB hiccup at boot cannot silently disable or
// over-aggressively enable image pruning.
let image_retention = match config_service.get_settings().await {
Ok(settings) => settings.image_retention,
Err(e) => {
tracing::warn!(
error = %e,
"Could not read image retention settings; using defaults"
);
temps_core::ImageRetentionSettings::default()
}
};
let docker_cleanup = Arc::new(
crate::services::DockerCleanupService::new(
Arc::new(crate::services::DefaultDockerClient),
db.clone(),
cleanup_file_store,
)
.with_static_dir(config_service.static_dir()),
.with_static_dir(config_service.static_dir())
.with_image_retention(&image_retention),
);
tokio::spawn({
let cleanup_service = docker_cleanup.clone();
Expand Down
Loading