pub mod batch;
pub mod local;
mod local_fs;
pub mod process;
pub mod projects;
pub mod remote;
pub mod sftp;
pub mod winps;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::sync::Arc;
use serde::Serialize;
use super::apply::host::ApplyHost;
use super::layout::{RelPath, TAG_TYPES};
use super::manifest::Manifest;
use super::rules::GATEWAY_OWNED_FILES;
use super::target::{NodeTarget, TargetOs};
use super::tree::RenderedTree;
use crate::error::CoreError;
pub const CORE_COLLECTION: &str = "core";
pub const READ_MAX_FILE_BYTES: u64 = 67_108_864;
pub const READ_MAX_TOTAL_BYTES: u64 = 1_073_741_824;
pub const READ_MAX_ENTRIES: usize = 200_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileBlob {
pub bytes: Vec<u8>,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Scope {
pub mode: String,
pub types: Vec<(String, String)>,
pub tag_providers: Vec<String>,
}
impl Scope {
pub fn from_manifest(manifest: &Manifest, mode: &str, excluded: &[String]) -> Scope {
let types = manifest
.types
.iter()
.filter_map(|t| t.split_once('/'))
.filter(|(module, ty)| !(*module == "ignition" && TAG_TYPES.contains(ty)))
.map(|(module, ty)| (module.to_string(), ty.to_string()))
.collect();
Scope {
mode: mode.to_string(),
types,
tag_providers: manifest
.tag_providers
.iter()
.filter(|p| !super::exclude::is_excluded(p, excluded))
.cloned()
.collect(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ObservedFiles {
pub core: BTreeMap<RelPath, FileBlob>,
pub mode: BTreeMap<RelPath, FileBlob>,
pub local_units: Vec<String>,
pub unreadable: Vec<Unreadable>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unreadable {
pub collection: String,
pub path: RelPath,
pub length: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub path: RelPath,
pub dir: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeListing {
pub module: String,
pub ty: String,
pub entries: Vec<TreeEntry>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct DeleteSet {
pub files: Vec<RelPath>,
}
#[async_trait::async_trait]
pub trait TreeReader: Send + Sync {
async fn list_types(&self, collection: &str) -> Result<Vec<(String, String)>, CoreError>;
async fn list_tag_providers(&self, collection: &str) -> Result<Vec<String>, CoreError>;
async fn list_tree(
&self,
collection: &str,
dirs: &[(String, String)],
) -> Result<Vec<TreeListing>, CoreError>;
async fn read(&self, scope: &Scope) -> Result<ObservedFiles, CoreError>;
async fn read_ledger(&self, gateway: &str, mode: &str) -> Result<Option<Vec<u8>>, CoreError>;
async fn read_journal(&self, gateway: &str, mode: &str) -> Result<Option<Vec<u8>>, CoreError>;
}
enum NodeAccess {
Local(std::path::PathBuf),
Ssh {
shell: Box<remote::RemoteShell>,
data_dir: String,
windows: bool,
},
}
fn node_access(
repo: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
) -> Result<NodeAccess, CoreError> {
match (&node.ssh, &node.local) {
(Some(_), Some(_)) => Err(CoreError::deploy_tree_invalid(
target_file,
None,
None,
format!("{label}: set ssh or local, not both"),
)),
(None, Some(local)) => {
let configured = Path::new(&local.data_dir);
let data_dir = if configured.is_absolute() {
configured.to_path_buf()
} else {
std::path::absolute(repo.join(configured)).map_err(|e| {
CoreError::deploy_tree_invalid(
target_file,
None,
None,
format!(
"{label}.local.data_dir {:?}: cannot be made absolute ({:?})",
local.data_dir,
e.kind()
),
)
})?
};
Ok(NodeAccess::Local(data_dir))
}
(Some(ssh), None) => {
let windows = ssh.os == TargetOs::Windows;
let data_dir = if windows {
sftp::windows_sftp_root(&ssh.data_dir)
} else {
sftp::posix_root(&ssh.data_dir)
}
.map_err(|why| {
CoreError::deploy_tree_invalid(
target_file,
None,
None,
format!("{label}.ssh.data_dir: {why}"),
)
})?;
let shell = remote::RemoteShell::from_ssh(
repo,
target_file,
label,
ssh,
Arc::new(process::TokioProcessRunner),
)?;
Ok(NodeAccess::Ssh {
shell: Box::new(shell),
data_dir,
windows,
})
}
(None, None) => Err(CoreError::deploy_tree_invalid(
target_file,
None,
None,
format!("{label}: set ssh or local; this verb needs host access"),
)),
}
}
fn sftp_transport(
shell: remote::RemoteShell,
data_dir: String,
label: &str,
windows: bool,
) -> sftp::SftpTransport {
if windows {
sftp::SftpTransport::new_windows(shell, data_dir, label)
} else {
sftp::SftpTransport::new(shell, data_dir, label)
}
}
pub fn for_node(
repo: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
) -> Result<Box<dyn TreeReader>, CoreError> {
Ok(match node_access(repo, target_file, label, node)? {
NodeAccess::Local(data_dir) => Box::new(local::LocalFsTransport::new(data_dir, label)),
NodeAccess::Ssh {
shell,
data_dir,
windows,
} => Box::new(sftp_transport(*shell, data_dir, label, windows)),
})
}
#[async_trait::async_trait]
pub trait TreeMirror: Send + Sync {
async fn stage(&self, tree: &RenderedTree) -> Result<Staged, CoreError>;
async fn commit(
&self,
staged: &Staged,
delete_set: &DeleteSet,
) -> Result<MirrorReport, CoreError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StagingMode {
Separate {
root: String,
},
InPlace,
}
#[derive(Debug)]
pub struct Staged {
pub files: Vec<(RelPath, String)>,
pub(crate) local: tempfile::TempDir,
pub(crate) remote_root: Option<String>,
pub(crate) owner: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct MirrorReport {
pub written: Vec<String>,
pub deleted: Vec<String>,
pub removed_dirs: Vec<String>,
}
pub fn mirror_for_node(
repo: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
mode: &str,
staging: StagingMode,
) -> Result<Box<dyn TreeMirror>, CoreError> {
let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
Ok(match node_access(repo, target_file, label, node)? {
NodeAccess::Local(data_dir) => Box::new(
local::LocalFsTransport::new(data_dir, label)
.with_mirror(mode, staging)
.map_err(|why| invalid(format!("{label}: {why}")))?,
),
NodeAccess::Ssh {
shell,
data_dir,
windows,
} => Box::new(
sftp_transport(*shell, data_dir, label, windows)
.with_mirror(mode, staging)
.map_err(|why| invalid(format!("{label}: {why}")))?,
),
})
}
pub fn apply_host_for_node(
repo: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
gateway: &str,
mode: &str,
) -> Result<Box<dyn ApplyHost>, CoreError> {
let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
if let Some(why) = mirror_mode_problem(mode) {
return Err(invalid(format!("{label}: {why}")));
}
let access = node_access(repo, target_file, label, node)?;
Ok(
match apply_host_kind(access, label, gateway, mode).map_err(invalid)? {
ApplyHostKind::Local(host) => Box::new(host),
ApplyHostKind::Sftp(host) => host,
},
)
}
enum ApplyHostKind {
Local(local::LocalFsTransport),
Sftp(Box<sftp::SftpTransport>),
}
fn apply_host_kind(
access: NodeAccess,
label: &str,
gateway: &str,
mode: &str,
) -> Result<ApplyHostKind, String> {
Ok(match access {
NodeAccess::Local(data_dir) => ApplyHostKind::Local(
local::LocalFsTransport::new(data_dir, label)
.with_apply(gateway, mode)
.map_err(|why| format!("{label}: {why}"))?,
),
NodeAccess::Ssh {
shell,
data_dir,
windows,
} => ApplyHostKind::Sftp(Box::new(
sftp_transport(*shell, data_dir, label, windows)
.with_apply(gateway, mode)
.map_err(|why| format!("{label}: {why}"))?,
)),
})
}
pub async fn default_staging_mode(
repo: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
gateway: &str,
mode: &str,
run_id: &str,
) -> Result<StagingMode, CoreError> {
let invalid = |detail: String| CoreError::deploy_tree_invalid(target_file, None, None, detail);
for (what, value) in [("gateway", gateway), ("mode", mode)] {
if let Some(why) = super::component_problem(value) {
return Err(invalid(format!("{label}: the staging {what} {why}")));
}
}
let run_ok = (1..=128).contains(&run_id.len())
&& !run_id.starts_with('.')
&& run_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
if !run_ok {
return Err(invalid(format!(
"{label}: the run id must be 1 to 128 characters of [A-Za-z0-9._-] and must not start with a dot"
)));
}
let rel = super::ledger::staging_rel(gateway, mode, run_id);
Ok(match node_access(repo, target_file, label, node)? {
NodeAccess::Local(data_dir) => {
let root = data_dir.join(&rel);
let root = root.to_str().ok_or_else(|| {
invalid(format!("{label}: the data directory is not valid UTF-8"))
})?;
StagingMode::Separate {
root: root.to_string(),
}
}
NodeAccess::Ssh {
shell,
data_dir,
windows,
} => {
sftp_transport(*shell, data_dir, label, windows)
.default_staging(gateway, mode, run_id)
.await?
}
})
}
pub(crate) fn mirror_mode_problem(mode: &str) -> Option<String> {
if let Some(why) = super::component_problem(mode) {
return Some(format!("the mode {why}"));
}
if mode == CORE_COLLECTION || mode == "local" {
return Some(format!(
"the mode {mode:?} names a collection a mirror never writes"
));
}
None
}
pub(crate) fn mirror_path_problem(path: &RelPath) -> Option<String> {
let parts: Vec<&str> = path.components().collect();
if parts.len() < MIRROR_MIN_COMPONENTS {
return Some(format!(
"{path} is not a collection path (<module>/<type>/<name>..., at least {MIRROR_MIN_COMPONENTS} components)"
));
}
for part in parts {
if part.starts_with('.') {
return Some(format!("{path} has a dot-named component {part:?}"));
}
if GATEWAY_OWNED_FILES.contains(&part) {
return Some(format!("{path} names the gateway-owned file {part:?}"));
}
if part.chars().any(char::is_control) {
return Some(format!("{path} has a control character in a name"));
}
}
None
}
pub(crate) fn apply_key_problem(key: &RelPath) -> Option<String> {
use crate::deploy::layout::{KeyCollection, is_pair_unsynced_tag_path, split_key};
match split_key(key.as_str()) {
(KeyCollection::Mode, _) => mirror_path_problem(key),
(KeyCollection::Core, rest) => {
let Ok(path) = RelPath::new(rest) else {
return Some(format!("{key} names no core collection path"));
};
if !is_pair_unsynced_tag_path(rest) {
return Some(format!(
"{key} names the core collection outside ignition/tag-definition and ignition/tag-type-definition"
));
}
mirror_path_problem(&path).map(|why| format!("core collection: {why}"))
}
}
}
pub(crate) fn core_tag_types(touched: &[String]) -> Vec<&'static str> {
use crate::deploy::layout::{KeyCollection, PAIR_UNSYNCED_TAG_TYPES, split_key};
PAIR_UNSYNCED_TAG_TYPES
.iter()
.copied()
.filter(|ty| {
touched.iter().any(|key| match split_key(key) {
(KeyCollection::Core, rest) => rest
.strip_prefix("ignition/")
.and_then(|r| r.split('/').next())
.is_some_and(|t| t == *ty),
(KeyCollection::Mode, _) => false,
})
})
.collect()
}
pub(crate) fn core_probe_problem(code: &str) -> Option<String> {
use crate::deploy::layout::PAIR_UNSYNCED_TAG_TYPES;
const SEE: &str = "see README Tags and redundant pairs";
let text = match code {
"core_missing" => format!(
"config/resources/core does not exist; a redundant pair's tag units are written there and it is never created ({SEE})"
),
"core_symlink" => format!("config/resources/core is a symlink; refused ({SEE})"),
"core_not_dir" => format!("config/resources/core is not a directory ({SEE})"),
_ => {
if let Some(ty) = code
.strip_prefix("core_tags_unsafe:")
.filter(|ty| PAIR_UNSYNCED_TAG_TYPES.contains(ty))
{
format!(
"config/resources/core/ignition/{ty} (or the folder above it, config/resources/core/ignition, or a folder below it on a path this run writes) is a symlink or not a directory; refused ({SEE})"
)
} else {
let ty = code
.strip_prefix("core_tags_unwritable:")
.filter(|ty| PAIR_UNSYNCED_TAG_TYPES.contains(ty))?;
format!(
"config/resources/core/ignition/{ty} (or a folder below it on a path this run writes, or, where a folder does not exist yet, the nearest existing one) is not writable or not searchable by the deploy account, which needs write access there on both nodes ({SEE})"
)
}
}
};
Some(text)
}
pub(crate) const MIRROR_MIN_COMPONENTS: usize = 3;
pub(crate) fn emptied_dir_floor(path: &str) -> usize {
use crate::deploy::layout::{KeyCollection, is_pair_unsynced_tag_path, split_key};
match split_key(path) {
(KeyCollection::Core, _) => MIRROR_MIN_COMPONENTS,
(KeyCollection::Mode, bare) if is_pair_unsynced_tag_path(bare) => 2,
(KeyCollection::Mode, _) => MIRROR_MIN_COMPONENTS,
}
}
pub(crate) fn validate_mirror_inputs(
tree: &RenderedTree,
delete_set: &DeleteSet,
staging: &StagingMode,
resources_root: &str,
) -> Result<(), String> {
let mut total: u64 = 0;
for (path, bytes) in &tree.files {
let len = bytes.len() as u64;
if len > READ_MAX_FILE_BYTES {
return Err(format!(
"{path} is {len} bytes, over the {READ_MAX_FILE_BYTES} byte file cap"
));
}
total = total.saturating_add(len);
if total > READ_MAX_TOTAL_BYTES {
return Err(format!(
"the staged tree exceeds {READ_MAX_TOTAL_BYTES} bytes in total (at {path})"
));
}
}
let staged: Vec<&RelPath> = tree.files.keys().collect();
validate_mirror_paths(&staged, delete_set)?;
validate_staging_root(staging, resources_root)
}
pub(crate) fn validate_mirror_paths(
staged: &[&RelPath],
delete_set: &DeleteSet,
) -> Result<(), String> {
for path in staged {
if let Some(why) = mirror_path_problem(path) {
return Err(format!("staged path refused: {why}"));
}
}
let mut deleting: BTreeSet<&str> = BTreeSet::new();
for path in &delete_set.files {
if let Some(why) = mirror_path_problem(path) {
return Err(format!("delete-set path refused: {why}"));
}
if !deleting.insert(path.as_str()) {
return Err(format!("delete-set path {path} is listed twice"));
}
}
let staging: BTreeSet<&str> = staged.iter().map(|p| p.as_str()).collect();
if staging.len() != staged.len() {
return Err("a path is staged twice".to_string());
}
if let Some(both) = deleting.iter().find(|p| staging.contains(**p)) {
return Err(format!("{both} is both staged and in the delete set"));
}
let mut dirs: BTreeSet<&str> = BTreeSet::new();
for path in staging.iter().chain(deleting.iter()) {
for (at, _) in path.match_indices('/') {
dirs.insert(&path[..at]);
}
}
if let Some(clash) = staging
.iter()
.chain(deleting.iter())
.find(|p| dirs.contains(**p))
{
return Err(format!(
"{clash} is a file and also the directory of another path"
));
}
if staging.len() + deleting.len() + dirs.len() > READ_MAX_ENTRIES {
return Err(format!(
"more than {READ_MAX_ENTRIES} entries (files, deletes and directories together)"
));
}
Ok(())
}
pub(crate) fn absolute_components(text: &str) -> Result<Vec<String>, String> {
if text.is_empty() || text.chars().any(char::is_control) {
return Err("must be a non-empty path without control characters".to_string());
}
let normal = text.replace('\\', "/");
let bytes = normal.as_bytes();
let absolute = normal.starts_with('/')
|| (bytes.len() >= 3
&& bytes[1] == b':'
&& bytes[2] == b'/'
&& bytes[0].is_ascii_alphabetic());
if !absolute {
return Err("must be an absolute path".to_string());
}
let mut parts = Vec::new();
for part in normal.split('/') {
match part {
"" => {}
"." | ".." => return Err("must not contain `.` or `..` components".to_string()),
other => parts.push(other.to_string()),
}
}
Ok(parts)
}
pub(crate) fn validate_staging_root(
staging: &StagingMode,
resources_root: &str,
) -> Result<(), String> {
validate_staging_root_for(staging, resources_root, cfg!(windows))
}
pub(crate) fn validate_staging_root_for(
staging: &StagingMode,
resources_root: &str,
case_insensitive: bool,
) -> Result<(), String> {
let StagingMode::Separate { root } = staging else {
return Ok(());
};
let staging_parts =
absolute_components(root).map_err(|why| format!("the staging root {root:?} {why}"))?;
if staging_parts.len() < 2 {
return Err(format!(
"the staging root {root:?} must be at least two directories deep"
));
}
let resources_parts = absolute_components(resources_root)
.map_err(|why| format!("the resources root {resources_root:?} {why}"))?;
let same = |a: &String, b: &String| {
if case_insensitive {
a.eq_ignore_ascii_case(b)
} else {
a == b
}
};
let starts_with = |long: &[String], short: &[String]| {
long.len() >= short.len() && long.iter().zip(short).all(|(a, b)| same(a, b))
};
if starts_with(&staging_parts, &resources_parts) {
return Err(format!(
"the staging root {root:?} is inside config/resources; staging entries there would be scanned by the gateway"
));
}
if starts_with(&resources_parts, &staging_parts) {
return Err(format!(
"the staging root {root:?} contains config/resources"
));
}
Ok(())
}
pub(crate) fn copy_to_temp(
tree: &RenderedTree,
) -> Result<(tempfile::TempDir, Vec<(RelPath, String)>), String> {
use std::io::Write;
let temp = tempfile::Builder::new()
.prefix("ign-stage-")
.tempdir()
.map_err(|e| format!("cannot create a staging dir ({})", e.kind()))?;
let mut files = Vec::with_capacity(tree.files.len());
for (path, bytes) in &tree.files {
let target = temp.path().join(path.as_str());
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create the staging dir for {path} ({})", e.kind()))?;
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)
.map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
file.write_all(bytes)
.map_err(|e| format!("cannot stage {path} ({})", e.kind()))?;
files.push((path.clone(), super::tree::sha256_hex(bytes)));
}
Ok((temp, files))
}
pub(crate) fn read_staged_copy(
base: &Path,
rel: &RelPath,
expect_sha: &str,
) -> Result<Vec<u8>, String> {
use super::tree::{OpenError, open_regular_file, sha256_hex};
use std::io::Read;
let path = base.join(rel.as_str());
let before = std::fs::symlink_metadata(&path)
.map_err(|e| format!("the staged copy of {rel} is gone ({})", e.kind()))?;
if !before.file_type().is_file() {
return Err(format!("the staged copy of {rel} is not a regular file"));
}
let (file, held) =
open_regular_file(base, Path::new(rel.as_str()), &before).map_err(|e| match e {
OpenError::Changed => format!("the staged copy of {rel} changed while it was read"),
OpenError::Io(e) => format!("cannot read the staged copy of {rel} ({})", e.kind()),
})?;
if held.len() > READ_MAX_FILE_BYTES {
return Err(format!(
"the staged copy of {rel} is over the {READ_MAX_FILE_BYTES} byte file cap"
));
}
let mut bytes = Vec::with_capacity(held.len() as usize);
file.take(READ_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| format!("cannot read the staged copy of {rel} ({})", e.kind()))?;
if bytes.len() as u64 > READ_MAX_FILE_BYTES || sha256_hex(&bytes) != expect_sha {
return Err(format!(
"the staged copy of {rel} changed after it was staged"
));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deploy::target::{Ssh, TargetOs};
fn ssh_node(os: TargetOs, data_dir: &str) -> NodeTarget {
NodeTarget {
profile: "p".to_string(),
ssh: Some(Ssh {
host: "gw.example.test".to_string(),
port: None,
user: "svc".to_string(),
key_file: None,
key_env: None,
os,
data_dir: data_dir.to_string(),
known_hosts: "ssh/prod.known_hosts".to_string(),
connect_timeout_s: None,
op_timeout_s: None,
}),
local: None,
gateway_name: None,
}
}
fn repo() -> tempfile::TempDir {
let repo = tempfile::tempdir().expect("repo");
std::fs::create_dir_all(repo.path().join("ssh")).expect("ssh dir");
std::fs::write(
repo.path().join("ssh/prod.known_hosts"),
"gw.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPinnedPinnedPinnedPinnedPinnedPinnedPinned\n",
)
.expect("known_hosts");
repo
}
#[test]
fn core_probe_problem_names_an_unsafe_type_folder() {
let text = core_probe_problem("core_tags_unsafe:tag-definition").expect("a text");
assert!(
text.contains("config/resources/core/ignition/tag-definition"),
"{text}"
);
assert!(text.contains("symlink or not a directory"), "{text}");
assert_eq!(core_probe_problem("core_tags_unsafe:tag-group"), None);
}
#[test]
fn apply_host_for_node_linux_ssh() {
let repo = repo();
let linux = ssh_node(TargetOs::Linux, "/usr/local/bin/ignition/data");
assert!(
apply_host_for_node(
repo.path(),
"targets/prod.yaml",
"nodes.master",
&linux,
"gw",
"prod"
)
.is_ok(),
"a Linux ssh node builds an apply host"
);
let access =
node_access(repo.path(), "targets/prod.yaml", "nodes.master", &linux).expect("access");
let ApplyHostKind::Sftp(host) =
apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
else {
panic!("an ssh node gets the sftp host");
};
assert!(!host.is_windows_for_tests());
let local = NodeTarget {
profile: "p".to_string(),
ssh: None,
local: Some(crate::deploy::target::Local {
data_dir: repo.path().join("data").display().to_string(),
}),
gateway_name: None,
};
let access =
node_access(repo.path(), "targets/prod.yaml", "nodes.master", &local).expect("access");
assert!(matches!(
apply_host_kind(access, "nodes.master", "gw", "prod").expect("host"),
ApplyHostKind::Local(_)
));
for (gateway, mode) in [
(".hidden", "prod"),
("gw", "core"),
("gw", "local"),
("g/w", "prod"),
] {
let err = apply_host_for_node(
repo.path(),
"targets/prod.yaml",
"nodes.master",
&linux,
gateway,
mode,
)
.err()
.unwrap_or_else(|| panic!("{gateway}/{mode} is refused"));
assert_eq!(err.code(), "deploy_tree_invalid", "{gateway}/{mode}");
}
}
#[test]
fn apply_host_for_node_windows_ssh() {
let repo = repo();
let windows = ssh_node(TargetOs::Windows, "C:\\Ignition\\data");
assert!(
apply_host_for_node(
repo.path(),
"targets/prod.yaml",
"nodes.master",
&windows,
"gw",
"prod"
)
.is_ok(),
"a Windows ssh node builds an apply host"
);
let access = node_access(repo.path(), "targets/prod.yaml", "nodes.master", &windows)
.expect("access");
let ApplyHostKind::Sftp(host) =
apply_host_kind(access, "nodes.master", "gw", "prod").expect("host")
else {
panic!("a Windows ssh node gets the sftp host");
};
assert!(host.is_windows_for_tests());
let err = apply_host_for_node(
repo.path(),
"targets/prod.yaml",
"nodes.master",
&windows,
"gw",
"core",
)
.err()
.expect("core is refused");
assert_eq!(err.code(), "deploy_tree_invalid");
}
fn key(s: &str) -> RelPath {
RelPath::new(s).expect("a RelPath")
}
#[test]
fn core_key_apply_key_problem() {
for ok in [
".core/ignition/tag-type-definition/default/T/udts.json",
".core/ignition/tag-definition/default/Deploy/tags.json",
"ignition/database-translator/X/config.json",
"ignition/tag-definition/default/Deploy/tags.json",
] {
assert_eq!(apply_key_problem(&key(ok)), None, "{ok}");
}
for bad in [
".core/ignition/database-translator/X/config.json",
".core/ignition/tag-provider/p/config.json",
".core/.core/ignition/tag-definition/a/b",
".core/ignition/tag-definition/a/.hidden",
".core/ignition/tag-definition",
".core",
] {
assert!(apply_key_problem(&key(bad)).is_some(), "{bad}");
}
assert!(RelPath::new(".core/").is_err());
assert!(
mirror_path_problem(&key(
".core/ignition/tag-definition/default/Deploy/tags.json"
))
.is_some()
);
}
#[test]
fn core_key_emptied_dir_floor() {
assert_eq!(
emptied_dir_floor("ignition/tag-definition/default/D/tags.json"),
2
);
assert_eq!(
emptied_dir_floor(".core/ignition/tag-definition/default/D/tags.json"),
MIRROR_MIN_COMPONENTS
);
assert_eq!(
emptied_dir_floor("ignition/database-translator/X/config.json"),
MIRROR_MIN_COMPONENTS
);
}
}