Skip to main content

punktf_lib/visit/deploy/
mod.rs

1//! A [`Visit`](`crate::visit::Visitor`) implementation which deploys the items.
2
3pub mod deployment;
4
5use cfg_if::cfg_if;
6use color_eyre::eyre::Context;
7
8use crate::profile::{source::PunktfSource, MergeMode};
9use crate::visit::*;
10
11use crate::profile::transform::Transform as _;
12use crate::profile::LayeredProfile;
13use crate::visit::deploy::deployment::{Deployment, DeploymentBuilder, ItemStatus};
14use std::borrow::Borrow;
15use std::path::Path;
16
17use crate::visit::{ResolvingVisitor, TemplateVisitor};
18
19/// Represents the contents of a file as returned by [`safe_read`].
20enum SafeRead {
21	/// File was a normal text file.
22	String(String),
23
24	/// File was unable to be interpreted as a text file.
25	Binary(Vec<u8>),
26}
27
28/// Reads the contents of a file, first trying to interpret them as a string and if that fails
29/// returning the raw bytes.
30fn safe_read<P: AsRef<Path>>(path: P) -> io::Result<SafeRead> {
31	/// Inner function to reduce size of monomorphization.
32	fn inner(path: &Path) -> io::Result<SafeRead> {
33		match std::fs::read_to_string(path) {
34			Ok(s) => Ok(SafeRead::String(s)),
35			Err(err) if err.kind() == io::ErrorKind::InvalidData => {
36				std::fs::read(path).map(SafeRead::Binary)
37			}
38			Err(err) => Err(err),
39		}
40	}
41
42	inner(path.as_ref())
43}
44
45impl<'a> Item<'a> {
46	/// Adds this item to the given
47	/// [`DeploymentBuilder`](`crate::visit::deploy::deployment::DeploymentBuilder`).
48	fn add_to_builder<S: Into<ItemStatus>>(&self, builder: &mut DeploymentBuilder, status: S) {
49		let status = status.into();
50
51		let resolved_target_path = self
52			.target_path
53			.canonicalize()
54			.unwrap_or_else(|_| self.target_path.clone());
55
56		match &self.kind {
57			Kind::Root(dotfile) => {
58				builder.add_dotfile(resolved_target_path, (*dotfile).clone(), status)
59			}
60			Kind::Child {
61				root_target_path, ..
62			} => {
63				let resolved_root_target_path = root_target_path
64					.canonicalize()
65					.unwrap_or_else(|_| root_target_path.clone());
66
67				builder.add_child(resolved_target_path, resolved_root_target_path, status)
68			}
69		};
70	}
71}
72
73impl Symlink {
74	/// Adds this item to the given
75	/// [`DeploymentBuilder`](`crate::visit::deploy::deployment::DeploymentBuilder`).
76	fn add_to_builder<S: Into<ItemStatus>>(&self, builder: &mut DeploymentBuilder, status: S) {
77		builder.add_link(
78			self.source_path.clone(),
79			self.target_path.clone(),
80			status.into(),
81		);
82	}
83}
84
85/// Marks the given item as successfully deployed.
86macro_rules! success {
87	($builder:expr, $item:expr) => {
88		$item.add_to_builder($builder, ItemStatus::success());
89	};
90}
91
92/// Marks the given item as skipped.
93///
94/// This will instantly return from the out function after reporting the skip.
95macro_rules! skipped {
96	($builder:expr, $item:expr, $reason:expr => $ret:expr ) => {
97		$item.add_to_builder($builder, ItemStatus::skipped($reason));
98		return Ok($ret);
99	};
100	($builder:expr, $item:expr, $reason:expr) => {
101		$item.add_to_builder($builder, ItemStatus::skipped($reason));
102		return Ok(());
103	};
104}
105
106/// Marks the given item as failed.
107///
108/// This will instantly return from the out function after reporting the error.
109macro_rules! failed {
110	($builder:expr, $item:expr, $reason:expr => Err($ret:expr) ) => {
111		$item.add_to_builder($builder, ItemStatus::failed($reason));
112		return Err($ret);
113	};
114	($builder:expr, $item:expr, $reason:expr => $ret:expr ) => {
115		$item.add_to_builder($builder, ItemStatus::failed($reason));
116		return Ok($ret);
117	};
118	($builder:expr, $item:expr, $reason:expr) => {
119		$item.add_to_builder($builder, ItemStatus::failed($reason));
120		return Ok(());
121	};
122}
123
124/// Configuration options for the [`Deployer`].
125#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
126pub struct DeployOptions {
127	/// If this flag is set, it will prevent any write operations from occurring
128	/// during the deployment.
129	///
130	/// This includes write, copy and directory creation operations.
131	pub dry_run: bool,
132}
133
134/// Responsible for deploying a [profile](`crate::profile::Profile`).
135///
136/// This includes checking for merge conflicts, resolving children of a
137/// directory dotfile, parsing and resolving of templates and the actual
138/// writing of the dotfile to the target destination.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Deployer<F> {
141	/// Configuration options
142	options: DeployOptions,
143
144	/// This function gets called when a dotfile at the target destination
145	/// already exists and the merge mode is
146	/// [MergeMode::Ask](`crate::profile::MergeMode::Ask`).
147	///
148	/// The arguments for the function are (dotfile_source_path, dotfile_target_path).
149	merge_ask_fn: F,
150
151	/// Builder for the deployment.
152	///
153	/// This holds information about each item which was processed,
154	/// keeps track of the time and also stores a overall status of the deployment.
155	builder: DeploymentBuilder,
156}
157
158impl<F> Deployer<F>
159where
160	F: Fn(&Path, &Path) -> color_eyre::Result<bool>,
161{
162	/// Creates a new instance.
163	pub fn new(options: DeployOptions, merge_ask_fn: F) -> Self {
164		Self {
165			options,
166			merge_ask_fn,
167			builder: DeploymentBuilder::default(),
168		}
169	}
170
171	/// Retrieves the finished deployment from this instance.
172	pub fn into_deployment(self) -> Deployment {
173		self.builder.finish()
174	}
175
176	/// Tries to deploy the given `profile`.
177	///
178	/// # Errors
179	///
180	/// Only hard errors will be returned as error, everything else will be
181	/// recorded in the [Deployment](`crate::visit::deploy::deployment::Deployment`)
182	/// on a dotfile level.
183	pub fn deploy(self, source: &PunktfSource, profile: &mut LayeredProfile) -> Deployment {
184		// General flow:
185		//	- get deployment path
186		//	- check if dotfile already deployed
187		//	- YES:
188		//		- compare priorities
189		//		- LOWER: continue next dotfile
190		//		- SAME/HIGHER: next step
191		//	- check if dotfile exists
192		//	- YES:
193		//		- check merge operation
194		//		- if merge operation == ASK
195		//			- Run merge_ask_fn
196		//			- FALSE: continue next dotfile
197		//	- check if template
198		//	- YES: resolve template
199		//	- IF FILE: write dotfile
200		//	- IF DIR: for each dotfile in dir START AT TOP
201
202		for hook in profile.pre_hooks() {
203			log::info!("Executing pre-hook: {}", hook.command());
204			// No files are deployed yet, meaning if an error during hook
205			// execution occurs it will return with an error instead of just
206			// logging it.
207
208			if let Err(err) = hook
209				.execute(source.profiles())
210				.wrap_err("Failed to execute pre-hook")
211			{
212				log::error!("Failed to execute pre-hook ({})", err);
213				return self.builder.failed(err.to_string());
214			};
215		}
216
217		let mut resolver = ResolvingVisitor(self);
218		let walker = Walker::new(profile);
219		if let Err(err) = walker.walk(source, &mut resolver) {
220			return resolver.into_inner().builder.failed(err.to_string());
221		}
222
223		let this = resolver.into_inner();
224
225		for hook in profile.post_hooks() {
226			log::info!("Executing post-hook: {}", hook.command());
227			if let Err(err) = hook.execute(source.profiles()) {
228				log::error!("Failed to execute post-hook ({})", err);
229				return this.builder.failed(err.to_string());
230			}
231		}
232
233		this.into_deployment()
234	}
235
236	/// Checks common things for a given file item before deploying it.
237	///
238	/// The returned boolean indicates if the deployment of the file should
239	/// continue.
240	fn pre_deploy_checks(&mut self, file: &File<'_>) -> color_eyre::Result<bool> {
241		let other_priority = self.builder.get_priority(&file.target_path);
242
243		match (file.dotfile().priority.as_ref(), other_priority) {
244			(Some(a), Some(b)) if b > a => {
245				log::info!(
246					"[{}] Dotfile with higher priority is already deployed at {}",
247					file.relative_source_path.display(),
248					file.target_path.display()
249				);
250
251				skipped!(&mut self.builder, file, "Dotfile with higher priority is already deployed" => false);
252			}
253			(_, _) => {}
254		};
255
256		if file.target_path.exists() {
257			// No previously deployed dotfile at `deploy_path`. Check for merge.
258
259			log::debug!(
260				"[{}] Dotfile already exists at {}",
261				file.relative_source_path.display(),
262				file.target_path.display()
263			);
264
265			match file.dotfile().merge.unwrap_or_default() {
266				MergeMode::Overwrite => {
267					log::info!(
268						"[{}] Overwriting existing dotfile",
269						file.relative_source_path.display()
270					)
271				}
272				MergeMode::Keep => {
273					log::info!(
274						"[{}] Skipping existing dotfile",
275						file.relative_source_path.display()
276					);
277
278					skipped!(&mut self.builder, file, format!("Dotfile already exists and merge mode is {:?}", MergeMode::Keep) => false);
279				}
280				MergeMode::Ask => {
281					log::info!(
282						"[{}] Asking for action",
283						file.relative_source_path.display()
284					);
285
286					let should_deploy = match (self.merge_ask_fn)(
287						&file.source_path,
288						file.target_path.borrow(),
289					)
290					.wrap_err("Error evaluating user response")
291					{
292						Ok(should_deploy) => should_deploy,
293						Err(err) => {
294							log::error!(
295								"[{}] Failed to execute ask function ({})",
296								file.relative_source_path.display(),
297								err
298							);
299
300							failed!(&mut self.builder, file, format!("Failed to execute merge ask function: {err}") => false);
301						}
302					};
303
304					if !should_deploy {
305						log::info!("{} Merge was denied", file.relative_source_path.display());
306
307						skipped!(&mut self.builder, file, "Dotfile already exists and merge ask was denied" => false);
308					}
309				}
310			}
311		}
312
313		if let Some(parent) = file.target_path.parent() {
314			if !self.options.dry_run {
315				match std::fs::create_dir_all(parent) {
316					Ok(_) => {}
317					Err(err) => {
318						log::error!(
319							"[{}] Failed to create directory ({})",
320							file.relative_source_path.display(),
321							err
322						);
323
324						failed!(&mut self.builder, file, format!("Failed to create parent directory: {err}") => false);
325					}
326				}
327			}
328		}
329
330		Ok(true)
331	}
332
333	/// Applies any relevant [`Transform`](`crate::profile::transform::Transform`)
334	/// for the given file.
335	fn transform_content(
336		&mut self,
337		profile: &LayeredProfile,
338		file: &File<'_>,
339		content: String,
340	) -> color_eyre::Result<String> {
341		let mut content = content;
342
343		// Copy so we exec_dotfile is not referenced by this in case an error occurs.
344		let exec_transformers: Vec<_> = file.dotfile().transformers.to_vec();
345
346		// Apply transformers.
347		// Order:
348		//   - Transformers which are specified in the profile root
349		//   - Transformers which are specified on a specific dotfile of a profile
350		for transformer in profile.transformers().chain(exec_transformers.iter()) {
351			content = match transformer.transform(content) {
352				Ok(content) => content,
353				Err(err) => {
354					log::info!(
355						"[{}] Failed to apply content transformer `{}`: `{}`",
356						file.relative_source_path.display(),
357						transformer,
358						err
359					);
360
361					failed!(&mut self.builder, file, format!("Failed to apply content transformer `{transformer}`: `{err}`") => Err(err));
362				}
363			};
364		}
365
366		Ok(content)
367	}
368}
369
370impl<F> Visitor for Deployer<F>
371where
372	F: Fn(&Path, &Path) -> color_eyre::Result<bool>,
373{
374	/// Accepts a file item and tries to deploy it.
375	fn accept_file<'a>(
376		&mut self,
377		_: &PunktfSource,
378		profile: &LayeredProfile,
379		file: &File<'a>,
380	) -> Result {
381		log::info!("[{}] Deploying file", file.relative_source_path.display());
382
383		let cont = self.pre_deploy_checks(file)?;
384
385		if !cont {
386			return Ok(());
387		}
388
389		// Fast path
390		if profile.transformers_len() == 0 && file.dotfile().transformers.is_empty() {
391			// File is no template and no transformers are specified. This means
392			// we can take the fast path of just copying via the filesystem.
393
394			// Allowed for readability
395			#[allow(clippy::collapsible_else_if)]
396			if !self.options.dry_run {
397				if let Err(err) = std::fs::copy(&file.source_path, &file.target_path) {
398					log::info!(
399						"[{}] Failed to copy file",
400						file.relative_source_path.display()
401					);
402
403					failed!(&mut self.builder, file, format!("Failed to copy: {err}"));
404				}
405			}
406		} else {
407			let content = match safe_read(&file.source_path) {
408				Ok(SafeRead::Binary(b)) => {
409					log::info!(
410						"[{}] Not evaluated as template - Binary data",
411						file.relative_source_path.display()
412					);
413
414					b
415				}
416				Ok(SafeRead::String(s)) => {
417					let Ok(content) = self.transform_content(profile, file, s) else {
418						// Error is already recorded
419						return Ok(());
420					};
421
422					content.into_bytes()
423				}
424				Err(err) => {
425					log::info!(
426						"[{}] Failed to read file",
427						file.relative_source_path.display()
428					);
429
430					failed!(&mut self.builder, file, format!("Failed to read: {err}"));
431				}
432			};
433
434			if !self.options.dry_run {
435				if let Err(err) = std::fs::write(&file.target_path, content) {
436					log::info!(
437						"[{}] Failed to write content",
438						file.relative_source_path.display()
439					);
440
441					failed!(
442						&mut self.builder,
443						file,
444						format!("Failed to write content: {err}")
445					);
446				}
447			}
448		}
449
450		log::info!(
451			"[{}] File successfully deployed",
452			file.relative_source_path.display()
453		);
454
455		success!(&mut self.builder, file);
456
457		Ok(())
458	}
459
460	/// Accepts a directory item and tries to deploy it.
461	fn accept_directory<'a>(
462		&mut self,
463		_: &PunktfSource,
464		_: &LayeredProfile,
465		directory: &Directory<'a>,
466	) -> Result {
467		log::info!(
468			"[{}] Deploying directory",
469			directory.relative_source_path.display()
470		);
471
472		if !self.options.dry_run {
473			if let Err(err) = std::fs::create_dir_all(&directory.target_path) {
474				log::error!(
475					"[{}] Failed to create directory ({})",
476					directory.relative_source_path.display(),
477					err
478				);
479
480				failed!(
481					&mut self.builder,
482					directory,
483					format!("Failed to create directory: {err}")
484				);
485			} else {
486				success!(&mut self.builder, directory);
487			}
488		} else {
489			success!(&mut self.builder, directory);
490		}
491
492		log::info!(
493			"[{}] Directory successfully deployed",
494			directory.relative_source_path.display()
495		);
496
497		Ok(())
498	}
499
500	/// Accepts a link item and tries to deploy it.
501	fn accept_link(&mut self, _: &PunktfSource, _: &LayeredProfile, link: &Symlink) -> Result {
502		log::info!("[{}] Deploying symlink", link.source_path.display());
503
504		// Log an warning if deploying of links is not supported for the
505		// operating system.
506		#[cfg(all(not(unix), not(windows)))]
507		{
508			log::warn!(
509				"[{}] Symlink operations are only supported for unix and windows systems",
510				source_path.display()
511			);
512			skipped!(
513				&mut self.builder,
514				link,
515				"Symlink operations are only supported on unix and windows systems"
516			);
517		}
518
519		let source_path = &link.source_path;
520		let target_path = &link.target_path;
521
522		// Check that the source exists
523		if !source_path.exists() {
524			log::error!("[{}] Links source does not exist", source_path.display());
525
526			failed!(&mut self.builder, link, "Link source does not exist");
527		}
528
529		// Check that either the target does not exist or that i can be replaced
530		if target_path.exists() {
531			if link.replace {
532				if !self.options.dry_run {
533					// Verify that the target is a symlink
534					let target_metadata = match target_path.symlink_metadata() {
535						Ok(m) => m,
536						Err(err) => {
537							log::error!("[{}] Failed to read metadata", source_path.display());
538
539							failed!(
540								&mut self.builder,
541								link,
542								format!("Failed get link target metadata: {err}")
543							);
544						}
545					};
546
547					if target_metadata.is_symlink() {
548						// Get metadata of symlink target
549						let res = if let Ok(target_metadata) = target_path.metadata() {
550							if target_metadata.is_dir() {
551								std::fs::remove_dir(target_path)
552							} else {
553								std::fs::remove_file(target_path)
554							}
555						} else {
556							std::fs::remove_file(target_path)
557								.or_else(|_| std::fs::remove_dir(target_path))
558						};
559
560						if let Err(err) = res {
561							log::error!(
562								"[{}] Failed to remove old link at target",
563								source_path.display()
564							);
565
566							failed!(
567								&mut self.builder,
568								link,
569								format!("Failed to remove old link target: {err}")
570							);
571						} else {
572							log::info!(
573								"[{}] Removed old link target at {}",
574								source_path.display(),
575								target_path.display()
576							);
577						}
578					} else {
579						log::error!(
580							"[{}] Target already exists and is no link",
581							source_path.display()
582						);
583
584						failed!(&mut self.builder, link, "Not allowed to replace target");
585					}
586				}
587			} else {
588				log::error!(
589					"[{}] Target already exists and is not allowed to be replaced",
590					source_path.display()
591				);
592
593				skipped!(&mut self.builder, link, "Link target does already exist");
594			}
595		}
596
597		if !self.options.dry_run {
598			cfg_if! {
599				if #[cfg(unix)] {
600					if let Err(err) = std::os::unix::fs::symlink(source_path, target_path) {
601						log::error!("[{}] Failed to create link", source_path.display());
602
603						failed!(&mut self.builder, link, format!("Failed create link: {err}"));
604					};
605				} else if #[cfg(windows)] {
606					let metadata = match source_path.symlink_metadata() {
607						Ok(m) => m,
608						Err(err) => {
609							log::error!("[{}] Failed to read metadata", source_path.display());
610
611							failed!(&mut self.builder, link, format!("Failed get link source metadata: {err}"));
612						}
613					};
614
615					if metadata.is_dir() {
616						if let Err(err) = std::os::windows::fs::symlink_dir(source_path, target_path) {
617							log::error!("[{}] Failed to create directory link", source_path.display());
618
619							failed!(&mut self.builder, link, format!("Failed create directory link: {err}"));
620						};
621					} else if metadata.is_file() {
622						if let Err(err) = std::os::windows::fs::symlink_file(source_path, target_path) {
623							log::error!("[{}] Failed to create file link", source_path.display());
624
625							failed!(&mut self.builder, link, format!("Failed create file link: {err}"));
626						};
627					} else {
628						log::error!("[{}] Invalid link source type", source_path.display());
629
630						failed!(&mut self.builder, link, "Invalid type of link source");
631					}
632				} else {
633					log::warn!("[{}] Link operations are only supported for unix and windows systems", source_path.display());
634
635					skipped!(&mut self.builder, link, "Link operations are only supported on unix and windows systems");
636				}
637			}
638		}
639
640		success!(&mut self.builder, link);
641
642		Ok(())
643	}
644
645	/// Accepts a rejected item and reports it.
646	fn accept_rejected<'a>(
647		&mut self,
648		_: &PunktfSource,
649		_: &LayeredProfile,
650		rejected: &Rejected<'a>,
651	) -> Result {
652		log::info!(
653			"[{}] Rejected - {}",
654			rejected.relative_source_path.display(),
655			rejected.reason
656		);
657
658		skipped!(&mut self.builder, rejected, rejected.reason.clone());
659	}
660
661	/// Accepts a errored item and reports it.
662	fn accept_errored<'a>(
663		&mut self,
664		_: &PunktfSource,
665		_: &LayeredProfile,
666		errored: &Errored<'a>,
667	) -> Result {
668		log::error!(
669			"[{}] Failed - {}",
670			errored.relative_source_path.display(),
671			errored
672		);
673
674		failed!(&mut self.builder, errored, errored.to_string());
675	}
676}
677
678impl<F> TemplateVisitor for Deployer<F>
679where
680	F: Fn(&Path, &Path) -> color_eyre::Result<bool>,
681{
682	/// Accepts a file template item and tries to deploy it.
683	///
684	/// Before the deployment the template is parsed and resolved.
685	fn accept_template<'a>(
686		&mut self,
687		_: &PunktfSource,
688		profile: &LayeredProfile,
689		file: &File<'a>,
690		// Returns a function to resolve the content to make the resolving lazy
691		// for upstream visitors.
692		resolve_content: impl FnOnce(&str) -> color_eyre::Result<String>,
693	) -> Result {
694		log::info!(
695			"[{}] Deploying template",
696			file.relative_source_path.display()
697		);
698
699		let cont = self.pre_deploy_checks(file)?;
700
701		if !cont {
702			return Ok(());
703		}
704
705		let content = match safe_read(&file.source_path) {
706			Ok(SafeRead::Binary(b)) => {
707				log::info!(
708					"[{}] Not evaluated as template - Binary data",
709					file.relative_source_path.display()
710				);
711
712				b
713			}
714			Ok(SafeRead::String(s)) => {
715				let content = match resolve_content(&s) {
716					Ok(content) => content,
717					Err(err) => {
718						log::info!(
719							"[{}] Failed to resolve template",
720							file.relative_source_path.display()
721						);
722
723						failed!(
724							&mut self.builder,
725							file,
726							format!("Failed to resolve template: {err}")
727						);
728					}
729				};
730
731				let Ok(content) = self.transform_content(profile, file, content) else {
732					// Error is already recorded
733					return Ok(());
734				};
735
736				content.into_bytes()
737			}
738			Err(err) => {
739				log::info!(
740					"[{}] Failed to read file",
741					file.relative_source_path.display()
742				);
743
744				failed!(&mut self.builder, file, format!("Failed to read: {err}"));
745			}
746		};
747
748		if !self.options.dry_run {
749			if let Err(err) = std::fs::write(&file.target_path, content) {
750				log::info!(
751					"[{}] Failed to write content",
752					file.relative_source_path.display()
753				);
754
755				failed!(
756					&mut self.builder,
757					file,
758					format!("Failed to write content: {err}")
759				);
760			}
761		}
762
763		log::info!(
764			"[{}] Template successfully deployed",
765			file.relative_source_path.display()
766		);
767
768		success!(&mut self.builder, file);
769
770		Ok(())
771	}
772}