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
80 changes: 58 additions & 22 deletions crates/commons-servers/src/backup_jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,36 +92,46 @@ impl RetentionPolicy {
}

/// Merge a per-`(group, type)` retention override with the type's default and
/// the org floor: the schedule override JSON wins; else the type default JSON;
/// else (or on parse error) the floor baseline. The result always has the floor
/// enforced. Pure so the precedence/floor logic is unit-testable without a DB.
fn resolve_policy(override_json: Option<Value>, default_json: Option<Value>) -> RetentionPolicy {
let json = override_json.or(default_json);
match json.map(serde_json::from_value::<RetentionPolicy>) {
Some(Ok(policy)) => policy.enforce_floor(),
_ => RetentionPolicy::floor_baseline(),
/// the org floor: the schedule override wins; else the type default; else (or on
/// parse error) the floor baseline. The floor is enforced on the result **unless**
/// the winning source opted out (`allow_below_floor`, a dangerous per-config
/// toggle for backups we're not authorised to keep). Each source is an
/// `(json, allow_below_floor)` pair. Pure so the precedence/floor logic is
/// unit-testable without a DB.
fn resolve_policy(
override_: Option<(Value, bool)>,
default: Option<(Value, bool)>,
) -> RetentionPolicy {
match override_.or(default) {
Some((json, allow_below_floor)) => match serde_json::from_value::<RetentionPolicy>(json) {
Ok(policy) if allow_below_floor => policy,
Ok(policy) => policy.enforce_floor(),
Err(_) => RetentionPolicy::floor_baseline(),
},
None => RetentionPolicy::floor_baseline(),
}
}

/// Resolve the effective retention policy for each backup type **declared** in
/// the group (not just enabled): schedule override → type default → org floor,
/// with the floor always enforced. Returns one `(type, policy)` pair per declared
/// type — so a manual backup of a non-scheduled (disabled) type is still retained
/// under its own type policy rather than only the repo's global baseline.
/// the group (not just enabled): schedule override → type default → org floor.
/// The floor is enforced unless the winning source set `allow_below_floor`.
/// Returns one `(type, policy)` pair per declared type — so a manual backup of a
/// non-scheduled (disabled) type is still retained under its own type policy
/// rather than only the repo's global baseline.
pub async fn effective_retention_for_group(
db: &mut AsyncPgConnection,
group_id: Uuid,
) -> Result<Vec<(BackupType, RetentionPolicy)>> {
let types = ServerBackupCapability::declared_types_for_group(db, group_id).await?;
let mut out = Vec::with_capacity(types.len());
for ty in types {
let override_json = ServerGroupBackupSchedule::get(db, group_id, &ty)
let override_ = ServerGroupBackupSchedule::get(db, group_id, &ty)
.await?
.and_then(|s| s.retention);
let default_json = BackupTypeDefault::get(db, &ty)
.and_then(|s| s.retention.map(|r| (r, s.allow_below_floor)));
let default = BackupTypeDefault::get(db, &ty)
.await?
.map(|d| d.default_retention);
out.push((ty, resolve_policy(override_json, default_json)));
.map(|d| (d.default_retention, d.allow_below_floor));
out.push((ty, resolve_policy(override_, default)));
}
Ok(out)
}
Expand Down Expand Up @@ -489,13 +499,13 @@ mod tests {
fn resolve_policy_precedence_and_floor() {
// Override wins over default.
let p = resolve_policy(
Some(serde_json::json!({"keep_daily": 30})),
Some(serde_json::json!({"keep_daily": 10})),
Some((serde_json::json!({"keep_daily": 30}), false)),
Some((serde_json::json!({"keep_daily": 10}), false)),
);
assert_eq!(p.keep_daily, 30, "override wins");

// No override → default applies.
let p = resolve_policy(None, Some(serde_json::json!({"keep_monthly": 12})));
let p = resolve_policy(None, Some((serde_json::json!({"keep_monthly": 12}), false)));
assert_eq!(p.keep_monthly, 12, "default fallback");
assert_eq!(p.keep_daily, 7, "floor still enforced on default");

Expand All @@ -508,17 +518,43 @@ mod tests {

// Below-floor override → clamped up to the floor.
let p = resolve_policy(
Some(serde_json::json!({"keep_daily": 2, "keep_weekly": 1})),
Some((
serde_json::json!({"keep_daily": 2, "keep_weekly": 1}),
false,
)),
None,
);
assert_eq!(p.keep_daily, 7, "clamped up");
assert_eq!(p.keep_weekly, 4, "clamped up");

// Garbage JSON → floor baseline (parse error path).
let p = resolve_policy(Some(serde_json::json!("not a policy")), None);
let p = resolve_policy(Some((serde_json::json!("not a policy"), false)), None);
assert_eq!(p.keep_daily, 7);
}

#[test]
fn resolve_policy_allow_below_floor_skips_floor() {
// A dangerous override below the floor is preserved verbatim, not clamped.
let p = resolve_policy(
Some((serde_json::json!({"keep_daily": 2, "keep_weekly": 0}), true)),
None,
);
assert_eq!(p.keep_daily, 2, "below-floor preserved");
assert_eq!(p.keep_weekly, 0, "below-floor preserved");

// A dangerous default is also exempt when it's the winning source.
let p = resolve_policy(None, Some((serde_json::json!({"keep_daily": 1}), true)));
assert_eq!(p.keep_daily, 1, "dangerous default exempt");

// The override's flag governs — a dangerous default doesn't exempt a
// non-dangerous override (the override is the winning source).
let p = resolve_policy(
Some((serde_json::json!({"keep_daily": 3}), false)),
Some((serde_json::json!({"keep_daily": 1}), true)),
);
assert_eq!(p.keep_daily, 7, "non-dangerous override still floored");
}

#[test]
fn retention_json_roundtrip_defaults() {
// keep_latest defaults to 1, others to 0, when absent.
Expand Down
13 changes: 12 additions & 1 deletion crates/database/src/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ use crate::pg_duration::PgDuration;

/// kopia `keep-*` retention policy. Org-minimum floors
/// (`keep_daily ≥ 7, keep_weekly ≥ 4, keep_monthly ≥ 6`) are enforced by
/// [`RetentionPolicy::validate_floor`] on create/update.
/// [`RetentionPolicy::validate_floor`] on create/update — unless the config
/// opts out via its `allow_below_floor` flag (dangerous).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RetentionPolicy {
#[serde(default = "RetentionPolicy::default_keep_latest")]
Expand Down Expand Up @@ -355,6 +356,9 @@ pub struct BackupTypeDefault {
pub default_interval: Option<PgDuration>,
pub default_retention: JsonValue,
pub auto_enable: bool,
/// Opt out of the org retention floor for this type's default (dangerous):
/// the floor is neither validated on write nor enforced on resolve.
pub allow_below_floor: bool,
}

#[derive(Debug, Clone, Insertable)]
Expand All @@ -366,6 +370,7 @@ pub struct NewBackupTypeDefault {
pub default_interval: Option<PgDuration>,
pub default_retention: JsonValue,
pub auto_enable: bool,
pub allow_below_floor: bool,
}

impl BackupTypeDefault {
Expand Down Expand Up @@ -401,6 +406,7 @@ impl BackupTypeDefault {
dsl::default_interval.eq(new.default_interval),
dsl::default_retention.eq(&new.default_retention),
dsl::auto_enable.eq(new.auto_enable),
dsl::allow_below_floor.eq(new.allow_below_floor),
))
.returning(Self::as_select())
.get_result(db)
Expand Down Expand Up @@ -570,6 +576,9 @@ pub struct ServerGroupBackupSchedule {
pub created_at: Timestamp,
#[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)]
pub updated_at: Timestamp,
/// Opt out of the org retention floor for this override (dangerous): the
/// floor is neither validated on write nor enforced on resolve.
pub allow_below_floor: bool,
}

#[derive(Debug, Clone, Insertable)]
Expand All @@ -581,6 +590,7 @@ pub struct NewServerGroupBackupSchedule {
pub r#type: BackupType,
pub expected_interval: Option<PgDuration>,
pub retention: Option<JsonValue>,
pub allow_below_floor: bool,
}

impl ServerGroupBackupSchedule {
Expand Down Expand Up @@ -624,6 +634,7 @@ impl ServerGroupBackupSchedule {
.set((
dsl::expected_interval.eq(new.expected_interval),
dsl::retention.eq(&new.retention),
dsl::allow_below_floor.eq(new.allow_below_floor),
dsl::updated_at.eq(now),
))
.returning(Self::as_select())
Expand Down
2 changes: 2 additions & 0 deletions crates/database/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ diesel::table! {
default_interval -> Nullable<Interval>,
default_retention -> Jsonb,
auto_enable -> Bool,
allow_below_floor -> Bool,
}
}

Expand Down Expand Up @@ -351,6 +352,7 @@ diesel::table! {
retention -> Nullable<Jsonb>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
allow_below_floor -> Bool,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/database/tests/backup_detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async fn insert_schedule(
r#type: ty.clone(),
expected_interval: Some(PgDuration(interval)),
retention: Some(retention()),
allow_below_floor: false,
},
)
.await
Expand Down
4 changes: 4 additions & 0 deletions crates/database/tests/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async fn type_defaults_retention_must_be_object() {
default_interval: Some(PgDuration(SignedDuration::from_hours(24))),
default_retention: retention(),
auto_enable: true,
allow_below_floor: false,
},
)
.await
Expand All @@ -153,6 +154,7 @@ async fn type_defaults_retention_must_be_object() {
default_interval: None,
default_retention: serde_json::json!([1, 2, 3]),
auto_enable: false,
allow_below_floor: false,
},
)
.await;
Expand Down Expand Up @@ -706,6 +708,7 @@ async fn schedule_upsert_and_get() {
r#type: pg.clone(),
expected_interval: Some(PgDuration(SignedDuration::from_hours(12))),
retention: Some(retention()),
allow_below_floor: false,
},
)
.await
Expand All @@ -729,6 +732,7 @@ async fn schedule_upsert_and_get() {
r#type: pg.clone(),
expected_interval: None,
retention: None,
allow_below_floor: false,
},
)
.await
Expand Down
38 changes: 35 additions & 3 deletions crates/private-server/src/fns/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ pub struct ScheduleView {
#[schema(value_type = Option<i64>, format = "int64")]
pub expected_interval: Option<PgDuration>,
pub retention: Option<RetentionPolicy>,
/// Whether this override opts out of the org retention floor (dangerous).
pub allow_below_floor: bool,
}

/// Full config + lifecycle for a group. Never includes the passphrase value.
Expand Down Expand Up @@ -122,6 +124,7 @@ impl BackupConfigView {
r#type: s.r#type,
expected_interval: s.expected_interval,
retention: s.retention.as_ref().and_then(RetentionPolicy::from_json),
allow_below_floor: s.allow_below_floor,
})
.collect();
Ok(Self {
Expand Down Expand Up @@ -217,8 +220,13 @@ pub struct SetScheduleArgs {
/// Seconds; None = manual-only (no schedule), distinct from 0.
#[schema(value_type = Option<i64>, format = "int64")]
pub expected_interval: Option<PgDuration>,
/// None = inherit the type default. A present policy is floor-validated.
/// None = inherit the type default. A present policy is floor-validated
/// unless `allow_below_floor` is set.
pub retention: Option<RetentionPolicy>,
/// Dangerous: opt this override out of the org retention floor, allowing a
/// retention smaller than the org minimum. Defaults false.
#[serde(default)]
pub allow_below_floor: bool,
}

#[derive(Deserialize, ToSchema)]
Expand Down Expand Up @@ -851,7 +859,9 @@ pub async fn set_schedule(
let mut conn = state.db.get().await?;
require_config(&mut conn, args.server_group_id).await?;
if let Some(policy) = &args.retention {
policy.validate_floor()?;
if !args.allow_below_floor {
policy.validate_floor()?;
}
}
ServerGroupBackupSchedule::upsert(
&mut conn,
Expand All @@ -860,6 +870,7 @@ pub async fn set_schedule(
r#type: args.r#type,
expected_interval: args.expected_interval,
retention: args.retention.map(|r| r.to_json()),
allow_below_floor: args.allow_below_floor,
},
)
.await?;
Expand Down Expand Up @@ -925,6 +936,9 @@ pub struct GroupTypeScheduleView {
pub effective_interval: Option<i64>,
pub effective_retention: RetentionPolicy,
pub has_override: bool,
/// Whether the effective config opts out of the org retention floor — taken
/// from the override if present, else the type default.
pub allow_below_floor: bool,
/// When the next scheduled backup of this type is expected: the group's most
/// recent successful backup of the type plus the interval — or "now" if the
/// type is scheduled but has never succeeded yet. Null for manual-only types.
Expand Down Expand Up @@ -989,6 +1003,14 @@ pub async fn group_schedules(
.and_then(|d| RetentionPolicy::from_json(&d.default_retention))
})
.unwrap_or(FLOOR_RETENTION);
// Mirror the retention precedence: the override's flag governs when it
// supplies the effective retention, else the type default's.
let allow_below_floor = over
.as_ref()
.filter(|s| s.retention.is_some())
.map(|s| s.allow_below_floor)
.or_else(|| def.as_ref().map(|d| d.allow_below_floor))
.unwrap_or(false);
// Scheduled types: latest success + interval (or now if never run yet).
// Manual-only types (no interval) have no expected next run.
let next_run_at = effective_interval.map(|secs| {
Expand All @@ -1001,6 +1023,7 @@ pub async fn group_schedules(
r#type: ty,
effective_interval,
effective_retention,
allow_below_floor,
has_override: over.is_some(),
next_run_at,
});
Expand All @@ -1018,6 +1041,8 @@ pub struct TypeDefaultView {
pub default_interval: Option<i64>,
pub default_retention: Option<RetentionPolicy>,
pub auto_enable: bool,
/// Whether this default opts out of the org retention floor (dangerous).
pub allow_below_floor: bool,
}

/// List the canopy-wide per-type defaults (the "global" schedule/retention each
Expand All @@ -1043,6 +1068,7 @@ pub async fn type_defaults(
default_interval: d.default_interval.map(|pg| pg.0.as_secs()),
default_retention: RetentionPolicy::from_json(&d.default_retention),
auto_enable: d.auto_enable,
allow_below_floor: d.allow_below_floor,
})
.collect(),
))
Expand All @@ -1059,6 +1085,9 @@ pub struct SetTypeDefaultArgs {
pub default_retention: RetentionPolicy,
#[serde(default)]
pub auto_enable: bool,
/// Dangerous: opt this default out of the org retention floor. Defaults false.
#[serde(default)]
pub allow_below_floor: bool,
}

/// Set the canopy-wide default schedule/retention for a backup type
Expand All @@ -1077,7 +1106,9 @@ pub async fn set_type_default(
_admin: TailscaleAdmin,
Json(args): Json<SetTypeDefaultArgs>,
) -> Result<Json<()>> {
args.default_retention.validate_floor()?;
if !args.allow_below_floor {
args.default_retention.validate_floor()?;
}
let mut conn = state.db.get().await?;
BackupTypeDefault::upsert(
&mut conn,
Expand All @@ -1086,6 +1117,7 @@ pub async fn set_type_default(
default_interval: args.default_interval,
default_retention: args.default_retention.to_json(),
auto_enable: args.auto_enable,
allow_below_floor: args.allow_below_floor,
},
)
.await?;
Expand Down
Loading