Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,14 @@ pub struct StorageConfig {
/// Number of rows to delete per chunk during storage enforcement
#[serde(default = "default_cleanup_chunk_size")]
pub cleanup_chunk_size: usize,
/// Time window (hours) during which high-severity (err/crit/alert/emerg) logs
/// are protected from disk-pressure deletion. 0 = disable the err+ floor.
#[serde(default = "default_err_floor_window_hours")]
pub err_floor_window_hours: u64,
/// Maximum err+ rows protected per source IP within the floor window. Bounds
/// any single source's share of the protected set. 0 = disable the floor.
#[serde(default = "default_err_floor_per_source_cap")]
pub err_floor_per_source_cap: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -463,10 +471,29 @@ fn default_recovery_db_size_mb() -> u64 {
900
}
fn default_min_free_disk_mb() -> u64 {
512
// 0 = disabled. Whole-filesystem free space is an EXTERNAL condition cortex
// cannot resolve by deleting its OWN data — see syslog-mcp-w4hh. When this is
// non-zero the enforcement path treats low free disk as a write-block signal,
// NOT as a trigger to self-trim. Default 0 to stop the self-wipe out of the box.
0
}
fn default_recovery_free_disk_mb() -> u64 {
768
// MUST stay paired with default_min_free_disk_mb: validate_storage_config
// requires recovery_free_disk_mb == 0 when min_free_disk_mb == 0, so a fresh
// StorageConfig::default() would FAIL validation if this were non-zero.
0
}
fn default_err_floor_window_hours() -> u64 {
// err+ rows received within this window are protected from disk-pressure
// deletion. Time-windowed (not unbounded) so an unauthenticated source cannot
// pin the floor indefinitely with severity=err spam (syslog-mcp-w4hh W1).
24
}
fn default_err_floor_per_source_cap() -> usize {
// Maximum err+ rows protected per source IP within the window. Bounds how much
// of the protected set any single (attacker-controlled) source can occupy, so
// one hostile sender cannot monopolise the floor (syslog-mcp-w4hh W1).
10_000
}
fn default_cleanup_interval_secs() -> u64 {
60
Expand Down Expand Up @@ -537,6 +564,8 @@ impl Default for StorageConfig {
recovery_free_disk_mb: default_recovery_free_disk_mb(),
cleanup_interval_secs: default_cleanup_interval_secs(),
cleanup_chunk_size: default_cleanup_chunk_size(),
err_floor_window_hours: default_err_floor_window_hours(),
err_floor_per_source_cap: default_err_floor_per_source_cap(),
}
}
}
Expand Down Expand Up @@ -713,6 +742,14 @@ impl Config {
"CORTEX_CLEANUP_CHUNK_SIZE",
&mut config.storage.cleanup_chunk_size,
)?;
env_override_parse(
"CORTEX_ERR_FLOOR_WINDOW_HOURS",
&mut config.storage.err_floor_window_hours,
)?;
env_override_parse(
"CORTEX_ERR_FLOOR_PER_SOURCE_CAP",
&mut config.storage.err_floor_per_source_cap,
)?;

// [mcp.auth] env overrides.
env_override_auth_mode("CORTEX_AUTH_MODE", &mut config.mcp.auth.mode)?;
Expand Down Expand Up @@ -1328,6 +1365,20 @@ fn validate_storage_config(storage: &StorageConfig) -> anyhow::Result<()> {
));
}

// err+ retention floor (syslog-mcp-w4hh). The floor is dimensioned in
// (time window × per-source row count), NOT bytes, so there is no
// meaningful "floor < max_db_size_mb" byte comparison. The coherent
// invariant is that the floor must not be self-contradictory: if a window
// is configured, the per-source cap must be > 0, otherwise the floor would
// protect a non-empty time window yet retain zero rows from it — a silent
// footgun that re-enables the err+ self-wipe the floor exists to prevent.
if storage.err_floor_window_hours > 0 && storage.err_floor_per_source_cap == 0 {
return Err(anyhow::anyhow!(
"err_floor_per_source_cap must be > 0 when err_floor_window_hours is set \
(a window with a zero per-source cap protects no err+ rows)"
));
}

Ok(())
}

