use std::collections::HashMap;
use std::time::Duration;
use serde::Deserialize;
use crate::score::alumet::config::DEFAULT_ENERGY_INTERVAL_SECS;
use crate::score::alumet::{AlumetBrokerConfig, AlumetConfig, AlumetDatabaseConfig};
use crate::score::cloud_energy::config::{CloudEnergyConfig, ServiceCloudConfig};
use crate::score::kepler::{KeplerConfig, KeplerMetricKind};
use crate::score::redfish::{RedfishConfig, RedfishEndpoint};
use crate::score::scaphandre::{ProcessMatcher, ScaphandreConfig};
use crate::score::carbon::DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2;
use super::validate::has_control_char;
use super::{
Config, DEFAULT_FULCIO_URL, DEFAULT_REKOR_URL, DaemonAckConfig, DaemonArchiveConfig,
DaemonConfig, DaemonCorsConfig, DaemonEnvironment, DaemonTlsConfig, DetectionConfig,
GreenConfig, ReportingConfig, SigstoreConfig, ThresholdsConfig,
};
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct RawConfig {
thresholds: ThresholdsSection,
detection: DetectionSection,
pub(super) green: GreenSection,
pub(super) daemon: DaemonSection,
reporting: ReportingSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ReportingSection {
intent: Option<String>,
confidentiality_level: Option<String>,
org_config_path: Option<String>,
disclose_output_path: Option<String>,
disclose_period: Option<String>,
sigstore: SigstoreSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct SigstoreSection {
rekor_url: Option<String>,
fulcio_url: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct ArchiveSection {
path: Option<String>,
max_size_mb: Option<u64>,
max_files: Option<u32>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
#[allow(clippy::struct_field_names)] struct ThresholdsSection {
n_plus_one_sql_critical_max: Option<u32>,
n_plus_one_http_warning_max: Option<u32>,
n_plus_one_messaging_warning_max: Option<u32>,
io_waste_ratio_max: Option<f64>,
min_usable_span_ratio: Option<f64>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DetectionSection {
window_duration_ms: Option<u64>,
n_plus_one_min_occurrences: Option<u32>,
slow_query_threshold_ms: Option<u64>,
slow_query_min_occurrences: Option<u32>,
max_fanout: Option<u32>,
chatty_service_min_calls: Option<u32>,
pool_saturation_concurrent_threshold: Option<u32>,
serialized_min_sequential: Option<u32>,
sanitizer_aware_classification: Option<String>,
grouping_attributes: Option<Vec<String>>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct GreenSection {
enabled: Option<bool>,
default_region: Option<String>,
service_regions: HashMap<String, String>,
embodied_carbon_per_request_gco2: Option<f64>,
use_hourly_profiles: Option<bool>,
scaphandre: ScaphandreSection,
pub(super) kepler: KeplerSection,
pub(super) alumet: AlumetSection,
redfish: RedfishSection,
cloud: CloudSection,
pub(super) broker_static: BrokerStaticSection,
per_operation_coefficients: Option<bool>,
include_network_transport: Option<bool>,
network_energy_per_byte_kwh: Option<f64>,
hourly_profiles_file: Option<String>,
calibration_file: Option<String>,
electricity_maps: ElectricityMapsSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct ScaphandreSection {
pub(super) endpoint: Option<String>,
pub(super) scrape_interval_secs: Option<u64>,
pub(super) process_map: HashMap<String, ProcessMatcher>,
pub(super) auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct KeplerSection {
pub(super) endpoint: Option<String>,
pub(super) scrape_interval_secs: Option<u64>,
pub(super) metric_kind: Option<String>,
pub(super) service_mappings: HashMap<String, String>,
pub(super) auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct AlumetSection {
pub(super) endpoint: Option<String>,
pub(super) scrape_interval_secs: Option<u64>,
pub(super) metric_name: Option<String>,
pub(super) label_key: Option<String>,
pub(super) energy_interval_secs: Option<f64>,
pub(super) service_mappings: HashMap<String, String>,
pub(super) auth_header: Option<String>,
pub(super) database: Option<AlumetDatabaseSection>,
pub(super) broker: Option<AlumetBrokerSection>,
}
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct AlumetBrokerSection {
pub(super) label_value: Option<String>,
pub(super) region: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct AlumetDatabaseSection {
pub(super) label_value: Option<String>,
pub(super) region: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct RedfishSection {
pub(super) endpoints: HashMap<String, RedfishEndpoint>,
pub(super) scrape_interval_secs: Option<u64>,
pub(super) service_mappings: HashMap<String, String>,
pub(super) ca_bundle_path: Option<String>,
pub(super) auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct CloudSection {
pub(super) prometheus_endpoint: Option<String>,
pub(super) scrape_interval_secs: Option<u64>,
pub(super) default_provider: Option<String>,
pub(super) default_instance_type: Option<String>,
pub(super) cpu_metric: Option<String>,
pub(super) services: HashMap<String, CloudServiceRaw>,
pub(super) auth_header: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct CloudServiceRaw {
provider: Option<String>,
instance_type: Option<String>,
idle_watts: Option<f64>,
max_watts: Option<f64>,
cpu_query: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct ElectricityMapsSection {
pub(super) api_key: Option<String>,
pub(super) endpoint: Option<String>,
pub(super) poll_interval_secs: Option<u64>,
pub(super) region_map: HashMap<String, String>,
pub(super) emission_factor_type: Option<String>,
pub(super) temporal_granularity: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub(super) struct DaemonSection {
listen_address: Option<String>,
listen_port_http: Option<u16>,
listen_port_grpc: Option<u16>,
json_socket: Option<String>,
max_active_traces: Option<usize>,
trace_ttl_ms: Option<u64>,
sampling_rate: Option<f64>,
max_events_per_trace: Option<usize>,
max_payload_size: Option<usize>,
pub(super) environment: Option<String>,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
max_retained_findings: Option<usize>,
max_retained_traces: Option<usize>,
ingest_queue_capacity: Option<usize>,
analysis_queue_capacity: Option<usize>,
memory_high_water_pct: Option<u8>,
api_enabled: Option<bool>,
correlation: CorrelationSection,
ack: DaemonAckSection,
cors: DaemonCorsSection,
archive: ArchiveSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct CorrelationSection {
enabled: Option<bool>,
window_minutes: Option<u64>,
lag_threshold_ms: Option<u64>,
min_co_occurrences: Option<u32>,
min_confidence: Option<f64>,
max_tracked_pairs: Option<usize>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DaemonAckSection {
enabled: Option<bool>,
storage_path: Option<String>,
api_key: Option<String>,
toml_path: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct DaemonCorsSection {
allowed_origins: Vec<String>,
}
#[allow(deprecated)] impl From<RawConfig> for Config {
#[allow(clippy::too_many_lines)] fn from(raw: RawConfig) -> Self {
let thresholds_defaults = ThresholdsConfig::default();
let detection_defaults = DetectionConfig::default();
let green_defaults = GreenConfig::default();
let daemon_defaults = DaemonConfig::default();
let correlation_defaults = crate::detect::correlate_cross::CorrelationConfig::default();
let ack_defaults = DaemonAckConfig::default();
Self {
thresholds: ThresholdsConfig {
n_plus_one_sql_critical_max: raw
.thresholds
.n_plus_one_sql_critical_max
.unwrap_or(thresholds_defaults.n_plus_one_sql_critical_max),
n_plus_one_http_warning_max: raw
.thresholds
.n_plus_one_http_warning_max
.unwrap_or(thresholds_defaults.n_plus_one_http_warning_max),
n_plus_one_messaging_warning_max: raw
.thresholds
.n_plus_one_messaging_warning_max
.unwrap_or(thresholds_defaults.n_plus_one_messaging_warning_max),
io_waste_ratio_max: raw
.thresholds
.io_waste_ratio_max
.unwrap_or(thresholds_defaults.io_waste_ratio_max),
min_usable_span_ratio: raw.thresholds.min_usable_span_ratio,
},
detection: DetectionConfig {
n_plus_one_threshold: raw
.detection
.n_plus_one_min_occurrences
.unwrap_or(detection_defaults.n_plus_one_threshold),
window_duration_ms: raw
.detection
.window_duration_ms
.unwrap_or(detection_defaults.window_duration_ms),
slow_query_threshold_ms: raw
.detection
.slow_query_threshold_ms
.unwrap_or(detection_defaults.slow_query_threshold_ms),
slow_query_min_occurrences: raw
.detection
.slow_query_min_occurrences
.unwrap_or(detection_defaults.slow_query_min_occurrences),
max_fanout: raw
.detection
.max_fanout
.unwrap_or(detection_defaults.max_fanout),
chatty_service_min_calls: raw
.detection
.chatty_service_min_calls
.unwrap_or(detection_defaults.chatty_service_min_calls),
pool_saturation_concurrent_threshold: raw
.detection
.pool_saturation_concurrent_threshold
.unwrap_or(detection_defaults.pool_saturation_concurrent_threshold),
serialized_min_sequential: raw
.detection
.serialized_min_sequential
.unwrap_or(detection_defaults.serialized_min_sequential),
sanitizer_aware_classification:
crate::detect::sanitizer_aware::SanitizerAwareMode::from_config(
raw.detection.sanitizer_aware_classification.as_deref(),
),
grouping_attributes: raw.detection.grouping_attributes.map_or_else(
|| detection_defaults.grouping_attributes.clone(),
|attrs| {
let kept: Vec<String> = attrs
.into_iter()
.filter(|a| !a.trim().is_empty())
.collect();
if kept.len() > super::MAX_GROUPING_ATTRIBUTES {
tracing::warn!(
cap = super::MAX_GROUPING_ATTRIBUTES,
configured = kept.len(),
dropped = ?kept[super::MAX_GROUPING_ATTRIBUTES..],
"[detection] grouping_attributes exceeds the cap, extra entries ignored"
);
}
kept.into_iter()
.take(super::MAX_GROUPING_ATTRIBUTES)
.collect()
},
),
},
green: {
if raw.green.network_energy_per_byte_kwh.is_some() {
tracing::warn!(
"[green] network_energy_per_byte_kwh is deprecated and ignored \
since 0.9.25, the transport coefficient is fixed"
);
}
if raw.green.include_network_transport.is_some() {
tracing::warn!(
"[green] include_network_transport is deprecated and ignored \
since 0.9.25, the transport term is always counted and displayed"
);
}
if raw.green.embodied_carbon_per_request_gco2 == Some(0.0) {
tracing::warn!(
"[green] embodied_carbon_per_request_gco2 = 0.0 is no longer honoured \
since 0.9.25, no hardware has zero embodied carbon: using the default \
{DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2}"
);
}
GreenConfig {
include_network_transport: true,
network_energy_per_byte_kwh:
crate::score::carbon::DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH,
enabled: raw.green.enabled.unwrap_or(green_defaults.enabled),
default_region: raw.green.default_region.map(|s| s.to_ascii_lowercase()),
service_regions: raw
.green
.service_regions
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect(),
embodied_carbon_per_request_gco2: raw
.green
.embodied_carbon_per_request_gco2
.filter(|v| *v != 0.0)
.unwrap_or(green_defaults.embodied_carbon_per_request_gco2),
use_hourly_profiles: raw
.green
.use_hourly_profiles
.unwrap_or(green_defaults.use_hourly_profiles),
scaphandre: convert_scaphandre_section(&raw.green.scaphandre),
kepler: convert_kepler_section(&raw.green.kepler),
alumet: convert_alumet_section(&raw.green.alumet),
redfish: convert_redfish_section(&raw.green.redfish),
cloud_energy: convert_cloud_section(&raw.green.cloud),
broker_static: convert_broker_static_section(&raw.green.broker_static),
per_operation_coefficients: raw
.green
.per_operation_coefficients
.unwrap_or(green_defaults.per_operation_coefficients),
hourly_profiles_file: raw.green.hourly_profiles_file.clone(),
custom_hourly_profiles: raw.green.hourly_profiles_file.as_ref().and_then(|path| {
if has_control_char(path) {
tracing::warn!(
"hourly_profiles_file path contains control characters, skipping"
);
return None;
}
let p = std::path::Path::new(path);
match crate::score::carbon::load_custom_profiles(p) {
Ok(profiles) => Some(std::sync::Arc::new(profiles)),
Err(e) => {
tracing::debug!(
error = %e,
"Custom hourly profiles failed to load"
);
None
}
}
}),
calibration_file: raw.green.calibration_file.clone(),
calibration: raw.green.calibration_file.as_ref().and_then(|path| {
if has_control_char(path) {
tracing::warn!(
"calibration_file path contains control characters, skipping"
);
return None;
}
match crate::calibrate::load_calibration_file(path) {
Ok(data) => Some(data),
Err(e) => {
tracing::debug!(
error = %e,
"Calibration file failed to load"
);
None
}
}
}),
electricity_maps: convert_electricity_maps_section(&raw.green.electricity_maps),
}
},
daemon: DaemonConfig {
listen_addr: raw
.daemon
.listen_address
.unwrap_or(daemon_defaults.listen_addr),
listen_port: raw
.daemon
.listen_port_http
.unwrap_or(daemon_defaults.listen_port),
listen_port_grpc: raw
.daemon
.listen_port_grpc
.unwrap_or(daemon_defaults.listen_port_grpc),
json_socket: raw
.daemon
.json_socket
.unwrap_or(daemon_defaults.json_socket),
max_active_traces: raw
.daemon
.max_active_traces
.unwrap_or(daemon_defaults.max_active_traces),
trace_ttl_ms: raw
.daemon
.trace_ttl_ms
.unwrap_or(daemon_defaults.trace_ttl_ms),
sampling_rate: raw
.daemon
.sampling_rate
.unwrap_or(daemon_defaults.sampling_rate),
max_events_per_trace: raw
.daemon
.max_events_per_trace
.unwrap_or(daemon_defaults.max_events_per_trace),
max_payload_size: raw
.daemon
.max_payload_size
.unwrap_or(daemon_defaults.max_payload_size),
environment: match raw.daemon.environment.as_deref() {
None => daemon_defaults.environment,
Some(s) => parse_daemon_environment(s).unwrap_or(DaemonEnvironment::Staging),
},
max_retained_findings: raw
.daemon
.max_retained_findings
.unwrap_or(daemon_defaults.max_retained_findings),
max_retained_traces: raw
.daemon
.max_retained_traces
.unwrap_or(daemon_defaults.max_retained_traces),
ingest_queue_capacity: raw
.daemon
.ingest_queue_capacity
.unwrap_or(daemon_defaults.ingest_queue_capacity),
analysis_queue_capacity: raw
.daemon
.analysis_queue_capacity
.unwrap_or(daemon_defaults.analysis_queue_capacity),
memory_high_water_pct: raw
.daemon
.memory_high_water_pct
.unwrap_or(daemon_defaults.memory_high_water_pct),
api_enabled: raw
.daemon
.api_enabled
.unwrap_or(daemon_defaults.api_enabled),
tls: DaemonTlsConfig {
cert_path: raw.daemon.tls_cert_path,
key_path: raw.daemon.tls_key_path,
},
ack: DaemonAckConfig {
enabled: raw.daemon.ack.enabled.unwrap_or(ack_defaults.enabled),
storage_path: raw.daemon.ack.storage_path,
api_key: resolve_ack_api_key(raw.daemon.ack.api_key, || {
std::env::var("PERF_SENTINEL_ACK_API_KEY").ok()
}),
toml_path: raw.daemon.ack.toml_path,
},
cors: DaemonCorsConfig {
allowed_origins: raw.daemon.cors.allowed_origins,
},
correlation: {
let c = &raw.daemon.correlation;
crate::detect::correlate_cross::CorrelationConfig {
enabled: c.enabled.unwrap_or(correlation_defaults.enabled),
window_ms: c
.window_minutes
.map_or(correlation_defaults.window_ms, |m| m.saturating_mul(60_000)),
lag_threshold_ms: c
.lag_threshold_ms
.unwrap_or(correlation_defaults.lag_threshold_ms),
min_co_occurrences: c
.min_co_occurrences
.unwrap_or(correlation_defaults.min_co_occurrences),
min_confidence: c
.min_confidence
.unwrap_or(correlation_defaults.min_confidence),
max_tracked_pairs: c
.max_tracked_pairs
.unwrap_or(correlation_defaults.max_tracked_pairs),
}
},
archive: convert_archive_section(&raw.daemon.archive),
},
reporting: ReportingConfig {
intent: raw.reporting.intent,
confidentiality_level: raw.reporting.confidentiality_level,
org_config_path: raw.reporting.org_config_path,
disclose_output_path: raw.reporting.disclose_output_path,
disclose_period: raw.reporting.disclose_period,
sigstore: SigstoreConfig {
rekor_url: raw
.reporting
.sigstore
.rekor_url
.unwrap_or_else(|| DEFAULT_REKOR_URL.to_string()),
fulcio_url: raw
.reporting
.sigstore
.fulcio_url
.unwrap_or_else(|| DEFAULT_FULCIO_URL.to_string()),
},
},
}
}
}
fn convert_archive_section(raw: &ArchiveSection) -> Option<DaemonArchiveConfig> {
let path = raw.path.clone()?;
let defaults = DaemonArchiveConfig::default();
Some(DaemonArchiveConfig {
path,
max_size_mb: raw.max_size_mb.unwrap_or(defaults.max_size_mb),
max_files: raw.max_files.unwrap_or(defaults.max_files),
})
}
pub(super) fn parse_daemon_environment(value: &str) -> Option<DaemonEnvironment> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("staging") {
Some(DaemonEnvironment::Staging)
} else if trimmed.eq_ignore_ascii_case("production") {
Some(DaemonEnvironment::Production)
} else {
None
}
}
fn convert_cloud_section(raw: &CloudSection) -> Option<CloudEnergyConfig> {
convert_cloud_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_CLOUD_AUTH_HEADER").ok()
})
}
pub(super) fn convert_cloud_section_with_env(
raw: &CloudSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<CloudEnergyConfig> {
let endpoint = raw.prometheus_endpoint.as_ref()?;
let mut services = HashMap::with_capacity(raw.services.len());
for (name, svc) in &raw.services {
let config = if svc.idle_watts.is_some() || svc.max_watts.is_some() {
ServiceCloudConfig::ManualWatts {
idle_watts: svc.idle_watts.unwrap_or(0.0),
max_watts: svc.max_watts.unwrap_or(0.0),
cpu_query: svc.cpu_query.clone(),
}
} else {
ServiceCloudConfig::InstanceType {
provider: svc.provider.clone(),
instance_type: svc.instance_type.clone().unwrap_or_default(),
cpu_query: svc.cpu_query.clone(),
}
};
services.insert(name.clone(), config);
}
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.cloud] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_CLOUD_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(CloudEnergyConfig {
prometheus_endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(15)),
default_provider: raw.default_provider.clone(),
default_instance_type: raw.default_instance_type.clone(),
cpu_metric: raw.cpu_metric.clone(),
services,
auth_header,
})
}
fn convert_scaphandre_section(raw: &ScaphandreSection) -> Option<ScaphandreConfig> {
convert_scaphandre_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_SCAPHANDRE_AUTH_HEADER").ok()
})
}
pub(super) fn convert_scaphandre_section_with_env(
raw: &ScaphandreSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<ScaphandreConfig> {
let endpoint = raw.endpoint.as_ref()?;
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.scaphandre] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_SCAPHANDRE_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(ScaphandreConfig {
endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(5)),
process_map: raw.process_map.clone(),
auth_header,
})
}
pub(super) fn parse_kepler_metric_kind(raw: Option<&str>) -> Result<KeplerMetricKind, String> {
let Some(literal) = raw else {
return Ok(KeplerMetricKind::Container);
};
if has_control_char(literal) {
return Err("[green.kepler] metric_kind contains control characters".to_string());
}
let trimmed = literal.trim();
if trimmed.is_empty() {
return Err(format!(
"[green.kepler] metric_kind '{literal}' is empty; \
remove the field for the default or set it to 'container' or 'process'"
));
}
if trimmed.eq_ignore_ascii_case("container") {
return Ok(KeplerMetricKind::Container);
}
if trimmed.eq_ignore_ascii_case("process") {
return Ok(KeplerMetricKind::Process);
}
if trimmed.eq_ignore_ascii_case("process_package")
|| trimmed.eq_ignore_ascii_case("process_dram")
{
return Err(format!(
"[green.kepler] metric_kind '{literal}' was removed in v0.7.5. \
Kepler v2 only exposes per-process CPU joules, use 'process' instead."
));
}
Err(format!(
"[green.kepler] metric_kind '{literal}' is not recognized \
(expected 'container' or 'process')"
))
}
fn convert_kepler_section(raw: &KeplerSection) -> Option<KeplerConfig> {
convert_kepler_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_KEPLER_AUTH_HEADER").ok()
})
}
pub(super) fn convert_kepler_section_with_env(
raw: &KeplerSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<KeplerConfig> {
let endpoint = raw.endpoint.as_ref()?;
let metric_kind = match parse_kepler_metric_kind(raw.metric_kind.as_deref()) {
Ok(k) => k,
Err(msg) => {
tracing::error!("{msg}");
return None;
}
};
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.kepler] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_KEPLER_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(KeplerConfig {
endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(5)),
metric_kind,
service_mappings: raw.service_mappings.clone(),
auth_header,
})
}
#[derive(Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub(super) struct BrokerStaticSection {
pub(super) nodes: Option<u32>,
pub(super) instance_type: Option<String>,
pub(super) provider: Option<String>,
pub(super) region: Option<String>,
}
fn convert_broker_static_section(
raw: &BrokerStaticSection,
) -> Option<crate::score::broker_static::StaticBrokerConfig> {
let nodes = raw.nodes?;
let instance_type = raw.instance_type.as_ref()?.trim().to_string();
Some(crate::score::broker_static::StaticBrokerConfig {
nodes,
instance_type,
provider: raw
.provider
.as_deref()
.map(|p| p.trim().to_ascii_lowercase())
.filter(|p| !p.is_empty())
.unwrap_or_else(|| "generic".to_string()),
region: raw.region.clone(),
})
}
pub(super) fn validate_broker_static_raw(raw: &BrokerStaticSection) -> Result<(), String> {
match (raw.nodes.is_some(), raw.instance_type.is_some()) {
(true, false) => Err(
"[green.broker_static] instance_type is required when nodes is set; \
without it the section is silently inert and the messaging waste \
figure never appears"
.to_string(),
),
(false, true) => Err(
"[green.broker_static] nodes is required when instance_type is set; \
without it the section is silently inert and the messaging waste \
figure never appears"
.to_string(),
),
_ => Ok(()),
}
}
pub(super) fn validate_alumet_raw(raw: &AlumetSection) -> Result<(), String> {
if raw.endpoint.is_none() {
if raw.database.is_some() {
return Err(
"[green.alumet.database] is set but [green.alumet] endpoint is missing; \
without an endpoint no scraper starts and the figure never appears"
.to_string(),
);
}
if raw.broker.is_some() {
return Err(
"[green.alumet.broker] is set but [green.alumet] endpoint is missing; \
without an endpoint no scraper starts and the figure never appears"
.to_string(),
);
}
return Ok(());
}
require_alumet_field(raw.metric_name.as_deref(), "metric_name")?;
require_alumet_field(raw.label_key.as_deref(), "label_key")?;
if let Some(secs) = raw.energy_interval_secs
&& (!secs.is_finite() || secs <= 0.0 || secs > 3600.0)
{
return Err(format!(
"[green.alumet] energy_interval_secs must be a finite value in (0, 3600], got {secs}. \
It mirrors the poll_interval of the Alumet source feeding the metric."
));
}
if let Some(db) = raw.database.as_ref() {
let Some(label_value) = db.label_value.as_deref() else {
return Err(
"[green.alumet.database] label_value is required when the section is present"
.to_string(),
);
};
super::validate::validate_workload_fields(
"[green.alumet.database]",
label_value,
db.region.as_deref(),
)?;
}
if let Some(broker) = raw.broker.as_ref() {
let Some(label_value) = broker.label_value.as_deref() else {
return Err(
"[green.alumet.broker] label_value is required when the section is present"
.to_string(),
);
};
super::validate::validate_workload_fields(
"[green.alumet.broker]",
label_value,
broker.region.as_deref(),
)?;
}
Ok(())
}
fn require_alumet_field(value: Option<&str>, field: &str) -> Result<(), String> {
let Some(literal) = value else {
return Err(format!(
"[green.alumet] {field} is required when endpoint is set. \
Alumet's prometheus-exporter names metrics with an operator-chosen \
prefix/suffix (default suffix '_alumet'), so there is no safe default. \
Run `curl <endpoint> | grep -i energy` and copy the name verbatim."
));
};
if has_control_char(literal) {
return Err(format!(
"[green.alumet] {field} contains control characters"
));
}
if literal.trim().is_empty() {
return Err(format!("[green.alumet] {field} must not be empty"));
}
Ok(())
}
fn convert_alumet_section(raw: &AlumetSection) -> Option<AlumetConfig> {
convert_alumet_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_ALUMET_AUTH_HEADER").ok()
})
}
pub(super) fn convert_alumet_section_with_env(
raw: &AlumetSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<AlumetConfig> {
let endpoint = raw.endpoint.as_ref()?;
if let Err(msg) = validate_alumet_raw(raw) {
tracing::error!("{msg}");
return None;
}
let metric_name = raw.metric_name.as_ref()?.trim().to_string();
let label_key = raw.label_key.as_ref()?.trim().to_string();
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.alumet] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_ALUMET_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(AlumetConfig {
endpoint: endpoint.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(5)),
metric_name,
label_key,
energy_interval_secs: raw
.energy_interval_secs
.unwrap_or(DEFAULT_ENERGY_INTERVAL_SECS),
service_mappings: raw.service_mappings.clone(),
auth_header,
database: raw.database.as_ref().and_then(|db| {
db.label_value.as_ref().map(|lv| AlumetDatabaseConfig {
label_value: lv.clone(),
region: db.region.clone(),
})
}),
broker: raw.broker.as_ref().and_then(|b| {
b.label_value.as_ref().map(|lv| AlumetBrokerConfig {
label_value: lv.clone(),
region: b.region.clone(),
})
}),
})
}
fn convert_redfish_section(raw: &RedfishSection) -> Option<RedfishConfig> {
convert_redfish_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_REDFISH_AUTH_HEADER").ok()
})
}
pub(super) fn convert_redfish_section_with_env(
raw: &RedfishSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<RedfishConfig> {
if raw.endpoints.is_empty() {
return None;
}
let from_env = env_lookup();
let auth_header = from_env.clone().or_else(|| raw.auth_header.clone());
if from_env.is_none() && raw.auth_header.is_some() {
tracing::warn!(
"[green.redfish] auth_header is set in the config file. \
Prefer the PERF_SENTINEL_REDFISH_AUTH_HEADER environment variable \
to avoid committing secrets to version control."
);
}
Some(RedfishConfig {
endpoints: raw.endpoints.clone(),
scrape_interval: Duration::from_secs(raw.scrape_interval_secs.unwrap_or(60)),
service_mappings: raw.service_mappings.clone(),
ca_bundle_path: raw.ca_bundle_path.clone(),
auth_header,
})
}
pub(super) fn convert_electricity_maps_section(
raw: &ElectricityMapsSection,
) -> Option<crate::score::electricity_maps::ElectricityMapsConfig> {
convert_electricity_maps_section_with_env(raw, || {
std::env::var("PERF_SENTINEL_EMAPS_TOKEN").ok()
})
}
pub(super) fn convert_electricity_maps_section_with_env(
raw: &ElectricityMapsSection,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<crate::score::electricity_maps::ElectricityMapsConfig> {
let from_env = env_lookup();
let token = from_env.clone().or_else(|| raw.api_key.clone())?;
if token.is_empty() {
return None;
}
if from_env.is_none() && raw.api_key.is_some() {
tracing::warn!(
"[green.electricity_maps] api_key is set in the config file. \
Prefer the PERF_SENTINEL_EMAPS_TOKEN environment variable \
to avoid committing secrets to version control."
);
}
let poll_secs = raw.poll_interval_secs.unwrap_or(300);
let api_endpoint = raw
.endpoint
.clone()
.unwrap_or_else(|| {
crate::score::electricity_maps::config::DEFAULT_ELECTRICITY_MAPS_ENDPOINT.to_string()
})
.trim_end_matches('/')
.to_string();
let emission_factor_type =
crate::score::electricity_maps::config::EmissionFactorType::from_config(
raw.emission_factor_type.as_deref(),
);
let temporal_granularity =
crate::score::electricity_maps::config::TemporalGranularity::from_config(
raw.temporal_granularity.as_deref(),
);
Some(crate::score::electricity_maps::ElectricityMapsConfig {
api_endpoint,
auth_token: token,
poll_interval: Duration::from_secs(poll_secs),
region_map: raw
.region_map
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
.collect(),
emission_factor_type,
temporal_granularity,
})
}
pub(super) fn resolve_ack_api_key(
config_value: Option<String>,
env_lookup: impl FnOnce() -> Option<String>,
) -> Option<String> {
env_lookup().or(config_value).map(|s| s.trim().to_string())
}