1use crate::{BackendId, Op};
6use alembic_core::{TypeName, Uid};
7use anyhow::{anyhow, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::fs::{File, OpenOptions};
11use std::hash::{DefaultHasher, Hash, Hasher};
12use std::io::{Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use tempfile::NamedTempFile;
15
16#[derive(Debug, Serialize, Deserialize)]
17pub struct Journal {
18 #[serde(skip)]
19 file: Option<(File, PathBuf)>,
20 ops: Vec<OpWithMeta>,
21}
22
23impl Journal {
24 pub fn stable_file_name(directory: &Path, adapter_name: &str, ops: &[Op]) -> PathBuf {
25 let mut hasher = DefaultHasher::new();
26 ops.hash(&mut hasher);
27 let hash = hasher.finish();
28 let file_name: PathBuf = format!("{}_journal_{}.yaml", adapter_name, hash).into();
29 directory.join(file_name)
30 }
31
32 pub fn load_or_create(directory: &Path, adapter_name: &str, ops: &[Op]) -> Result<Self> {
36 fs::create_dir_all(directory)?;
39 let file_name = Self::stable_file_name(directory, adapter_name, ops);
40 if fs::metadata(&file_name).is_ok() {
41 Self::new_from_existing_file(directory, adapter_name, ops)
42 } else {
43 Self::new_with_file(directory, adapter_name, ops)
44 }
45 }
46
47 fn new_from_existing_file(
49 directory: &Path,
50 adapter_name: &str,
51 expected_ops: &[Op],
52 ) -> Result<Self> {
53 let file_name = Self::stable_file_name(directory, adapter_name, expected_ops);
54
55 let mut file = OpenOptions::new()
56 .read(true)
57 .write(true)
58 .create(false)
59 .append(false)
60 .open(&file_name)?;
61 let mut contents = String::new();
62 file.read_to_string(&mut contents)?;
63
64 let mut journal: Journal = serde_yaml::from_str(&contents)?;
65
66 let journal_keys = journal
67 .ops
68 .iter()
69 .map(|op_with_meta| {
70 (
71 op_with_meta.op_uid,
72 &op_with_meta.op_typename,
73 op_with_meta.op_hash,
74 )
75 })
76 .collect::<Vec<(Uid, &TypeName, u64)>>();
77
78 let expected_keys = expected_ops
79 .iter()
80 .filter(|op| !matches!(op, Op::Delete { .. }))
81 .map(|op| (op.uid(), op.type_name(), op.hashed()))
82 .collect::<Vec<(Uid, &TypeName, u64)>>();
83 if journal_keys != expected_keys {
84 return Err(anyhow!(
85 "the ops in the loaded journal file `{}` don't match the expected ops",
86 file_name.display()
87 ));
88 }
89
90 journal.file = Some((file, file_name));
91 Ok(journal)
92 }
93
94 fn new_with_file(directory: &Path, adapter_name: &str, ops: &[Op]) -> Result<Self> {
96 let file_name = Self::stable_file_name(directory, adapter_name, ops);
97 let mut journal = Self::new_ephemeral(ops);
98
99 let mut file = OpenOptions::new()
101 .read(true)
102 .write(true)
103 .create(true)
104 .truncate(true)
105 .append(false)
106 .open(&file_name)?;
107 file.set_len(0)?;
108 file.rewind()?;
109
110 journal.file = Some((file, file_name));
111 journal.save()?;
112
113 Ok(journal)
114 }
115
116 pub fn new_ephemeral(ops: &[Op]) -> Self {
118 Self {
119 file: None,
120 ops: ops
121 .iter()
122 .filter(|op| !matches!(op, Op::Delete { .. }))
123 .map(OpWithMeta::new)
124 .collect(),
125 }
126 }
127
128 pub fn done_ops(&self) -> Vec<(Uid, TypeName, u64)> {
129 self.ops
130 .iter()
131 .filter(|owm| owm.done)
132 .map(|owm| (owm.op_uid, owm.op_typename.clone(), owm.op_hash))
133 .collect()
134 }
135
136 pub fn done_ops_count(&self) -> usize {
137 self.ops.iter().filter(|op| op.done).count()
138 }
139
140 pub fn is_completed(&self) -> bool {
141 self.ops.iter().all(|op| op.done)
142 }
143
144 pub fn mark_op_as_done(&mut self, op: &Op) -> Result<()> {
147 let op_hash = op.hashed();
148 let op_uid = op.uid();
149 let op_typename = op.type_name();
150
151 let Some(op_index) = self.ops.iter().position(|op| {
152 !op.done
153 && op.op_hash == op_hash
154 && op.op_uid == op_uid
155 && &op.op_typename == op_typename
156 }) else {
157 return Err(anyhow!(
158 "no matching op found in journal, can't mark any as done"
159 ));
160 };
161
162 self.ops[op_index].done = true;
164
165 Ok(())
166 }
167
168 pub fn save(&mut self) -> Result<()> {
169 let str = serde_yaml::to_string(self)?;
170
171 let (_, path) = self
172 .file
173 .as_ref()
174 .ok_or_else(|| anyhow!("can't save journal because it's missing a backing file"))?;
175
176 let path = path.clone();
177 let dir = path
178 .parent()
179 .ok_or_else(|| anyhow!("file path has no parent directory"))?;
180
181 let mut temp_file = NamedTempFile::new_in(dir)?;
182 temp_file.write_all(str.as_bytes())?;
183 temp_file.as_file().sync_all()?; temp_file.persist(&path)?;
185 File::open(dir)?.sync_all()?;
186 let new_file = OpenOptions::new().read(true).write(true).open(&path)?;
187 self.file = Some((new_file, path));
188
189 Ok(())
190 }
191
192 pub fn delete_backing_file(&mut self) -> Result<()> {
193 if let Some((file, file_path)) = self.file.take() {
194 drop(file);
195 fs::remove_file(file_path)?;
196 }
197 Ok(())
198 }
199}
200
201impl Drop for Journal {
202 fn drop(&mut self) {
203 if let Some((file, _)) = self.file.take() {
204 let _ = file.sync_all();
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211struct OpWithMeta {
212 op_uid: Uid,
213 op_typename: TypeName,
214 op_hash: u64,
215 done: bool,
216 backend_id: Option<BackendId>, }
218
219impl OpWithMeta {
220 fn new(op: &Op) -> Self {
221 OpWithMeta {
222 op_uid: op.uid(),
223 op_typename: op.type_name().clone(),
224 op_hash: op.hashed(),
225 done: false,
226 backend_id: None,
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use alembic_core::{Object, TypeName};
235 use tempfile::tempdir;
236
237 fn test_ops() -> Vec<Op> {
238 vec![
239 Op::Create {
240 uid: Uid::from_u128(1),
241 type_name: TypeName::new("dcim.device"),
242 desired: Object {
243 uid: Uid::from_u128(1),
244 type_name: TypeName::new("dcim.device"),
245 key: Default::default(),
246 attrs: Default::default(),
247 source: None,
248 },
249 },
250 Op::Create {
251 uid: Uid::from_u128(2),
252 type_name: TypeName::new("dcim.device"),
253 desired: Object {
254 uid: Uid::from_u128(2),
255 type_name: TypeName::new("dcim.device"),
256 key: Default::default(),
257 attrs: Default::default(),
258 source: None,
259 },
260 },
261 Op::Update {
262 uid: Uid::from_u128(3),
263 type_name: TypeName::new("dcim.site"),
264 desired: Object {
265 uid: Uid::from_u128(3),
266 type_name: TypeName::new("dcim.site"),
267 key: Default::default(),
268 attrs: Default::default(),
269 source: None,
270 },
271 changes: vec![],
272 backend_id: None,
273 },
274 ]
275 }
276
277 #[test]
278 fn save_and_load_journal() {
279 let dir = tempdir().unwrap();
280 let ops = test_ops();
281 {
282 let mut journal = Journal::new_with_file(dir.path(), "test", &ops).unwrap();
283 journal.save().unwrap();
284 drop(journal);
285 }
286 {
287 let journal = Journal::new_from_existing_file(dir.path(), "test", &ops).unwrap();
288 assert_eq!(
289 journal
290 .ops
291 .iter()
292 .map(|owm| (owm.op_uid, &owm.op_typename))
293 .collect::<Vec<(Uid, &TypeName)>>(),
294 ops.iter()
295 .map(|op| (op.uid(), op.type_name()))
296 .collect::<Vec<(Uid, &TypeName)>>()
297 );
298 }
299 }
300
301 #[test]
302 fn load_and_save_existing_journal() {
303 let dir = tempdir().unwrap();
304 let ops = test_ops();
305 {
306 let mut journal = Journal::new_with_file(dir.path(), "test", &ops).unwrap();
307 journal.save().unwrap();
308 }
309 {
310 let mut journal = Journal::new_from_existing_file(dir.path(), "test", &ops).unwrap();
311 journal.save().unwrap();
312 assert_eq!(journal.ops.len(), 3);
313 }
314 }
315
316 #[test]
317 fn mark_ops_as_done() {
318 let ops = test_ops();
319 let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
320 journal.mark_op_as_done(&ops[0]).unwrap();
321 assert!(!journal.is_completed());
322 journal.mark_op_as_done(&ops[1]).unwrap();
323 assert!(!journal.is_completed());
324 journal.mark_op_as_done(&ops[2]).unwrap();
325 assert!(journal.is_completed());
326 }
327
328 #[test]
329 fn mark_ops_as_done_backwards() {
330 let ops = test_ops();
331 let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
332 journal.mark_op_as_done(&ops[2]).unwrap();
333 assert!(!journal.is_completed());
334 journal.mark_op_as_done(&ops[1]).unwrap();
335 assert!(!journal.is_completed());
336 journal.mark_op_as_done(&ops[0]).unwrap();
337 assert!(journal.is_completed());
338 }
339
340 #[test]
341 fn mark_invalid_op_as_done() {
342 let ops = test_ops();
343 let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
344 journal
345 .mark_op_as_done(&Op::Create {
346 uid: Uid::from_u128(999),
347 type_name: TypeName::new("dcim.site"),
348 desired: Object {
349 uid: Uid::from_u128(999),
350 type_name: TypeName::new("dcim.site"),
351 key: Default::default(),
352 attrs: Default::default(),
353 source: None,
354 },
355 })
356 .expect_err("should fail");
357 assert!(!journal.is_completed());
358 }
359
360 #[test]
361 fn mark_same_op_as_done_twice() {
362 let ops = test_ops();
363 let mut journal = Journal::new_with_file(tempdir().unwrap().path(), "test", &ops).unwrap();
364 journal.mark_op_as_done(&ops[1]).unwrap();
365 journal.mark_op_as_done(&ops[1]).expect_err("should fail");
366 }
367
368 #[test]
369 fn delete_backing_file() {
370 let dir = tempdir().unwrap();
371 let ops = test_ops();
372 let mut journal = Journal::new_with_file(dir.path(), "test", &ops).unwrap();
373 journal.save().unwrap();
374 let file_path = Journal::stable_file_name(dir.path(), "test", &ops);
375 assert!(file_path.exists());
376 journal.delete_backing_file().unwrap();
377 assert!(!file_path.exists());
378 }
379}