1pub mod batch;
34pub mod local;
35mod local_fs;
36pub mod process;
37pub mod projects;
38pub mod remote;
39pub mod sftp;
40pub mod winps;
41
42use std::collections::{BTreeMap, BTreeSet};
43use std::path::Path;
44use std::sync::Arc;
45
46use serde::Serialize;
47
48use super::apply::host::ApplyHost;
49use super::layout::{RelPath, TAG_TYPES};
50use super::manifest::Manifest;
51use super::rules::GATEWAY_OWNED_FILES;
52use super::target::{NodeTarget, TargetOs};
53use super::tree::RenderedTree;
54use crate::error::CoreError;
55
56pub const CORE_COLLECTION: &str = "core";
58
59pub const READ_MAX_FILE_BYTES: u64 = 67_108_864;
61pub const READ_MAX_TOTAL_BYTES: u64 = 1_073_741_824;
63pub const READ_MAX_ENTRIES: usize = 200_000;
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct FileBlob {
69 pub bytes: Vec<u8>,
71 pub sha256: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Scope {
87 pub mode: String,
89 pub types: Vec<(String, String)>,
91 pub tag_providers: Vec<String>,
93}
94
95impl Scope {
96 pub fn from_manifest(manifest: &Manifest, mode: &str, excluded: &[String]) -> Scope {
104 let types = manifest
105 .types
106 .iter()
107 .filter_map(|t| t.split_once('/'))
108 .filter(|(module, ty)| !(*module == "ignition" && TAG_TYPES.contains(ty)))
109 .map(|(module, ty)| (module.to_string(), ty.to_string()))
110 .collect();
111 Scope {
112 mode: mode.to_string(),
113 types,
114 tag_providers: manifest
115 .tag_providers
116 .iter()
117 .filter(|p| !super::exclude::is_excluded(p, excluded))
118 .cloned()
119 .collect(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
127pub struct ObservedFiles {
128 pub core: BTreeMap<RelPath, FileBlob>,
130 pub mode: BTreeMap<RelPath, FileBlob>,
132 pub local_units: Vec<String>,
136 pub unreadable: Vec<Unreadable>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct Unreadable {
149 pub collection: String,
151 pub path: RelPath,
153 pub length: usize,
155}
156
157#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
162pub struct DeleteSet {
163 pub files: Vec<RelPath>,
165}
166
167#[async_trait::async_trait]
169pub trait TreeReader: Send + Sync {
170 async fn list_types(&self, collection: &str) -> Result<Vec<(String, String)>, CoreError>;
173
174 async fn list_tag_providers(&self, collection: &str) -> Result<Vec<String>, CoreError>;
180
181 async fn read(&self, scope: &Scope) -> Result<ObservedFiles, CoreError>;
184
185 async fn read_ledger(&self, gateway: &str, mode: &str) -> Result<Option<Vec<u8>>, CoreError>;
194
195 async fn read_journal(&self, gateway: &str, mode: &str) -> Result<Option<Vec<u8>>, CoreError>;
206}
207
208enum NodeAccess {
210 Local(std::path::PathBuf),
212 Ssh {
214 shell: Box<remote::RemoteShell>,
215 data_dir: String,
218 windows: bool,
220 },
221}
222
223fn node_access(
225 repo: &Path,
226 target_file: &str,
227 label: &str,
228 node: &NodeTarget,
229) -> Result<NodeAccess, CoreError> {
230 match (&node.ssh, &node.local) {
231 (Some(_), Some(_)) => Err(CoreError::deploy_tree_invalid(
232 target_file,
233 None,
234 None,
235 format!("{label}: set ssh or local, not both"),
236 )),
237 (None, Some(local)) => {
238 let configured = Path::new(&local.data_dir);
239 let data_dir = if configured.is_absolute() {
243 configured.to_path_buf()
244 } else {
245 std::path::absolute(repo.join(configured)).map_err(|e| {
246 CoreError::deploy_tree_invalid(
247 target_file,
248 None,
249 None,
250 format!(
251 "{label}.local.data_dir {:?}: cannot be made absolute ({:?})",
252 local.data_dir,
253 e.kind()
254 ),
255 )
256 })?
257 };
258 Ok(NodeAccess::Local(data_dir))
259 }
260 (Some(ssh), None) => {
261 let windows = ssh.os == TargetOs::Windows;
262 let data_dir = if windows {
263 sftp::windows_sftp_root(&ssh.data_dir)
264 } else {
265 sftp::posix_root(&ssh.data_dir)
266 }
267 .map_err(|why| {
268 CoreError::deploy_tree_invalid(
269 target_file,
270 None,
271 None,
272 format!("{label}.ssh.data_dir: {why}"),
273 )
274 })?;
275 let shell = remote::RemoteShell::from_ssh(
276 repo,
277 target_file,
278 label,
279 ssh,
280 Arc::new(process::TokioProcessRunner),
281 )?;
282 Ok(NodeAccess::Ssh {
283 shell: Box::new(shell),
284 data_dir,
285 windows,
286 })
287 }
288 (None, None) => Err(CoreError::deploy_tree_invalid(
289 target_file,
290 None,
291 None,
292 format!("{label}: set ssh or local; this verb needs host access"),
293 )),
294 }
295}
296
297fn sftp_transport(
299 shell: remote::RemoteShell,
300 data_dir: String,
301 label: &str,
302 windows: bool,
303) -> sftp::SftpTransport {
304 if windows {
305 sftp::SftpTransport::new_windows(shell, data_dir, label)
306 } else {
307 sftp::SftpTransport::new(shell, data_dir, label)
308 }
309}
310
311pub fn for_node(
318 repo: &Path,
319 target_file: &str,
320 label: &str,
321 node: &NodeTarget,
322) -> Result<Box<dyn TreeReader>, CoreError> {
323 Ok(match node_access(repo, target_file, label, node)? {
324 NodeAccess::Local(data_dir) => Box::new(local::LocalFsTransport::new(data_dir, label)),
325 NodeAccess::Ssh {
326 shell,
327 data_dir,
328 windows,
329 } => Box::new(sftp_transport(*shell, data_dir, label, windows)),
330 })
331}
332
333#[async_trait::async_trait]
378pub trait TreeMirror: Send + Sync {
379 async fn stage(&self, tree: &RenderedTree) -> Result<Staged, CoreError>;
381
382 async fn commit(
386 &self,
387 staged: &Staged,
388 delete_set: &DeleteSet,
389 ) -> Result<MirrorReport, CoreError>;
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum StagingMode {
395 Separate {
398 root: String,
401 },
402 InPlace,
404}
405
406#[derive(Debug)]
408pub struct Staged {
409 pub files: Vec<(RelPath, String)>,
411 pub(crate) local: tempfile::TempDir,
415 pub(crate) remote_root: Option<String>,
418 pub(crate) owner: String,
420}
421
422#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
424pub struct MirrorReport {
425 pub written: Vec<String>,
427 pub deleted: Vec<String>,
429 pub removed_dirs: Vec<String>,
431}
432
433pub fn mirror_for_node(
440 repo: &Path,
441 target_file: &str,
442 label: &str,
443 node: &NodeTarget,
444 mode: &str,
445 staging: StagingMode,
446) -> Result<Box<dyn TreeMirror>, CoreError> {
447 let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
448 Ok(match node_access(repo, target_file, label, node)? {
449 NodeAccess::Local(data_dir) => Box::new(
450 local::LocalFsTransport::new(data_dir, label)
451 .with_mirror(mode, staging)
452 .map_err(|why| invalid(format!("{label}: {why}")))?,
453 ),
454 NodeAccess::Ssh {
455 shell,
456 data_dir,
457 windows,
458 } => Box::new(
459 sftp_transport(*shell, data_dir, label, windows)
460 .with_mirror(mode, staging)
461 .map_err(|why| invalid(format!("{label}: {why}")))?,
462 ),
463 })
464}
465
466pub fn apply_host_for_node(
476 repo: &Path,
477 target_file: &str,
478 label: &str,
479 node: &NodeTarget,
480 gateway: &str,
481 mode: &str,
482) -> Result<Box<dyn ApplyHost>, CoreError> {
483 let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
484 if let Some(why) = mirror_mode_problem(mode) {
485 return Err(invalid(format!("{label}: {why}")));
486 }
487 let access = node_access(repo, target_file, label, node)?;
488 Ok(
489 match apply_host_kind(access, label, gateway, mode).map_err(invalid)? {
490 ApplyHostKind::Local(host) => Box::new(host),
491 ApplyHostKind::Sftp(host) => host,
492 },
493 )
494}
495
496enum ApplyHostKind {
498 Local(local::LocalFsTransport),
499 Sftp(Box<sftp::SftpTransport>),
500}
501
502fn apply_host_kind(
503 access: NodeAccess,
504 label: &str,
505 gateway: &str,
506 mode: &str,
507) -> Result<ApplyHostKind, String> {
508 Ok(match access {
509 NodeAccess::Local(data_dir) => ApplyHostKind::Local(
510 local::LocalFsTransport::new(data_dir, label)
511 .with_apply(gateway, mode)
512 .map_err(|why| format!("{label}: {why}"))?,
513 ),
514 NodeAccess::Ssh {
515 shell,
516 data_dir,
517 windows,
518 } => ApplyHostKind::Sftp(Box::new(
519 sftp_transport(*shell, data_dir, label, windows)
520 .with_apply(gateway, mode)
521 .map_err(|why| format!("{label}: {why}"))?,
522 )),
523 })
524}
525
526pub async fn default_staging_mode(
541 repo: &Path,
542 target_file: &str,
543 label: &str,
544 node: &NodeTarget,
545 gateway: &str,
546 mode: &str,
547 run_id: &str,
548) -> Result<StagingMode, CoreError> {
549 let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
550 for (what, value) in [("gateway", gateway), ("mode", mode)] {
551 if let Some(why) = super::component_problem(value) {
552 return Err(invalid(format!("{label}: the staging {what} {why}")));
553 }
554 }
555 let run_ok = (1..=128).contains(&run_id.len())
556 && !run_id.starts_with('.')
557 && run_id
558 .bytes()
559 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
560 if !run_ok {
561 return Err(invalid(format!(
562 "{label}: the run id must be 1 to 128 characters of [A-Za-z0-9._-] and must not start with a dot"
563 )));
564 }
565 let rel = super::ledger::staging_rel(gateway, mode, run_id);
566 Ok(match node_access(repo, target_file, label, node)? {
567 NodeAccess::Local(data_dir) => {
568 let root = data_dir.join(&rel);
569 let root = root.to_str().ok_or_else(|| {
570 invalid(format!("{label}: the data directory is not valid UTF-8"))
571 })?;
572 StagingMode::Separate {
573 root: root.to_string(),
574 }
575 }
576 NodeAccess::Ssh {
577 shell,
578 data_dir,
579 windows,
580 } => {
581 sftp_transport(*shell, data_dir, label, windows)
582 .default_staging(gateway, mode, run_id)
583 .await?
584 }
585 })
586}
587
588pub(crate) fn mirror_mode_problem(mode: &str) -> Option<String> {
591 if let Some(why) = super::component_problem(mode) {
592 return Some(format!("the mode {why}"));
593 }
594 if mode == CORE_COLLECTION || mode == "local" {
595 return Some(format!(
596 "the mode {mode:?} names a collection a mirror never writes"
597 ));
598 }
599 None
600}
601
602pub(crate) fn mirror_path_problem(path: &RelPath) -> Option<String> {
608 let parts: Vec<&str> = path.components().collect();
609 if parts.len() < MIRROR_MIN_COMPONENTS {
610 return Some(format!(
611 "{path} is not a collection path (<module>/<type>/<name>..., at least {MIRROR_MIN_COMPONENTS} components)"
612 ));
613 }
614 for part in parts {
615 if part.starts_with('.') {
616 return Some(format!("{path} has a dot-named component {part:?}"));
617 }
618 if GATEWAY_OWNED_FILES.contains(&part) {
619 return Some(format!("{path} names the gateway-owned file {part:?}"));
620 }
621 if part.chars().any(char::is_control) {
622 return Some(format!("{path} has a control character in a name"));
623 }
624 }
625 None
626}
627
628pub(crate) const MIRROR_MIN_COMPONENTS: usize = 3;
630
631pub(crate) fn validate_mirror_inputs(
636 tree: &RenderedTree,
637 delete_set: &DeleteSet,
638 staging: &StagingMode,
639 resources_root: &str,
640) -> Result<(), String> {
641 let mut total: u64 = 0;
642 for (path, bytes) in &tree.files {
643 let len = bytes.len() as u64;
644 if len > READ_MAX_FILE_BYTES {
645 return Err(format!(
646 "{path} is {len} bytes, over the {READ_MAX_FILE_BYTES} byte file cap"
647 ));
648 }
649 total = total.saturating_add(len);
650 if total > READ_MAX_TOTAL_BYTES {
651 return Err(format!(
652 "the staged tree exceeds {READ_MAX_TOTAL_BYTES} bytes in total (at {path})"
653 ));
654 }
655 }
656 let staged: Vec<&RelPath> = tree.files.keys().collect();
657 validate_mirror_paths(&staged, delete_set)?;
658 validate_staging_root(staging, resources_root)
659}
660
661pub(crate) fn validate_mirror_paths(
664 staged: &[&RelPath],
665 delete_set: &DeleteSet,
666) -> Result<(), String> {
667 for path in staged {
668 if let Some(why) = mirror_path_problem(path) {
669 return Err(format!("staged path refused: {why}"));
670 }
671 }
672 let mut deleting: BTreeSet<&str> = BTreeSet::new();
673 for path in &delete_set.files {
674 if let Some(why) = mirror_path_problem(path) {
675 return Err(format!("delete-set path refused: {why}"));
676 }
677 if !deleting.insert(path.as_str()) {
678 return Err(format!("delete-set path {path} is listed twice"));
679 }
680 }
681 let staging: BTreeSet<&str> = staged.iter().map(|p| p.as_str()).collect();
682 if staging.len() != staged.len() {
683 return Err("a path is staged twice".to_string());
684 }
685 if let Some(both) = deleting.iter().find(|p| staging.contains(**p)) {
686 return Err(format!("{both} is both staged and in the delete set"));
687 }
688 let mut dirs: BTreeSet<&str> = BTreeSet::new();
690 for path in staging.iter().chain(deleting.iter()) {
691 for (at, _) in path.match_indices('/') {
692 dirs.insert(&path[..at]);
693 }
694 }
695 if let Some(clash) = staging
696 .iter()
697 .chain(deleting.iter())
698 .find(|p| dirs.contains(**p))
699 {
700 return Err(format!(
701 "{clash} is a file and also the directory of another path"
702 ));
703 }
704 if staging.len() + deleting.len() + dirs.len() > READ_MAX_ENTRIES {
705 return Err(format!(
706 "more than {READ_MAX_ENTRIES} entries (files, deletes and directories together)"
707 ));
708 }
709 Ok(())
710}
711
712pub(crate) fn absolute_components(text: &str) -> Result<Vec<String>, String> {
715 if text.is_empty() || text.chars().any(char::is_control) {
716 return Err("must be a non-empty path without control characters".to_string());
717 }
718 let normal = text.replace('\\', "/");
719 let bytes = normal.as_bytes();
720 let absolute = normal.starts_with('/')
721 || (bytes.len() >= 3
722 && bytes[1] == b':'
723 && bytes[2] == b'/'
724 && bytes[0].is_ascii_alphabetic());
725 if !absolute {
726 return Err("must be an absolute path".to_string());
727 }
728 let mut parts = Vec::new();
729 for part in normal.split('/') {
730 match part {
731 "" => {}
732 "." | ".." => return Err("must not contain `.` or `..` components".to_string()),
733 other => parts.push(other.to_string()),
734 }
735 }
736 Ok(parts)
737}
738
739pub(crate) fn validate_staging_root(
742 staging: &StagingMode,
743 resources_root: &str,
744) -> Result<(), String> {
745 validate_staging_root_for(staging, resources_root, cfg!(windows))
746}
747
748pub(crate) fn validate_staging_root_for(
753 staging: &StagingMode,
754 resources_root: &str,
755 case_insensitive: bool,
756) -> Result<(), String> {
757 let StagingMode::Separate { root } = staging else {
758 return Ok(());
759 };
760 let staging_parts =
761 absolute_components(root).map_err(|why| format!("the staging root {root:?} {why}"))?;
762 if staging_parts.len() < 2 {
763 return Err(format!(
764 "the staging root {root:?} must be at least two directories deep"
765 ));
766 }
767 let resources_parts = absolute_components(resources_root)
768 .map_err(|why| format!("the resources root {resources_root:?} {why}"))?;
769 let same = |a: &String, b: &String| {
770 if case_insensitive {
771 a.eq_ignore_ascii_case(b)
772 } else {
773 a == b
774 }
775 };
776 let starts_with = |long: &[String], short: &[String]| {
777 long.len() >= short.len() && long.iter().zip(short).all(|(a, b)| same(a, b))
778 };
779 if starts_with(&staging_parts, &resources_parts) {
780 return Err(format!(
781 "the staging root {root:?} is inside config/resources; staging entries there would be scanned by the gateway"
782 ));
783 }
784 if starts_with(&resources_parts, &staging_parts) {
785 return Err(format!(
786 "the staging root {root:?} contains config/resources"
787 ));
788 }
789 Ok(())
790}
791
792pub(crate) fn copy_to_temp(
795 tree: &RenderedTree,
796) -> Result<(tempfile::TempDir, Vec<(RelPath, String)>), String> {
797 use std::io::Write;
798 let temp = tempfile::Builder::new()
799 .prefix("ign-stage-")
800 .tempdir()
801 .map_err(|e| format!("cannot create a staging dir ({})", e.kind()))?;
802 let mut files = Vec::with_capacity(tree.files.len());
803 for (path, bytes) in &tree.files {
804 let target = temp.path().join(path.as_str());
805 if let Some(parent) = target.parent() {
806 std::fs::create_dir_all(parent)
807 .map_err(|e| format!("cannot create the staging dir for {path} ({})", e.kind()))?;
808 }
809 let mut file = std::fs::OpenOptions::new()
810 .write(true)
811 .create_new(true)
812 .open(&target)
813 .map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
814 file.write_all(bytes)
815 .map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
816 files.push((path.clone(), super::tree::sha256_hex(bytes)));
817 }
818 Ok((temp, files))
819}
820
821pub(crate) fn read_staged_copy(
826 base: &Path,
827 rel: &RelPath,
828 expect_sha: &str,
829) -> Result<Vec<u8>, String> {
830 use super::tree::{OpenError, open_regular_file, sha256_hex};
831 use std::io::Read;
832 let path = base.join(rel.as_str());
833 let before = std::fs::symlink_metadata(&path)
834 .map_err(|e| format!("the staged copy of {rel} is gone ({})", e.kind()))?;
835 if !before.file_type().is_file() {
836 return Err(format!("the staged copy of {rel} is not a regular file"));
837 }
838 let (file, held) =
839 open_regular_file(base, Path::new(rel.as_str()), &before).map_err(|e| match e {
840 OpenError::Changed => format!("the staged copy of {rel} changed while it was read"),
841 OpenError::Io(e) => format!("cannot read the staged copy of {rel} ({})", e.kind()),
842 })?;
843 if held.len() > READ_MAX_FILE_BYTES {
844 return Err(format!(
845 "the staged copy of {rel} is over the {READ_MAX_FILE_BYTES} byte file cap"
846 ));
847 }
848 let mut bytes = Vec::with_capacity(held.len() as usize);
849 file.take(READ_MAX_FILE_BYTES + 1)
850 .read_to_end(&mut bytes)
851 .map_err(|e| format!("cannot read the staged copy of {rel} ({})", e.kind()))?;
852 if bytes.len() as u64 > READ_MAX_FILE_BYTES || sha256_hex(&bytes) != expect_sha {
853 return Err(format!(
854 "the staged copy of {rel} changed after it was staged"
855 ));
856 }
857 Ok(bytes)
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::deploy::target::{Ssh, TargetOs};
864
865 fn ssh_node(os: TargetOs, data_dir: &str) -> NodeTarget {
866 NodeTarget {
867 profile: "p".to_string(),
868 ssh: Some(Ssh {
869 host: "gw.example.test".to_string(),
870 port: None,
871 user: "svc".to_string(),
872 key_file: None,
873 key_env: None,
874 os,
875 data_dir: data_dir.to_string(),
876 known_hosts: "ssh/prod.known_hosts".to_string(),
877 connect_timeout_s: None,
878 op_timeout_s: None,
879 }),
880 local: None,
881 gateway_name: None,
882 }
883 }
884
885 fn repo() -> tempfile::TempDir {
886 let repo = tempfile::tempdir().expect("repo");
887 std::fs::create_dir_all(repo.path().join("ssh")).expect("ssh dir");
888 std::fs::write(
889 repo.path().join("ssh/prod.known_hosts"),
890 "gw.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPinnedPinnedPinnedPinnedPinnedPinnedPinned\n",
891 )
892 .expect("known_hosts");
893 repo
894 }
895
896 #[test]
899 fn apply_host_for_node_linux_ssh() {
900 let repo = repo();
901 let linux = ssh_node(TargetOs::Linux, "/usr/local/bin/ignition/data");
902 assert!(
903 apply_host_for_node(
904 repo.path(),
905 "targets/prod.yaml",
906 "nodes.master",
907 &linux,
908 "gw",
909 "prod"
910 )
911 .is_ok(),
912 "a Linux ssh node builds an apply host"
913 );
914 let access =
915 node_access(repo.path(), "targets/prod.yaml", "nodes.master", &linux).expect("access");
916 let ApplyHostKind::Sftp(host) =
917 apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
918 else {
919 panic!("an ssh node gets the sftp host");
920 };
921 assert!(!host.is_windows_for_tests());
922 let local = NodeTarget {
923 profile: "p".to_string(),
924 ssh: None,
925 local: Some(crate::deploy::target::Local {
926 data_dir: repo.path().join("data").display().to_string(),
927 }),
928 gateway_name: None,
929 };
930 let access =
931 node_access(repo.path(), "targets/prod.yaml", "nodes.master", &local).expect("access");
932 assert!(matches!(
933 apply_host_kind(access, "nodes.master", "gw", "prod").expect("host"),
934 ApplyHostKind::Local(_)
935 ));
936 for (gateway, mode) in [
937 (".hidden", "prod"),
938 ("gw", "core"),
939 ("gw", "local"),
940 ("g/w", "prod"),
941 ] {
942 let err = apply_host_for_node(
943 repo.path(),
944 "targets/prod.yaml",
945 "nodes.master",
946 &linux,
947 gateway,
948 mode,
949 )
950 .err()
951 .unwrap_or_else(|| panic!("{gateway}/{mode} is refused"));
952 assert_eq!(err.code(), "deploy_tree_invalid", "{gateway}/{mode}");
953 }
954 }
955
956 #[test]
960 fn apply_host_for_node_windows_ssh() {
961 let repo = repo();
962 let windows = ssh_node(TargetOs::Windows, "C:\\Ignition\\data");
963 assert!(
964 apply_host_for_node(
965 repo.path(),
966 "targets/prod.yaml",
967 "nodes.master",
968 &windows,
969 "gw",
970 "prod"
971 )
972 .is_ok(),
973 "a Windows ssh node builds an apply host"
974 );
975 let access = node_access(repo.path(), "targets/prod.yaml", "nodes.master", &windows)
976 .expect("access");
977 let ApplyHostKind::Sftp(host) =
978 apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
979 else {
980 panic!("a Windows ssh node gets the sftp host");
981 };
982 assert!(host.is_windows_for_tests());
983 let err = apply_host_for_node(
985 repo.path(),
986 "targets/prod.yaml",
987 "nodes.master",
988 &windows,
989 "gw",
990 "core",
991 )
992 .err()
993 .expect("core is refused");
994 assert_eq!(err.code(), "deploy_tree_invalid");
995 }
996}