Expand Down Expand Up @@ -1378,6 +1429,8 @@ impl StorageConfig {
recovery_free_disk_mb: 0,
cleanup_interval_secs: 60,
cleanup_chunk_size: 1,
err_floor_window_hours: default_err_floor_window_hours(),
err_floor_per_source_cap: default_err_floor_per_source_cap(),
}
}
}
Expand Down
79 changes: 71 additions & 8 deletions src/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,11 @@ fn defaults_include_storage_budget_settings() {
let cfg = Config::default();
assert_eq!(cfg.storage.max_db_size_mb, 1024);
assert_eq!(cfg.storage.recovery_db_size_mb, 900);
assert_eq!(cfg.storage.min_free_disk_mb, 512);
assert_eq!(cfg.storage.recovery_free_disk_mb, 768);
// syslog-mcp-w4hh: free-disk guardrail defaults to 0 (disabled) so cortex
// does not self-wipe to chase external whole-filesystem pressure. The two
// free-disk fields MUST default to 0 together to pass validate_storage_config.
assert_eq!(cfg.storage.min_free_disk_mb, 0);
assert_eq!(cfg.storage.recovery_free_disk_mb, 0);
assert_eq!(cfg.storage.cleanup_interval_secs, 60);
}

Expand Down Expand Up @@ -451,9 +454,10 @@ fn docker_ingest_requires_hosts_when_enabled() {
config.hosts.clear();

let err = validate_docker_ingest_config(&config).unwrap_err();
assert!(err
.to_string()
.contains("docker_ingest.hosts must not be empty"));
assert!(
err.to_string()
.contains("docker_ingest.hosts must not be empty")
);
}

#[test]
Expand All @@ -478,9 +482,10 @@ fn docker_ingest_rejects_duplicate_host_names() {
};

let err = validate_docker_ingest_config(&config).unwrap_err();
assert!(err
.to_string()
.contains("duplicate docker_ingest host name"));
assert!(
err.to_string()
.contains("duplicate docker_ingest host name")
);
}

#[test]
Expand Down Expand Up @@ -1017,3 +1022,61 @@ fn repo_local_oauth_config_rejects_allowed_emails_until_enforced() {
"wrong error: {err}"
);
}

// ---- syslog-mcp-w4hh: storage budget defaults + validation ----

/// The self-wipe stop-the-bleed: min_free_disk_mb defaults to 0 so cortex does
/// not treat external whole-FS pressure as a trigger to delete its own data.
#[test]
fn min_free_disk_mb_default_is_zero() {
let storage = StorageConfig::default();
assert_eq!(
storage.min_free_disk_mb, 0,
"min_free_disk_mb must default to 0 (no external-pressure self-wipe)"
);
}

/// W2 MUST-FIX: StorageConfig::default() must PASS validate_storage_config.
/// validate_storage_config rejects recovery_free_disk_mb != 0 when
/// min_free_disk_mb == 0, so default_recovery_free_disk_mb must also be 0 — else
/// fresh deploys crash at startup. StorageConfig::for_test uses 0/0 and so cannot
/// catch this; this test asserts the real Default impl.
#[test]
fn default_storage_config_passes_validation() {
let storage = StorageConfig::default();
assert_eq!(
storage.recovery_free_disk_mb, 0,
"recovery_free_disk_mb must default to 0 to pair with min_free_disk_mb=0"
);
validate_storage_config(&storage)
.expect("StorageConfig::default() must pass validate_storage_config (W2)");
}

/// A TOML config with no [storage] overrides must also deserialize to defaults
/// that pass validation — guards the serde-default path, not just Default::default.
#[test]
fn default_toml_storage_config_passes_validation() {
#[derive(serde::Deserialize)]
struct Wrapper {
#[serde(default)]
storage: StorageConfig,
}
let parsed: Wrapper = toml::from_str("").expect("empty config must deserialize");
validate_storage_config(&parsed.storage)
.expect("default-deserialized StorageConfig must pass validation (W2)");
}

/// The err+ floor invariant: a window with a zero per-source cap is rejected.
#[test]
fn err_floor_window_with_zero_cap_is_rejected() {
let storage = StorageConfig {
err_floor_window_hours: 24,
err_floor_per_source_cap: 0,
..StorageConfig::default()
};
let err = validate_storage_config(&storage).unwrap_err();
assert!(
err.to_string().contains("err_floor_per_source_cap"),
"wrong error: {err}"
);
}
5 changes: 3 additions & 2 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ pub use ingest::insert_logs_batch;
pub(crate) use ingest::insert_logs_batch_in_tx;
pub use maintenance::{
db_full_vacuum, db_incremental_vacuum, db_integrity_check, db_wal_checkpoint,
enforce_storage_budget, exceeds_trigger, get_storage_metrics, physical_size_bytes,
purge_by_tag_window, purge_old_heartbeats, purge_old_logs, DiskSpaceProbe,
enforce_storage_budget, enforce_storage_budget_with_state, exceeds_trigger,
get_storage_metrics, physical_size_bytes, purge_by_tag_window, purge_old_heartbeats,
purge_old_logs, DiskSpaceProbe, SystemDiskSpaceProbe,
};
pub(crate) use maintenance::{db_pragma_i64, db_pragma_string, PragmaName};
pub use models::{
Expand Down
Loading
Loading