diff --git a/Cargo.lock b/Cargo.lock index c871251..90cc0ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1331,9 +1331,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.80" +version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" +checksum = "03b07a082330a35e43f63177cc01689da34fbffa0105e1246cf0311472cac73a" [[package]] name = "libdbus-sys" @@ -1531,6 +1531,18 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "nix" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa9b4819da1bc61c0ea48b63b7bc8604064dd43013e7cc325df098d49cd7c18a" +dependencies = [ + "bitflags 1.2.1", + "cc", + "cfg-if 1.0.0", + "libc", +] + [[package]] name = "notify" version = "5.0.0-pre.4" @@ -2711,10 +2723,13 @@ dependencies = [ "kube", "kube-derive", "kubelet", + "lazy_static", "log 0.4.14", + "nix", "oci-distribution", "phf", "pnet", + "regex", "reqwest", "rstest", "serde", @@ -2723,6 +2738,8 @@ dependencies = [ "serde_yaml", "shellexpand", "stackable_config", + "strum", + "strum_macros", "tar", "thiserror", "tokio", @@ -2833,6 +2850,27 @@ dependencies = [ "syn 1.0.60", ] +[[package]] +name = "strum" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7318c509b5ba57f18533982607f24070a55d353e90d4cae30c467cdb2ad5ac5c" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8bc6b87a5112aeeab1f4a9f7ab634fe6cbefc4850006df31267f4cfb9e3149" +dependencies = [ + "heck", + "proc-macro2 1.0.24", + "quote 1.0.7", + "syn 1.0.60", +] + [[package]] name = "syn" version = "0.15.44" diff --git a/Cargo.toml b/Cargo.toml index abfdc61..9f1136b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,11 @@ phf = { version = "0.7.24", features = ["macros"] } dbus = "0.9.2" hostname = "0.3" shellexpand = "2.1" +regex = "1" +nix = "0.20" +lazy_static = "1" +strum = { version = "0.20", features = ["derive"] } +strum_macros = "0.20" [dev-dependencies] indoc = "1.0" @@ -55,4 +60,4 @@ systemd-units = { enable = false } assets = [ ["packaging/config/agent.conf", "etc/stackable-agent/", "644"], ["target/release/agent", "opt/stackable-agent/stackable-agent", "755"], -] \ No newline at end of file +] diff --git a/src/fsext.rs b/src/fsext.rs new file mode 100644 index 0000000..8e301ee --- /dev/null +++ b/src/fsext.rs @@ -0,0 +1,64 @@ +//! Filesystem manipulation operations. +//! +//! This module contains additional operations which are not present in +//! `std::fs` and `std::os::$platform`. + +use anyhow::{anyhow, Result}; +use nix::unistd; +use std::path::Path; + +/// User identifier +pub struct Uid(unistd::Uid); + +impl Uid { + /// Gets a Uid by user name. + /// + /// If no user with the given `user_name` exists then `Ok(None)` is returned. + /// + /// # Errors + /// + /// If this function encounters any form of I/O or other error, an error + /// variant will be returned. + pub fn from_name(user_name: &str) -> Result> { + match unistd::User::from_name(user_name) { + Ok(maybe_user) => Ok(maybe_user.map(|user| Uid(user.uid))), + Err(err) => Err(anyhow!("Could not retrieve user [{}]. {}", user_name, err)), + } + } +} + +/// Changes the ownership of the file or directory at `path` to be owned by the +/// given `uid`. +/// +/// # Errors +/// +/// If this function encounters any form of I/O or other error, an error +/// variant will be returned. +pub fn change_owner(path: &Path, uid: &Uid) -> Result<()> { + Ok(unistd::chown(path, Some(uid.0), None)?) +} + +/// Changes the ownership of the file or directory at `path` recursively to be +/// owned by the given `uid`. +/// +/// # Errors +/// +/// If this function encounters any form of I/O or other error, an error +/// variant will be returned. +pub fn change_owner_recursively(root_path: &Path, uid: &Uid) -> Result<()> { + visit_recursively(root_path, &|path| change_owner(path, uid)) +} + +/// Calls the function `cb` on the given `path` and its contents recursively. +fn visit_recursively(path: &Path, cb: &F) -> Result<()> +where + F: Fn(&Path) -> Result<()>, +{ + cb(path)?; + if path.is_dir() { + for entry in path.read_dir()? { + visit_recursively(entry?.path().as_path(), cb)?; + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 84c6e89..dcf668d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ pub mod config; +pub mod fsext; pub mod provider; diff --git a/src/provider/states/creating_config.rs b/src/provider/states/creating_config.rs index a673de6..185ec3a 100644 --- a/src/provider/states/creating_config.rs +++ b/src/provider/states/creating_config.rs @@ -425,12 +425,11 @@ mod tests { // Test if an undefined variable leads to an error let template_with_undefined_var = "{{var4}}test"; - let result = CreatingConfig::render_config_template(&context, template_with_undefined_var); + let _ = CreatingConfig::render_config_template(&context, template_with_undefined_var); - match CreatingConfig::render_config_template(&context, template_with_undefined_var) { - Ok(_) => assert!(false), - Err(_) => {} - } + assert!( + CreatingConfig::render_config_template(&context, template_with_undefined_var).is_err() + ); } #[test] diff --git a/src/provider/states/creating_service.rs b/src/provider/states/creating_service.rs index 72d07d0..0a1972f 100644 --- a/src/provider/states/creating_service.rs +++ b/src/provider/states/creating_service.rs @@ -32,6 +32,8 @@ impl State for CreatingService { } } + let user_mode = pod_state.systemd_manager.is_user_mode(); + // Naming schema // Service name: `namespace-podname` // SystemdUnit: `namespace-podname-containername` @@ -40,7 +42,7 @@ impl State for CreatingService { // Create a template from those settings that are derived directly from the pod, not // from container objects - let unit_template = match SystemDUnit::new_from_pod(&pod) { + let unit_template = match SystemDUnit::new_from_pod(&pod, user_mode) { Ok(unit) => unit, Err(pod_error) => { error!( @@ -58,7 +60,13 @@ impl State for CreatingService { .containers() .iter() .map(|container| { - SystemDUnit::new(&unit_template, &service_prefix, container, pod_state) + SystemDUnit::new( + &unit_template, + &service_prefix, + container, + user_mode, + pod_state, + ) }) .collect() { diff --git a/src/provider/systemdmanager/manager.rs b/src/provider/systemdmanager/manager.rs index a31c487..b093328 100644 --- a/src/provider/systemdmanager/manager.rs +++ b/src/provider/systemdmanager/manager.rs @@ -17,7 +17,7 @@ use std::path::PathBuf; use std::time::Duration; /// Enum that lists the supported unit types -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum UnitTypes { Service, } @@ -35,6 +35,7 @@ pub struct SystemdManager { units_directory: PathBuf, connection: SyncConnection, //TODO does this need to be closed? timeout: Duration, + user_mode: bool, // TODO Use the same naming (user_mode or session_mode) everywhere } /// By default the manager will connect to the system-wide instance of systemd, @@ -73,9 +74,14 @@ impl SystemdManager { units_directory, connection, timeout, + user_mode, }) } + pub fn is_user_mode(&self) -> bool { + self.user_mode + } + // The main method for interacting with dbus, all other functions will delegate the actual // dbus access to this function. // Private on purpose as this should not be used by external dependencies diff --git a/src/provider/systemdmanager/systemdunit.rs b/src/provider/systemdmanager/systemdunit.rs index 934f5b0..271da41 100644 --- a/src/provider/systemdmanager/systemdunit.rs +++ b/src/provider/systemdmanager/systemdunit.rs @@ -1,8 +1,9 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; use kubelet::container::Container; use kubelet::pod::Pod; -use phf::{Map, OrderedSet}; +use phf::Map; use crate::provider::error::StackableError; @@ -10,9 +11,13 @@ use crate::provider::error::StackableError::PodValidationError; use crate::provider::states::creating_config::CreatingConfig; use crate::provider::systemdmanager::manager::UnitTypes; use crate::provider::PodState; -use log::{debug, error, trace, warn}; +use lazy_static::lazy_static; +use log::{debug, error, info, trace, warn}; +use regex::Regex; use std::fmt; use std::fmt::{Display, Formatter}; +use std::iter; +use strum::{Display, EnumIter, IntoEnumIterator}; // This is used to map from Kubernetes restart lingo to systemd restart terms static RESTART_POLICY_MAP: Map<&'static str, &'static str> = phf::phf_map! { @@ -21,23 +26,29 @@ static RESTART_POLICY_MAP: Map<&'static str, &'static str> = phf::phf_map! { "Never" => "no", }; -pub const SECTION_SERVICE: &str = "Service"; -pub const SECTION_UNIT: &str = "Unit"; -pub const SECTION_INSTALL: &str = "Install"; +/// List of sections in the systemd unit +/// +/// The sections are written in the same order as listed here into the unit file. +#[derive(Clone, Copy, Debug, Display, EnumIter, Eq, Hash, PartialEq)] +pub enum Section { + Unit, + Service, + Install, +} -// TODO: This will be used later to ensure the same ordering of known sections in -// unit files, I'll leave it in for now -#[allow(dead_code)] -static SECTION_ORDER: OrderedSet<&'static str> = - phf::phf_ordered_set! {"Unit", "Service", "Install"}; +lazy_static! { + // Pattern for user names to comply with the strict mode of systemd + // see https://systemd.io/USER_NAMES/ + static ref USER_NAME_PATTERN: Regex = + Regex::new("^[a-zA-Z_][a-zA-Z0-9_-]{0,30}$").unwrap(); +} /// A struct that represents an individual systemd unit -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct SystemDUnit { pub name: String, pub unit_type: UnitTypes, - pub sections: HashMap>, - pub environment: HashMap, + pub sections: HashMap>, } // TODO: The parsing code is also highly stackable specific, we should @@ -50,7 +61,43 @@ impl SystemDUnit { common_properties: &SystemDUnit, name_prefix: &str, container: &Container, + user_mode: bool, pod_state: &PodState, + ) -> Result { + // Create template data to be used when rendering template strings + let template_data = if let Ok(data) = CreatingConfig::create_render_data(&pod_state) { + data + } else { + error!("Unable to parse directories for command template as UTF8"); + return Err(PodValidationError { + msg: format!( + "Unable to parse directories for command template as UTF8 for container [{}].", + container.name() + ), + }); + }; + + let package_root = pod_state.get_service_package_directory(); + + SystemDUnit::new_from_container( + common_properties, + name_prefix, + container, + &pod_state.service_name, + &template_data, + &package_root, + user_mode, + ) + } + + fn new_from_container( + common_properties: &SystemDUnit, + name_prefix: &str, + container: &Container, + service_name: &str, + template_data: &BTreeMap, + package_root: &Path, + user_mode: bool, ) -> Result { let mut unit = common_properties.clone(); @@ -64,39 +111,82 @@ impl SystemDUnit { unit.name = format!("{}{}", name_prefix, trimmed_name); - unit.add_property(SECTION_UNIT, "Description", &unit.name.clone()); + unit.add_property(Section::Unit, "Description", &unit.name.clone()); unit.add_property( - SECTION_SERVICE, + Section::Service, "ExecStart", - &SystemDUnit::get_command(container, pod_state)?, + &SystemDUnit::get_command(container, template_data, package_root)?, ); - let env_vars = SystemDUnit::get_environment(container, pod_state)?; - - for (name, value) in env_vars { - unit.add_env_var(&name, &value); + let env_vars = SystemDUnit::get_environment(container, service_name, template_data)?; + if !env_vars.is_empty() { + let mut assignments = env_vars + .iter() + .map(|(k, v)| format!("\"{}={}\"", k, v)) + .collect::>(); + assignments.sort(); + // TODO Put every environment variable on a separate line + unit.add_property(Section::Service, "Environment", &assignments.join(" ")); } // These are currently hard-coded, as this is not something we expect to change soon - unit.add_property(SECTION_SERVICE, "StandardOutput", "journal"); - unit.add_property(SECTION_SERVICE, "StandardError", "journal"); + unit.add_property(Section::Service, "StandardOutput", "journal"); + unit.add_property(Section::Service, "StandardError", "journal"); + + if let Some(user_name) = + SystemDUnit::get_user_name_from_security_context(container, &unit.name)? + { + if !user_mode { + unit.add_property(Section::Service, "User", user_name); + } else { + info!("The user name [{}] in spec.containers[name = {}].securityContext.windowsOptions.runAsUserName is not set in the systemd unit because the agent runs in session mode.", user_name, container.name()); + } + } + // This one is mandatory, as otherwise enabling the unit fails - unit.add_property(SECTION_INSTALL, "WantedBy", "multi-user.target"); + unit.add_property(Section::Install, "WantedBy", "multi-user.target"); Ok(unit) } + fn get_user_name_from_security_context<'a>( + container: &'a Container, + pod_name: &str, + ) -> Result, StackableError> { + let validate = |user_name| { + if USER_NAME_PATTERN.is_match(user_name) { + Ok(user_name) + } else { + Err(PodValidationError { + msg: format!( + r#"The validation of the pod [{}] failed. The user name [{}] in spec.containers[name = {}].securityContext.windowsOptions.runAsUserName must match the regular expression "{}"."#, + pod_name, + user_name, + container.name(), + USER_NAME_PATTERN.to_string() + ), + }) + } + }; + + container + .security_context() + .and_then(|security_context| security_context.windows_options.as_ref()) + .and_then(|windows_options| windows_options.run_as_user_name.as_ref()) + .map(|user_name| validate(user_name)) + .transpose() + } + /// Parse a pod object and retrieve the generic settings which will be the same across /// all service units created for containers in this pod. /// This is designed to then be used as `common_properties` parameter when calling ///[`SystemdUnit::new`] - pub fn new_from_pod(pod: &Pod) -> Result { + pub fn new_from_pod(pod: &Pod, user_mode: bool) -> Result { let mut unit = SystemDUnit { name: pod.name().to_string(), unit_type: UnitTypes::Service, sections: Default::default(), - environment: Default::default(), }; let restart_policy = match &pod.as_kube_pod().spec { @@ -119,10 +209,45 @@ impl SystemDUnit { } }; - unit.add_property(SECTION_SERVICE, "Restart", restart_policy); + unit.add_property(Section::Service, "Restart", restart_policy); + + if let Some(user_name) = SystemDUnit::get_user_name_from_pod_security_context(pod)? { + if !user_mode { + unit.add_property(Section::Service, "User", user_name); + } else { + info!("The user name [{}] in spec.securityContext.windowsOptions.runAsUserName is not set in the systemd unit because the agent runs in session mode.", user_name); + } + } + Ok(unit) } + fn get_user_name_from_pod_security_context(pod: &Pod) -> Result, StackableError> { + let validate = |user_name| { + if USER_NAME_PATTERN.is_match(user_name) { + Ok(user_name) + } else { + Err(PodValidationError { + msg: format!( + r#"The validation of the pod [{}] failed. The user name [{}] in spec.securityContext.windowsOptions.runAsUserName must match the regular expression "{}"."#, + pod.name(), + user_name, + USER_NAME_PATTERN.to_string() + ), + }) + } + }; + + pod.as_kube_pod() + .spec + .as_ref() + .and_then(|spec| spec.security_context.as_ref()) + .and_then(|security_context| security_context.windows_options.as_ref()) + .and_then(|windows_options| windows_options.run_as_user_name.as_ref()) + .map(|user_name| validate(user_name)) + .transpose() + } + /// Convenience function to retrieve the _fully qualified_ systemd name, which includes the /// `.servicetype` part. pub fn get_name(&self) -> String { @@ -131,38 +256,34 @@ impl SystemDUnit { } /// Add a key=value entry to the specified section - fn add_property(&mut self, section: &'static str, key: &str, value: &str) { - let section = self - .sections - .entry(String::from(section)) - .or_insert_with(HashMap::new); + fn add_property(&mut self, section: Section, key: &str, value: &str) { + let section = self.sections.entry(section).or_insert_with(HashMap::new); section.insert(String::from(key), String::from(value)); } - fn add_env_var(&mut self, name: &str, value: &str) { - self.environment - .insert(String::from(name), String::from(value)); - } - /// Retrieve content of the unit file as it should be written to disk pub fn get_unit_file_content(&self) -> String { - let mut unit_file_content = String::new(); + Section::iter() + .map(|section| self.sections.get_key_value(§ion)) + .flatten() + .map(|(section, entries)| SystemDUnit::write_section(section, entries)) + .collect::>() + .join("\n\n") + } - // Iterate over all sections and write out its header and content - for (section, entries) in &self.sections { - unit_file_content.push_str(&format!("[{}]\n", section)); - for (key, value) in entries { - unit_file_content.push_str(&format!("{}={}\n", key, value)); - } - if section == SECTION_SERVICE { - // Add environment variables to Service section - for (name, value) in &self.environment { - unit_file_content.push_str(&format!("Environment=\"{}={}\"\n", name, value)); - } - } - unit_file_content.push('\n'); - } - unit_file_content + fn write_section(section: &Section, entries: &HashMap) -> String { + let header = format!("[{}]", section); + + let mut body = entries + .iter() + .map(|(key, value)| format!("{}={}", key, value)) + .collect::>(); + body.sort(); + + iter::once(header) + .chain(body) + .collect::>() + .join("\n") } fn get_type_string(&self) -> &str { @@ -173,21 +294,9 @@ impl SystemDUnit { fn get_environment( container: &Container, - pod_state: &PodState, + service_name: &str, + template_data: &BTreeMap, ) -> Result, StackableError> { - // Create template data to be used when rendering template strings - let template_data = if let Ok(data) = CreatingConfig::create_render_data(&pod_state) { - data - } else { - error!("Unable to parse directories for command template as UTF8"); - return Err(PodValidationError { - msg: format!( - "Unable to parse directories for command template as UTF8 for container [{}].", - container.name() - ), - }); - }; - // Check if environment variables are set on the container - if some are present // we render all values as templates to replace configroot, packageroot and logroot // directories in case they are referenced in the values @@ -202,10 +311,7 @@ impl SystemDUnit { // an Error. If any error occurred, iteration stops on the first error and returns // that in the outer result. let env_variables = if let Some(vars) = container.env() { - debug!( - "Got environment vars: {:?} service {}", - vars, pod_state.service_name - ); + debug!("Got environment vars: {:?} service {}", vars, service_name); let render_result = vars .iter() .map(|env_var| { @@ -231,22 +337,23 @@ impl SystemDUnit { } } else { // No environment variables present for this container -> empty vec - debug!( - "No environment vars set for service {}", - pod_state.service_name - ); + debug!("No environment vars set for service {}", service_name); vec![] }; debug!( "Setting environment for service {} to {:?}", - pod_state.service_name, &env_variables + service_name, &env_variables ); Ok(env_variables) } // Retrieve a copy of the command object in the pod, or return an error if it is missing - fn get_command(container: &Container, pod_state: &PodState) -> Result { + fn get_command( + container: &Container, + template_data: &BTreeMap, + package_root: &Path, + ) -> Result { // Return an error if no command was specified in the container // TODO: We should discuss if there can be a valid scenario for this // This clones because we perform some in place mutations on the elements @@ -262,8 +369,6 @@ impl SystemDUnit { } }; - let package_root = pod_state.get_service_package_directory(); - trace!( "Command before replacing variables and adding packageroot: {:?}", command @@ -311,19 +416,6 @@ impl SystemDUnit { binary.replace_range(.., &binary_with_path); } - // Create template data to be used when rendering template strings - let template_data = if let Ok(data) = CreatingConfig::create_render_data(&pod_state) { - data - } else { - error!("Unable to parse directories for command template as UTF8"); - return Err(PodValidationError { - msg: format!( - "Unable to parse directories for command template as UTF8 for container [{}].", - container.name() - ), - }); - }; - // Append values from args array to command array // This is necessary as we only have the ExecStart field in a systemd service unit. // There is no specific place to put arguments separate from the command. @@ -358,3 +450,157 @@ impl Display for SystemDUnit { write!(f, "{}", self.get_name()) } } + +#[cfg(test)] +mod test { + use super::*; + use dbus::channel::BusType; + use indoc::indoc; + use rstest::rstest; + use std::path::PathBuf; + + #[rstest(bus_type, pod_config, expected_unit_file_name, expected_unit_file_content, + case::without_containers_on_system_bus( + BusType::System, + indoc! {" + apiVersion: v1 + kind: Pod + metadata: + name: stackable + spec: + containers: [] + restartPolicy: Always + securityContext: + windowsOptions: + runAsUserName: pod-user"}, + "stackable.service", + indoc! {" + [Service] + Restart=always + User=pod-user"} + ), + case::with_container_on_system_bus( + BusType::System, + indoc! {r#" + apiVersion: v1 + kind: Pod + metadata: + name: stackable + spec: + containers: + - name: test-container.service + command: + - start.sh + args: + - arg + - "{{configroot}}" + env: + - name: LOG_LEVEL + value: INFO + - name: LOG_DIR + value: "{{logroot}}" + securityContext: + windowsOptions: + runAsUserName: container-user + securityContext: + windowsOptions: + runAsUserName: pod-user"#}, + "default-stackable-test-container.service", + indoc! {r#" + [Unit] + Description=default-stackable-test-container + + [Service] + Environment="LOG_DIR=/var/log/default-stackable" "LOG_LEVEL=INFO" + ExecStart=start.sh arg /etc/default-stackable + Restart=no + StandardError=journal + StandardOutput=journal + User=container-user + + [Install] + WantedBy=multi-user.target"#} + ), + case::with_container_on_session_bus( + BusType::Session, + indoc! {r#" + apiVersion: v1 + kind: Pod + metadata: + name: stackable + spec: + containers: + - name: test-container.service + command: + - start.sh + securityContext: + windowsOptions: + runAsUserName: container-user + securityContext: + windowsOptions: + runAsUserName: pod-user"#}, + "default-stackable-test-container.service", + indoc! {r#" + [Unit] + Description=default-stackable-test-container + + [Service] + ExecStart=start.sh + Restart=no + StandardError=journal + StandardOutput=journal + + [Install] + WantedBy=multi-user.target"#} + ), + )] + fn create_unit_from_pod( + bus_type: BusType, + pod_config: &str, + expected_unit_file_name: &str, + expected_unit_file_content: &str, + ) { + let pod = parse_pod_from_yaml(pod_config); + + let mut result = SystemDUnit::new_from_pod(&pod, bus_type == BusType::Session); + + if let Ok(common_properties) = &result { + if let Some(container) = pod.containers().first() { + let service_name = format!("{}-{}", pod.namespace(), pod.name()); + let name_prefix = format!("{}-", service_name); + let mut template_data = BTreeMap::new(); + template_data.insert( + String::from("logroot"), + format!("/var/log/{}", &service_name), + ); + template_data.insert( + String::from("configroot"), + format!("/etc/{}", &service_name), + ); + let package_root = PathBuf::new(); + + result = SystemDUnit::new_from_container( + common_properties, + &name_prefix, + container, + &service_name, + &template_data, + &package_root, + bus_type == BusType::Session, + ); + } + } + + if let Ok(unit) = result { + assert_eq!(expected_unit_file_name, unit.get_name()); + assert_eq!(expected_unit_file_content, unit.get_unit_file_content()); + } else { + panic!("Systemd unit expected but got {:?}", result); + } + } + + fn parse_pod_from_yaml(pod_config: &str) -> Pod { + let kube_pod: k8s_openapi::api::core::v1::Pod = serde_yaml::from_str(pod_config).unwrap(); + Pod::from(kube_pod) + } +}