1use std::error::Error;
2use std::fmt;
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::executor::{ApplyEntryResult, ApplyEntryStatus, ApplyReport, ApplyTotals};
10use crate::model::{Plan, PlanSkip, PlanTotals};
11use crate::persistence::{PersistedTimestamp, PlanId};
12use crate::policy::PolicyKind;
13use crate::watcher::{WatcherDecision, WatcherDecisionState, WatcherTriggerReason};
14
15pub const BACKGROUND_RUN_LOG_SCHEMA_VERSION: u16 = 1;
16
17pub type BackgroundRunLogResult<T> = Result<T, BackgroundRunLogError>;
18
19#[derive(Debug)]
20pub enum BackgroundRunLogError {
21 Io {
22 path: PathBuf,
23 source: std::io::Error,
24 },
25 Serialize {
26 path: PathBuf,
27 source: serde_json::Error,
28 },
29 Json {
30 path: PathBuf,
31 line: usize,
32 source: serde_json::Error,
33 },
34 UnsupportedSchemaVersion {
35 path: PathBuf,
36 line: usize,
37 found: u16,
38 expected: u16,
39 },
40}
41
42impl fmt::Display for BackgroundRunLogError {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::Io { path, source } => {
46 write!(
47 formatter,
48 "background run log IO error at {}: {source}",
49 path.display()
50 )
51 }
52 Self::Serialize { path, source } => {
53 write!(
54 formatter,
55 "failed to serialize background run log record for {}: {source}",
56 path.display()
57 )
58 }
59 Self::Json { path, line, source } => {
60 write!(
61 formatter,
62 "failed to parse background run log record at {} line {line}: {source}",
63 path.display()
64 )
65 }
66 Self::UnsupportedSchemaVersion {
67 path,
68 line,
69 found,
70 expected,
71 } => {
72 write!(
73 formatter,
74 "unsupported background run log schema version {found} at {} line {line}; expected {expected}",
75 path.display()
76 )
77 }
78 }
79 }
80}
81
82impl Error for BackgroundRunLogError {
83 fn source(&self) -> Option<&(dyn Error + 'static)> {
84 match self {
85 Self::Io { source, .. } => Some(source),
86 Self::Serialize { source, .. } | Self::Json { source, .. } => Some(source),
87 Self::UnsupportedSchemaVersion { .. } => None,
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct BackgroundRunLogRecord {
94 pub schema_version: u16,
95 pub run_id: String,
96 pub recorded_at: PersistedTimestamp,
97 #[serde(rename = "event")]
98 pub kind: BackgroundRunEventKind,
99 pub trigger: Option<BackgroundTriggerSummary>,
100 pub selected_policy: Option<String>,
101 pub plan: Option<BackgroundPlanSummary>,
102 #[serde(default)]
103 pub skipped_projects: Vec<BackgroundSkippedProject>,
104 pub apply: Option<BackgroundApplySummary>,
105 #[serde(default)]
106 pub recommendations: Vec<String>,
107 #[serde(default)]
108 pub problems: Vec<String>,
109}
110
111impl BackgroundRunLogRecord {
112 pub fn new(
113 run_id: impl Into<String>,
114 recorded_at: PersistedTimestamp,
115 kind: BackgroundRunEventKind,
116 ) -> Self {
117 Self {
118 schema_version: BACKGROUND_RUN_LOG_SCHEMA_VERSION,
119 run_id: run_id.into(),
120 recorded_at,
121 kind,
122 trigger: None,
123 selected_policy: None,
124 plan: None,
125 skipped_projects: Vec::new(),
126 apply: None,
127 recommendations: Vec::new(),
128 problems: Vec::new(),
129 }
130 }
131
132 pub fn with_trigger(mut self, trigger: BackgroundTriggerSummary) -> Self {
133 self.trigger = Some(trigger);
134 self
135 }
136
137 pub fn with_selected_policy(mut self, policy: PolicyKind) -> Self {
138 self.selected_policy = Some(policy_label(policy).to_owned());
139 self
140 }
141
142 pub fn with_plan(mut self, plan: BackgroundPlanSummary) -> Self {
143 self.plan = Some(plan);
144 self
145 }
146
147 pub fn with_plan_skipped_paths(mut self, plan: &Plan) -> Self {
148 self.skipped_projects.extend(
149 plan.skipped_paths
150 .iter()
151 .map(BackgroundSkippedProject::from_skip),
152 );
153 self
154 }
155
156 pub fn with_apply(mut self, apply: BackgroundApplySummary) -> Self {
157 self.apply = Some(apply);
158 self
159 }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum BackgroundRunEventKind {
165 Started,
166 Triggered,
167 PlanBuilt,
168 ApplyCompleted,
169 Skipped,
170 Failed,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct BackgroundTriggerSummary {
175 pub state: String,
176 pub reasons: Vec<BackgroundTriggerReasonSummary>,
177}
178
179impl BackgroundTriggerSummary {
180 pub fn from_watcher_decision(decision: &WatcherDecision) -> Self {
181 Self {
182 state: watcher_decision_state_label(decision.state).to_owned(),
183 reasons: decision
184 .reasons
185 .iter()
186 .map(BackgroundTriggerReasonSummary::from_watcher_trigger_reason)
187 .collect(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(tag = "kind", rename_all = "snake_case")]
194pub enum BackgroundTriggerReasonSummary {
195 TargetSizeExceeded {
196 path: String,
197 size_bytes: u64,
198 max_target_size_bytes: u64,
199 },
200 DiskFreeBelow {
201 free_basis_points: u16,
202 threshold_basis_points: u16,
203 },
204 DiskFreeBytesBelow {
205 free_bytes: u64,
206 min_free_disk_bytes: u64,
207 },
208}
209
210impl BackgroundTriggerReasonSummary {
211 fn from_watcher_trigger_reason(reason: &WatcherTriggerReason) -> Self {
212 match reason {
213 WatcherTriggerReason::TargetSizeExceeded {
214 path,
215 size_bytes,
216 max_target_size_bytes,
217 } => Self::TargetSizeExceeded {
218 path: path.display().to_string(),
219 size_bytes: *size_bytes,
220 max_target_size_bytes: *max_target_size_bytes,
221 },
222 WatcherTriggerReason::DiskFreeBelow {
223 free_basis_points,
224 threshold_basis_points,
225 } => Self::DiskFreeBelow {
226 free_basis_points: *free_basis_points,
227 threshold_basis_points: *threshold_basis_points,
228 },
229 WatcherTriggerReason::DiskFreeBytesBelow {
230 free_bytes,
231 min_free_disk_bytes,
232 } => Self::DiskFreeBytesBelow {
233 free_bytes: *free_bytes,
234 min_free_disk_bytes: *min_free_disk_bytes,
235 },
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct BackgroundPlanSummary {
242 pub plan_id: Option<String>,
243 pub policy: String,
244 pub totals: BackgroundPlanTotals,
245}
246
247impl BackgroundPlanSummary {
248 pub fn from_plan(policy: PolicyKind, plan: &Plan) -> Self {
249 Self::from_totals(policy, None, plan.totals)
250 }
251
252 pub fn from_plan_id_and_totals(
253 policy: PolicyKind,
254 plan_id: &PlanId,
255 totals: PlanTotals,
256 ) -> Self {
257 Self::from_totals(policy, Some(plan_id.as_str().to_owned()), totals)
258 }
259
260 fn from_totals(policy: PolicyKind, plan_id: Option<String>, totals: PlanTotals) -> Self {
261 Self {
262 plan_id,
263 policy: policy_label(policy).to_owned(),
264 totals: BackgroundPlanTotals::from_plan_totals(totals),
265 }
266 }
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
270pub struct BackgroundPlanTotals {
271 pub entry_count: usize,
272 pub total_bytes: u64,
273 pub preserved_count: usize,
274 pub delete_candidate_count: usize,
275 #[serde(default, skip_serializing_if = "is_zero")]
276 pub skipped_path_count: usize,
277}
278
279impl BackgroundPlanTotals {
280 fn from_plan_totals(totals: PlanTotals) -> Self {
281 Self {
282 entry_count: totals.entry_count,
283 total_bytes: totals.total_bytes,
284 preserved_count: totals.preserved_count,
285 delete_candidate_count: totals.delete_candidate_count,
286 skipped_path_count: totals.skipped_path_count,
287 }
288 }
289}
290
291fn is_zero(value: &usize) -> bool {
292 *value == 0
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct BackgroundSkippedProject {
297 pub path: String,
298 pub reason: String,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub message: Option<String>,
301}
302
303impl BackgroundSkippedProject {
304 fn from_skip(skip: &PlanSkip) -> Self {
305 Self {
306 path: skip.path.display().to_string(),
307 reason: skip.reason.label().to_owned(),
308 message: skip.message.clone(),
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct BackgroundApplySummary {
315 pub plan_id: String,
316 pub dry_run: bool,
317 pub totals: BackgroundApplyTotals,
318 pub notable_entries: Vec<BackgroundApplyEntrySummary>,
319}
320
321impl BackgroundApplySummary {
322 pub fn from_apply_report(report: &ApplyReport) -> Self {
323 Self {
324 plan_id: report.plan_id.as_str().to_owned(),
325 dry_run: report.dry_run,
326 totals: BackgroundApplyTotals::from_apply_totals(&report.totals),
327 notable_entries: report
328 .entries
329 .iter()
330 .filter(|entry| {
331 matches!(
332 entry.status,
333 ApplyEntryStatus::Deleted
334 | ApplyEntryStatus::DeleteFailed
335 | ApplyEntryStatus::SkipStalePlan
336 )
337 })
338 .map(BackgroundApplyEntrySummary::from_apply_entry_result)
339 .collect(),
340 }
341 }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345pub struct BackgroundApplyTotals {
346 pub entry_count: usize,
347 pub delete_candidate_count: usize,
348 pub would_delete_count: usize,
349 pub skipped_count: usize,
350 pub stale_skip_count: usize,
351 pub applied_count: usize,
352 pub failed_count: usize,
353 pub would_delete_bytes: u64,
354 pub applied_bytes: u64,
355}
356
357impl BackgroundApplyTotals {
358 fn from_apply_totals(totals: &ApplyTotals) -> Self {
359 Self {
360 entry_count: totals.entry_count,
361 delete_candidate_count: totals.delete_candidate_count,
362 would_delete_count: totals.would_delete_count,
363 skipped_count: totals.skipped_count,
364 stale_skip_count: totals.stale_skip_count,
365 applied_count: totals.applied_count,
366 failed_count: totals.failed_count,
367 would_delete_bytes: totals.would_delete_bytes,
368 applied_bytes: totals.applied_bytes,
369 }
370 }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374pub struct BackgroundApplyEntrySummary {
375 pub path: String,
376 pub planned_action: String,
377 pub status: String,
378 pub reason: String,
379 pub size_bytes: u64,
380 pub deleted_bytes: Option<u64>,
381}
382
383impl BackgroundApplyEntrySummary {
384 fn from_apply_entry_result(entry: &ApplyEntryResult) -> Self {
385 Self {
386 path: entry.path.clone(),
387 planned_action: entry.planned_action.clone(),
388 status: apply_entry_status_label(entry.status).to_owned(),
389 reason: entry.reason.clone(),
390 size_bytes: entry.size_bytes,
391 deleted_bytes: entry.deleted_bytes,
392 }
393 }
394}
395
396pub fn append_background_run_log_record(
397 path: impl AsRef<Path>,
398 record: &BackgroundRunLogRecord,
399) -> BackgroundRunLogResult<()> {
400 let path = path.as_ref();
401
402 if let Some(parent) = path.parent()
403 && !parent.as_os_str().is_empty()
404 {
405 fs::create_dir_all(parent).map_err(|source| BackgroundRunLogError::Io {
406 path: parent.to_path_buf(),
407 source,
408 })?;
409 }
410
411 let mut file = OpenOptions::new()
412 .create(true)
413 .append(true)
414 .open(path)
415 .map_err(|source| BackgroundRunLogError::Io {
416 path: path.to_path_buf(),
417 source,
418 })?;
419
420 serde_json::to_writer(&mut file, record).map_err(|source| {
421 BackgroundRunLogError::Serialize {
422 path: path.to_path_buf(),
423 source,
424 }
425 })?;
426 file.write_all(b"\n")
427 .and_then(|_| file.flush())
428 .map_err(|source| BackgroundRunLogError::Io {
429 path: path.to_path_buf(),
430 source,
431 })
432}
433
434pub fn read_background_run_log(
435 path: impl AsRef<Path>,
436) -> BackgroundRunLogResult<Vec<BackgroundRunLogRecord>> {
437 let path = path.as_ref();
438 let file =
439 OpenOptions::new()
440 .read(true)
441 .open(path)
442 .map_err(|source| BackgroundRunLogError::Io {
443 path: path.to_path_buf(),
444 source,
445 })?;
446
447 let mut records = Vec::new();
448 for (line_index, line) in BufReader::new(file).lines().enumerate() {
449 let line_number = line_index + 1;
450 let line = line.map_err(|source| BackgroundRunLogError::Io {
451 path: path.to_path_buf(),
452 source,
453 })?;
454 let record: BackgroundRunLogRecord =
455 serde_json::from_str(&line).map_err(|source| BackgroundRunLogError::Json {
456 path: path.to_path_buf(),
457 line: line_number,
458 source,
459 })?;
460
461 if record.schema_version != BACKGROUND_RUN_LOG_SCHEMA_VERSION {
462 return Err(BackgroundRunLogError::UnsupportedSchemaVersion {
463 path: path.to_path_buf(),
464 line: line_number,
465 found: record.schema_version,
466 expected: BACKGROUND_RUN_LOG_SCHEMA_VERSION,
467 });
468 }
469
470 records.push(record);
471 }
472
473 Ok(records)
474}
475
476fn policy_label(policy: PolicyKind) -> &'static str {
477 match policy {
478 PolicyKind::Observe => "observe",
479 PolicyKind::Conservative => "conservative",
480 PolicyKind::Balanced => "balanced",
481 PolicyKind::Aggressive => "aggressive",
482 PolicyKind::Custom => "custom",
483 }
484}
485
486fn watcher_decision_state_label(state: WatcherDecisionState) -> &'static str {
487 match state {
488 WatcherDecisionState::Inactive => "inactive",
489 WatcherDecisionState::NonThresholdMode => "non_threshold_mode",
490 WatcherDecisionState::NotTriggered => "not_triggered",
491 WatcherDecisionState::TriggeredPlanOnly => "triggered_plan_only",
492 WatcherDecisionState::TriggeredPlanAndApply => "triggered_plan_and_apply",
493 }
494}
495
496fn apply_entry_status_label(status: ApplyEntryStatus) -> &'static str {
497 match status {
498 ApplyEntryStatus::WouldDelete => "would_delete",
499 ApplyEntryStatus::Deleted => "deleted",
500 ApplyEntryStatus::NotPlannedForDeletion => "not_planned_for_deletion",
501 ApplyEntryStatus::SkipStalePlan => "skip_stale_plan",
502 ApplyEntryStatus::DeleteFailed => "delete_failed",
503 }
504}