Skip to main content

canic_backup/persistence/
mod.rs

1mod integrity;
2
3pub use integrity::{
4    ArtifactIntegrityReport, BackupExecutionIntegrityReport, BackupIntegrityReport,
5    resolve_backup_artifact_path,
6};
7use integrity::{verify_execution_integrity, verify_layout_integrity};
8
9#[cfg(test)]
10use crate::artifacts::ArtifactChecksum;
11use crate::{
12    artifacts::ArtifactChecksumError,
13    execution::{BackupExecutionJournal, BackupExecutionJournalError},
14    journal::DownloadJournal,
15    manifest::{FleetBackupManifest, ManifestValidationError},
16    plan::{BackupPlan, BackupPlanError},
17};
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use std::{
21    fs::{self, File},
22    io,
23    path::{Path, PathBuf},
24};
25use thiserror::Error as ThisError;
26
27const MANIFEST_FILE_NAME: &str = "fleet-backup-manifest.json";
28const BACKUP_PLAN_FILE_NAME: &str = "backup-plan.json";
29const JOURNAL_FILE_NAME: &str = "download-journal.json";
30const EXECUTION_JOURNAL_FILE_NAME: &str = "backup-execution-journal.json";
31
32///
33/// BackupLayout
34///
35
36#[derive(Clone, Debug)]
37pub struct BackupLayout {
38    root: PathBuf,
39}
40
41impl BackupLayout {
42    /// Create a filesystem layout rooted at one backup directory.
43    #[must_use]
44    pub const fn new(root: PathBuf) -> Self {
45        Self { root }
46    }
47
48    /// Return the root backup directory path.
49    #[must_use]
50    pub fn root(&self) -> &Path {
51        &self.root
52    }
53
54    /// Return the canonical manifest path for this backup layout.
55    #[must_use]
56    pub fn manifest_path(&self) -> PathBuf {
57        self.root.join(MANIFEST_FILE_NAME)
58    }
59
60    /// Return the canonical backup plan path for this layout.
61    #[must_use]
62    pub fn backup_plan_path(&self) -> PathBuf {
63        self.root.join(BACKUP_PLAN_FILE_NAME)
64    }
65
66    /// Return the canonical mutable journal path for this backup layout.
67    #[must_use]
68    pub fn journal_path(&self) -> PathBuf {
69        self.root.join(JOURNAL_FILE_NAME)
70    }
71
72    /// Return the canonical backup execution journal path for this layout.
73    #[must_use]
74    pub fn execution_journal_path(&self) -> PathBuf {
75        self.root.join(EXECUTION_JOURNAL_FILE_NAME)
76    }
77
78    /// Write a validated manifest with atomic replace semantics.
79    pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
80        manifest.validate()?;
81        write_json_atomic(&self.manifest_path(), manifest)
82    }
83
84    /// Read and validate a manifest from this backup layout.
85    pub fn read_manifest(&self) -> Result<FleetBackupManifest, PersistenceError> {
86        let manifest = read_json(&self.manifest_path())?;
87        FleetBackupManifest::validate(&manifest)?;
88        Ok(manifest)
89    }
90
91    /// Write a validated backup plan with atomic replace semantics.
92    pub fn write_backup_plan(&self, plan: &BackupPlan) -> Result<(), PersistenceError> {
93        plan.validate()?;
94        write_json_atomic(&self.backup_plan_path(), plan)
95    }
96
97    /// Read and validate a backup plan from this layout.
98    pub fn read_backup_plan(&self) -> Result<BackupPlan, PersistenceError> {
99        let plan = read_json(&self.backup_plan_path())?;
100        BackupPlan::validate(&plan)?;
101        Ok(plan)
102    }
103
104    /// Write a validated download journal with atomic replace semantics.
105    pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
106        journal.validate()?;
107        write_json_atomic(&self.journal_path(), journal)
108    }
109
110    /// Read and validate a download journal from this backup layout.
111    pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
112        let journal = read_json(&self.journal_path())?;
113        DownloadJournal::validate(&journal)?;
114        Ok(journal)
115    }
116
117    /// Write a validated backup execution journal with atomic replace semantics.
118    pub fn write_execution_journal(
119        &self,
120        journal: &BackupExecutionJournal,
121    ) -> Result<(), PersistenceError> {
122        journal.validate()?;
123        write_json_atomic(&self.execution_journal_path(), journal)
124    }
125
126    /// Read and validate a backup execution journal from this layout.
127    pub fn read_execution_journal(&self) -> Result<BackupExecutionJournal, PersistenceError> {
128        let journal = read_json(&self.execution_journal_path())?;
129        BackupExecutionJournal::validate(&journal)?;
130        Ok(journal)
131    }
132
133    /// Validate the manifest, journal, and durable artifact checksums.
134    pub fn verify_integrity(&self) -> Result<BackupIntegrityReport, PersistenceError> {
135        let manifest = self.read_manifest()?;
136        let journal = self.read_journal()?;
137        verify_layout_integrity(self, &manifest, &journal)
138    }
139
140    /// Validate the persisted backup plan and execution journal agree.
141    pub fn verify_execution_integrity(
142        &self,
143    ) -> Result<BackupExecutionIntegrityReport, PersistenceError> {
144        let plan = self.read_backup_plan()?;
145        let journal = self.read_execution_journal()?;
146        verify_execution_integrity(&plan, &journal)
147    }
148}
149
150///
151/// PersistenceError
152///
153
154#[derive(Debug, ThisError)]
155pub enum PersistenceError {
156    #[error(transparent)]
157    Io(#[from] io::Error),
158
159    #[error(transparent)]
160    Json(#[from] serde_json::Error),
161
162    #[error(transparent)]
163    InvalidManifest(#[from] ManifestValidationError),
164
165    #[error(transparent)]
166    InvalidJournal(#[from] crate::journal::JournalValidationError),
167
168    #[error(transparent)]
169    InvalidBackupPlan(#[from] BackupPlanError),
170
171    #[error(transparent)]
172    InvalidExecutionJournal(#[from] BackupExecutionJournalError),
173
174    #[error(transparent)]
175    Checksum(#[from] ArtifactChecksumError),
176
177    #[error("manifest backup id {manifest} does not match journal backup id {journal}")]
178    BackupIdMismatch { manifest: String, journal: String },
179
180    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
181    NonDurableArtifact {
182        canister_id: String,
183        snapshot_id: String,
184    },
185
186    #[error("journal artifact {canister_id} snapshot {snapshot_id} has no checksum")]
187    MissingJournalArtifactChecksum {
188        canister_id: String,
189        snapshot_id: String,
190    },
191
192    #[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
193    MissingJournalArtifact {
194        canister_id: String,
195        snapshot_id: String,
196    },
197
198    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
199    UnexpectedJournalArtifact {
200        canister_id: String,
201        snapshot_id: String,
202    },
203
204    #[error(
205        "manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
206    )]
207    ManifestJournalChecksumMismatch {
208        canister_id: String,
209        snapshot_id: String,
210        manifest: String,
211        journal: String,
212    },
213
214    #[error(
215        "manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
216    )]
217    ManifestJournalArtifactPathMismatch {
218        canister_id: String,
219        snapshot_id: String,
220        manifest: String,
221        journal: String,
222    },
223
224    #[error("manifest topology receipt {field} does not match journal topology receipt")]
225    ManifestJournalTopologyReceiptMismatch {
226        field: String,
227        manifest: String,
228        journal: Option<String>,
229    },
230
231    #[error("backup plan {field} does not match execution journal")]
232    PlanJournalMismatch {
233        field: &'static str,
234        plan: String,
235        journal: String,
236    },
237
238    #[error("backup plan operation {sequence} {field} does not match execution journal")]
239    PlanJournalOperationMismatch {
240        sequence: usize,
241        field: &'static str,
242        plan: String,
243        journal: String,
244    },
245
246    #[error("backup execution operation {sequence} is {state} but has no matching receipt")]
247    ExecutionOperationMissingReceipt { sequence: usize, state: String },
248
249    #[error("backup execution operation {sequence} timestamp does not match latest receipt")]
250    ExecutionOperationReceiptTimestampMismatch { sequence: usize },
251
252    #[error("artifact path escapes backup root: {artifact_path}")]
253    ArtifactPathEscapesBackup { artifact_path: String },
254
255    #[error("artifact path does not exist: {0}")]
256    MissingArtifact(String),
257}
258
259// Write JSON to a temporary sibling path and then atomically replace the target.
260fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
261where
262    T: Serialize,
263{
264    if let Some(parent) = path.parent() {
265        fs::create_dir_all(parent)?;
266    }
267
268    let tmp_path = temp_path_for(path);
269    let mut file = File::create(&tmp_path)?;
270    serde_json::to_writer_pretty(&mut file, value)?;
271    file.sync_all()?;
272    drop(file);
273
274    fs::rename(&tmp_path, path)?;
275
276    if let Some(parent) = path.parent() {
277        File::open(parent)?.sync_all()?;
278    }
279
280    Ok(())
281}
282
283// Read one JSON document from disk.
284fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
285where
286    T: DeserializeOwned,
287{
288    let file = File::open(path)?;
289    Ok(serde_json::from_reader(file)?)
290}
291
292// Build the sibling temporary path used for atomic writes.
293fn temp_path_for(path: &Path) -> PathBuf {
294    let mut file_name = path
295        .file_name()
296        .and_then(|name| name.to_str())
297        .unwrap_or("canic-backup")
298        .to_string();
299    file_name.push_str(".tmp");
300    path.with_file_name(file_name)
301}
302
303#[cfg(test)]
304mod tests;