diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d3b0544..77c4ce4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Modules for log aggregation added ([#517]). + +[#517]: https://github.com/stackabletech/operator-rs/pull/517 + ## [0.28.8] - 2022-12-08 ### Added diff --git a/src/lib.rs b/src/lib.rs index 2ade4e604..03d2a53a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod memory; pub mod namespace; pub mod pod_utils; pub mod product_config_utils; +pub mod product_logging; pub mod role_utils; pub mod utils; pub mod validation; diff --git a/src/product_config_utils.rs b/src/product_config_utils.rs index 1bdf0e39d..ffb404778 100644 --- a/src/product_config_utils.rs +++ b/src/product_config_utils.rs @@ -92,19 +92,16 @@ impl Configuration for Box { } /// Type to sort config properties via kind (files, env, cli), via groups and via roles. -/// HashMap>>> pub type RoleConfigByPropertyKind = HashMap>>>>; /// Type to sort config properties via kind (files, env, cli) and via groups. -/// HashMap>> pub type RoleGroupConfigByPropertyKind = HashMap>>>; /// Type to sort config properties via kind (files, env, cli), via groups and via roles. This /// is the validated output to be used in other operators. The difference to [`RoleConfigByPropertyKind`] /// is that the properties BTreeMap does not contain any options. -/// /// HashMap>>>> pub type ValidatedRoleConfigByPropertyKind = HashMap>>>; diff --git a/src/product_logging/framework.rs b/src/product_logging/framework.rs new file mode 100644 index 000000000..ef7140756 --- /dev/null +++ b/src/product_logging/framework.rs @@ -0,0 +1,653 @@ +//! Log aggregation framework + +use std::cmp; + +use crate::{ + builder::ContainerBuilder, commons::product_image_selection::ResolvedProductImage, + k8s_openapi::api::core::v1::Container, kube::Resource, role_utils::RoleGroupRef, +}; + +use super::spec::{ + AutomaticContainerLogConfig, ContainerLogConfig, ContainerLogConfigChoice, LogLevel, +}; + +/// Config directory used in the Vector log agent container +const STACKABLE_CONFIG_DIR: &str = "/stackable/config"; +/// Directory which contains a subdirectory for every container which themselves contain the +/// corresponding log files +const STACKABLE_LOG_DIR: &str = "/stackable/log"; + +/// File name of the Vector config file +pub const VECTOR_CONFIG_FILE: &str = "vector.toml"; + +/// Create a Bash command which filters stdout and stderr according to the given log configuration +/// and additionally stores the output in log files +/// +/// # Example +/// +/// ``` +/// use stackable_operator::{ +/// builder::ContainerBuilder, +/// config::fragment, +/// product_logging, +/// product_logging::spec::{ +/// ContainerLogConfig, ContainerLogConfigChoice, Logging, +/// }, +/// }; +/// # use stackable_operator::product_logging::spec::default_logging; +/// # use strum::{Display, EnumIter}; +/// # +/// # #[derive(Clone, Display, Eq, EnumIter, Ord, PartialEq, PartialOrd)] +/// # pub enum Container { +/// # Init, +/// # } +/// # +/// # let logging = fragment::validate::>(default_logging()).unwrap(); +/// +/// const STACKABLE_LOG_DIR: &str = "/stackable/log"; +/// +/// let mut args = Vec::new(); +/// +/// if let Some(ContainerLogConfig { +/// choice: Some(ContainerLogConfigChoice::Automatic(log_config)), +/// }) = logging.containers.get(&Container::Init) +/// { +/// args.push(product_logging::framework::capture_shell_output( +/// STACKABLE_LOG_DIR, +/// "init", +/// &log_config, +/// )); +/// } +/// args.push("echo Test".into()); +/// +/// let init_container = ContainerBuilder::new("init") +/// .unwrap() +/// .command(vec!["bash".to_string(), "-c".to_string()]) +/// .args(vec![args.join(" && ")]) +/// .build(); +/// ``` +pub fn capture_shell_output( + log_dir: &str, + container: &str, + log_config: &AutomaticContainerLogConfig, +) -> String { + let root_log_level = log_config.root_log_level(); + let console_log_level = cmp::max( + root_log_level, + log_config + .console + .as_ref() + .and_then(|console| console.level) + .unwrap_or_default(), + ); + let file_log_level = cmp::max( + root_log_level, + log_config + .file + .as_ref() + .and_then(|file| file.level) + .unwrap_or_default(), + ); + + let log_file_dir = format!("{log_dir}/{container}"); + + let stdout_redirect = match ( + console_log_level <= LogLevel::INFO, + file_log_level <= LogLevel::INFO, + ) { + (true, true) => format!(" > >(tee {log_file_dir}/container.stdout.log)"), + (true, false) => "".into(), + (false, true) => format!(" > {log_file_dir}/container.stdout.log"), + (false, false) => " > /dev/null".into(), + }; + + let stderr_redirect = match ( + console_log_level <= LogLevel::ERROR, + file_log_level <= LogLevel::ERROR, + ) { + (true, true) => format!(" 2> >(tee {log_file_dir}/container.stderr.log >&2)"), + (true, false) => "".into(), + (false, true) => format!(" 2> {log_file_dir}/container.stderr.log"), + (false, false) => " 2> /dev/null".into(), + }; + + let mut args = Vec::new(); + if file_log_level <= LogLevel::ERROR { + args.push(format!("mkdir --parents {log_file_dir}")); + } + if stdout_redirect.is_empty() && stderr_redirect.is_empty() { + args.push(":".into()); + } else { + args.push(format!("exec{stdout_redirect}{stderr_redirect}")); + } + + args.join(" && ") +} + +/// Create the content of a log4j properties file according to the given log configuration +/// +/// # Arguments +/// +/// * `log_dir` - Directory where the log files are stored +/// * `log_file` - Name of the active log file; When the file is rolled over then a number is +/// appended. +/// * `max_size_in_mib` - Maximum size of all log files in MiB; This value can be slightly +/// exceeded. The value is set to 2 if the given value is lower (1 MiB for the active log +/// file and 1 MiB for the archived one). +/// * `console_conversion_pattern` - Logback conversion pattern for the console appender +/// * `config` - The logging configuration for the container +/// +/// # Example +/// +/// ``` +/// use stackable_operator::{ +/// builder::{ +/// ConfigMapBuilder, +/// meta::ObjectMetaBuilder, +/// }, +/// config::fragment, +/// product_logging, +/// product_logging::spec::{ +/// ContainerLogConfig, ContainerLogConfigChoice, Logging, +/// }, +/// }; +/// # use stackable_operator::product_logging::spec::default_logging; +/// # use strum::{Display, EnumIter}; +/// # +/// # #[derive(Clone, Display, Eq, EnumIter, Ord, PartialEq, PartialOrd)] +/// # pub enum Container { +/// # MyProduct, +/// # } +/// # +/// # let logging = fragment::validate::>(default_logging()).unwrap(); +/// +/// const STACKABLE_LOG_DIR: &str = "/stackable/log"; +/// const LOG4J_CONFIG_FILE: &str = "log4j.properties"; +/// const MY_PRODUCT_LOG_FILE: &str = "my-product.log4j.xml"; +/// const MAX_LOG_FILE_SIZE_IN_MIB: u32 = 10; +/// const CONSOLE_CONVERSION_PATTERN: &str = "%d{ISO8601} %-5p %m%n"; +/// +/// let mut cm_builder = ConfigMapBuilder::new(); +/// cm_builder.metadata(ObjectMetaBuilder::default().build()); +/// +/// if let Some(ContainerLogConfig { +/// choice: Some(ContainerLogConfigChoice::Automatic(log_config)), +/// }) = logging.containers.get(&Container::MyProduct) +/// { +/// cm_builder.add_data( +/// LOG4J_CONFIG_FILE, +/// product_logging::framework::create_log4j_config( +/// &format!("{STACKABLE_LOG_DIR}/my-product"), +/// MY_PRODUCT_LOG_FILE, +/// MAX_LOG_FILE_SIZE_IN_MIB, +/// CONSOLE_CONVERSION_PATTERN, +/// log_config, +/// ), +/// ); +/// } +/// +/// cm_builder.build().unwrap(); +/// ``` +pub fn create_log4j_config( + log_dir: &str, + log_file: &str, + max_size_in_mib: u32, + console_conversion_pattern: &str, + config: &AutomaticContainerLogConfig, +) -> String { + let number_of_archived_log_files = 1; + + let loggers = config + .loggers + .iter() + .filter(|(name, _)| name.as_str() != AutomaticContainerLogConfig::ROOT_LOGGER) + .map(|(name, logger_config)| { + format!( + "log4j.logger.{name}={level}\n", + name = name.escape_default(), + level = logger_config.level.to_log4j_literal(), + ) + }) + .collect::(); + + format!( + r#"log4j.rootLogger={root_log_level}, CONSOLE, FILE + +log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender +log4j.appender.CONSOLE.Threshold={console_log_level} +log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout +log4j.appender.CONSOLE.layout.ConversionPattern={console_conversion_pattern} + +log4j.appender.FILE=org.apache.log4j.RollingFileAppender +log4j.appender.FILE.Threshold={file_log_level} +log4j.appender.FILE.File={log_dir}/{log_file} +log4j.appender.FILE.MaxFileSize={max_log_file_size_in_mib}MB +log4j.appender.FILE.MaxBackupIndex={number_of_archived_log_files} +log4j.appender.FILE.layout=org.apache.log4j.xml.XMLLayout + +{loggers}"#, + max_log_file_size_in_mib = + cmp::max(1, max_size_in_mib / (1 + number_of_archived_log_files)), + root_log_level = config.root_log_level().to_log4j_literal(), + console_log_level = config + .console + .as_ref() + .and_then(|console| console.level) + .unwrap_or_default() + .to_log4j_literal(), + file_log_level = config + .file + .as_ref() + .and_then(|file| file.level) + .unwrap_or_default() + .to_log4j_literal(), + ) +} + +/// Create the content of a logback XML configuration file according to the given log configuration +/// +/// # Arguments +/// +/// * `log_dir` - Directory where the log files are stored +/// * `log_file` - Name of the active log file; When the file is rolled over then a number is +/// appended. +/// * `max_size_in_mib` - Maximum size of all log files in MiB; This value can be slightly +/// exceeded. The value is set to 2 if the given value is lower (1 MiB for the active log +/// file and 1 MiB for the archived one). +/// * `console_conversion_pattern` - Logback conversion pattern for the console appender +/// * `config` - The logging configuration for the container +/// +/// # Example +/// +/// ``` +/// use stackable_operator::{ +/// builder::{ +/// ConfigMapBuilder, +/// meta::ObjectMetaBuilder, +/// }, +/// product_logging, +/// product_logging::spec::{ +/// ContainerLogConfig, ContainerLogConfigChoice, Logging, +/// }, +/// }; +/// # use stackable_operator::{ +/// # config::fragment, +/// # product_logging::spec::default_logging, +/// # }; +/// # use strum::{Display, EnumIter}; +/// # +/// # #[derive(Clone, Display, Eq, EnumIter, Ord, PartialEq, PartialOrd)] +/// # pub enum Container { +/// # MyProduct, +/// # } +/// # +/// # let logging = fragment::validate::>(default_logging()).unwrap(); +/// +/// const STACKABLE_LOG_DIR: &str = "/stackable/log"; +/// const LOGBACK_CONFIG_FILE: &str = "logback.xml"; +/// const MY_PRODUCT_LOG_FILE: &str = "my-product.log4j.xml"; +/// const MAX_LOG_FILE_SIZE_IN_MIB: u32 = 10; +/// const CONSOLE_CONVERSION_PATTERN: &str = "%d{ISO8601} %-5p %m%n"; +/// +/// let mut cm_builder = ConfigMapBuilder::new(); +/// cm_builder.metadata(ObjectMetaBuilder::default().build()); +/// +/// if let Some(ContainerLogConfig { +/// choice: Some(ContainerLogConfigChoice::Automatic(log_config)), +/// }) = logging.containers.get(&Container::MyProduct) +/// { +/// cm_builder.add_data( +/// LOGBACK_CONFIG_FILE, +/// product_logging::framework::create_logback_config( +/// &format!("{STACKABLE_LOG_DIR}/my-product"), +/// MY_PRODUCT_LOG_FILE, +/// MAX_LOG_FILE_SIZE_IN_MIB, +/// CONSOLE_CONVERSION_PATTERN, +/// log_config, +/// ), +/// ); +/// } +/// +/// cm_builder.build().unwrap(); +/// ``` +pub fn create_logback_config( + log_dir: &str, + log_file: &str, + max_size_in_mib: u32, + console_conversion_pattern: &str, + config: &AutomaticContainerLogConfig, +) -> String { + let number_of_archived_log_files = 1; + + let loggers = config + .loggers + .iter() + .filter(|(name, _)| name.as_str() != AutomaticContainerLogConfig::ROOT_LOGGER) + .map(|(name, logger_config)| { + format!( + " \n", + name = name.escape_default(), + level = logger_config.level.to_logback_literal(), + ) + }) + .collect::(); + + format!( + r#" + + + {console_conversion_pattern} + + + {console_log_level} + + + + + {log_dir}/{log_file} + + + + + {file_log_level} + + + 1 + {number_of_archived_log_files} + {log_dir}/{log_file}.%i + + + {max_log_file_size_in_mib}MB + + + +{loggers} + + + + + +"#, + max_log_file_size_in_mib = + cmp::max(1, max_size_in_mib / (1 + number_of_archived_log_files)), + root_log_level = config.root_log_level().to_logback_literal(), + console_log_level = config + .console + .as_ref() + .and_then(|console| console.level) + .unwrap_or_default() + .to_logback_literal(), + file_log_level = config + .file + .as_ref() + .and_then(|file| file.level) + .unwrap_or_default() + .to_logback_literal(), + ) +} + +/// Create the content of a Vector configuration file according to the given log configuration +/// +/// # Example +/// +/// ``` +/// use stackable_operator::{ +/// builder::{ +/// ConfigMapBuilder, +/// meta::ObjectMetaBuilder, +/// }, +/// product_logging, +/// product_logging::spec::{ +/// ContainerLogConfig, ContainerLogConfigChoice, Logging, +/// }, +/// }; +/// # use stackable_operator::{ +/// # config::fragment, +/// # k8s_openapi::api::core::v1::Pod, +/// # kube::runtime::reflector::ObjectRef, +/// # product_logging::spec::default_logging, +/// # role_utils::RoleGroupRef, +/// # }; +/// # use strum::{Display, EnumIter}; +/// # +/// # #[derive(Clone, Display, Eq, EnumIter, Ord, PartialEq, PartialOrd)] +/// # pub enum Container { +/// # Vector, +/// # } +/// # +/// # let logging = fragment::validate::>(default_logging()).unwrap(); +/// # let vector_aggregator_address = "vector-aggregator:6000"; +/// # let role_group = RoleGroupRef { +/// # cluster: ObjectRef::::new("test-cluster"), +/// # role: "role".into(), +/// # role_group: "role-group".into(), +/// # }; +/// +/// let mut cm_builder = ConfigMapBuilder::new(); +/// cm_builder.metadata(ObjectMetaBuilder::default().build()); +/// +/// let vector_log_config = if let Some(ContainerLogConfig { +/// choice: Some(ContainerLogConfigChoice::Automatic(log_config)), +/// }) = logging.containers.get(&Container::Vector) +/// { +/// Some(log_config) +/// } else { +/// None +/// }; +/// +/// if logging.enable_vector_agent { +/// cm_builder.add_data( +/// product_logging::framework::VECTOR_CONFIG_FILE, +/// product_logging::framework::create_vector_config( +/// &role_group, +/// vector_aggregator_address, +/// vector_log_config, +/// ), +/// ); +/// } +/// +/// cm_builder.build().unwrap(); +/// ``` +pub fn create_vector_config( + role_group: &RoleGroupRef, + vector_aggregator_address: &str, + config: Option<&AutomaticContainerLogConfig>, +) -> String +where + T: Resource, +{ + let vector_log_level = config + .and_then(|config| config.file.as_ref()) + .and_then(|file| file.level) + .unwrap_or_default(); + + let vector_log_level_filter_expression = match vector_log_level { + LogLevel::TRACE => "true", + LogLevel::DEBUG => r#".level != "TRACE""#, + LogLevel::INFO => r#"!includes(["TRACE", "DEBUG"], .metadata.level)"#, + LogLevel::WARN => r#"!includes(["TRACE", "DEBUG", "INFO"], .metadata.level)"#, + LogLevel::ERROR => r#"!includes(["TRACE", "DEBUG", "INFO", "WARN"], .metadata.level)"#, + LogLevel::FATAL => "false", + LogLevel::NONE => "false", + }; + + format!( + r#"data_dir = "/stackable/vector/var" + +[log_schema] +host_key = "pod" + +[sources.vector] +type = "internal_logs" + +[sources.files_stdout] +type = "file" +include = ["{STACKABLE_LOG_DIR}/*/*.stdout.log"] + +[sources.files_stderr] +type = "file" +include = ["{STACKABLE_LOG_DIR}/*/*.stderr.log"] + +[sources.files_log4j] +type = "file" +include = ["{STACKABLE_LOG_DIR}/*/*.log4j.xml"] + +[sources.files_log4j.multiline] +mode = "halt_with" +start_pattern = "^" + string!(.message) + "" +parsed_event = parse_xml!(wrapped_xml_event).root.event +.timestamp = to_timestamp!(to_float!(parsed_event.@timestamp) / 1000) +.logger = parsed_event.@logger +.level = parsed_event.@level +.message = join!( + filter([parsed_event.message, parsed_event.throwable]) -> |_index, value| {{ + !is_nullish(value) + }}, "\n") +''' + +[transforms.extended_logs_files] +inputs = ["processed_files_*"] +type = "remap" +source = ''' +. |= parse_regex!(.file, r'^{STACKABLE_LOG_DIR}/(?P.*?)/(?P.*?)$') +del(.source_type) +''' + +[transforms.filtered_logs_vector] +inputs = ["vector"] +type = "filter" +condition = '{vector_log_level_filter_expression}' + +[transforms.extended_logs_vector] +inputs = ["filtered_logs_vector"] +type = "remap" +source = ''' +.container = "vector" +.level = .metadata.level +.logger = .metadata.module_path +if exists(.file) {{ .processed_file = del(.file) }} +del(.metadata) +del(.pid) +del(.source_type) +''' + +[transforms.extended_logs] +inputs = ["extended_logs_*"] +type = "remap" +source = ''' +.namespace = "{namespace}" +.cluster = "{cluster_name}" +.role = "{role_name}" +.roleGroup = "{role_group_name}" +''' + +[sinks.aggregator] +inputs = ["extended_logs"] +type = "vector" +address = "{vector_aggregator_address}" +"#, + namespace = role_group.cluster.namespace.clone().unwrap_or_default(), + cluster_name = role_group.cluster.name, + role_name = role_group.role, + role_group_name = role_group.role_group + ) +} + +/// Create the specification of the Vector log agent container +/// +/// ``` +/// use stackable_operator::{ +/// builder::{ +/// meta::ObjectMetaBuilder, +/// PodBuilder, +/// }, +/// product_logging, +/// }; +/// # use stackable_operator::{ +/// # commons::product_image_selection::ResolvedProductImage, +/// # config::fragment, +/// # product_logging::spec::{default_logging, Logging}, +/// # }; +/// # use strum::{Display, EnumIter}; +/// # +/// # #[derive(Clone, Display, Eq, EnumIter, Ord, PartialEq, PartialOrd)] +/// # pub enum Container { +/// # Vector, +/// # } +/// # +/// # let logging = fragment::validate::>(default_logging()).unwrap(); +/// +/// # let resolved_product_image = ResolvedProductImage { +/// # product_version: "1.0.0".into(), +/// # app_version_label: "1.0.0".into(), +/// # image: "docker.stackable.tech/stackable/my-product:1.0.0-stackable1.0.0".into(), +/// # image_pull_policy: "Always".into(), +/// # pull_secrets: None, +/// # }; +/// +/// let mut pod_builder = PodBuilder::new(); +/// pod_builder.metadata(ObjectMetaBuilder::default().build()); +/// +/// if logging.enable_vector_agent { +/// pod_builder.add_container(product_logging::framework::vector_container( +/// &resolved_product_image, +/// "config", +/// "log", +/// logging.containers.get(&Container::Vector), +/// )); +/// } +/// +/// pod_builder.build().unwrap(); +/// ``` +pub fn vector_container( + image: &ResolvedProductImage, + config_volume_name: &str, + log_volume_name: &str, + log_config: Option<&ContainerLogConfig>, +) -> Container { + let log_level = if let Some(ContainerLogConfig { + choice: Some(ContainerLogConfigChoice::Automatic(automatic_log_config)), + }) = log_config + { + automatic_log_config.root_log_level() + } else { + LogLevel::INFO + }; + + ContainerBuilder::new("vector") + .unwrap() + .image_from_product_image(image) + .command(vec!["/stackable/vector/bin/vector".into()]) + .args(vec![ + "--config".into(), + format!("{STACKABLE_CONFIG_DIR}/{VECTOR_CONFIG_FILE}"), + ]) + .add_env_var("VECTOR_LOG", log_level.to_vector_literal()) + .add_volume_mount(config_volume_name, STACKABLE_CONFIG_DIR) + .add_volume_mount(log_volume_name, STACKABLE_LOG_DIR) + .build() +} diff --git a/src/product_logging/mod.rs b/src/product_logging/mod.rs new file mode 100644 index 000000000..12c9991f1 --- /dev/null +++ b/src/product_logging/mod.rs @@ -0,0 +1,4 @@ +//! Modules for product logging + +pub mod framework; +pub mod spec; diff --git a/src/product_logging/spec.rs b/src/product_logging/spec.rs new file mode 100644 index 000000000..652479b0b --- /dev/null +++ b/src/product_logging/spec.rs @@ -0,0 +1,778 @@ +//! Logging structure used within Custom Resource Definitions + +use std::collections::BTreeMap; +use std::fmt::Display; + +use crate::config::{ + fragment::{self, Fragment, FromFragment}, + merge::Atomic, + merge::Merge, +}; + +use derivative::Derivative; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Logging configuration +/// +/// The type parameter `T` should be an enum listing all containers: +/// +/// ``` +/// use serde::{Deserialize, Serialize}; +/// use stackable_operator::{ +/// product_logging, +/// schemars::JsonSchema, +/// }; +/// use strum::{Display, EnumIter}; +/// +/// #[derive( +/// Clone, +/// Debug, +/// Deserialize, +/// Display, +/// Eq, +/// EnumIter, +/// JsonSchema, +/// Ord, +/// PartialEq, +/// PartialOrd, +/// Serialize, +/// )] +/// #[serde(rename_all = "camelCase")] +/// pub enum Container { +/// Init, +/// Product, +/// Vector, +/// } +/// +/// let logging = product_logging::spec::default_logging::(); +/// ``` +#[derive(Clone, Debug, Derivative, Eq, Fragment, JsonSchema, PartialEq)] +#[derivative(Default(bound = ""))] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive( + Clone, + Debug, + Derivative, + Deserialize, + JsonSchema, + Merge, + PartialEq, + Serialize + ), + derivative(Default(bound = "")), + merge(path_overrides(merge = "crate::config::merge")), + serde( + bound(serialize = "T: Serialize", deserialize = "T: Deserialize<'de>",), + rename_all = "camelCase", + ) +)] +pub struct Logging +where + T: Clone + Display + Ord, +{ + /// Wether or not to deploy a container with the Vector log agent + pub enable_vector_agent: bool, + /// Log configuration per container + #[fragment_attrs(serde(default))] + pub containers: BTreeMap, +} + +/// Log configuration of the container +#[derive(Clone, Debug, Default, Eq, Fragment, JsonSchema, PartialEq)] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive( + Clone, + Debug, + Default, + Deserialize, + JsonSchema, + Merge, + PartialEq, + Serialize + ), + merge(path_overrides(merge = "crate::config::merge")), + serde(rename_all = "camelCase") +)] +pub struct ContainerLogConfig { + /// Custom or automatic log configuration + #[fragment_attrs(serde(flatten))] + pub choice: Option, +} + +/// Custom or automatic log configuration +/// +/// The custom log configuration takes precedence over the automatic one. +#[derive(Clone, Debug, Derivative, Eq, JsonSchema, PartialEq)] +#[derivative(Default)] +pub enum ContainerLogConfigChoice { + /// Custom log configuration provided in a ConfigMap + Custom(CustomContainerLogConfig), + /// Automatic log configuration according to the given values + #[derivative(Default)] + Automatic(AutomaticContainerLogConfig), +} + +/// Fragment derived from `ContainerLogConfigChoice` +#[derive(Clone, Debug, Derivative, Deserialize, JsonSchema, Merge, PartialEq, Serialize)] +#[derivative(Default)] +#[merge(path_overrides(merge = "crate::config::merge"))] +#[serde(untagged)] +pub enum ContainerLogConfigChoiceFragment { + /// Custom log configuration provided in a ConfigMap + Custom(CustomContainerLogConfigFragment), + #[derivative(Default)] + /// Automatic log configuration according to the given values + Automatic(AutomaticContainerLogConfigFragment), +} + +impl FromFragment for ContainerLogConfigChoice { + type Fragment = ContainerLogConfigChoiceFragment; + type RequiredFragment = ContainerLogConfigChoiceFragment; + + fn from_fragment( + fragment: Self::Fragment, + validator: fragment::Validator, + ) -> Result { + match fragment { + Self::Fragment::Custom(fragment) => Ok(Self::Custom(FromFragment::from_fragment( + fragment, validator, + )?)), + Self::Fragment::Automatic(fragment) => Ok(Self::Automatic( + FromFragment::from_fragment(fragment, validator)?, + )), + } + } +} + +/// Log configuration for a container provided in a ConfigMap +#[derive(Clone, Debug, Default, Eq, Fragment, JsonSchema, PartialEq)] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive( + Clone, + Debug, + Default, + Deserialize, + JsonSchema, + Merge, + PartialEq, + Serialize + ), + merge(path_overrides(merge = "crate::config::merge")), + serde(rename_all = "camelCase") +)] +pub struct CustomContainerLogConfig { + pub custom: ConfigMapLogConfig, +} + +/// Log configuration provided in a ConfigMap +#[derive(Clone, Debug, Default, Eq, Fragment, JsonSchema, PartialEq)] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive( + Clone, + Debug, + Default, + Deserialize, + JsonSchema, + Merge, + PartialEq, + Serialize + ), + merge(path_overrides(merge = "crate::config::merge")), + serde(rename_all = "camelCase") +)] +pub struct ConfigMapLogConfig { + /// ConfigMap containing the log configuration files + #[fragment_attrs(serde(default))] + pub config_map: String, +} + +/// Generic log configuration +#[derive(Clone, Debug, Default, Eq, Fragment, JsonSchema, PartialEq)] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize), + serde(rename_all = "camelCase") +)] +pub struct AutomaticContainerLogConfig { + /// Configuration per logger + #[fragment_attrs(serde(default))] + pub loggers: BTreeMap, + /// Configuration for the console appender + pub console: Option, + /// Configuration for the file appender + pub file: Option, +} + +impl Merge for AutomaticContainerLogConfigFragment { + fn merge(&mut self, defaults: &Self) { + self.loggers.merge(&defaults.loggers); + if let Some(console) = &mut self.console { + if let Some(defaults_console) = &defaults.console { + console.merge(defaults_console); + } + } else { + self.console = defaults.console.clone(); + } + if let Some(file) = &mut self.file { + if let Some(defaults_file) = &defaults.file { + file.merge(defaults_file); + } + } else { + self.file = defaults.file.clone(); + } + } +} + +impl AutomaticContainerLogConfig { + /// Name of the root logger + pub const ROOT_LOGGER: &'static str = "ROOT"; + + /// Return the log level of the root logger + pub fn root_log_level(&self) -> LogLevel { + self.loggers + .get(Self::ROOT_LOGGER) + .map(|root| root.level.to_owned()) + .unwrap_or_default() + } +} + +/// Configuration of a logger +#[derive(Clone, Debug, Default, Eq, Fragment, JsonSchema, PartialEq)] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive( + Clone, + Debug, + Default, + Deserialize, + JsonSchema, + PartialEq, + Merge, + Serialize + ), + merge(path_overrides(merge = "crate::config::merge")), + serde(rename_all = "camelCase") +)] +pub struct LoggerConfig { + /// The log level threshold + /// + /// Log events with a lower log level are discarded. + pub level: LogLevel, +} + +/// Configuration of a log appender +#[derive(Clone, Debug, Default, Eq, Fragment, JsonSchema, PartialEq)] +#[fragment(path_overrides(fragment = "crate::config::fragment"))] +#[fragment_attrs( + derive( + Clone, + Debug, + Default, + Deserialize, + JsonSchema, + Merge, + PartialEq, + Serialize + ), + merge(path_overrides(merge = "crate::config::merge")), + serde(rename_all = "camelCase") +)] +pub struct AppenderConfig { + /// The log level threshold + /// + /// Log events with a lower log level are discarded. + pub level: Option, +} + +/// Log levels +#[derive( + Clone, + Copy, + Debug, + Derivative, + Deserialize, + Eq, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, +)] +#[derivative(Default)] +pub enum LogLevel { + TRACE, + DEBUG, + #[derivative(Default)] + INFO, + WARN, + ERROR, + FATAL, + /// Turn logging off + NONE, +} + +impl Atomic for LogLevel {} + +impl LogLevel { + /// Convert the log level to a string understood by Vector + pub fn to_vector_literal(&self) -> String { + match self { + LogLevel::TRACE => "trace", + LogLevel::DEBUG => "debug", + LogLevel::INFO => "info", + LogLevel::WARN => "warn", + LogLevel::ERROR => "error", + LogLevel::FATAL => "error", + LogLevel::NONE => "off", + } + .into() + } + + /// Convert the log level to a string understood by logback + pub fn to_logback_literal(&self) -> String { + match self { + LogLevel::TRACE => "TRACE", + LogLevel::DEBUG => "DEBUG", + LogLevel::INFO => "INFO", + LogLevel::WARN => "WARN", + LogLevel::ERROR => "ERROR", + LogLevel::FATAL => "ERROR", + LogLevel::NONE => "OFF", + } + .into() + } + + /// Convert the log level to a string understood by log4j + pub fn to_log4j_literal(&self) -> String { + match self { + LogLevel::TRACE => "TRACE", + LogLevel::DEBUG => "DEBUG", + LogLevel::INFO => "INFO", + LogLevel::WARN => "WARN", + LogLevel::ERROR => "ERROR", + LogLevel::FATAL => "FATAL", + LogLevel::NONE => "OFF", + } + .into() + } +} + +/// Create the default logging configuration +pub fn default_logging() -> LoggingFragment +where + T: Clone + Display + Ord + strum::IntoEnumIterator, +{ + LoggingFragment { + enable_vector_agent: Some(true), + containers: T::iter() + .map(|container| (container, default_container_log_config())) + .collect(), + } +} + +/// Create the default logging configuration for a container +pub fn default_container_log_config() -> ContainerLogConfigFragment { + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: [( + AutomaticContainerLogConfig::ROOT_LOGGER.into(), + LoggerConfigFragment { + level: Some(LogLevel::INFO), + }, + )] + .into(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + }, + )), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::config::{fragment, merge}; + + use super::{ + AppenderConfig, AppenderConfigFragment, AutomaticContainerLogConfig, + AutomaticContainerLogConfigFragment, ConfigMapLogConfig, ConfigMapLogConfigFragment, + ContainerLogConfig, ContainerLogConfigChoice, ContainerLogConfigChoiceFragment, + ContainerLogConfigFragment, CustomContainerLogConfig, CustomContainerLogConfigFragment, + LogLevel, + }; + + #[test] + fn serialize_container_log_config() { + // automatic configuration + assert_eq!( + "{\"loggers\":{},\"console\":{\"level\":\"INFO\"},\"file\":{\"level\":\"WARN\"}}" + .to_string(), + serde_json::to_string(&ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }) + .unwrap() + ); + + // custom configuration + assert_eq!( + "{\"custom\":{\"configMap\":\"configMap\"}}".to_string(), + serde_json::to_string(&ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Custom( + CustomContainerLogConfigFragment { + custom: ConfigMapLogConfigFragment { + config_map: Some("configMap".into()) + } + }, + )), + }) + .unwrap() + ); + } + + #[test] + fn deserialize_container_log_config() { + // automatic configuration if only automatic configuration is given + assert_eq!( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }, + serde_json::from_str::( + "{\"loggers\":{},\"console\":{\"level\":\"INFO\"},\"file\":{\"level\":\"WARN\"}}" + ) + .unwrap() + ); + + // custom configuration if only custom configuration is given + assert_eq!( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Custom( + CustomContainerLogConfigFragment { + custom: ConfigMapLogConfigFragment { + config_map: Some("configMap".into()) + } + } + )), + }, + serde_json::from_str::( + "{\"custom\":{\"configMap\":\"configMap\"}}" + ) + .unwrap() + ); + + // automatic configuration if no configuration is given + assert_eq!( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: None, + file: None, + }, + )), + }, + serde_json::from_str::("{}").unwrap() + ); + + // custom configuration if custom and automatic configurations are given + assert_eq!( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Custom( + CustomContainerLogConfigFragment { + custom: ConfigMapLogConfigFragment { + config_map: Some("configMap".into()) + } + } + )), + }, + serde_json::from_str::( + "{\"custom\":{\"configMap\":\"configMap\"},\"loggers\":{},\"console\":{\"level\":\"INFO\"},\"file\":{\"level\":\"WARN\"}}" + ) + .unwrap() + ); + } + + #[test] + fn merge_automatic_container_log_config_fragment() { + // no overriding log level + no default log level -> no log level + assert_eq!( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: None, + file: None, + }, + merge::merge( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: None, + file: None, + }, + &AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: None, + file: None, + } + ) + ); + + // overriding log level + no default log level -> overriding log level + assert_eq!( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + merge::merge( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + &AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: None, + file: None, + } + ) + ); + + // no overriding log level + default log level -> default log level + assert_eq!( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + merge::merge( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: None, + file: None, + }, + &AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + } + ) + ); + + // overriding log level + default log level -> overriding log level + assert_eq!( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::ERROR), + }), + }, + merge::merge( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { level: None }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::ERROR), + }), + }, + &AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + } + ) + ); + } + + #[test] + fn merge_container_log_config() { + // overriding automatic config + default custom config -> overriding automatic config + assert_eq!( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }, + merge::merge( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }, + &ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Custom( + CustomContainerLogConfigFragment { + custom: ConfigMapLogConfigFragment { + config_map: Some("configMap".into()) + } + }, + )), + } + ) + ); + + // overriding automatic config + default automatic config -> merged automatic config + assert_eq!( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }, + merge::merge( + ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { level: None }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }, + &ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { level: None }), + }, + )), + } + ) + ); + } + + #[test] + fn validate_automatic_container_log_config() { + assert_eq!( + ContainerLogConfig { + choice: Some(ContainerLogConfigChoice::Automatic( + AutomaticContainerLogConfig { + loggers: BTreeMap::new(), + console: Some(AppenderConfig { + level: Some(LogLevel::INFO) + }), + file: Some(AppenderConfig { + level: Some(LogLevel::WARN) + }), + } + )) + }, + fragment::validate::(ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Automatic( + AutomaticContainerLogConfigFragment { + loggers: BTreeMap::new(), + console: Some(AppenderConfigFragment { + level: Some(LogLevel::INFO), + }), + file: Some(AppenderConfigFragment { + level: Some(LogLevel::WARN), + }), + }, + )), + }) + .unwrap() + ); + } + + #[test] + fn validate_custom_container_log_config() { + assert_eq!( + ContainerLogConfig { + choice: Some(ContainerLogConfigChoice::Custom(CustomContainerLogConfig { + custom: ConfigMapLogConfig { + config_map: "configMap".into() + } + })) + }, + fragment::validate::(ContainerLogConfigFragment { + choice: Some(ContainerLogConfigChoiceFragment::Custom( + CustomContainerLogConfigFragment { + custom: ConfigMapLogConfigFragment { + config_map: Some("configMap".into()) + } + }, + )), + }) + .unwrap() + ); + } +}