use std::path::Path;
use serde::Serialize;
use super::{NodeAccess, local, node_access, sftp_transport};
use crate::deploy::target::NodeTarget;
use crate::error::CoreError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GitEntry {
Absent,
Directory,
File,
Symlink,
Other,
}
impl GitEntry {
pub fn is_present(self) -> bool {
!matches!(self, GitEntry::Absent)
}
pub fn as_str(self) -> &'static str {
match self {
GitEntry::Absent => "absent",
GitEntry::Directory => "directory",
GitEntry::File => "file",
GitEntry::Symlink => "symlink",
GitEntry::Other => "other",
}
}
}
pub fn git_entry_location(project: &str) -> String {
format!("projects/{project}/.git")
}
#[async_trait::async_trait]
pub trait ProjectHost: Send + Sync {
async fn git_entry(&self, project: &str) -> Result<GitEntry, CoreError>;
async fn read_release_record(
&self,
gateway: &str,
project: &str,
) -> Result<Option<Vec<u8>>, CoreError>;
}
#[async_trait::async_trait]
pub trait ReleaseRecordWriter: Send + Sync {
async fn write_release_record(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError>;
}
pub fn release_writer_for_node(
base: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
) -> Result<Box<dyn ReleaseRecordWriter>, CoreError> {
match node_access(base, target_file, label, node)? {
NodeAccess::Local(data_dir) => Ok(Box::new(local::LocalFsTransport::new(data_dir, label))),
NodeAccess::Ssh {
shell,
data_dir,
windows,
} => Ok(Box::new(sftp_transport(*shell, data_dir, label, windows))),
}
}
pub fn project_host_for_node(
base: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
) -> Result<Box<dyn ProjectHost>, CoreError> {
match node_access(base, target_file, label, node)? {
NodeAccess::Local(data_dir) => Ok(Box::new(local::LocalFsTransport::new(data_dir, label))),
NodeAccess::Ssh {
shell,
data_dir,
windows,
} => Ok(Box::new(sftp_transport(*shell, data_dir, label, windows))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_writer_for_node_builds_local_and_ssh() {
let dir = tempfile::tempdir().expect("tempdir");
let local_yaml = format!(
"schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n local:\n data_dir: '{}'\n",
dir.path().display()
);
let local = crate::deploy::target::Target::parse("targets/dev.yaml", local_yaml.as_bytes())
.expect("local target")
.nodes
.master;
release_writer_for_node(dir.path(), "targets/dev.yaml", "nodes.master", &local)
.map(|_| ())
.expect("a local node has a writer");
let ssh = ssh_target("hosts/lnx.known_hosts").expect("target parses");
let err = release_writer_for_node(dir.path(), "targets/live.yaml", "nodes.master", &ssh)
.map(|_| ())
.expect_err("no known_hosts under this base");
let CoreError::DeployTreeInvalid(detail) = err else {
panic!("expected deploy_tree_invalid, got {err:?}");
};
assert!(
format!("{detail:?}").contains("hosts/lnx.known_hosts"),
"{detail:?}"
);
std::fs::create_dir_all(dir.path().join("hosts")).expect("hosts dir");
std::fs::write(
dir.path().join("hosts/lnx.known_hosts"),
"lnx.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeHostKeyForTheUnitTestOnly0000000\n",
)
.expect("known_hosts");
release_writer_for_node(dir.path(), "targets/live.yaml", "nodes.master", &ssh)
.map(|_| ())
.expect("an ssh node has a writer");
}
#[test]
fn git_entry_kinds_name_themselves() {
assert!(!GitEntry::Absent.is_present());
for kind in [
GitEntry::Directory,
GitEntry::File,
GitEntry::Symlink,
GitEntry::Other,
] {
assert!(kind.is_present(), "{kind:?}");
}
assert_eq!(GitEntry::Directory.as_str(), "directory");
assert_eq!(GitEntry::File.as_str(), "file");
assert_eq!(GitEntry::Symlink.as_str(), "symlink");
assert_eq!(git_entry_location("CapApp"), "projects/CapApp/.git");
}
fn ssh_target(known_hosts: &str) -> Result<NodeTarget, CoreError> {
let yaml = format!(
"schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n ssh:\n host: lnx.invalid\n user: svc\n os: linux\n data_dir: /usr/local/bin/ignition/data\n known_hosts: {known_hosts}\n"
);
crate::deploy::target::Target::parse("targets/live.yaml", yaml.as_bytes())
.map(|target| target.nodes.master)
}
#[test]
fn project_host_known_hosts_from_targets_root() {
let entry = "lnx.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeHostKeyForTheUnitTestOnly0000000\n";
let node = ssh_target("hosts/lnx.known_hosts").expect("target parses");
let targets_root = tempfile::tempdir().expect("targets root");
std::fs::create_dir_all(targets_root.path().join("hosts")).expect("hosts dir");
std::fs::write(targets_root.path().join("hosts/lnx.known_hosts"), entry)
.expect("known_hosts");
project_host_for_node(
targets_root.path(),
"targets/live.yaml",
"nodes.master",
&node,
)
.map(|_| ())
.expect("the ssh host is built from the targets root");
let elsewhere = tempfile::tempdir().expect("elsewhere");
let err =
project_host_for_node(elsewhere.path(), "targets/live.yaml", "nodes.master", &node)
.map(|_| ())
.expect_err("no known_hosts under this base");
let CoreError::DeployTreeInvalid(detail) = err else {
panic!("expected deploy_tree_invalid, got {err:?}");
};
let shown = format!("{detail:?}");
assert!(shown.contains("hosts/lnx.known_hosts"), "{shown}");
for escaping in ["../x", "hosts/../../x", "/etc/ssh/ssh_known_hosts"] {
let err = ssh_target(escaping).expect_err(escaping);
let CoreError::DeployTreeInvalid(detail) = err else {
panic!("expected deploy_tree_invalid, got {err:?}");
};
assert!(
detail
.findings
.iter()
.any(|f| f.message.starts_with("nodes.master.ssh.known_hosts:")),
"{escaping}: {:?}",
detail.findings
);
}
}
}