Skip to content

Commit

Permalink
horustctl: initial client side implementation and support for status …
Browse files Browse the repository at this point in the history
…command
  • Loading branch information
FedericoPonzi committed Nov 2, 2024
1 parent eaeea26 commit af29785
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 44 deletions.
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions horustctl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ log = "~0.4"
env_logger = "~0.11"
crossbeam = "~0.8"
clap = { version = "~4.5", features = ["derive"] }
horust-commands-lib = { path = "../commands" }

[dev-dependencies]
assert_cmd = "~2.0"
predicates = "~3.1"
rand = "~0.8"
tempdir = "~0.3"
14 changes: 14 additions & 0 deletions horustctl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Horustctl

A command line interface to interact with an horust process. It works using Unix Domain Sockets.
Each horust process will create a new uds socket inside /var/run/horust/horust-<pid>.sock folder (can be configured).

You can use horustctl to interact with your horust process. The communication is handled by the `commands` crate. They
exchange protobuf messages.

Supported commands:

* status [servicename]: get the status of your service `servicename`. If not specified, it will return the status for
all services.
* change <servicename> <newstatus>: can be used to change the status of `servicename`.
Supported `newstatus` options are start, stop.
93 changes: 49 additions & 44 deletions horustctl/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
use anyhow::{bail, Result};
use anyhow::{anyhow, bail, Result};
use clap::{Args, Parser, Subcommand};
use env_logger::Env;
use horust_commands_lib::{get_path, ClientHandler};
use log::debug;
use std::env;
use std::fs::read_dir;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::os::unix::fs::FileTypeExt;
use std::path::PathBuf;

