depl 2.4.3

Toolkit for a bunch of local and remote CI/CD actions
Documentation
//! Remote host module.
//!
//! Any remote host struct is a set of properties needed to connect and authenticate by `ssh`.

use anyhow::bail;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::net::ToSocketAddrs;

use crate::entities::info::ShortName;
use crate::utils::compose_output;

/// Remote host.
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Hash)]
pub struct RemoteHost {
  /// Short name (remote host identifier inside Deployer's Registry).
  pub short_name: ShortName,
  /// IP address of SSH server.
  pub ip: IpAddr,
  /// Port of SSH server.
  pub port: u16,
  /// Username under which you plan to perform operations on the host.
  pub username: String,
  /// Path to private SSH key file.
  #[serde(skip_serializing_if = "Option::is_none")]
  pub ssh_private_key_file: Option<PathBuf>,
}

impl RemoteHost {
  /// Checks the remote host connectivity, authorization and Deployer installation existence.
  pub async fn check(&self) -> anyhow::Result<()> {
    const PKG_NAME: &str = env!("CARGO_PKG_NAME");
    const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");

    if self.ssh_private_key_file.is_none() {
      bail!(
        "Specify `ssh_private_key_file` field on `{}` remote host!",
        self.short_name.as_str()
      );
    }

    let shell = match std::env::var("DEPLOYER_SH_PATH") {
      Ok(path) => path,
      Err(_) => "/bin/bash".to_string(),
    };

    let mut session = Session::connect(
      self.ssh_private_key_file.as_ref().unwrap(),
      &self.username,
      (self.ip, self.port),
    )
    .await?;
    let out = session
      .call(&format!(r#"{shell} -c "~/.cargo/bin/deployer -V""#))
      .await?;
    session.close().await?;
    if out.is_empty() || !out.contains(PKG_NAME) {
      bail!("There is no Deployer installed remotely.")
    } else {
      if !out.contains(&format!("{PKG_NAME} {PKG_VERSION}")) {
        println!(
          r#"Deployer versions aren't the same. Consider to update Deployer on your hosts. (out: "{}")"#,
          out.trim()
        );
      }
      Ok(())
    }
  }

  /// Starts Deployer's Pipeline execution on the remote host with given remote build folder.
  pub fn call_deployer_to_build(&self, remote_build_dir: &Path, pipeline: &str) -> anyhow::Result<(bool, Vec<String>)> {
    if self.ssh_private_key_file.is_none() {
      bail!(
        "Specify `ssh_private_key_file` field on `{}` remote host!",
        self.short_name.as_str()
      );
    }

    let shell = match std::env::var("DEPLOYER_SH_PATH") {
      Ok(path) => path,
      Err(_) => "/bin/bash".to_string(),
    };

    let rt = tokio::runtime::Runtime::new()?;
    let cmd = format!(
      r#"{} -c "DEPLOYER_REMOTE_AS_WORKER={} ~/.cargo/bin/deployer run {} && echo $?""#,
      shell,
      remote_build_dir
        .to_str()
        .expect("`remote_build_dir` contains non-UTF8 symbols!"),
      pipeline,
    );
    rt.block_on(async {
      let mut session = Session::connect(
        self.ssh_private_key_file.as_ref().unwrap(),
        &self.username,
        (self.ip, self.port),
      )
      .await?;
      let out = session.call(cmd.as_str()).await?;
      session.close().await?;
      let success = out.ends_with("\n0\n");
      let mut out = compose_output(cmd, out, true);
      out.pop();
      Ok((success, out))
    })
  }

  /// Opens the session with given runtime.
  pub async fn open_session(&self) -> anyhow::Result<Session> {
    if self.ssh_private_key_file.is_none() {
      bail!(
        "Specify `ssh_private_key_file` field on `{}` remote host!",
        self.short_name.as_str()
      );
    }

    Session::connect(
      self.ssh_private_key_file.as_ref().unwrap(),
      &self.username,
      (self.ip, self.port),
    )
    .await
  }

  /// Closes the session with given runtime.
  pub async fn close_session(session: &mut Session) -> anyhow::Result<()> {
    session.close().await
  }

  /// Executes single shell command with given session and runtime.
  pub async fn exec(&self, bash_c: &str, session: &mut Session) -> anyhow::Result<(bool, String)> {
    let out = session.call(bash_c).await?;
    let success = out.ends_with("\n0\n");
    Ok((success, out))
  }
}

struct Client {}

impl russh::client::Handler for Client {
  type Error = anyhow::Error;

  /// WARNING: allows any server keys without authorization.
  ///
  /// May lead to any security consequences, but simplifies remote host setup.
  async fn check_server_key(&mut self, _: &russh::keys::ssh_key::PublicKey) -> Result<bool, Self::Error> {
    Ok(true)
  }
}

/// Remote connection session.
pub struct Session {
  session: russh::client::Handle<Client>,
}

impl Session {
  async fn connect<P: AsRef<Path>, A: ToSocketAddrs>(
    key_path: P,
    user: impl Into<String>,
    addrs: A,
  ) -> anyhow::Result<Self> {
    use russh::keys::{key::PrivateKeyWithHashAlg, load_secret_key};

    let key_pair = load_secret_key(key_path, None)?;
    let config = russh::client::Config {
      preferred: russh::Preferred {
        kex: Cow::Owned(vec![russh::kex::DH_G14_SHA256]),
        ..Default::default()
      },
      ..<_>::default()
    };

    let config = Arc::new(config);
    let sh = Client {};

    let mut session = match russh::client::connect(config, addrs, sh).await {
      Ok(s) => s,
      Err(e) => bail!("Client connect failed: {e:?}"),
    };
    let auth_res = session
      .authenticate_publickey(
        user,
        PrivateKeyWithHashAlg::new(Arc::new(key_pair), Some(russh::keys::HashAlg::Sha256)),
      )
      .await?;

    if !auth_res.success() {
      bail!("Authentication failed: {auth_res:?}");
    }

    Ok(Self { session })
  }

  async fn call(&mut self, command: &str) -> anyhow::Result<String> {
    use russh::ChannelMsg;

    let mut channel = self.session.channel_open_session().await?;
    channel.exec(true, command).await?;

    let mut out_buf = vec![];
    while let Some(msg) = channel.wait().await {
      if let ChannelMsg::Data { ref data } = msg {
        out_buf.extend_from_slice(data);
      }
    }

    Ok(String::from_utf8_lossy(&out_buf).to_string())
  }

  async fn close(&mut self) -> anyhow::Result<()> {
    self
      .session
      .disconnect(russh::Disconnect::ByApplication, "deployer", "English")
      .await?;
    Ok(())
  }
}