diff --git a/horust/Cargo.toml b/horust/Cargo.toml index a19d783..ceafc56 100644 --- a/horust/Cargo.toml +++ b/horust/Cargo.toml @@ -35,6 +35,7 @@ oci-spec = "0.7.1" [target.'cfg(target_os = "linux")'.dependencies] libcgroups = { version = "0.5.3", features = ["v1", "v2"], default-features = false, git = "https://github.com/youki-dev/youki.git", rev = "1b840bb0936e61990f9eabbb0e094d08235b2220"} +notify = "7.0.0" [features] default = ["http-healthcheck"] diff --git a/horust/src/horust/error.rs b/horust/src/horust/error.rs index 4a63413..2948942 100644 --- a/horust/src/horust/error.rs +++ b/horust/src/horust/error.rs @@ -11,7 +11,7 @@ impl ValidationErrors { fn validation_errors(errors: &[ValidationError]) -> String { errors .iter() - .map(|s| format!("* {}", s)) + .map(|s| format!("* {s}")) .collect::>() .join("\n") } diff --git a/horust/src/horust/formats/mod.rs b/horust/src/horust/formats/mod.rs index b933e99..4ab7430 100644 --- a/horust/src/horust/formats/mod.rs +++ b/horust/src/horust/formats/mod.rs @@ -1,4 +1,5 @@ use nix::unistd::Pid; +use std::path::PathBuf; pub use horust_config::HorustConfig; pub use service::*; @@ -26,6 +27,7 @@ pub enum Event { Run(ServiceName), ShuttingDownInitiated(ShuttingDown), HealthCheck(ServiceName, HealthinessStatus), + ReloadConfig(PathBuf), } impl Event { diff --git a/horust/src/horust/formats/service.rs b/horust/src/horust/formats/service.rs index f2cc430..a121358 100644 --- a/horust/src/horust/formats/service.rs +++ b/horust/src/horust/formats/service.rs @@ -57,6 +57,7 @@ pub struct Service { pub termination: Termination, #[serde(default)] pub resource_limit: ResourceLimit, + pub config_file: Option, } fn default_as_false() -> bool { @@ -127,6 +128,7 @@ impl Default for Service { failure: Default::default(), termination: Default::default(), resource_limit: Default::default(), + config_file: None, } } } @@ -240,10 +242,11 @@ impl Environment { /// Create the environment K=V variables, used for exec into the new process. /// User defined environment variables overwrite the predefined variables. pub(crate) fn get_environment(&self, user_name: String, user_home: String) -> Vec { - let mut initial: HashMap = self - .keep_env - .then(|| std::env::vars().collect()) - .unwrap_or_default(); + let mut initial: HashMap = if self.keep_env { + std::env::vars().collect() + } else { + Default::default() + }; let mut additional = self.additional.clone(); @@ -294,7 +297,7 @@ impl Environment { // This is the suitable format for `exec` additional .into_iter() - .map(|(k, v)| format!("{}={}", k, v)) + .map(|(k, v)| format!("{k}={v}")) .collect() } } @@ -357,7 +360,7 @@ impl User { match &self { User::Name(name) => { let user = unistd::User::from_name(name)? - .with_context(|| format!("User `{}` not found", name))?; + .with_context(|| format!("User `{name}` not found"))?; Ok(user.uid) } User::Uid(uid) => Ok(unistd::Uid::from_raw(*uid)), @@ -367,7 +370,7 @@ impl User { fn get_raw_user(&self) -> Result { let uid = self.get_uid()?; let user = - unistd::User::from_uid(uid)?.with_context(|| format!("User `{}` not found", uid))?; + unistd::User::from_uid(uid)?.with_context(|| format!("User `{uid}` not found"))?; Ok(user) } @@ -627,7 +630,7 @@ impl From for Signal { } } -#[derive(Serialize, Clone, Deserialize, Debug, PartialEq)] +#[derive(Serialize, Clone, Deserialize, Debug, Default, PartialEq)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ResourceLimit { #[serde(default)] @@ -647,16 +650,6 @@ impl ResourceLimit { } } -impl Default for ResourceLimit { - fn default() -> Self { - ResourceLimit { - cpu: None, - memory: None, - pids_max: None, - } - } -} - impl Eq for ResourceLimit {} impl ResourceLimit { @@ -679,13 +672,13 @@ impl ResourceLimit { } // has to be an absolute path for cgroups v2 - let cgroup_path = Path::new(DEFAULT_CGROUP_ROOT).join(format!("horust_{}", name)); + let cgroup_path = Path::new(DEFAULT_CGROUP_ROOT).join(format!("horust_{name}")); let manager = create_cgroup_manager(CgroupConfig { cgroup_path: cgroup_path.to_path_buf(), systemd_cgroup: false, container_name: name.to_string(), }) - .with_context(|| format!("Failed to create cgroup manager for {}", name))?; + .with_context(|| format!("Failed to create cgroup manager for {name}"))?; let mut resource = LinuxResources::default(); if let Some(cpu) = self.cpu { let cpu = LinuxCpuBuilder::default() @@ -705,7 +698,7 @@ impl ResourceLimit { manager .add_task(pid) - .with_context(|| format!("Failed to add task to cgroup {}", name))?; + .with_context(|| format!("Failed to add task to cgroup {name}"))?; manager .apply(&ControllerOpt { resources: &resource, @@ -713,7 +706,7 @@ impl ResourceLimit { oom_score_adj: None, freezer_state: None, }) - .with_context(|| format!("Failed to apply resource limits to cgroup {}", name))?; + .with_context(|| format!("Failed to apply resource limits to cgroup {name}"))?; Ok(()) } @@ -802,6 +795,7 @@ mod test { let current_user_name: String = super::User::default().get_name().unwrap(); let expected = Service { name: "".to_string(), + config_file: None, command: "/bin/bash -c \'echo hello world\'".to_string(), user: super::User::Name(current_user_name), environment: Environment { diff --git a/horust/src/horust/healthcheck/checks.rs b/horust/src/horust/healthcheck/checks.rs index 8e5d6c1..cfff6c7 100644 --- a/horust/src/horust/healthcheck/checks.rs +++ b/horust/src/horust/healthcheck/checks.rs @@ -85,10 +85,10 @@ pub(crate) struct CommandCheck {} impl CommandCheck { fn prepare_cmd(&self, cmd: &str) -> anyhow::Result<()> { - let mut chunks = shlex::split(cmd).context(format!("Failed to split command: {}", cmd))?; + let mut chunks = shlex::split(cmd).context(format!("Failed to split command: {cmd}"))?; let program = chunks .first() - .context(format!("Failed to get program from command: {}", cmd))?; + .context(format!("Failed to get program from command: {cmd}"))?; let path = if program.contains('/') { program.to_string() } else { @@ -128,7 +128,7 @@ impl Check for CommandCheck { .as_ref() .map(|command| { self.prepare_cmd(command) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + .map_err(|e| std::io::Error::other(e.to_string()))?; Ok(()) }) .unwrap_or(Ok(())) diff --git a/horust/src/horust/healthcheck/mod.rs b/horust/src/horust/healthcheck/mod.rs index 5aa7b4d..305f824 100644 --- a/horust/src/horust/healthcheck/mod.rs +++ b/horust/src/horust/healthcheck/mod.rs @@ -194,7 +194,7 @@ mod test { let socket = SocketAddrV4::new(loopback, 0); let listener = TcpListener::bind(socket)?; let port = listener.local_addr()?.port(); - let endpoint = format!("http://localhost:{}", port); + let endpoint = format!("http://localhost:{port}"); let healthiness = Healthiness { file_path: None, http_endpoint: Some(endpoint), diff --git a/horust/src/horust/mod.rs b/horust/src/horust/mod.rs index 69138c6..26d4aa4 100644 --- a/horust/src/horust/mod.rs +++ b/horust/src/horust/mod.rs @@ -125,6 +125,7 @@ where let filename = path.file_name().unwrap().to_str().unwrap().to_owned(); service.name = filename; } + service.config_file = Some(path.clone()); service }) .map_err(|error| { diff --git a/horust/src/horust/signal_safe.rs b/horust/src/horust/signal_safe.rs index 48eac18..8253b21 100644 --- a/horust/src/horust/signal_safe.rs +++ b/horust/src/horust/signal_safe.rs @@ -102,7 +102,7 @@ mod test { fn test_int_to_string_conversion() { let test = |i| { let (res, digits) = i32_to_str_bytes(i); - assert_eq!(&res[digits..], format!("{}", i).as_bytes()); + assert_eq!(&res[digits..], format!("{i}").as_bytes()); }; for _ in 0..100 { diff --git a/horust/src/horust/supervisor/mod.rs b/horust/src/horust/supervisor/mod.rs index 4be8fd0..68e330e 100644 --- a/horust/src/horust/supervisor/mod.rs +++ b/horust/src/horust/supervisor/mod.rs @@ -17,7 +17,7 @@ pub(crate) use signal_handling::init; use crate::horust::bus::BusConnector; use crate::horust::formats::{Event, ExitStatus, Service, ServiceStatus, ShuttingDown}; -use crate::horust::healthcheck; +use crate::horust::{healthcheck, load_service}; mod process_spawner; mod reaper; @@ -81,7 +81,7 @@ impl Supervisor { && service_handler.is_early_state(), ); - let new_status = if has_failed + let new_status = if !service_handler.reload_config && has_failed || (service_handler.status == ServiceStatus::Running && service_handler.has_some_failed_healthchecks()) { @@ -111,6 +111,7 @@ impl Supervisor { } Event::Run(service_name) if self.repo.get_sh(&service_name).is_initial() => { let service_handler = self.repo.get_mut_sh(&service_name); + service_handler.reload_config = false; service_handler.status = ServiceStatus::Starting; let evs = vec![Event::StatusChanged(service_name, ServiceStatus::Starting)]; @@ -224,6 +225,19 @@ impl Supervisor { vec![] } } + Event::ReloadConfig(path) => { + info!("Reloading config: {path:?}"); + let service_handler = self + .repo + .get_service_by_path(&path) + .map(|service_name| self.repo.get_mut_sh(&service_name)) + .zip(load_service(&path).ok()); + if let Some((service_handler, service)) = service_handler { + service_handler.service = service; + service_handler.reload_config = true; + } + vec![] + } ev => { trace!("ignoring: {:?}", ev); vec![] diff --git a/horust/src/horust/supervisor/repo.rs b/horust/src/horust/supervisor/repo.rs index 484ec85..9105d77 100644 --- a/horust/src/horust/supervisor/repo.rs +++ b/horust/src/horust/supervisor/repo.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; +use std::path::Path; use nix::unistd::Pid; +use notify::event::ModifyKind; +use notify::{EventKind, Watcher}; use crate::horust::bus::BusConnector; use crate::horust::formats::{Service, ServiceName}; @@ -12,20 +15,66 @@ pub(crate) struct Repo { pub services: HashMap, pub(crate) bus: BusConnector, pub(crate) pid_map: HashMap, + _watcher: notify::RecommendedWatcher, +} + +struct ConfigWatcher { + bus: BusConnector, +} + +impl notify::EventHandler for ConfigWatcher { + fn handle_event(&mut self, event: notify::Result) { + if let Ok(notify::Event { + kind: EventKind::Modify(ModifyKind::Data(_)), + paths, + attrs: _, + }) = event + { + paths + .iter() + .for_each(|path| self.bus.send_event(Event::ReloadConfig(path.clone()))); + } + } } impl Repo { pub(crate) fn new(bus: BusConnector, services: Vec) -> Self { + let config_watcher = ConfigWatcher { + bus: bus.join_bus(), + }; + let mut watcher = notify::recommended_watcher(config_watcher).unwrap(); + services.iter().for_each(|service| { + if let Some(path) = service.config_file.as_ref() { + _ = watcher.watch(path.as_path(), notify::RecursiveMode::NonRecursive); + } + }); + let services = services - .into_iter() - .map(|service| (service.name.clone(), service.into())) + .iter() + .map(|service| (service.name.clone(), service.clone().into())) .collect(); + Self { bus, services, pid_map: HashMap::new(), + _watcher: watcher, } } + + pub fn get_service_by_path(&self, path: &Path) -> Option { + self.services + .iter() + .find(|(_, handler)| { + handler + .service() + .config_file + .as_ref() + .is_some_and(|config_file| config_file == path.as_os_str()) + }) + .map(|(service_name, _)| service_name.to_owned()) + } + pub(crate) fn insert_sh_by_name(&mut self, name: ServiceName, sh: ServiceHandler) { self.services.insert(name, sh); } diff --git a/horust/src/horust/supervisor/service_handler.rs b/horust/src/horust/supervisor/service_handler.rs index d56c5cc..e34f983 100644 --- a/horust/src/horust/supervisor/service_handler.rs +++ b/horust/src/horust/supervisor/service_handler.rs @@ -12,7 +12,8 @@ use super::{LifecycleStatus, ShuttingDown}; #[derive(Clone, Debug, Eq, PartialEq, Default)] pub(crate) struct ServiceHandler { - service: Service, + pub(super) service: Service, + pub(super) reload_config: bool, /// Status of this service. pub(super) status: ServiceStatus, /// Process ID of this service, if any @@ -150,15 +151,25 @@ fn next_events(repo: &Repo, service_handler: &ServiceHandler) -> Vec { // This will kill the service after 3 failed healthchecks in a row. // Maybe this should be parametrized ServiceStatus::Running - if service_handler.healthiness_checks_failed.unwrap_or(-1) - > service_handler.service.healthiness.max_failed => + if service_handler.reload_config + || service_handler.healthiness_checks_failed.unwrap_or(-1) + > service_handler.service.healthiness.max_failed => { vec![ ev_status(ServiceStatus::InKilling), Event::Kill(service_handler.name().clone()), ] } - ServiceStatus::Success => vec![handle_restart_strategy(service_handler, false)], + ServiceStatus::Success => { + if service_handler.reload_config { + vec![Event::new_status_update( + service_handler.name(), + ServiceStatus::Initial, + )] + } else { + vec![handle_restart_strategy(service_handler, false)] + } + } ServiceStatus::Failed => { let mut failure_evs = handle_failed_service( repo.get_dependents(service_handler.name()), @@ -252,17 +263,17 @@ fn handle_status_change( }; let allowed = allowed_transitions .get(&next_status) - .unwrap_or_else(|| panic!("New status: {} not found!", next_status)); + .unwrap_or_else(|| panic!("New status: {next_status} not found!")); if allowed.contains(&service_handler.status) { match next_status { - ServiceStatus::Started if allowed.contains(&service_handler.status) => { + ServiceStatus::Started => { new_service_handler.status = ServiceStatus::Started; new_service_handler.restart_attempts = 0; } - ServiceStatus::Running if allowed.contains(&service_handler.status) => { + ServiceStatus::Running => { new_service_handler.status = ServiceStatus::Running; } - ServiceStatus::InKilling if allowed.contains(&service_handler.status) => { + ServiceStatus::InKilling => { debug!( " service: {}, status: {}, new status: {}", service_handler.name(), @@ -398,9 +409,8 @@ mod test { r#"name="servicename" command = "Not relevant" [restart] -strategy = "{}" -"#, - strategy +strategy = "{strategy}" +"# ); let service: Service = Service::from_str(service.as_str()).unwrap(); let sh = service.into(); diff --git a/horust/tests/horust.rs b/horust/tests/horust.rs index ef62cf1..753c4cf 100644 --- a/horust/tests/horust.rs +++ b/horust/tests/horust.rs @@ -41,7 +41,7 @@ fn test_command_not_found() { .map(|x| x as char) .collect::(); let service_name = format!("{}.toml", rnd_name.as_str()); - let service = format!(r#"command = ",sorry_not_found{}""#, rnd_name); + let service = format!(r#"command = ",sorry_not_found{rnd_name}""#); std::fs::write(dir.join(&service_name), service).unwrap(); let recv = run_async(&mut cmd, true); // Error spawning process: NixError: ENOENT: No such file or directory diff --git a/horust/tests/section_failure.rs b/horust/tests/section_failure.rs index f865bce..0980db3 100644 --- a/horust/tests/section_failure.rs +++ b/horust/tests/section_failure.rs @@ -9,9 +9,8 @@ fn test_failure_strategy(strategy: &str) { let (mut cmd, temp_dir) = get_cli(); let failing_service = format!( r#"[failure] -strategy = "{}" -"#, - strategy +strategy = "{strategy}" +"# ); let failing_script = r#"#!/usr/bin/env bash # Let's give horust some time to spinup the other service as well. diff --git a/horust/tests/section_general.rs b/horust/tests/section_general.rs index a9d8f5d..75a5e0b 100644 --- a/horust/tests/section_general.rs +++ b/horust/tests/section_general.rs @@ -12,7 +12,7 @@ fn test_single_output_redirection(stream: &str, to: &str) { let (mut cmd, temp_dir) = get_cli(); let pattern = "Hello".to_string(); let to = if to == "FILE" { - let name = format!("{}.log", stream); + let name = format!("{stream}.log"); let path = temp_dir.path().join(name); path.display().to_string() } else { @@ -21,10 +21,9 @@ fn test_single_output_redirection(stream: &str, to: &str) { let redir = if stream == "stderr" { "1>&2" } else { "" }; let script = format!( r#"#!/usr/bin/env bash -printf "{}" {}"#, - pattern, redir +printf "{pattern}" {redir}"# ); - let service = format!(r#"{}="{}""#, stream, to); + let service = format!(r#"{stream}="{to}""#); store_service_script( temp_dir.path(), script.as_str(), @@ -80,8 +79,8 @@ sync pattern_len - 1 // - 1 is the new line. ); let service = [ - format!(r#"stdout="{}""#, output), - format!(r#"stdout-rotate-size="{}""#, max_log_size), + format!(r#"stdout="{output}""#), + format!(r#"stdout-rotate-size="{max_log_size}""#), ] .join("\n"); store_service_script( @@ -95,10 +94,7 @@ sync assert!(last_output.exists()); // it is effectively the last output - assert!(temp_dir - .path() - .join(format!("out.log.{}", num_logs)) - .exists()); + assert!(temp_dir.path().join(format!("out.log.{num_logs}")).exists()); let content = std::fs::read_to_string(&last_output).unwrap(); let last_file_first_number = total_iterations - (patterns_per_file); @@ -106,7 +102,7 @@ sync // the last patterns_file number, say from (90, 100] for i in last_file_first_number + 1..=total_iterations { let mut number = i.to_string(); - number = format!("{:0>4}", number); + number = format!("{number:0>4}"); expected_content.push_str(&number); expected_content.push('\n'); } @@ -134,7 +130,7 @@ fn test_cwd() { let (mut cmd, temp_dir) = get_cli(); let another_dir = TempDir::with_prefix("another").unwrap(); let displ = another_dir.path().display().to_string(); - let service = format!(r#"working-directory = "{}""#, displ); + let service = format!(r#"working-directory = "{displ}""#); let script = r#"#!/usr/bin/env bash pwd"#; store_service_script(temp_dir.path(), script, Some(service.as_str()), None); @@ -149,7 +145,7 @@ pwd"#; store_service_script(temp_dir.path(), script, None, Some("a")); cmd.assert() .success() - .stdout(contains(&temp_dir.path().display().to_string())); + .stdout(contains(temp_dir.path().display().to_string())); } #[test] diff --git a/horust/tests/section_healthiness.rs b/horust/tests/section_healthiness.rs index 791347a..768afa8 100644 --- a/horust/tests/section_healthiness.rs +++ b/horust/tests/section_healthiness.rs @@ -36,7 +36,7 @@ fn test_healthcheck_http() -> io::Result<()> { let socket = SocketAddrV4::new(loopback, 0); let listener = TcpListener::bind(socket)?; let port = listener.local_addr()?.port(); - let endpoint = format!("http://localhost:{}", port); + let endpoint = format!("http://localhost:{port}"); let service = format!( r#" [termination] @@ -44,8 +44,7 @@ wait = "1s" [restart] strategy = "never" [healthiness] -http-endpoint = "{}""#, - endpoint +http-endpoint = "{endpoint}""# ); let script = r#"#!/usr/bin/env bash sleep 2 @@ -58,8 +57,8 @@ http-endpoint = "{}""#, handle_requests(listener, sl_receiver).unwrap(); sender.send(()).expect("Chan closed"); }); - let mut cmd = cmd.args(vec!["--unsuccessful-exit-finished-failed"]); - run_async(&mut cmd, true).recv_or_kill(Duration::from_secs(15)); + let cmd = cmd.args(vec!["--unsuccessful-exit-finished-failed"]); + run_async(cmd, true).recv_or_kill(Duration::from_secs(15)); stop_listener.send(()).unwrap(); receiver .recv_timeout(Duration::from_millis(3000)) @@ -86,7 +85,7 @@ file-path = "{}""#, exit 0; "#; store_service_script(tempdir.path(), script, Some(service.as_str()), None); - let mut cmd = cmd.args(vec!["--unsuccessful-exit-finished-failed"]); - run_async(&mut cmd, true).recv_or_kill(Duration::from_secs(70)); + let cmd = cmd.args(vec!["--unsuccessful-exit-finished-failed"]); + run_async(cmd, true).recv_or_kill(Duration::from_secs(70)); Ok(()) } diff --git a/horust/tests/section_restart.rs b/horust/tests/section_restart.rs index 0575141..16316f5 100644 --- a/horust/tests/section_restart.rs +++ b/horust/tests/section_restart.rs @@ -95,9 +95,8 @@ fn test_restart_always_signal(signal: i32) -> Result<(), std::io::Error> { let suicide_script = format!( r#"#!/usr/bin/env bash echo "restarting" -kill -{} $$ -"#, - signal +kill -{signal} $$ +"# ); let service = r#" [restart] @@ -132,7 +131,7 @@ fn test_restart_always_killed_by_signals() -> Result<(), std::io::Error> { SIGSEGV, SIGSYS, SIGTERM, SIGTRAP, SIGUSR1, SIGUSR2, SIGVTALRM, SIGXCPU, SIGXFSZ, ]; for sig in DEFAULT_TERMINATE { - test_restart_always_signal(sig as i32)?; + test_restart_always_signal(sig)?; } Ok(()) } diff --git a/horust/tests/section_termination.rs b/horust/tests/section_termination.rs index 66cd6c1..6a99acc 100644 --- a/horust/tests/section_termination.rs +++ b/horust/tests/section_termination.rs @@ -48,22 +48,20 @@ trap_with_arg() {{ done }} func_trap() {{ - if [ "$1" == "{0}" ] ; then + if [ "$1" == "{friendly_name}" ] ; then exit 0 fi }} -trap_with_arg func_trap {0} +trap_with_arg func_trap {friendly_name} while true ; do sleep 0.3 done -"#, - friendly_name +"# ); let service = format!( r#"[termination] -signal = "{}" -wait = "10s""#, - friendly_name +signal = "{friendly_name}" +wait = "10s""# ); // wait is higher than the test duration. store_service_script( @@ -93,7 +91,7 @@ fn test_termination_all_custom_signals() { "VTALRM", "PROF", "WINCH", "IO", "SYS", ]; signals.into_iter().for_each(|friendly_name| { - eprintln!("Testing: {}", friendly_name); + eprintln!("Testing: {friendly_name}"); test_termination_custom_signal(friendly_name); }) } diff --git a/horust/tests/utils/mod.rs b/horust/tests/utils/mod.rs index 69717b8..f7d4259 100644 --- a/horust/tests/utils/mod.rs +++ b/horust/tests/utils/mod.rs @@ -41,7 +41,7 @@ pub fn store_service_script( ) -> String { let rnd_name = create_random_name(); let service_name = format!("{}.toml", service_name.unwrap_or(rnd_name.as_str())); - let script_name = format!("{}.sh", rnd_name); + let script_name = format!("{rnd_name}.sh"); let script_path = dir.join(script_name); std::fs::write(&script_path, script).unwrap(); let service = format!( @@ -88,7 +88,7 @@ pub fn get_cli() -> (Command, TempDir) { /// Run the cmd in a new process and send a message on receiver when it's done. /// This allows for ensuring termination of a test. pub fn run_async(cmd: &mut Command, should_succeed: bool) -> RecvWrapper { - println!("Cmd: {:?}", cmd); + println!("Cmd: {cmd:?}"); let mut child = cmd.spawn().unwrap(); thread::sleep(Duration::from_millis(500)); diff --git a/horustctl/tests/cli.rs b/horustctl/tests/cli.rs index 83d175d..446277c 100644 --- a/horustctl/tests/cli.rs +++ b/horustctl/tests/cli.rs @@ -24,7 +24,7 @@ pub fn store_service_script( .map(|x| x as char) .collect::(); let service_name = format!("{}.toml", filename.unwrap_or(rnd_name.as_str())); - let script_name = format!("{}.sh", rnd_name); + let script_name = format!("{rnd_name}.sh"); let script_path = dir.join(script_name); std::fs::write(&script_path, script).unwrap(); let service = format!(