/// Simple program to greet a person
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct HourstctlArgs {
/// Optional if only one horust is running in the system.
struct HorustctlArgs {
/// The pid of the horust process you want to query. Optional if only one horust is running in the system.
#[arg(short, long)]
pid: Option<u32>,
pid: Option<i32>,

#[arg(short, long, default_value = "/var/run/horust/")]
sockets_folder_path: PathBuf,
uds_folder_path: PathBuf,

// Specify the full path of the socket. It takes precedence other over arguments.
#[arg(long)]
socket_path: Option<PathBuf>,

#[command(subcommand)]
commands: Commands,
Expand All @@ -35,59 +38,61 @@ struct StatusArgs {

fn main() -> Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("debug")).init();
let args = HourstctlArgs::parse();
let args = HorustctlArgs::parse();
debug!("args: {args:?}");

let uds_path = get_uds_path(args.pid, args.sockets_folder_path)?;
let uds_path = args.socket_path.unwrap_or_else(|| {
get_uds_path(args.pid, args.uds_folder_path).expect("Failed to get uds_path.")
});
let mut uds_handler = ClientHandler::new_client(&uds_path)?;
match &args.commands {
Commands::Status(status_args) => {
debug!("Status command received: {status_args:?}");
debug!("uds path : {uds_path:?}")
debug!("uds path : {uds_path:?}");
let (service_name, service_status) =
uds_handler.send_status_request(status_args.service_name.clone().unwrap())?;
println!(
"Current status for '{service_name}' is: '{}'.",
service_status.as_str_name()
);
}
}
Ok(())
}

fn get_uds_path(pid: Option<u32>, sockets_folder_path: PathBuf) -> Result<PathBuf> {
fn get_uds_path(pid: Option<i32>, sockets_folder_path: PathBuf) -> Result<PathBuf> {
if !sockets_folder_path.exists() {
bail!("the specified sockets folder path '{sockets_folder_path:?}' does not exists.");
}
if !sockets_folder_path.is_dir() {
bail!("the specified sockets folder path '{sockets_folder_path:?}' is not a directory.");
}

let socket_file_name = if pid.is_none() {
let mut readdir_iter = read_dir(&sockets_folder_path)?;
let ret = readdir_iter
.next()
.unwrap()? // check if it's there.
.file_name()
.to_string_lossy()
.to_string();
if readdir_iter.count() > 0 {
bail!("There is more than one socket in {sockets_folder_path:?}.Please use --pid to specify the pid of the horust process you want to talk to.");
}
ret
} else {
pid.map(|p| format!("{p}.uds")).unwrap()
};
debug!("Socket filename: {socket_file_name}");
Ok(sockets_folder_path.join(socket_file_name))
}

fn handle_status(socket_path: PathBuf) -> Result<()> {
// `args` returns the arguments passed to the program
let args: Vec<String> = env::args().map(|x| x.to_string()).collect();
let socket_path = match pid {
None => {
let mut readdir_iter = read_dir(&sockets_folder_path)?
.filter_map(|d| d.ok()) // unwrap results
.filter_map(|d| -> Option<String> {
let is_socket = d.file_type().ok()?.is_socket();
let name = d.file_name();
let name = name.to_string_lossy();
if is_socket && name.starts_with("horust-") && name.ends_with(".sock") {
Some(name.to_string())
} else {
None
}
});

// Connect to socket
let mut stream = match UnixStream::connect(&socket_path) {
Err(_) => panic!("server is not running"),
Ok(stream) => stream,
let ret = readdir_iter
.next()
.ok_or_else(|| anyhow!("No socket found in {sockets_folder_path:?}"))?;
if readdir_iter.count() > 0 {
bail!("There is more than one socket in {sockets_folder_path:?}.Please use --pid to specify the pid of the horust process you want to talk to.");
}
sockets_folder_path.join(ret)
}
Some(pid) => get_path(&sockets_folder_path, pid),
};

// Send message
if let Err(_) = stream.write(b"hello") {
panic!("couldn't send message")
}
Ok(())
debug!("Socket filename: {socket_path:?}");
Ok(socket_path)
}
116 changes: 116 additions & 0 deletions horustctl/tests/cli.rs
Original file line number Diff line number Diff line change
@@ -1 +1,117 @@
use assert_cmd::Command;
use predicates::boolean::PredicateBooleanExt;
use predicates::str::contains;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use std::path::Path;
use std::thread;
use std::time::Duration;
use tempdir::TempDir;

/// Creates script and service file, and stores them in dir.
/// It will append a `command` at the top of the service, with a reference to script.
/// Returns the service name.
/// if service_name is None a random name will be used.
pub fn store_service_script(
dir: &Path,
script: &str,
service_content: Option<&str>,
filename: Option<&str>,
) -> String {
let rnd_name = thread_rng()
.sample_iter(&Alphanumeric)
.take(5)
.map(|x| x as char)
.collect::<String>();
let service_name = format!("{}.toml", filename.unwrap_or(rnd_name.as_str()));
let script_name = format!("{}.sh", rnd_name);
let script_path = dir.join(script_name);
std::fs::write(&script_path, script).unwrap();
let service = format!(
r#"command = "/usr/bin/env bash {}"
{}"#,
script_path.display(),
service_content.unwrap_or("")
);
std::fs::write(dir.join(&service_name), service).unwrap();
service_name
}

#[test]
fn test_cli_help() {
let mut cmd = Command::cargo_bin("horustctl").unwrap();
cmd.args(vec!["--help"]).assert().success();
}

static ENVIRONMENT_SCRIPT: &str = r#"#!/usr/bin/env bash
printenv"#;

#[test]
fn test_cli_status() {
let temp_dir = TempDir::new("horustctl").unwrap();
let mut horust_cmd = Command::cargo_bin("horust").unwrap();

horust_cmd.current_dir(&temp_dir).args(vec![
"--services-path",
temp_dir.path().display().to_string().as_str(),
"--uds-folder-path",
temp_dir.path().display().to_string().as_str(),
]);

store_service_script(
temp_dir.path(),
ENVIRONMENT_SCRIPT,
None,
Some("terminated"),
);
horust_cmd.assert().success().stdout(contains("bar").not());
// Exit after 5 seconds.
store_service_script(
temp_dir.path(),
r#"#!/usr/bin/env bash
trap 'quit=1' USR1
touch file
i=0;
while [ "$i" -lt 5 ]; do
sleep 1
done"#,
None,
Some("running"),
);

thread::spawn(move || {
horust_cmd.assert().success().stdout(contains("bar"));
});
let mut total_wait = 0;
const MAX_WAIT_TIME: u32 = 1000;
// created by running script
while !temp_dir.path().join("file").exists() && total_wait < MAX_WAIT_TIME {
total_wait += 50;
thread::sleep(Duration::from_millis(50));
}
let mut horustctl_cmd = Command::cargo_bin("horustctl").unwrap();
horustctl_cmd
.current_dir(&temp_dir)
.args(vec![
"--uds-folder-path",
temp_dir.path().display().to_string().as_str(),
"status",
"terminated.toml",
])
.assert()
.success()
.stdout(contains("terminated"));

let mut horustctl_cmd = Command::cargo_bin("horustctl").unwrap();
horustctl_cmd
.current_dir(&temp_dir)
.args(vec![
"--uds-folder-path",
temp_dir.path().display().to_string().as_str(),
"status",
"running.toml",
])
.assert()
.success()
.stdout(contains("running"));
}

0 comments on commit af29785

Please sign in to comment.