use std::path::Path;
use serde::Serialize;
use super::remote::safe_remote_path;
use super::winps::windows_component_problem;
use super::{NodeAccess, local, node_access, sftp_transport};
use crate::deploy::apply::run_store::RunId;
use crate::deploy::component_problem;
use crate::deploy::project_run::{PRE_IMAGE_MAX_BYTES, PreImageFile, PreImageRun};
use crate::deploy::target::{NodeTarget, TargetOs};
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 fn read_tag_record(
&self,
gateway: &str,
provider: &str,
) -> Result<Option<Vec<u8>>, CoreError>;
async fn read_journal(
&self,
gateway: &str,
project: &str,
) -> Result<Option<Vec<u8>>, CoreError>;
async fn read_pre_image(
&self,
gateway: &str,
project: &str,
name: &str,
) -> Result<Option<Vec<u8>>, CoreError>;
}
#[async_trait::async_trait]
pub trait ProjectRunWriter: Send + Sync {
async fn write_journal(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError>;
async fn clear_journal(&self, gateway: &str, project: &str) -> Result<(), CoreError>;
async fn write_pre_image(
&self,
gateway: &str,
project: &str,
run_id: &str,
name: &str,
bytes: &[u8],
) -> Result<(), CoreError>;
}
pub(crate) fn project_run_names_problem_on(
gateway: &str,
project: &str,
name: Option<&str>,
ssh_windows: Option<bool>,
) -> Option<String> {
if let Some(why) = tag_record_names_problem_on(gateway, project, ssh_windows) {
return Some(why.replacen("the provider name", "the project name", 1));
}
let name = name?;
let Some(file) = PreImageFile::parse(name) else {
return Some(
"the pre-image file name is not one of run.json, project.zip, \
release-record.json, tags-<P>.json, provider-<P>.json, record-<P>.json or \
sent-<P>.json"
.to_string(),
);
};
let provider = file.provider()?;
tag_record_names_problem_on(gateway, provider, ssh_windows)
.map(|why| format!("the pre-image file name: {why}"))
}
fn run_invalid(location: &str, problem: impl std::fmt::Display) -> CoreError {
CoreError::DeployRunInvalid {
run_id: "unknown".to_string(),
problem: format!("{location}: {problem}"),
}
}
pub(crate) fn check_pre_image_write(
location: &str,
gateway: &str,
project: &str,
run_id: &str,
name: &str,
bytes: &[u8],
ssh_windows: Option<bool>,
) -> Result<(), CoreError> {
if let Some(why) = project_run_names_problem_on(gateway, project, Some(name), ssh_windows) {
return Err(run_invalid(location, why));
}
if RunId::parse(run_id).is_err() {
return Err(run_invalid(
location,
"the run id is not 1 to 128 characters of [A-Za-z0-9._-]",
));
}
if bytes.len() > PRE_IMAGE_MAX_BYTES {
return Err(run_invalid(
location,
format!(
"the file is {} bytes, over the {PRE_IMAGE_MAX_BYTES} byte cap",
bytes.len()
),
));
}
if PreImageFile::parse(name) == Some(PreImageFile::Run) {
let run = PreImageRun::parse(bytes, location, gateway, project)?;
if run.run_id != run_id {
return Err(run_invalid(location, "run.json names another run"));
}
}
Ok(())
}
pub fn project_run_writer_for_node(
base: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
) -> Result<Box<dyn ProjectRunWriter>, 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))),
}
}
#[async_trait::async_trait]
pub trait ReleaseRecordWriter: Send + Sync {
async fn write_release_record(
&self,
gateway: &str,
project: &str,
bytes: &[u8],
) -> Result<(), CoreError>;
}
#[async_trait::async_trait]
pub trait TagRecordWriter: Send + Sync {
async fn write_tag_record(
&self,
gateway: &str,
provider: &str,
bytes: &[u8],
) -> Result<(), CoreError>;
}
pub fn tag_record_names_problem(
gateway: &str,
provider: &str,
node: &NodeTarget,
) -> Option<String> {
tag_record_names_problem_on(
gateway,
provider,
node.ssh.as_ref().map(|ssh| ssh.os == TargetOs::Windows),
)
}
pub(crate) fn tag_record_names_problem_on(
gateway: &str,
provider: &str,
ssh_windows: Option<bool>,
) -> Option<String> {
for (kind, value) in [("gateway", gateway), ("provider", provider)] {
if let Some(why) = component_problem(value) {
return Some(format!("the {kind} name {why}"));
}
if value.chars().any(char::is_control) {
return Some(format!("the {kind} name holds a control character"));
}
let Some(windows) = ssh_windows else {
continue;
};
if windows && let Some(why) = windows_component_problem(value) {
return Some(format!("the {kind} name: {why}"));
}
if let Err(why) = safe_remote_path(value) {
return Some(format!(
"the {kind} name cannot be used for the tag record on an ssh node ({why})"
));
}
}
None
}
pub fn tag_record_writer_for_node(
base: &Path,
target_file: &str,
label: &str,
node: &NodeTarget,
) -> Result<Box<dyn TagRecordWriter>, 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 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 tag_record_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;
tag_record_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 = tag_record_writer_for_node(dir.path(), "targets/live.yaml", "nodes.master", &ssh)
.map(|_| ())
.expect_err("no known_hosts under this base");
assert_eq!(err.code(), "deploy_tree_invalid", "{err}");
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");
tag_record_writer_for_node(dir.path(), "targets/live.yaml", "nodes.master", &ssh)
.map(|_| ())
.expect("an ssh node has a writer");
}
#[test]
fn tag_record_names_problem_matches_the_writers() {
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;
let ssh = ssh_target("hosts/lnx.known_hosts").expect("ssh target");
let win_yaml = "schema_version: 1\ngateway: g\nmode: m\nnodes:\n master:\n profile: p\n ssh:\n host: win.invalid\n user: svc\n os: windows\n data_dir: 'C:\\Program Files\\Inductive Automation\\Ignition\\data'\n known_hosts: hosts/win.known_hosts\n";
let win = crate::deploy::target::Target::parse("targets/win.yaml", win_yaml.as_bytes())
.expect("windows target")
.nodes
.master;
for node in [&local, &ssh, &win] {
assert_eq!(tag_record_names_problem("gw", "Plant A", node), None);
assert_eq!(tag_record_names_problem("gw", "MQTT Engine", node), None);
for bad in ["", "..", "a/b", ".x"] {
assert!(
tag_record_names_problem("gw", bad, node).is_some(),
"{bad:?}"
);
}
}
for glob in ["Plant[1]", "Plant*", "Plant?", "Plant{A}", "Plant\"A"] {
assert_eq!(tag_record_names_problem("gw", glob, &local), None, "{glob}");
for node in [&ssh, &win] {
let why = tag_record_names_problem("gw", glob, node).expect(glob);
assert!(why.contains("provider"), "{why}");
}
let why = tag_record_names_problem(glob, "PlantA", &ssh).expect(glob);
assert!(why.contains("gateway"), "{why}");
}
for windows_only in ["NUL", "PlantA.", "a:b"] {
assert_eq!(
tag_record_names_problem("gw", windows_only, &ssh),
None,
"{windows_only}"
);
assert!(
tag_record_names_problem("gw", windows_only, &win).is_some(),
"{windows_only}"
);
}
}
#[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
);
}
}
}