diff --git a/crates/common/tedge_config/src/lib.rs b/crates/common/tedge_config/src/lib.rs index e6cb7c02667..a51a780e6d3 100644 --- a/crates/common/tedge_config/src/lib.rs +++ b/crates/common/tedge_config/src/lib.rs @@ -27,11 +27,6 @@ impl TEdgeConfig { config_location.load().await } - pub fn load_sync(config_dir: impl AsRef) -> Result { - let config_location = TEdgeConfigLocation::from_custom_root(config_dir.as_ref()); - config_location.load_sync() - } - pub async fn update_toml( self, update: &impl Fn(&mut TEdgeConfigDto, &TEdgeConfigReader) -> ConfigSettingResult<()>, diff --git a/crates/common/tedge_config/src/tedge_toml/tedge_config.rs b/crates/common/tedge_config/src/tedge_toml/tedge_config.rs index 5a702bb7563..0d1e9fa70e4 100644 --- a/crates/common/tedge_config/src/tedge_toml/tedge_config.rs +++ b/crates/common/tedge_config/src/tedge_toml/tedge_config.rs @@ -1,7 +1,7 @@ mod version; use futures::Stream; use reqwest::NoProxy; -use tokio::fs::DirEntry; +use serde::Deserialize; use version::TEdgeTomlVersion; mod append_remove; @@ -31,9 +31,11 @@ use crate::models::AbsolutePath; use crate::tedge_toml::mapper_config::AwsMapperSpecificConfig; use crate::tedge_toml::mapper_config::AzMapperSpecificConfig; use crate::tedge_toml::mapper_config::C8yMapperSpecificConfig; +use crate::tedge_toml::mapper_config::HasPath as _; use crate::tedge_toml::mapper_config::HasUrl; use crate::tedge_toml::mapper_config::MapperConfigError; use crate::tedge_toml::mapper_config::SpecialisedCloudConfig; +use crate::ConfigDecision; use anyhow::anyhow; use anyhow::Context; use camino::Utf8Path; @@ -55,6 +57,7 @@ use reqwest::Certificate; use std::borrow::Borrow; use std::borrow::Cow; use std::collections::HashMap; +use std::io::ErrorKind; use std::io::Read; use std::iter::Iterator; use std::net::IpAddr; @@ -90,11 +93,13 @@ impl OptionalConfigError for OptionalConfig { } } +type AnyMap = anymap3::Map; + pub struct TEdgeConfig { + dto: TEdgeConfigDto, reader: TEdgeConfigReader, location: TEdgeConfigLocation, - cached_mapper_configs: - tokio::sync::Mutex>, + cached_mapper_configs: Arc>, } impl std::ops::Deref for TEdgeConfig { @@ -105,30 +110,86 @@ impl std::ops::Deref for TEdgeConfig { } } -/// Decision about which configuration source to use -pub enum ConfigDecision { - /// Load from new format file at the given path - LoadNew { path: Utf8PathBuf }, - /// Load from tedge.toml via compatibility layer - LoadLegacy, - /// Configuration file not found - NotFound { path: Utf8PathBuf }, - /// Permission error accessing mapper config directory - PermissionError { - mapper_config_dir: Utf8PathBuf, - error: std::io::Error, - }, +async fn read_file_if_exists(path: &Utf8Path) -> anyhow::Result> { + match tokio::fs::read_to_string(path).await { + Ok(contents) => Ok(Some(contents)), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).context(format!("failed to read mapper configuration from {path}")), + } +} + +impl TEdgeConfigDto { + async fn populate_single_mapper( + dto: &mut MultiDto, + location: &TEdgeConfigLocation, + ) -> anyhow::Result<()> { + use futures::StreamExt; + use futures::TryStreamExt; + + let mappers_dir = location.tedge_config_root_path().join("mappers"); + let all_profiles = location.mapper_config_profiles::().await; + let ty = T::expected_cloud_type(); + match all_profiles { + Some(profiles) => { + let toml_path = mappers_dir.join(format!("{ty}.toml")); + let default_profile_toml = read_file_if_exists(&toml_path).await?; + let mut default_profile_config: T::CloudDto = default_profile_toml.map_or_else( + || Ok(<_>::default()), + |toml| { + toml::from_str(&toml).with_context(|| { + format!("failed to deserialise mapper config in {toml_path}") + }) + }, + )?; + default_profile_config.set_path(toml_path); + dto.non_profile = default_profile_config; + + dto.profiles = profiles + .filter_map(|profile| futures::future::ready(profile)) + .then(|profile| async { + let toml_path = mappers_dir.join(format!("{ty}.d/{profile}.toml")); + let profile_toml = tokio::fs::read_to_string(&toml_path).await?; + let mut profiled_config: T::CloudDto = toml::from_str(&profile_toml) + .context("failed to deserialise mapper config")?; + profiled_config.set_path(toml_path); + Ok::<_, anyhow::Error>((profile, profiled_config)) + }) + .try_collect() + .await?; + } + None => (), + } + Ok(()) + } + + pub(crate) async fn populate_mapper_configs( + &mut self, + location: &TEdgeConfigLocation, + ) -> anyhow::Result<()> { + Self::populate_single_mapper::(&mut self.c8y, location).await?; + Self::populate_single_mapper::(&mut self.az, location).await?; + Self::populate_single_mapper::(&mut self.aws, location).await?; + Ok(()) + } } impl TEdgeConfig { - pub(crate) fn from_dto(dto: &TEdgeConfigDto, location: TEdgeConfigLocation) -> Self { + pub(crate) fn from_dto(dto: TEdgeConfigDto, location: TEdgeConfigLocation) -> Self { Self { - reader: TEdgeConfigReader::from_dto(dto, &location), + reader: TEdgeConfigReader::from_dto(&dto, &location), + dto, location, cached_mapper_configs: <_>::default(), } } + pub async fn decide_config_source(&self, profile: Option<&ProfileName>) -> ConfigDecision + where + T: ExpectedCloudType, + { + self.location.decide_config_source::(profile).await + } + pub(crate) fn location(&self) -> &TEdgeConfigLocation { &self.location } @@ -137,49 +198,6 @@ impl TEdgeConfig { self.location.tedge_config_root_path() } - /// Decide which configuration source to use for a given cloud and profile - /// - /// This function centralizes the decision logic for mapper configuration precedence: - /// 1. New format (`mappers/[cloud].toml` or `mappers/[cloud].d/[profile].toml`) takes precedence - /// 2. If new format exists for some profiles but not the requested one, returns NotFound - /// 3. If the config directory is inaccessible due to a permissions error, return Error - /// 4. If no new format exists at all, fall back to legacy tedge.toml format - pub async fn decide_config_source(&self, profile: Option<&ProfileName>) -> ConfigDecision - where - T: ExpectedCloudType, - { - use tokio::fs::try_exists; - - let mapper_config_dir = self.mappers_config_dir(); - let ty = T::expected_cloud_type().to_string(); - - let filename = profile.map_or_else(|| format!("{ty}.toml"), |p| format!("{ty}.d/{p}.toml")); - let path = mapper_config_dir.join(&filename); - - let default_profile_path = mapper_config_dir.join(format!("{ty}.toml")); - let profile_dir_path = mapper_config_dir.join(format!("{ty}.d")); - - match ( - try_exists(&default_profile_path).await, - try_exists(&profile_dir_path).await, - try_exists(&path).await, - ) { - // The specific config we're looking for exists - (_, _, Ok(true)) => ConfigDecision::LoadNew { path }, - - // New format configs exist for this cloud, but not the specific profile requested - (Ok(true), _, _) | (_, Ok(true), _) => ConfigDecision::NotFound { path }, - - // No new format configs exist for this cloud, use legacy - (Ok(false), Ok(false), _) => ConfigDecision::LoadLegacy, - - // Permission error accessing mapper config directory - (Err(err), _, _) | (_, Err(err), _) => ConfigDecision::PermissionError { - mapper_config_dir, - error: err, - }, - } - } pub async fn c8y_mapper_config( &self, profile: &Option>, @@ -206,7 +224,11 @@ impl TEdgeConfig { let profile = profile.as_ref().map(|p| p.borrow().to_owned()); let ty = T::expected_cloud_type().to_string(); - match self.decide_config_source::(profile.as_ref()).await { + match self + .location + .decide_config_source::(profile.as_ref()) + .await + { ConfigDecision::LoadNew { path } => { // Check for config conflict: both new format and legacy exist if self.has_legacy_config::(profile.as_ref()) { @@ -231,6 +253,7 @@ impl TEdgeConfig { let map = load_mapper_config::( &AbsolutePath::try_new(path.as_str()).unwrap(), self, + profile.as_deref(), ) .await?; @@ -252,10 +275,6 @@ impl TEdgeConfig { } } - pub fn mappers_config_dir(&self) -> Utf8PathBuf { - self.root_dir().join("mappers") - } - /// Check if a legacy tedge.toml configuration exists for the given cloud /// type and profile by checking if the URL is configured /// @@ -289,7 +308,7 @@ impl TEdgeConfig { } pub fn profiled_config_directories(&self) -> impl Iterator + use<'_> { - CloudType::iter().map(|ty| self.mappers_config_dir().join(format!("{ty}.d"))) + CloudType::iter().map(|ty| self.location.mappers_config_dir().join(format!("{ty}.d"))) } async fn all_profiles( @@ -298,37 +317,10 @@ impl TEdgeConfig { where T: ExpectedCloudType, { - use futures::future::ready; - use futures::StreamExt; - - fn file_name_string(entry: tokio::io::Result) -> Option { - entry.ok()?.file_name().into_string().ok() - } - - fn profile_name_from_filename(filename: &str) -> Option { - ProfileName::try_from(filename.strip_suffix(".toml")?.to_owned()).ok() - } - - let ty = T::expected_cloud_type(); - - match self.decide_config_source::(None).await { - ConfigDecision::LoadNew { .. } - | ConfigDecision::NotFound { .. } - | ConfigDecision::PermissionError { .. } => { - let default_profile = futures::stream::once(ready(None)); - match tokio::fs::read_dir(self.mappers_config_dir().join(format!("{ty}.d"))).await { - Ok(profile_dir) => Box::new( - default_profile.chain( - tokio_stream::wrappers::ReadDirStream::new(profile_dir) - .filter_map(|entry| ready(file_name_string(entry))) - .filter_map(|s| ready(profile_name_from_filename(&s))) - .map(Some), - ), - ), - Err(_) => Box::new(default_profile), - } - } - ConfigDecision::LoadLegacy => match ty { + if let Some(migrated_profiles) = self.location.mapper_config_profiles::().await { + migrated_profiles + } else { + match T::expected_cloud_type() { CloudType::C8y => Box::new(futures::stream::iter( self.c8y.keys().map(|p| p.map(<_>::to_owned)), )), @@ -338,7 +330,7 @@ impl TEdgeConfig { CloudType::Aws => Box::new(futures::stream::iter( self.aws.keys().map(|p| p.map(<_>::to_owned)), )), - }, + } } } @@ -600,6 +592,12 @@ pub static READABLE_KEYS: Lazy, doku::Type)>> = Lazy::new struct_field_paths(None, &fields) }); +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, doku::Document, serde::Serialize)] +pub enum MapperConfigLocation { + TedgeToml, + SeparateFile(#[doku(as = "String")] camino::Utf8PathBuf), +} + define_tedge_config! { #[tedge_config(reader(skip))] config: { @@ -727,6 +725,10 @@ define_tedge_config! { #[tedge_config(multi, reader(private))] c8y: { + #[tedge_config(reader(skip))] + #[serde(skip)] + read_from: Utf8PathBuf, + /// Endpoint URL of Cumulocity tenant #[tedge_config(example = "your-tenant.cumulocity.com")] // Config consumers should use `c8y.http`/`c8y.mqtt` as appropriate, hence this field is private @@ -967,6 +969,10 @@ define_tedge_config! { #[tedge_config(multi)] #[tedge_config(reader(private))] az: { + #[tedge_config(reader(skip))] + #[serde(skip)] + read_from: Utf8PathBuf, + /// Endpoint URL of Azure IoT tenant #[tedge_config(example = "myazure.azure-devices.net")] url: ConnectUrl, @@ -1054,6 +1060,10 @@ define_tedge_config! { #[tedge_config(multi)] #[tedge_config(reader(private))] aws: { + #[tedge_config(reader(skip))] + #[serde(skip)] + read_from: Utf8PathBuf, + /// Endpoint URL of AWS IoT tenant #[tedge_config(example = "your-endpoint.amazonaws.com")] url: ConnectUrl, @@ -2113,7 +2123,8 @@ mod tests { let error = tedge_config .mapper_config::(&profile_name(None)) .await - .unwrap_err(); + .err() + .unwrap(); assert_eq!( format!("{error:#}"), format!( diff --git a/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/compat.rs b/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/compat.rs index 4b6da9b175e..991085904b2 100644 --- a/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/compat.rs +++ b/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/compat.rs @@ -3,15 +3,46 @@ use crate::tedge_toml::tedge_config::TEdgeConfigReaderAws; use crate::tedge_toml::tedge_config::TEdgeConfigReaderAz; use crate::tedge_toml::tedge_config::TEdgeConfigReaderC8y; use crate::tedge_toml::ReadableKey; +use crate::tedge_toml::WritableKey; use crate::TEdgeConfig; +pub trait IsCloudConfig { + fn cloud_type_for(&self) -> Option<(CloudType, Option)>; +} + +impl IsCloudConfig for WritableKey { + fn cloud_type_for(&self) -> Option<(CloudType, Option)> { + cloud_type_for(self.to_cow_str()) + } +} + +impl IsCloudConfig for ReadableKey { + fn cloud_type_for(&self) -> Option<(CloudType, Option)> { + cloud_type_for(self.to_cow_str()) + } +} + +fn cloud_type_for(key: Cow<'static, str>) -> Option<(CloudType, Option)> { + match key.split_once(".") { + Some(("c8y", rest)) => Some((CloudType::C8y, extract_profile_name(rest))), + Some(("az", rest)) => Some((CloudType::Az, extract_profile_name(rest))), + Some(("aws", rest)) => Some((CloudType::Aws, extract_profile_name(rest))), + _ => None, + } +} + +fn extract_profile_name(partial_config_key: &str) -> Option { + let partial_config_key = partial_config_key.strip_prefix("profiles.")?; + let (profile, _rest) = partial_config_key.split_once(".")?; + Some(profile.parse().unwrap()) +} /// Trait for creating cloud-specific mapper configuration from tedge.toml cloud sections /// /// This trait enables backward compatibility by loading the new `MapperConfig` format /// from the legacy c8y/az/aws configuration sections in tedge.toml. pub trait FromCloudConfig: Sized { /// The corresponding TEdgeConfigReader type for this cloud - type CloudConfigReader; + type CloudConfigReader: CloudConfigAccessor + Send + Sync + 'static; /// Returns the cloud type for this configuration fn load_cloud_mapper_config( @@ -34,7 +65,7 @@ pub fn load_cloud_mapper_config( tedge_config: &TEdgeConfig, ) -> Result, MapperConfigError> where - T: FromCloudConfig + ApplyRuntimeDefaults + SpecialisedCloudConfig, + T: SpecialisedCloudConfig, { T::load_cloud_mapper_config(profile, tedge_config) } @@ -50,7 +81,7 @@ impl FromCloudConfig for C8yMapperSpecificConfig { MapperConfigError::ConfigRead(format!("C8y profile '{}' not found", profile.unwrap())) })?; - build_mapper_config(c8y_config, tedge_config, profile) + build_mapper_config(c8y_config.clone(), profile) } fn from_cloud_config(c8y: &Self::CloudConfigReader, profile: Option<&str>) -> Self { @@ -74,8 +105,7 @@ impl FromCloudConfig for C8yMapperSpecificConfig { address: c8y.proxy.bind.address, port: Keyed { value: c8y.proxy.bind.port, - key: ReadableKey::C8yProxyBindPort(profile.map(<_>::to_owned)).to_cow_str(), - accessed: Arc::new(AtomicBool::new(false)), + key: ReadableKey::C8yProxyBindPort(profile.map(<_>::to_owned)), }, }, client: ProxyClientConfig { @@ -112,6 +142,11 @@ impl FromCloudConfig for C8yMapperSpecificConfig { enabled: c8y.mqtt_service.enabled, topics: c8y.mqtt_service.topics.clone(), }, + bridge: C8yBridgeConfig { + include: C8yBridgeIncludeConfig { + local_cleansession: c8y.bridge.include.local_cleansession, + }, + }, } } } @@ -127,11 +162,16 @@ impl FromCloudConfig for AzMapperSpecificConfig { MapperConfigError::ConfigRead(format!("Azure profile '{}' not found", profile.unwrap())) })?; - build_mapper_config(az_config, tedge_config, profile) + build_mapper_config(az_config.clone(), profile) } - fn from_cloud_config(_az: &Self::CloudConfigReader, _profile: Option<&str>) -> Self { - AzMapperSpecificConfig {} + fn from_cloud_config(az: &Self::CloudConfigReader, _profile: Option<&str>) -> Self { + AzMapperSpecificConfig { + mapper: AzCloudMapperConfig { + timestamp: az.mapper.timestamp, + timestamp_format: az.mapper.timestamp_format, + }, + } } } @@ -146,30 +186,33 @@ impl FromCloudConfig for AwsMapperSpecificConfig { MapperConfigError::ConfigRead(format!("AWS profile '{}' not found", profile.unwrap())) })?; - build_mapper_config(aws_config, tedge_config, profile) + build_mapper_config(aws_config.clone(), profile) } - fn from_cloud_config(_aws: &Self::CloudConfigReader, _profile: Option<&str>) -> Self { - AwsMapperSpecificConfig {} + fn from_cloud_config(aws: &Self::CloudConfigReader, _profile: Option<&str>) -> Self { + AwsMapperSpecificConfig { + mapper: AwsCloudMapperConfig { + timestamp: aws.mapper.timestamp, + timestamp_format: aws.mapper.timestamp_format, + }, + } } } /// Generic helper to build MapperConfig from any cloud config reader -fn build_mapper_config( - cloud_config: &R, - tedge_config: &TEdgeConfig, +pub fn build_mapper_config( + cloud_config: T::CloudConfigReader, profile: Option<&str>, ) -> Result, MapperConfigError> where - T: FromCloudConfig + ApplyRuntimeDefaults + SpecialisedCloudConfig, - R: CloudConfigAccessor, + T: SpecialisedCloudConfig, { let url = cloud_config.url().clone(); let device = DeviceConfig { id: to_optional_config( cloud_config.device_id().ok().map(|s| s.to_string()), - cloud_config.device_id_key(profile), + &cloud_config.device_id_key(profile), ), key_path: cloud_config.device_key_path().to_owned(), cert_path: cloud_config.device_cert_path().to_owned(), @@ -181,7 +224,6 @@ where let bridge = BridgeConfig { topic_prefix: cloud_config.bridge_topic_prefix(profile), keepalive_interval: cloud_config.bridge_keepalive_interval().clone(), - include: cloud_config.bridge_include_config(), }; let topics = cloud_config.topics().clone(); @@ -190,16 +232,10 @@ where let max_payload_size = cloud_config.max_payload_size(); - let mut cloud_specific = T::from_cloud_config(cloud_config, profile); - - cloud_specific.apply_runtime_defaults( - &url, - tedge_config, - &AbsolutePath::try_new(tedge_config.location.tedge_config_root_path().as_str()) - .expect("valid absolute path"), - ); + let cloud_specific = T::from_cloud_config(&cloud_config, profile); Ok(MapperConfig { + tedge_config_reader: cloud_config, url, root_cert_path, device, @@ -207,7 +243,6 @@ where bridge, mapper: MapperMapperConfig { mqtt: MqttConfig { max_payload_size }, - cloud_specific: cloud_config.mapper_specific(), }, cloud_specific, }) @@ -217,13 +252,10 @@ where /// /// This trait provides a uniform interface for accessing common fields /// from different cloud configuration readers (C8y, Az, Aws). -trait CloudConfigAccessor { - /// The mapper-specific type for this cloud - type MapperSpecific; - +pub trait CloudConfigAccessor { fn url(&self) -> &OptionalConfig; fn device_id(&self) -> Result; - fn device_id_key(&self, profile: Option<&str>) -> Cow<'static, str>; + fn device_id_key(&self, profile: Option<&str>) -> ReadableKey; fn device_key_path(&self) -> &AbsolutePath; fn device_cert_path(&self) -> &AbsolutePath; fn device_csr_path(&self) -> &AbsolutePath; @@ -231,22 +263,18 @@ trait CloudConfigAccessor { fn device_key_pin(&self) -> Option>; fn bridge_topic_prefix(&self, profile: Option<&str>) -> Keyed; fn bridge_keepalive_interval(&self) -> &SecondsOrHumanTime; - fn bridge_include_config(&self) -> BridgeIncludeConfig; fn topics(&self) -> &TemplatesSet; fn root_cert_path(&self, profile: Option<&str>) -> Keyed; fn max_payload_size(&self) -> MqttPayloadLimit; - fn mapper_specific(&self) -> Self::MapperSpecific; } impl CloudConfigAccessor for TEdgeConfigReaderC8y { - type MapperSpecific = EmptyMapperSpecific; - fn url(&self) -> &OptionalConfig { &self.url } - fn device_id_key(&self, profile: Option<&str>) -> Cow<'static, str> { - ReadableKey::C8yDeviceId(profile.map(<_>::to_owned)).to_cow_str() + fn device_id_key(&self, profile: Option<&str>) -> ReadableKey { + ReadableKey::C8yDeviceId(profile.map(<_>::to_owned)) } fn device_id(&self) -> Result { @@ -276,7 +304,7 @@ impl CloudConfigAccessor for TEdgeConfigReaderC8y { fn bridge_topic_prefix(&self, profile: Option<&str>) -> Keyed { Keyed::new( self.bridge.topic_prefix.clone(), - ReadableKey::C8yBridgeTopicPrefix(profile.map(<_>::to_owned)).to_cow_str(), + ReadableKey::C8yBridgeTopicPrefix(profile.map(<_>::to_owned)), ) } @@ -284,12 +312,6 @@ impl CloudConfigAccessor for TEdgeConfigReaderC8y { &self.bridge.keepalive_interval } - fn bridge_include_config(&self) -> BridgeIncludeConfig { - BridgeIncludeConfig { - local_cleansession: self.bridge.include.local_cleansession, - } - } - fn topics(&self) -> &TemplatesSet { &self.topics } @@ -297,28 +319,22 @@ impl CloudConfigAccessor for TEdgeConfigReaderC8y { fn root_cert_path(&self, profile: Option<&str>) -> Keyed { Keyed::new( self.root_cert_path.clone(), - ReadableKey::C8yRootCertPath(profile.map(<_>::to_owned)).to_cow_str(), + ReadableKey::C8yRootCertPath(profile.map(<_>::to_owned)), ) } fn max_payload_size(&self) -> MqttPayloadLimit { self.mapper.mqtt.max_payload_size } - - fn mapper_specific(&self) -> Self::MapperSpecific { - EmptyMapperSpecific {} - } } impl CloudConfigAccessor for TEdgeConfigReaderAz { - type MapperSpecific = AzMapperSpecific; - fn url(&self) -> &OptionalConfig { &self.url } - fn device_id_key(&self, profile: Option<&str>) -> Cow<'static, str> { - ReadableKey::AzDeviceId(profile.map(<_>::to_owned)).to_cow_str() + fn device_id_key(&self, profile: Option<&str>) -> ReadableKey { + ReadableKey::AzDeviceId(profile.map(<_>::to_owned)) } fn device_id(&self) -> Result { @@ -348,7 +364,7 @@ impl CloudConfigAccessor for TEdgeConfigReaderAz { fn bridge_topic_prefix(&self, profile: Option<&str>) -> Keyed { Keyed::new( self.bridge.topic_prefix.clone(), - ReadableKey::AzBridgeTopicPrefix(profile.map(<_>::to_owned)).to_cow_str(), + ReadableKey::AzBridgeTopicPrefix(profile.map(<_>::to_owned)), ) } @@ -356,10 +372,6 @@ impl CloudConfigAccessor for TEdgeConfigReaderAz { &self.bridge.keepalive_interval } - fn bridge_include_config(&self) -> BridgeIncludeConfig { - <_>::default() - } - fn topics(&self) -> &TemplatesSet { &self.topics } @@ -367,31 +379,22 @@ impl CloudConfigAccessor for TEdgeConfigReaderAz { fn root_cert_path(&self, profile: Option<&str>) -> Keyed { Keyed::new( self.root_cert_path.clone(), - ReadableKey::AzRootCertPath(profile.map(<_>::to_owned)).to_cow_str(), + ReadableKey::AzRootCertPath(profile.map(<_>::to_owned)), ) } fn max_payload_size(&self) -> MqttPayloadLimit { self.mapper.mqtt.max_payload_size } - - fn mapper_specific(&self) -> Self::MapperSpecific { - AzMapperSpecific { - timestamp: self.mapper.timestamp, - timestamp_format: self.mapper.timestamp_format, - } - } } impl CloudConfigAccessor for TEdgeConfigReaderAws { - type MapperSpecific = AwsMapperSpecific; - fn url(&self) -> &OptionalConfig { &self.url } - fn device_id_key(&self, profile: Option<&str>) -> Cow<'static, str> { - ReadableKey::AwsDeviceId(profile.map(<_>::to_owned)).to_cow_str() + fn device_id_key(&self, profile: Option<&str>) -> ReadableKey { + ReadableKey::AwsDeviceId(profile.map(<_>::to_owned)) } fn device_id(&self) -> Result { @@ -421,7 +424,7 @@ impl CloudConfigAccessor for TEdgeConfigReaderAws { fn bridge_topic_prefix(&self, profile: Option<&str>) -> Keyed { Keyed::new( self.bridge.topic_prefix.clone(), - ReadableKey::AwsBridgeTopicPrefix(profile.map(<_>::to_owned)).to_cow_str(), + ReadableKey::AwsBridgeTopicPrefix(profile.map(<_>::to_owned)), ) } @@ -429,10 +432,6 @@ impl CloudConfigAccessor for TEdgeConfigReaderAws { &self.bridge.keepalive_interval } - fn bridge_include_config(&self) -> BridgeIncludeConfig { - <_>::default() - } - fn topics(&self) -> &TemplatesSet { &self.topics } @@ -440,20 +439,13 @@ impl CloudConfigAccessor for TEdgeConfigReaderAws { fn root_cert_path(&self, profile: Option<&str>) -> Keyed { Keyed::new( self.root_cert_path.clone(), - ReadableKey::AwsRootCertPath(profile.map(<_>::to_owned)).to_cow_str(), + ReadableKey::AwsRootCertPath(profile.map(<_>::to_owned)), ) } fn max_payload_size(&self) -> MqttPayloadLimit { self.mapper.mqtt.max_payload_size } - - fn mapper_specific(&self) -> Self::MapperSpecific { - AwsMapperSpecific { - timestamp: self.mapper.timestamp, - timestamp_format: self.mapper.timestamp_format, - } - } } #[cfg(test)] @@ -469,7 +461,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -497,7 +489,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -518,7 +510,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -528,9 +520,9 @@ mod tests { config.url().or_none().unwrap().as_str(), "mydevice.azure-devices.net" ); - assert!(config.mapper.cloud_specific.timestamp); + assert!(config.cloud_specific.mapper.timestamp); assert_eq!( - config.mapper.cloud_specific.timestamp_format, + config.cloud_specific.mapper.timestamp_format, TimeFormat::Unix ); } @@ -543,7 +535,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -553,9 +545,9 @@ mod tests { config.url().or_none().unwrap().as_str(), "mydevice.amazonaws.com" ); - assert!(config.mapper.cloud_specific.timestamp); + assert!(config.cloud_specific.mapper.timestamp); assert_eq!( - config.mapper.cloud_specific.timestamp_format, + config.cloud_specific.mapper.timestamp_format, TimeFormat::Unix ); } @@ -568,7 +560,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -585,7 +577,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -613,7 +605,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -636,7 +628,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -657,7 +649,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); @@ -686,7 +678,7 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/tmp/tedge"), ); diff --git a/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/mod.rs b/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/mod.rs index b9988063746..5c7d7340d4c 100644 --- a/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/mod.rs +++ b/crates/common/tedge_config/src/tedge_toml/tedge_config/mapper_config/mod.rs @@ -2,9 +2,13 @@ pub mod compat; use crate::models::CloudType; use crate::tedge_toml::tedge_config::cert_error_into_config_error; -use crate::tedge_toml::tedge_config::default_credentials_path; use crate::tedge_toml::ReadableKey; +use crate::tedge_toml::TEdgeConfigDtoAws; +use crate::tedge_toml::TEdgeConfigDtoAz; +use crate::tedge_toml::TEdgeConfigDtoC8y; use crate::TEdgeConfig; +use crate::TEdgeConfigDto; +use crate::TEdgeConfigReader; use super::super::models::auth_method::AuthMethod; use super::super::models::AbsolutePath; @@ -24,23 +28,21 @@ use super::MultiError; use super::OptionalConfig; use super::ReadError; use camino::Utf8Path; +use camino::Utf8PathBuf; use certificate::PemCertificate; -use doku::Document; use serde::de::DeserializeOwned; -use serde::Deserialize; use std::borrow::Cow; use std::fmt::Display; use std::net::IpAddr; -use std::net::Ipv4Addr; use std::ops::Deref; -use std::sync::atomic::AtomicBool; use std::sync::Arc; +use tedge_config_macros::MultiDto; +use tedge_config_macros::ProfileName; pub use compat::load_cloud_mapper_config; pub use compat::FromCloudConfig; /// Device-specific configuration fields shared across all cloud types -#[derive(Debug, Document)] pub struct DeviceConfig { /// Device identifier (optional, will be derived from certificate if not set) id: OptionalConfig, @@ -81,40 +83,70 @@ impl DeviceConfig { } /// Bridge configuration fields shared across all cloud types -#[derive(Debug, Document)] pub struct BridgeConfig { /// The topic prefix for the bridge MQTT topic pub topic_prefix: Keyed, /// The amount of time after which the bridge should send a ping pub keepalive_interval: SecondsOrHumanTime, +} - pub include: BridgeIncludeConfig, +pub struct C8yBridgeConfig { + pub include: C8yBridgeIncludeConfig, } -/// Trait linking cloud-specific config to its mapper-specific configuration +/// Trait linking cloud-specific config to its DTO and config reader pub trait SpecialisedCloudConfig: - Sized - + DeserializeOwned - + ApplyRuntimeDefaults - + ExpectedCloudType - + FromCloudConfig - + Send - + Sync - + 'static + Sized + ExpectedCloudType + FromCloudConfig + Send + Sync + 'static { - /// The mapper-specific configuration type for this cloud - type SpecialisedMapperConfig: DeserializeOwned - + std::fmt::Debug - + Document - + Default - + Send - + Sync; + type CloudDto: HasPath + Default + DeserializeOwned + Send + Sync + 'static; + + fn into_config_reader( + dto: Self::CloudDto, + base_config: &TEdgeConfig, + profile: Option<&str>, + ) -> Self::CloudConfigReader; +} + +pub trait HasPath { + fn set_path(&mut self, path: Utf8PathBuf); + fn get_path(&self) -> Option<&Utf8Path>; +} + +impl HasPath for TEdgeConfigDtoC8y { + fn set_path(&mut self, path: Utf8PathBuf) { + self.read_from = Some(path) + } + + fn get_path(&self) -> Option<&Utf8Path> { + self.read_from.as_deref() + } +} + +impl HasPath for TEdgeConfigDtoAz { + fn set_path(&mut self, path: Utf8PathBuf) { + self.read_from = Some(path) + } + + fn get_path(&self) -> Option<&Utf8Path> { + self.read_from.as_deref() + } +} + +impl HasPath for TEdgeConfigDtoAws { + fn set_path(&mut self, path: Utf8PathBuf) { + self.read_from = Some(path) + } + + fn get_path(&self) -> Option<&Utf8Path> { + self.read_from.as_deref() + } } /// Base mapper configuration with common fields and cloud-specific fields via generics -#[derive(Debug, Document)] pub struct MapperConfig { + pub tedge_config_reader: T::CloudConfigReader, + /// Endpoint URL of the cloud tenant url: OptionalConfig, @@ -130,169 +162,97 @@ pub struct MapperConfig { /// Bridge configuration pub bridge: BridgeConfig, - pub mapper: MapperMapperConfig, + pub mapper: MapperMapperConfig, - /// Cloud-specific configuration fields (flattened into the same level) + /// Cloud-specific configuration fields pub cloud_specific: T, } -/// Empty mapper-specific configuration for C8y (no cloud-specific mapper fields) -#[derive(Debug, Deserialize, Document, Default)] -pub struct EmptyMapperSpecific {} - -/// AWS-specific mapper configuration fields -#[derive(Debug, Deserialize, Document)] -pub struct AwsMapperSpecific { +/// AWS cloud-specific mapper configuration +pub struct AwsCloudMapperConfig { /// Whether to add timestamps to messages - #[serde(default = "default_timestamp")] pub timestamp: bool, /// The timestamp format to use - #[serde(default = "default_timestamp_format")] pub timestamp_format: TimeFormat, } -/// Azure-specific mapper configuration fields -#[derive(Debug, Deserialize, Document)] -pub struct AzMapperSpecific { +/// Azure cloud-specific mapper configuration +pub struct AzCloudMapperConfig { /// Whether to add timestamps to messages - #[serde(default = "default_timestamp")] pub timestamp: bool, /// The timestamp format to use - #[serde(default = "default_timestamp_format")] pub timestamp_format: TimeFormat, } -#[derive(Debug, Deserialize, Document)] -pub struct PartialMapperMapperConfig { - #[serde(default)] - mqtt: PartialMqttConfig, - - /// Cloud-specific mapper configuration (e.g., timestamp settings for AWS/Azure) - #[serde(flatten)] - pub cloud_specific: M, -} - -#[derive(Debug, Deserialize, Document, Default)] -pub struct PartialMqttConfig { - #[serde(default)] - pub max_payload_size: Option, -} - -#[derive(Debug, Document)] -pub struct MapperMapperConfig { +pub struct MapperMapperConfig { pub mqtt: MqttConfig, - - /// Cloud-specific mapper configuration - pub cloud_specific: M, } -#[derive(Debug, Document)] pub struct MqttConfig { /// Maximum MQTT payload size pub max_payload_size: MqttPayloadLimit, } /// SmartREST configuration for Cumulocity -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct SmartrestConfig { /// Set of SmartREST template IDs the device should subscribe to - #[serde(default)] pub templates: TemplatesSet, /// Switch using 501-503 or 504-506 SmartREST messages for operation status update - #[serde(default = "default_smartrest_use_operation_id")] pub use_operation_id: bool, /// SmartREST child device configuration - #[serde(default)] pub child_device: SmartrestChildDeviceConfig, } -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct Smartrest1Config { /// Set of SmartREST 1 template IDs the device should subscribe to - #[serde(default)] pub templates: TemplatesSet, } /// Child device SmartREST configuration -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct SmartrestChildDeviceConfig { /// Attach the c8y_IsDevice fragment to child devices on creation - #[serde(default = "default_smartrest_child_device_create_with_marker")] pub create_with_device_marker: bool, } /// Proxy bind configuration -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct ProxyBindConfig { /// The IP address local proxy binds to - #[serde(default = "default_proxy_bind_address")] pub address: IpAddr, /// The port local proxy binds to - #[serde(default = "default_proxy_bind_port")] pub port: Keyed, } #[derive(Debug)] pub struct Keyed { value: T, - key: Cow<'static, str>, - accessed: Arc, + key: ReadableKey, } impl Keyed { - fn new(value: T, key: impl Into>) -> Self { - Self { - value, - key: key.into(), - accessed: Arc::new(AtomicBool::new(false)), - } + fn new(value: T, key: ReadableKey) -> Self { + Self { value, key } } pub fn value(&self) -> &T { - self.accessed - .store(true, std::sync::atomic::Ordering::SeqCst); &self.value } - pub fn key(&self) -> &Cow<'static, str> { + pub fn key(&self) -> &ReadableKey { &self.key } } -impl Drop for Keyed { - fn drop(&mut self) { - // The key should always be set, but this has to happen after deserialising - // If the value has been used - if self.accessed.load(std::sync::atomic::Ordering::SeqCst) { - debug_assert!( - !self.key.is_empty(), - "Must set a key for a `Keyed` value after deserialising" - ) - } - } -} - impl Display for Keyed { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.value().fmt(f) } } -impl Document for Keyed { - fn ty() -> doku::Type { - T::ty() - } -} - impl Deref for Keyed { type Target = T; @@ -307,276 +267,253 @@ impl PartialEq for Keyed { } } -impl<'de, T: Deserialize<'de>> Deserialize<'de> for Keyed { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - Ok(Self { - value: T::deserialize(deserializer)?, - key: "".into(), - accessed: Arc::new(AtomicBool::new(false)), - }) - } -} - -/// Proxy client configuration -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct ProxyClientConfig { /// The address of the host on which the proxy is running - #[serde(default = "default_proxy_client_host")] pub host: Arc, /// The port number on which the proxy is running - #[serde(default = "default_proxy_client_port")] pub port: u16, } -/// Helper function to deserialize OptionalConfig from Option -fn deserialize_optional_config<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, - T: serde::Deserialize<'de>, -{ - Option::::deserialize(deserializer).map(|opt| { - opt.map(|v| OptionalConfig::present(v, "")) - .unwrap_or_else(|| OptionalConfig::empty("")) - }) -} - /// HTTP proxy configuration for Cumulocity -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct ProxyConfig { /// Proxy bind configuration - #[serde(default)] pub bind: ProxyBindConfig, /// Proxy client configuration - #[serde(default)] pub client: ProxyClientConfig, /// Server certificate path for the proxy - #[serde( - default = "default_optional_config", - deserialize_with = "deserialize_optional_config" - )] pub cert_path: OptionalConfig, /// Server private key path for the proxy - #[serde( - default = "default_optional_config", - deserialize_with = "deserialize_optional_config" - )] pub key_path: OptionalConfig, /// CA certificates path for the proxy - #[serde( - default = "default_optional_config", - deserialize_with = "deserialize_optional_config" - )] pub ca_path: OptionalConfig, } /// Entity store configuration -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct EntityStoreConfig { /// Enable auto registration feature - #[serde(default = "default_entity_store_auto_register")] pub auto_register: bool, /// On a clean start, resend the whole device state to the cloud - #[serde(default = "default_entity_store_clean_start")] pub clean_start: bool, } /// Software management configuration -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct SoftwareManagementConfig { /// Software management API to use (legacy or advanced) - #[serde(default = "default_software_management_api")] pub api: SoftwareManagementApiFlag, /// Enable publishing c8y_SupportedSoftwareTypes fragment - #[serde(default = "default_software_management_with_types")] pub with_types: bool, } /// Operations configuration -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct OperationsConfig { /// Auto-upload the operation log once it finishes - #[serde(default = "default_operations_auto_log_upload")] pub auto_log_upload: AutoLogUpload, } /// Availability/heartbeat configuration for Cumulocity -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct AvailabilityConfig { /// Enable sending heartbeat to Cumulocity periodically - #[serde(default = "default_availability_enable")] pub enable: bool, /// Heartbeat interval to be sent to Cumulocity - #[serde(default = "default_availability_interval")] pub interval: SecondsOrHumanTime, } /// Feature enable/disable flags -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct EnableConfig { /// Enable log_upload feature - #[serde(default = "default_enable_log_upload")] pub log_upload: bool, /// Enable config_snapshot feature - #[serde(default = "default_enable_config_snapshot")] pub config_snapshot: bool, /// Enable config_update feature - #[serde(default = "default_enable_config_update")] pub config_update: bool, /// Enable firmware_update feature - #[serde(default = "default_enable_firmware_update")] pub firmware_update: bool, /// Enable device_profile feature - #[serde(default = "default_enable_device_profile")] pub device_profile: bool, } /// Bridge include configuration -#[derive(Debug, Deserialize, Document)] -pub struct BridgeIncludeConfig { +pub struct C8yBridgeIncludeConfig { /// Set the bridge local clean session flag - #[serde(default = "default_bridge_include_local_cleansession")] pub local_cleansession: AutoFlag, } /// MQTT service configuration for Cumulocity -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct MqttServiceConfig { /// Whether to connect to the MQTT service endpoint or not - #[serde(default = "default_mqtt_service_enabled")] pub enabled: bool, /// Set of MQTT topics for the MQTT service endpoint - #[serde(default = "default_mqtt_service_topics")] pub topics: TemplatesSet, } -impl Default for AwsMapperSpecific { - fn default() -> Self { - Self { - timestamp: default_timestamp(), - timestamp_format: default_timestamp_format(), - } - } -} - -impl Default for AzMapperSpecific { - fn default() -> Self { - Self { - timestamp: default_timestamp(), - timestamp_format: default_timestamp_format(), - } - } -} - /// Cumulocity-specific mapper configuration fields -#[derive(Debug, Deserialize, Document)] -#[serde(default)] pub struct C8yMapperSpecificConfig { /// Authentication method (certificate, basic, or auto) - #[serde(default = "default_auth_method")] pub auth_method: AuthMethod, /// Path to credentials file for basic auth - #[serde(default = "serde_placeholder_credentials_path")] pub credentials_path: AbsolutePath, /// SmartREST configuration - #[serde(default = "default_smartrest_config")] pub smartrest: SmartrestConfig, /// SmartREST1 configuration - #[serde(default = "default_smartrest1_config")] pub smartrest1: Smartrest1Config, /// HTTP endpoint for Cumulocity - // Note: http will be derived from url at runtime, no serde default - #[serde( - default = "default_optional_config", - deserialize_with = "deserialize_optional_config" - )] pub http: OptionalConfig>, /// MQTT endpoint for Cumulocity - // Note: mqtt will be derived from url at runtime, no serde default - #[serde( - default = "default_optional_config", - deserialize_with = "deserialize_optional_config" - )] pub mqtt: OptionalConfig>, /// HTTP proxy configuration - #[serde(default)] pub proxy: ProxyConfig, /// Entity store configuration - #[serde(default = "default_entity_store_config")] pub entity_store: EntityStoreConfig, /// Software management configuration - #[serde(default = "default_software_management_config")] pub software_management: SoftwareManagementConfig, /// Operations configuration - #[serde(default = "default_operations_config")] pub operations: OperationsConfig, /// Availability/heartbeat configuration - #[serde(default)] pub availability: AvailabilityConfig, /// Feature enable/disable flags - #[serde(default = "default_enable_config")] pub enable: EnableConfig, /// MQTT service configuration - #[serde(default)] pub mqtt_service: MqttServiceConfig, + + pub bridge: C8yBridgeConfig, } /// Azure IoT-specific mapper configuration fields -#[derive(Debug, Deserialize, Document)] -pub struct AzMapperSpecificConfig {} +pub struct AzMapperSpecificConfig { + pub mapper: AzCloudMapperConfig, +} /// AWS IoT-specific mapper configuration fields -#[derive(Debug, Deserialize, Document)] -pub struct AwsMapperSpecificConfig {} +pub struct AwsMapperSpecificConfig { + pub mapper: AwsCloudMapperConfig, +} /// CloudConfig implementation for C8y impl SpecialisedCloudConfig for C8yMapperSpecificConfig { - type SpecialisedMapperConfig = EmptyMapperSpecific; + type CloudDto = TEdgeConfigDtoC8y; + + fn into_config_reader( + dto: Self::CloudDto, + base_config: &TEdgeConfig, + profile: Option<&str>, + ) -> Self::CloudConfigReader { + let mut multi_dto = MultiDto::default(); + match profile { + Some(profile) => { + multi_dto.profiles.insert(profile.parse().unwrap(), dto); + } + None => multi_dto.non_profile = dto, + }; + let mut reader = TEdgeConfigReader::from_dto( + &TEdgeConfigDto { + c8y: multi_dto, + ..base_config.dto.clone() + }, + &base_config.location, + ); + match profile { + None => reader.c8y.non_profile, + Some(profile) => reader + .c8y + .profiles + .remove(&profile.parse::().unwrap()) + .unwrap(), + } + } } /// CloudConfig implementation for Azure impl SpecialisedCloudConfig for AzMapperSpecificConfig { - type SpecialisedMapperConfig = AzMapperSpecific; + type CloudDto = TEdgeConfigDtoAz; + + fn into_config_reader( + dto: Self::CloudDto, + base_config: &TEdgeConfig, + profile: Option<&str>, + ) -> Self::CloudConfigReader { + let mut multi_dto = MultiDto::default(); + match profile { + Some(profile) => { + multi_dto.profiles.insert(profile.parse().unwrap(), dto); + } + None => multi_dto.non_profile = dto, + }; + let mut reader = TEdgeConfigReader::from_dto( + &TEdgeConfigDto { + az: multi_dto, + ..base_config.dto.clone() + }, + &base_config.location, + ); + match profile { + None => reader.az.non_profile, + Some(profile) => reader + .az + .profiles + .remove(&profile.parse::().unwrap()) + .unwrap(), + } + } } /// CloudConfig implementation for AWS impl SpecialisedCloudConfig for AwsMapperSpecificConfig { - type SpecialisedMapperConfig = AwsMapperSpecific; + type CloudDto = TEdgeConfigDtoAws; + + fn into_config_reader( + dto: Self::CloudDto, + base_config: &TEdgeConfig, + profile: Option<&str>, + ) -> Self::CloudConfigReader { + let mut multi_dto = MultiDto::default(); + match profile { + Some(profile) => { + multi_dto.profiles.insert(profile.parse().unwrap(), dto); + } + None => multi_dto.non_profile = dto, + }; + // TODO take TEdgeConfigLocation + let mut reader = TEdgeConfigReader::from_dto( + &TEdgeConfigDto { + aws: multi_dto, + ..base_config.dto.clone() + }, + &base_config.location, + ); + match profile { + None => reader.aws.non_profile, + Some(profile) => reader + .aws + .profiles + .remove(&profile.parse::().unwrap()) + .unwrap(), + } + } } /// Type alias for Cumulocity mapper configuration @@ -616,44 +553,6 @@ impl From for MapperConfigError { } } -/// Partial device configuration with all fields optional for deserialization -#[derive(Debug, Deserialize)] -struct PartialDeviceConfig { - id: Option, - key_path: Option, - cert_path: Option, - csr_path: Option, - key_uri: Option>, - key_pin: Option>, -} - -/// Partial bridge configuration with all fields optional for deserialization -#[derive(Debug, Deserialize)] -struct PartialBridgeConfig { - topic_prefix: Option, - keepalive_interval: Option, - include: BridgeIncludeConfig, -} - -/// Partial mapper configuration with optional common fields -#[derive(Debug, Deserialize)] -#[serde(bound( - deserialize = "T: DeserializeOwned, T::SpecialisedMapperConfig: Default + DeserializeOwned" -))] -struct PartialMapperConfig { - url: Option, - root_cert_path: Option, - device: Option, - topics: Option, - bridge: Option, - - #[serde(default)] - mapper: PartialMapperMapperConfig, - - #[serde(flatten)] - cloud_specific: T, -} - /// Load and populate a mapper configuration from an external TOML file /// /// This function reads a mapper configuration file and applies defaults from @@ -670,128 +569,26 @@ struct PartialMapperConfig { pub(crate) async fn load_mapper_config( config_path: &AbsolutePath, tedge_config: &TEdgeConfig, + profile: Option<&str>, ) -> Result, MapperConfigError> where - T: DeserializeOwned + ApplyRuntimeDefaults + SpecialisedCloudConfig, + T: SpecialisedCloudConfig, { let toml_content = tokio::fs::read_to_string(config_path.as_std_path()).await?; - load_mapper_config_from_string(&toml_content, tedge_config, config_path) + load_mapper_config_from_string(&toml_content, tedge_config, profile) } fn load_mapper_config_from_string( toml_content: &str, tedge_config: &TEdgeConfig, - config_path: &AbsolutePath, + profile: Option<&str>, ) -> Result, MapperConfigError> where - T: DeserializeOwned + ApplyRuntimeDefaults + SpecialisedCloudConfig, + T: SpecialisedCloudConfig, { - let partial: PartialMapperConfig = toml::from_str(toml_content)?; - - let device = if let Some(partial_device) = partial.device { - DeviceConfig { - // device.id is optional - will be derived from certificate if not set - id: to_optional_config( - partial_device.id, - format!("{config_path}: device.id").into(), - ), - key_path: partial_device - .key_path - .unwrap_or_else(|| tedge_config.device.key_path.clone()), - cert_path: partial_device - .cert_path - .unwrap_or_else(|| tedge_config.device.cert_path.clone()), - csr_path: partial_device - .csr_path - .unwrap_or_else(|| tedge_config.device.csr_path.clone()), - key_uri: partial_device - .key_uri - .or_else(|| tedge_config.device.key_uri.or_none().cloned()), - key_pin: partial_device - .key_pin - .or_else(|| tedge_config.device.key_pin.or_none().cloned()), - } - } else { - // No device section in file, use all defaults from tedge_config - DeviceConfig { - // device.id is optional - will be derived from certificate if not set - id: to_optional_config( - tedge_config.device.id().ok().map(|s| s.to_string()), - "device.id".into(), - ), - key_path: tedge_config.device.key_path.clone(), - cert_path: tedge_config.device.cert_path.clone(), - csr_path: tedge_config.device.csr_path.clone(), - key_uri: tedge_config.device.key_uri.or_none().cloned(), - key_pin: tedge_config.device.key_pin.or_none().cloned(), - } - }; - - // Apply defaults for bridge fields - let bridge = if let Some(partial_bridge) = partial.bridge { - BridgeConfig { - topic_prefix: Keyed::new( - partial_bridge - .topic_prefix - .unwrap_or_else(T::default_bridge_topic_prefix), - format!("{config_path}: bridge.topic_prefix"), - ), - keepalive_interval: partial_bridge - .keepalive_interval - .unwrap_or_else(default_keepalive_interval), - include: partial_bridge.include, - } - } else { - // No bridge section, use all defaults - BridgeConfig { - topic_prefix: Keyed::new( - T::default_bridge_topic_prefix(), - format!("{config_path}: bridge.topic_prefix"), - ), - keepalive_interval: default_keepalive_interval(), - include: default_bridge_include_config(), - } - }; - - // Apply default for root_cert_path - let root_cert_path = Keyed::new( - partial - .root_cert_path - .unwrap_or_else(default_root_cert_path), - format!("{config_path}: root_cert_path"), - ); - - let url = to_optional_config(partial.url, format!("{config_path}: url").into()); - - // Apply default topics - let topics = partial.topics.unwrap_or_else(T::default_topics); - - // Apply default max_payload_size - let max_payload_size = partial - .mapper - .mqtt - .max_payload_size - .unwrap_or_else(T::default_max_payload_size); - - // Get cloud-specific config (already has serde defaults applied) - let mut cloud_specific = partial.cloud_specific; - - // Apply runtime defaults to cloud_specific - cloud_specific.apply_runtime_defaults(&url, tedge_config, config_path); - - // Construct the final configuration - Ok(MapperConfig { - url, - root_cert_path, - device, - topics, - bridge, - mapper: MapperMapperConfig { - mqtt: MqttConfig { max_payload_size }, - cloud_specific: partial.mapper.cloud_specific, - }, - cloud_specific, - }) + let cloud_dto: T::CloudDto = toml::from_str(toml_content)?; + let cloud_reader = T::into_config_reader(cloud_dto, tedge_config, profile); + compat::build_mapper_config(cloud_reader, profile) } pub trait ExpectedCloudType { @@ -816,25 +613,6 @@ impl ExpectedCloudType for AwsMapperSpecificConfig { } } -/// Trait for applying runtime defaults to cloud-specific configurations -pub trait ApplyRuntimeDefaults { - fn apply_runtime_defaults( - &mut self, - url: &OptionalConfig, - tedge_config: &TEdgeConfig, - config_path: &AbsolutePath, - ); - - /// Returns the default bridge topic prefix for this cloud type - fn default_bridge_topic_prefix() -> TopicPrefix; - - /// Returns the default topics for this cloud type - fn default_topics() -> TemplatesSet; - - /// Returns the default max payload size for this cloud type - fn default_max_payload_size() -> MqttPayloadLimit; -} - pub trait HasUrl { // The configured URL field, used to check whether profiles are fn configured_url(&self) -> &OptionalConfig; @@ -846,447 +624,13 @@ impl HasUrl for MapperConfig { } } -fn default_keepalive_interval() -> SecondsOrHumanTime { - "60s".parse().expect("Valid duration") -} - -// Common MapperConfig defaults -fn default_root_cert_path() -> AbsolutePath { - "/etc/ssl/certs".parse().expect("Valid path") -} - -// C8y mapper specific defaults -fn default_auth_method() -> AuthMethod { - AuthMethod::Certificate -} - -fn serde_placeholder_credentials_path() -> AbsolutePath { - AbsolutePath::try_new("/").expect("valid path") -} - -fn default_smartrest_config() -> SmartrestConfig { - SmartrestConfig { - templates: TemplatesSet::default(), - use_operation_id: true, - child_device: SmartrestChildDeviceConfig::default(), - } -} - -fn default_smartrest1_config() -> Smartrest1Config { - Smartrest1Config { - templates: TemplatesSet::default(), - } -} - -fn default_smartrest_use_operation_id() -> bool { - true -} - -fn default_smartrest_child_device_create_with_marker() -> bool { - false -} - -fn default_proxy_bind_address() -> IpAddr { - IpAddr::V4(Ipv4Addr::LOCALHOST) -} - -fn default_proxy_bind_port() -> Keyed { - Keyed::new(8001, "") -} - -fn default_proxy_client_host() -> Arc { - Arc::from("127.0.0.1") -} - -fn default_proxy_client_port() -> u16 { - 8001 // Will be overridden at runtime if bind.port differs -} - -fn default_optional_config() -> OptionalConfig { - OptionalConfig::empty("") -} - -fn default_proxy_config() -> ProxyConfig { - ProxyConfig { - bind: ProxyBindConfig { - address: default_proxy_bind_address(), - port: default_proxy_bind_port(), - }, - client: ProxyClientConfig { - host: default_proxy_client_host(), - port: default_proxy_client_port(), - }, - cert_path: default_optional_config(), - key_path: default_optional_config(), - ca_path: default_optional_config(), - } -} - -fn default_bridge_include_local_cleansession() -> AutoFlag { - AutoFlag::Auto -} - -fn default_bridge_include_config() -> BridgeIncludeConfig { - BridgeIncludeConfig { - local_cleansession: default_bridge_include_local_cleansession(), - } -} - -fn default_entity_store_auto_register() -> bool { - true -} - -fn default_entity_store_clean_start() -> bool { - true -} - -fn default_entity_store_config() -> EntityStoreConfig { - EntityStoreConfig { - auto_register: default_entity_store_auto_register(), - clean_start: default_entity_store_clean_start(), - } -} - -fn default_software_management_api() -> SoftwareManagementApiFlag { - SoftwareManagementApiFlag::Legacy -} - -fn default_software_management_with_types() -> bool { - false -} - -fn default_software_management_config() -> SoftwareManagementConfig { - SoftwareManagementConfig { - api: default_software_management_api(), - with_types: default_software_management_with_types(), - } -} - -fn default_operations_auto_log_upload() -> AutoLogUpload { - AutoLogUpload::OnFailure -} - -fn default_operations_config() -> OperationsConfig { - OperationsConfig { - auto_log_upload: default_operations_auto_log_upload(), - } -} - -fn default_availability_enable() -> bool { - true -} - -fn default_availability_interval() -> SecondsOrHumanTime { - "60m".parse().expect("Valid duration") -} - -fn default_availability_config() -> AvailabilityConfig { - AvailabilityConfig { - enable: default_availability_enable(), - interval: default_availability_interval(), - } -} - -fn default_mqtt_service_enabled() -> bool { - false -} - -fn default_mqtt_service_topics() -> TemplatesSet { - "$demo,$error".parse().expect("Valid templates set") -} - -fn default_enable_log_upload() -> bool { - true -} - -fn default_enable_config_snapshot() -> bool { - true -} - -fn default_enable_config_update() -> bool { - true -} - -fn default_enable_firmware_update() -> bool { - true -} - -fn default_enable_device_profile() -> bool { - true -} - -fn default_enable_config() -> EnableConfig { - EnableConfig { - log_upload: default_enable_log_upload(), - config_snapshot: default_enable_config_snapshot(), - config_update: default_enable_config_update(), - firmware_update: default_enable_firmware_update(), - device_profile: default_enable_device_profile(), - } -} - -// Azure/AWS timestamp defaults -fn default_timestamp() -> bool { - true -} - -fn default_timestamp_format() -> TimeFormat { - TimeFormat::Unix -} - -impl Default for SmartrestConfig { - fn default() -> Self { - default_smartrest_config() - } -} - -impl Default for Smartrest1Config { - fn default() -> Self { - default_smartrest1_config() - } -} - -impl Default for SmartrestChildDeviceConfig { - fn default() -> Self { - Self { - create_with_device_marker: default_smartrest_child_device_create_with_marker(), - } - } -} - -impl Default for ProxyBindConfig { - fn default() -> Self { - Self { - address: default_proxy_bind_address(), - port: default_proxy_bind_port(), - } - } -} - -impl Default for ProxyClientConfig { - fn default() -> Self { - Self { - host: default_proxy_client_host(), - port: default_proxy_client_port(), - } - } -} - -impl Default for ProxyConfig { - fn default() -> Self { - default_proxy_config() - } -} - -impl Default for EntityStoreConfig { - fn default() -> Self { - default_entity_store_config() - } -} - -impl Default for SoftwareManagementConfig { - fn default() -> Self { - default_software_management_config() - } -} - -impl Default for OperationsConfig { - fn default() -> Self { - default_operations_config() - } -} - -impl Default for AvailabilityConfig { - fn default() -> Self { - default_availability_config() - } -} - -impl Default for EnableConfig { - fn default() -> Self { - default_enable_config() - } -} - -impl Default for BridgeIncludeConfig { - fn default() -> Self { - default_bridge_include_config() - } -} - -impl Default for MqttServiceConfig { - fn default() -> Self { - Self { - enabled: default_mqtt_service_enabled(), - topics: default_mqtt_service_topics(), - } - } -} - -impl Default for C8yMapperSpecificConfig { - fn default() -> Self { - Self { - auth_method: default_auth_method(), - credentials_path: AbsolutePath::try_new("/").expect("Valid path"), - smartrest: default_smartrest_config(), - smartrest1: default_smartrest1_config(), - http: OptionalConfig::Empty("".into()), // Will be derived from url at runtime - mqtt: OptionalConfig::Empty("".into()), // Will be derived from url at runtime - proxy: ProxyConfig::default(), - entity_store: default_entity_store_config(), - software_management: default_software_management_config(), - operations: default_operations_config(), - availability: default_availability_config(), - enable: default_enable_config(), - mqtt_service: MqttServiceConfig::default(), - } - } -} - -impl Default for PartialMapperMapperConfig { - fn default() -> Self { - Self { - mqtt: PartialMqttConfig::default(), - cloud_specific: T::default(), - } - } -} - -fn set_key_if_blank(field: &mut OptionalConfig, value: Cow<'static, str>) { - use OptionalConfig as OC; +fn to_optional_config(field: Option, key: &ReadableKey) -> OptionalConfig { match field { - OC::Present { ref mut key, .. } | OC::Empty(ref mut key) if key.is_empty() => *key = value, - _ => (), - } -} - -fn set_key_if_blank2(field: &mut Keyed, value: Cow<'static, str>) { - if field.key.is_empty() { - field.key = value - } -} - -fn convert_optional_value>(field: &OptionalConfig) -> OptionalConfig { - match field.clone() { - OptionalConfig::Present { value, key } => OptionalConfig::Present { - value: value.into(), - key, + Some(value) => OptionalConfig::Present { + value, + key: key.to_cow_str(), }, - OptionalConfig::Empty(key) => OptionalConfig::Empty(key), - } -} - -fn to_optional_config(field: Option, key: Cow<'static, str>) -> OptionalConfig { - match field { - Some(value) => OptionalConfig::Present { value, key }, - None => OptionalConfig::Empty(key), - } -} - -impl ApplyRuntimeDefaults for C8yMapperSpecificConfig { - fn apply_runtime_defaults( - &mut self, - url: &OptionalConfig, - tedge_config: &TEdgeConfig, - config_path: &AbsolutePath, - ) { - // Derive http endpoint from url if it's not been set - if self.http.or_none().is_none() { - self.http = convert_optional_value(url); - } - - // Derive mqtt endpoint from url if it's not been set - if self.mqtt.or_none().is_none() { - self.mqtt = convert_optional_value(url); - } - - // Apply proxy port inheritance: client.port defaults to bind.port - if self.proxy.client.port == 8001 && self.proxy.bind.port != 8001 { - self.proxy.client.port = *self.proxy.bind.port; - } - - if self.credentials_path == serde_placeholder_credentials_path() { - self.credentials_path = default_credentials_path(&tedge_config.location) - } - - // Don't need to set the key for http or mqtt as these are set from url - set_key_if_blank( - &mut self.proxy.cert_path, - format!("{}: proxy.cert_path", config_path).into(), - ); - set_key_if_blank( - &mut self.proxy.key_path, - format!("{}: proxy.key_path", config_path).into(), - ); - set_key_if_blank( - &mut self.proxy.ca_path, - format!("{}: proxy.ca_path", config_path).into(), - ); - set_key_if_blank2( - &mut self.proxy.bind.port, - format!("{}: proxy.bind.port", config_path).into(), - ); - } - - fn default_bridge_topic_prefix() -> TopicPrefix { - TopicPrefix::try_new("c8y").unwrap() - } - - fn default_topics() -> TemplatesSet { - "te/+/+/+/+,te/+/+/+/+/twin/+,te/+/+/+/+/m/+,te/+/+/+/+/m/+/meta,te/+/+/+/+/e/+,te/+/+/+/+/a/+,te/+/+/+/+/status/health".parse().expect("Valid templateset") - } - - fn default_max_payload_size() -> MqttPayloadLimit { - super::c8y_mqtt_payload_limit() - } -} - -impl ApplyRuntimeDefaults for AzMapperSpecificConfig { - fn apply_runtime_defaults( - &mut self, - _url: &OptionalConfig, - _tedge_config: &TEdgeConfig, - _config_path: &AbsolutePath, - ) { - // Azure config has no runtime defaults currently - } - - fn default_bridge_topic_prefix() -> TopicPrefix { - TopicPrefix::try_new("az").unwrap() - } - - fn default_topics() -> TemplatesSet { - "te/+/+/+/+/m/+,te/+/+/+/+/e/+,te/+/+/+/+/a/+,te/+/+/+/+/status/health" - .parse() - .expect("Valid templateset") - } - - fn default_max_payload_size() -> MqttPayloadLimit { - super::az_mqtt_payload_limit() - } -} - -impl ApplyRuntimeDefaults for AwsMapperSpecificConfig { - fn apply_runtime_defaults( - &mut self, - _url: &OptionalConfig, - _tedge_config: &TEdgeConfig, - _config_path: &AbsolutePath, - ) { - // AWS config has no runtime defaults currently - } - - fn default_bridge_topic_prefix() -> TopicPrefix { - TopicPrefix::try_new("aws").unwrap() - } - fn default_topics() -> TemplatesSet { - "te/+/+/+/+/m/+,te/+/+/+/+/e/+,te/+/+/+/+/a/+,te/+/+/+/+/status/health" - .parse() - .expect("Valid templateset") - } - - fn default_max_payload_size() -> MqttPayloadLimit { - super::aws_mqtt_payload_limit() + None => OptionalConfig::Empty(key.to_cow_str()), } } @@ -1341,25 +685,27 @@ mod tests { #[test] fn empty_file_deserializes_with_all_defaults() { - let toml = ""; - let config: C8yMapperSpecificConfig = toml::from_str(toml).unwrap(); + let config = deserialize_from_str::("").unwrap(); // Verify all defaults are applied - assert_eq!(config.auth_method, AuthMethod::Certificate); - assert!(config.smartrest.use_operation_id); - assert!(config.entity_store.auto_register); - assert!(config.entity_store.clean_start); + assert_eq!(config.cloud_specific.auth_method, AuthMethod::Certificate); + assert!(config.cloud_specific.smartrest.use_operation_id); + assert!(config.cloud_specific.entity_store.auto_register); + assert!(config.cloud_specific.entity_store.clean_start); assert_eq!( - config.software_management.api, + config.cloud_specific.software_management.api, SoftwareManagementApiFlag::Legacy ); - assert!(!config.software_management.with_types); - assert_eq!(config.operations.auto_log_upload, AutoLogUpload::OnFailure); - assert!(config.enable.log_upload); - assert!(config.enable.config_snapshot); - assert!(config.enable.config_update); - assert!(config.enable.firmware_update); - assert!(config.enable.device_profile); + assert!(!config.cloud_specific.software_management.with_types); + assert_eq!( + config.cloud_specific.operations.auto_log_upload, + AutoLogUpload::OnFailure + ); + assert!(config.cloud_specific.enable.log_upload); + assert!(config.cloud_specific.enable.config_snapshot); + assert!(config.cloud_specific.enable.config_update); + assert!(config.cloud_specific.enable.firmware_update); + assert!(config.cloud_specific.enable.device_profile); } #[test] @@ -1430,24 +776,25 @@ mod tests { device_profile = false "#; - let config: C8yMapperSpecificConfig = toml::from_str(toml).unwrap(); + let config = deserialize_from_str::(toml).unwrap(); + let c8y_config = &config.cloud_specific; // All explicit values preserved, no defaults applied - assert_eq!(config.auth_method, AuthMethod::Basic); - assert!(!config.smartrest.use_operation_id); - assert!(!config.entity_store.auto_register); - assert!(!config.entity_store.clean_start); + assert_eq!(c8y_config.auth_method, AuthMethod::Basic); + assert!(!c8y_config.smartrest.use_operation_id); + assert!(!c8y_config.entity_store.auto_register); + assert!(!c8y_config.entity_store.clean_start); assert_eq!( - config.software_management.api, + c8y_config.software_management.api, SoftwareManagementApiFlag::Advanced ); - assert!(config.software_management.with_types); - assert_eq!(config.operations.auto_log_upload, AutoLogUpload::Always); - assert!(!config.enable.log_upload); - assert!(!config.enable.config_snapshot); - assert!(!config.enable.config_update); - assert!(!config.enable.firmware_update); - assert!(!config.enable.device_profile); + assert!(c8y_config.software_management.with_types); + assert_eq!(c8y_config.operations.auto_log_upload, AutoLogUpload::Always); + assert!(!c8y_config.enable.log_upload); + assert!(!c8y_config.enable.config_snapshot); + assert!(!c8y_config.enable.config_update); + assert!(!c8y_config.enable.firmware_update); + assert!(!c8y_config.enable.device_profile); } #[test] @@ -1461,15 +808,12 @@ mod tests { "#; let tedge_config = TEdgeConfig::from_dto( - &toml::from_str(tedge_toml).unwrap(), + toml::from_str(tedge_toml).unwrap(), TEdgeConfigLocation::from_custom_root("/not/a/real/directory"), ); - let config: C8yMapperConfig = load_mapper_config_from_string( - mapper_toml, - &tedge_config, - &AbsolutePath::try_new("notondisk.toml").unwrap(), - ) - .unwrap(); + let config: C8yMapperConfig = + load_mapper_config_from_string(mapper_toml, &tedge_config, Some("random-profile")) + .unwrap(); // Device fields should come from tedge_config defaults // Call the id() method to get the device ID (which should be set from tedge_toml) @@ -1502,7 +846,7 @@ mod tests { let config = deserialize_from_str::(toml).unwrap(); // For C8y, we check mqtt key since url is private - assert_eq!(config.mqtt().key(), "/not/on/disk.toml: url"); + assert_eq!(config.mqtt().key(), "c8y.url"); } #[test] @@ -1701,11 +1045,11 @@ mod tests { let config = deserialize_from_str::(toml).unwrap(); assert_eq!(config.mapper.mqtt.max_payload_size, MqttPayloadLimit(12345)); - assert!(!config.mapper.cloud_specific.timestamp); + assert!(!config.cloud_specific.mapper.timestamp); } #[test] - fn empty_proxy_cert_path_has_file_in_empty_key_name() { + fn empty_proxy_cert_path_matches_legacy_c8y_key_name() { let toml = r#" url = "tenant.cumulocity.com" "#; @@ -1714,20 +1058,16 @@ mod tests { assert_eq!( config.cloud_specific.proxy.cert_path.key(), - "/not/on/disk.toml: proxy.cert_path" + "c8y.proxy.cert_path" ) } fn deserialize_from_str(toml: &str) -> Result, MapperConfigError> where - T: DeserializeOwned + ApplyRuntimeDefaults + SpecialisedCloudConfig, + T: SpecialisedCloudConfig, { let tedge_config = - TEdgeConfig::from_dto(&TEdgeConfigDto::default(), TEdgeConfigLocation::default()); - load_mapper_config_from_string( - toml, - &tedge_config, - &AbsolutePath::try_new("/not/on/disk.toml").unwrap(), - ) + TEdgeConfig::from_dto(TEdgeConfigDto::default(), TEdgeConfigLocation::default()); + load_mapper_config_from_string(toml, &tedge_config, None) } } diff --git a/crates/common/tedge_config/src/tedge_toml/tedge_config_location.rs b/crates/common/tedge_config/src/tedge_toml/tedge_config_location.rs index 97498b30114..6b2e787f43e 100644 --- a/crates/common/tedge_config/src/tedge_toml/tedge_config_location.rs +++ b/crates/common/tedge_config/src/tedge_toml/tedge_config_location.rs @@ -1,5 +1,7 @@ use std::path::Path; +use crate::tedge_toml::mapper_config::ExpectedCloudType; +use crate::tedge_toml::mapper_config::HasPath; use crate::tedge_toml::DtoKey; use crate::ConfigSettingResult; use crate::TEdgeConfig; @@ -12,12 +14,12 @@ use camino::Utf8PathBuf; use serde::Deserialize as _; use serde::Serialize; use std::path::PathBuf; +use tedge_config_macros::MultiDto; +use tedge_config_macros::ProfileName; use tedge_utils::file::change_mode; -use tedge_utils::file::change_mode_sync; use tedge_utils::file::change_user_and_group; -use tedge_utils::file::change_user_and_group_sync; use tedge_utils::fs::atomically_write_file_async; -use tedge_utils::fs::atomically_write_file_sync; +use tokio::fs::DirEntry; use tracing::debug; use tracing::subscriber::NoSubscriber; use tracing::warn; @@ -81,6 +83,54 @@ impl TEdgeConfigLocation { &self.tedge_config_root_path } + pub fn mappers_config_dir(&self) -> Utf8PathBuf { + self.tedge_config_root_path.join("mappers") + } + + /// Decide which configuration source to use for a given cloud and profile + /// + /// This function centralizes the decision logic for mapper configuration precedence: + /// 1. New format (`mappers/[cloud].toml` or `mappers/[cloud].d/[profile].toml`) takes precedence + /// 2. If new format exists for some profiles but not the requested one, returns NotFound + /// 3. If the config directory is inaccessible due to a permissions error, return Error + /// 4. If no new format exists at all, fall back to legacy tedge.toml format + pub async fn decide_config_source(&self, profile: Option<&ProfileName>) -> ConfigDecision + where + T: ExpectedCloudType, + { + use tokio::fs::try_exists; + + let mapper_config_dir = self.mappers_config_dir(); + let ty = T::expected_cloud_type().to_string(); + + let filename = profile.map_or_else(|| format!("{ty}.toml"), |p| format!("{ty}.d/{p}.toml")); + let path = mapper_config_dir.join(&filename); + + let default_profile_path = mapper_config_dir.join(format!("{ty}.toml")); + let profile_dir_path = mapper_config_dir.join(format!("{ty}.d")); + + match ( + try_exists(&default_profile_path).await, + try_exists(&profile_dir_path).await, + try_exists(&path).await, + ) { + // The specific config we're looking for exists + (_, _, Ok(true)) => ConfigDecision::LoadNew { path }, + + // New format configs exist for this cloud, but not the specific profile requested + (Ok(true), _, _) | (_, Ok(true), _) => ConfigDecision::NotFound { path }, + + // No new format configs exist for this cloud, use legacy + (Ok(false), Ok(false), _) => ConfigDecision::LoadLegacy, + + // Permission error accessing mapper config directory + (Err(err), _, _) | (_, Err(err), _) => ConfigDecision::PermissionError { + mapper_config_dir, + error: err, + }, + } + } + pub async fn update_toml( &self, update: &impl Fn(&mut TEdgeConfigDto, &TEdgeConfigReader) -> ConfigSettingResult<()>, @@ -89,7 +139,7 @@ impl TEdgeConfigLocation { let reader = TEdgeConfigReader::from_dto(&config, self); update(&mut config, &reader)?; - self.store(&config).await + self.store(config).await } fn toml_path(&self) -> &Utf8Path { @@ -102,16 +152,7 @@ impl TEdgeConfigLocation { "Loading configuration from {:?}", self.tedge_config_file_path ); - Ok(TEdgeConfig::from_dto(&dto, self.clone())) - } - - pub(crate) fn load_sync(self) -> Result { - let dto = self.load_dto_sync::()?; - debug!( - "Loading configuration from {:?}", - self.tedge_config_file_path - ); - Ok(TEdgeConfig::from_dto(&dto, self)) + Ok(TEdgeConfig::from_dto(dto, self.clone())) } async fn load_dto_from_toml_and_env(&self) -> Result { @@ -126,14 +167,6 @@ impl TEdgeConfigLocation { Ok(dto) } - fn load_dto_sync(&self) -> Result { - let (dto, warnings) = self.load_dto_with_warnings_sync::()?; - - warnings.emit(); - - Ok(dto) - } - #[cfg(feature = "test")] pub(crate) fn load_toml_str(toml: &str, location: TEdgeConfigLocation) -> TEdgeConfig { let (tedge_config, warnings) = Self::load_toml_str_with_warnings(toml, location); @@ -155,7 +188,49 @@ impl TEdgeConfigLocation { .fold(toml_value, |toml, migration| migration.apply_to(toml)); (dto, warnings) = deserialize_toml(migrated_toml, toml_path).unwrap(); } - (TEdgeConfig::from_dto(&dto, location), warnings) + (TEdgeConfig::from_dto(dto, location), warnings) + } + + pub(crate) async fn mapper_config_profiles( + &self, + ) -> Option< + Box> + Unpin + Send + Sync + '_>, + > + where + T: ExpectedCloudType, + { + use futures::future::ready; + use futures::StreamExt; + + fn file_name_string(entry: tokio::io::Result) -> Option { + entry.ok()?.file_name().into_string().ok() + } + + fn profile_name_from_filename(filename: &str) -> Option { + ProfileName::try_from(filename.strip_suffix(".toml")?.to_owned()).ok() + } + + let ty = T::expected_cloud_type(); + + match self.decide_config_source::(None).await { + ConfigDecision::LoadNew { .. } + | ConfigDecision::NotFound { .. } + | ConfigDecision::PermissionError { .. } => { + let default_profile = futures::stream::once(ready(None)); + match tokio::fs::read_dir(self.mappers_config_dir().join(format!("{ty}.d"))).await { + Ok(profile_dir) => Some(Box::new( + default_profile.chain( + tokio_stream::wrappers::ReadDirStream::new(profile_dir) + .filter_map(|entry| ready(file_name_string(entry))) + .filter_map(|s| ready(profile_name_from_filename(&s))) + .map(Some), + ), + )), + Err(_) => Some(Box::new(default_profile)), + } + } + ConfigDecision::LoadLegacy => None, + } } async fn load_dto_with_warnings( @@ -181,7 +256,7 @@ impl TEdgeConfigLocation { .into_iter() .fold(toml, |toml, migration| migration.apply_to(toml)); - self.store(&migrated_toml).await?; + self.store_in(self.toml_path(), &migrated_toml).await?; (dto, warnings) = deserialize_toml(migrated_toml, toml_path)?; } @@ -191,57 +266,31 @@ impl TEdgeConfigLocation { update_with_environment_variables(&mut dto, &mut warnings)?; } + dto.populate_mapper_configs(self).await?; + Ok((dto, warnings)) } - fn load_dto_with_warnings_sync( - &self, - ) -> Result<(TEdgeConfigDto, UnusedValueWarnings), TEdgeConfigError> { - let toml_path = self.toml_path(); - let mut tedge_toml_readable = true; - let config = std::fs::read_to_string(toml_path).unwrap_or_else(|_| { - tedge_toml_readable = false; - String::new() - }); - let toml: toml::Value = toml::de::from_str(&config)?; - let (mut dto, mut warnings) = deserialize_toml(toml, toml_path)?; - - if let Some(migrations) = dto.config.version.unwrap_or_default().migrations() { - if !tedge_toml_readable { - tracing::info!("Migrating tedge.toml configuration to version 2"); - - let toml = toml::de::from_str(&config)?; - let migrated_toml = migrations - .into_iter() - .fold(toml, |toml, migration| migration.apply_to(toml)); - - self.store_sync(&migrated_toml)?; - - // Reload DTO to get the settings in the right place - (dto, warnings) = deserialize_toml(migrated_toml, toml_path)?; - } - } - - if Sources::INCLUDE_ENVIRONMENT { - update_with_environment_variables(&mut dto, &mut warnings)?; - } - - Ok((dto, warnings)) + async fn store(&self, mut config: TEdgeConfigDto) -> Result<(), TEdgeConfigError> { + self.store_cloud(&mut config.c8y).await?; + self.store_cloud(&mut config.az).await?; + self.store_cloud(&mut config.aws).await?; + self.store_in(self.toml_path(), &config).await } - async fn store(&self, config: &S) -> Result<(), TEdgeConfigError> { + async fn store_in( + &self, + toml_path: &Utf8Path, + config: &S, + ) -> Result<(), TEdgeConfigError> { let toml = toml::to_string_pretty(&config)?; // Create `$HOME/.tedge` or `/etc/tedge` directory in case it does not exist yet - if !tokio::fs::try_exists(&self.tedge_config_root_path) - .await - .unwrap_or(false) - { - tokio::fs::create_dir(self.tedge_config_root_path()).await?; + if !tokio::fs::try_exists(toml_path).await.unwrap_or(false) { + tokio::fs::create_dir_all(toml_path.parent().expect("provided path must have parent")) + .await?; } - let toml_path = self.toml_path(); - atomically_write_file_async(toml_path, toml.as_bytes()).await?; if let Err(err) = @@ -257,26 +306,27 @@ impl TEdgeConfigLocation { Ok(()) } - fn store_sync(&self, config: &S) -> Result<(), TEdgeConfigError> { - let toml = toml::to_string_pretty(&config)?; - - // Create `$HOME/.tedge` or `/etc/tedge` directory in case it does not exist yet - if !self.tedge_config_root_path.exists() { - std::fs::create_dir(self.tedge_config_root_path())?; - } - - let toml_path = self.toml_path(); - - atomically_write_file_sync(toml_path, toml.as_bytes())?; - - if let Err(err) = change_user_and_group_sync(toml_path.as_ref(), "tedge", "tedge") { - warn!("failed to set file ownership for '{toml_path}': {err}"); + async fn store_cloud(&self, cloud: &mut MultiDto) -> Result<(), TEdgeConfigError> + where + S: Serialize + HasPath + Default + PartialEq, + { + if cloud.non_profile.get_path().is_some() { + self.store_cloud_entry(&cloud.non_profile).await?; + for profile in cloud.profiles.values() { + self.store_cloud_entry(profile).await?; + } + std::mem::take(cloud); } + Ok(()) + } - if let Err(err) = change_mode_sync(toml_path.as_ref(), 0o644) { - warn!("failed to set file permissions for '{toml_path}': {err}"); + async fn store_cloud_entry( + &self, + config: &S, + ) -> Result<(), TEdgeConfigError> { + if let Some(toml_path) = config.get_path() { + self.store_in(toml_path, config).await?; } - Ok(()) } } @@ -298,6 +348,21 @@ impl ConfigSources for FileOnly { const INCLUDE_ENVIRONMENT: bool = false; } +/// Decision about which configuration source to use +pub enum ConfigDecision { + /// Load from new format file at the given path + LoadNew { path: Utf8PathBuf }, + /// Load from tedge.toml via compatibility layer + LoadLegacy, + /// Configuration file not found + NotFound { path: Utf8PathBuf }, + /// Permission error accessing mapper config directory + PermissionError { + mapper_config_dir: Utf8PathBuf, + error: std::io::Error, + }, +} + #[derive(Default, Debug, PartialEq, Eq)] #[must_use] pub struct UnusedValueWarnings(Vec); diff --git a/crates/common/tedge_config/tests/mapper_config.rs b/crates/common/tedge_config/tests/mapper_config.rs index 944aa799152..18112a5629f 100644 --- a/crates/common/tedge_config/tests/mapper_config.rs +++ b/crates/common/tedge_config/tests/mapper_config.rs @@ -96,7 +96,7 @@ async fn partial_migration_default_new_profile_legacy_errors() { .mapper_config::(&Some(prod_profile)) .await; - let err = prod_result.unwrap_err(); + let err = prod_result.err().unwrap(); let expected_path = format!("{}/mappers/c8y.d/prod.toml", ttd.utf8_path()); assert!( err.to_string().contains(&expected_path), @@ -136,7 +136,7 @@ async fn partial_migration_default_legacy_profile_new_errors() { .mapper_config::(&None::) .await; - let err = default_result.unwrap_err(); + let err = default_result.err().unwrap(); let expected_path = format!("{}/mappers/c8y.toml", ttd.utf8_path()); assert!( err.to_string().contains(&expected_path), diff --git a/crates/common/tedge_config_macros/impl/src/dto.rs b/crates/common/tedge_config_macros/impl/src/dto.rs index 7c49e7a6095..7855a5eeb48 100644 --- a/crates/common/tedge_config_macros/impl/src/dto.rs +++ b/crates/common/tedge_config_macros/impl/src/dto.rs @@ -78,7 +78,7 @@ pub fn generate( let doc_comment_attr = (!doc_comment.is_empty()).then(|| quote_spanned!(name.span()=> #[doc = #doc_comment])); quote_spanned! {name.span()=> - #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[derive(Debug, Default, Clone, ::serde::Deserialize, ::serde::Serialize, PartialEq)] // We will add more configurations in the future, so this is // non_exhaustive (see // https://doc.rust-lang.org/reference/attributes/type_system.html) @@ -97,7 +97,7 @@ pub fn generate( impl #name { // If #name is a "multi" field, we don't use this method, but it's a pain to conditionally generate it, so just ignore the warning #[allow(unused)] - fn is_default(&self) -> bool { + pub fn is_default(&self) -> bool { self == &Self::default() } } @@ -166,7 +166,7 @@ mod tests { let generated = generate_test_dto(&input); let expected = parse_quote! { - #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[derive(Debug, Default, Clone, ::serde::Deserialize, ::serde::Serialize, PartialEq)] #[non_exhaustive] pub struct TEdgeConfigDto { #[serde(default)] @@ -184,7 +184,7 @@ mod tests { } } - #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[derive(Debug, Default, Clone, ::serde::Deserialize, ::serde::Serialize, PartialEq)] #[non_exhaustive] pub struct TEdgeConfigDtoC8y { pub url: Option, @@ -197,7 +197,7 @@ mod tests { } } - #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[derive(Debug, Default, Clone, ::serde::Deserialize, ::serde::Serialize, PartialEq)] #[non_exhaustive] pub struct TEdgeConfigDtoSudo { pub enable: Option, @@ -229,7 +229,7 @@ mod tests { .retain(only_struct_named("TEdgeConfigDtoDevice")); let expected = parse_quote! { - #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[derive(Debug, Default, Clone, ::serde::Deserialize, ::serde::Serialize, PartialEq)] #[non_exhaustive] pub struct TEdgeConfigDtoDevice { pub id: Option, @@ -254,7 +254,7 @@ mod tests { .retain(only_struct_named("TEdgeConfigDtoDevice")); let expected = parse_quote! { - #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[derive(Debug, Default, Clone, ::serde::Deserialize, ::serde::Serialize, PartialEq)] #[non_exhaustive] pub struct TEdgeConfigDtoDevice { pub id: Option, @@ -273,6 +273,7 @@ mod tests { syn::parse2(tokens).unwrap() } + #[track_caller] fn assert_eq(actual: &syn::File, expected: &syn::File) { pretty_assertions::assert_eq!( prettyplease::unparse(actual), diff --git a/crates/common/tedge_config_macros/impl/src/reader.rs b/crates/common/tedge_config_macros/impl/src/reader.rs index 7676690c260..c404c457814 100644 --- a/crates/common/tedge_config_macros/impl/src/reader.rs +++ b/crates/common/tedge_config_macros/impl/src/reader.rs @@ -53,7 +53,7 @@ fn generate_structs( for item in items { match item { - FieldOrGroup::Field(field) => { + FieldOrGroup::Field(field) if !field.reader().skip => { let ty = field.ty(); attrs.push(field.attrs().to_vec()); idents.push(field.ident()); @@ -144,6 +144,9 @@ fn generate_structs( false => parse_quote!(pub), }); } + FieldOrGroup::Field(_) => { + // Explicitly skipped using `#[tedge_config(reader(skip))]` + } FieldOrGroup::Group(_) => { // Explicitly skipped using `#[tedge_config(reader(skip))]` } @@ -550,7 +553,7 @@ fn generate_conversions( for item in items { match item { - FieldOrGroup::Field(field) => { + FieldOrGroup::Field(field) if !field.reader().skip => { let name = field.ident(); let value = reader_value_for_field(field, &parents, root_fields, Vec::new())?; field_conversions.push(quote_spanned!(name.span()=> #name: #value)); @@ -615,7 +618,7 @@ fn generate_conversions( generate_conversions(&sub_reader_name, &group.contents, parents, root_fields)?; rest.push(sub_conversions); } - FieldOrGroup::Group(_) | FieldOrGroup::Multi(_) => { + FieldOrGroup::Field(_) | FieldOrGroup::Group(_) | FieldOrGroup::Multi(_) => { // Skipped } } @@ -867,6 +870,40 @@ mod tests { ) } + #[test] + fn generate_structs_ignores_reader_skipped_fields_for_reader_only() { + let input: crate::input::Configuration = parse_quote!( + c8y: { + #[tedge_config(reader(skip))] + read_from: Utf8PathBuf, + }, + ); + let actual = generate_structs( + &parse_quote!(TEdgeConfigReader), + &input.groups, + Vec::new(), + "", + ) + .unwrap(); + let mut file: syn::File = syn::parse2(actual.clone()).unwrap(); + let target: syn::Ident = parse_quote!(TEdgeConfigReaderC8y); + file.items.retain( + |i| matches!(i, Item::Struct(ItemStruct { ident: self_ty, .. }) if *self_ty == target), + ); + + // Should contain no fields as all have been skipped + let expected = parse_quote! { + #[derive(::doku::Document, ::serde::Serialize, Debug, Clone)] + #[non_exhaustive] + pub struct TEdgeConfigReaderC8y {} + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&file), + prettyplease::unparse(&expected) + ); + } + #[test] fn fields_are_public_only_if_directly_readable() { let input: crate::input::Configuration = parse_quote!( @@ -1055,4 +1092,43 @@ mod tests { prettyplease::unparse(&expected) ) } + + #[test] + fn skipped_reader_values_are_excluded_from_from_dto() { + let input: crate::input::Configuration = parse_quote!( + c8y: { + #[tedge_config(reader(skip))] + #[serde(skip)] + read_from: Utf8PathBuf, + }, + ); + let actual = generate_conversions( + &parse_quote!(TEdgeConfigReader), + &input.groups, + Vec::new(), + &input.groups, + ) + .unwrap(); + let mut file: syn::File = syn::parse2(actual).unwrap(); + let target: syn::Type = parse_quote!(TEdgeConfigReaderC8y); + file.items + .retain(|i| matches!(i, Item::Impl(ItemImpl { self_ty, ..}) if **self_ty == target)); + + let expected = parse_quote! { + impl TEdgeConfigReaderC8y { + #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] + #[automatically_derived] + /// Converts the provided [TEdgeConfigDto] into a reader + pub(crate) fn from_dto(dto: &TEdgeConfigDto, location: &TEdgeConfigLocation) -> Self { + Self { + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&file), + prettyplease::unparse(&expected) + ) + } } diff --git a/crates/common/tedge_config_macros/src/multi.rs b/crates/common/tedge_config_macros/src/multi.rs index 1118fb10900..2c9de60a270 100644 --- a/crates/common/tedge_config_macros/src/multi.rs +++ b/crates/common/tedge_config_macros/src/multi.rs @@ -13,9 +13,9 @@ use std::str::FromStr; #[serde(bound(serialize = "T: Serialize + Default + PartialEq"), default)] pub struct MultiDto { #[serde(skip_serializing_if = "is_default")] - profiles: ::std::collections::HashMap, + pub profiles: ::std::collections::HashMap, #[serde(flatten)] - non_profile: T, + pub non_profile: T, } fn is_default(map: &HashMap) -> bool { @@ -77,8 +77,8 @@ impl FromStr for ProfileName { #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct MultiReader { - profiles: ::std::collections::HashMap, - non_profile: T, + pub profiles: ::std::collections::HashMap, + pub non_profile: T, parent: &'static str, } diff --git a/crates/core/tedge/src/cli/certificate/cli.rs b/crates/core/tedge/src/cli/certificate/cli.rs index ad074e002f9..38f0106e6de 100644 --- a/crates/core/tedge/src/cli/certificate/cli.rs +++ b/crates/core/tedge/src/cli/certificate/cli.rs @@ -804,7 +804,7 @@ mod tests { let cloud: Option = cloud_arg.map(<_>::try_into).transpose().unwrap(); let ttd = TempTedgeDir::new(); ttd.file("tedge.toml").with_toml_content(toml); - let config = TEdgeConfig::load_sync(ttd.path()).unwrap(); + let config = TEdgeConfig::load(ttd.path()).await.unwrap(); let id = input_id.map(|s| s.to_string()); let result = get_device_id(id, &config, &cloud).await; assert_eq!(result.unwrap().as_str(), expected); @@ -826,7 +826,7 @@ mod tests { let cloud: Option = cloud_arg.map(<_>::try_into).transpose().unwrap(); let ttd = TempTedgeDir::new(); ttd.file("tedge.toml").with_toml_content(toml); - let config = TEdgeConfig::load_sync(ttd.path()).unwrap(); + let config = TEdgeConfig::load(ttd.path()).await.unwrap(); let id = input_id.map(|s| s.to_string()); let result = get_device_id(id, &config, &cloud).await; assert!(result.is_err()); diff --git a/crates/core/tedge/src/cli/common.rs b/crates/core/tedge/src/cli/common.rs index ed10950cc86..0ac7646e317 100644 --- a/crates/core/tedge/src/cli/common.rs +++ b/crates/core/tedge/src/cli/common.rs @@ -4,6 +4,7 @@ use clap_complete::ArgValueCandidates; use clap_complete::CompletionCandidate; use std::borrow::Cow; use std::fmt; +use std::path::Path; use tedge_config::get_config_dir; use tedge_config::tedge_toml::ProfileName; use tedge_config::TEdgeConfig; @@ -214,7 +215,14 @@ impl MaybeBorrowedCloud<'_> { /// `TEDGE_CONFIGURATION_DIR` environment variable, or `/etc/tedge` if /// that is not set pub fn profile_completions() -> Vec { - let Ok(tc) = TEdgeConfig::load_sync(get_config_dir()) else { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(profile_completions_for_config_dir(get_config_dir())) + }) +} + +async fn profile_completions_for_config_dir(dir: impl AsRef) -> Vec { + let Ok(tc) = TEdgeConfig::load(dir).await else { return vec![]; }; tc.c8y_keys_str() @@ -222,12 +230,44 @@ pub fn profile_completions() -> Vec { .map(CompletionCandidate::new) .chain(tc.az_keys_str().flatten().map(CompletionCandidate::new)) .chain(tc.aws_keys_str().flatten().map(CompletionCandidate::new)) - .chain( - tc.profiled_config_directories() - .flat_map(|dir| std::fs::read_dir(dir).into_iter().flatten()) - .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) - .filter_map(|s| Some(s.strip_suffix(".toml")?.to_owned())) - .map(CompletionCandidate::new), - ) .collect() } + +#[cfg(test)] +mod tests { + use tedge_test_utils::fs::TempTedgeDir; + + use crate::cli::common::profile_completions_for_config_dir; + + #[tokio::test] + async fn profile_completions_include_tedge_toml_profile_names() { + let ttd = TempTedgeDir::new(); + ttd.file("tedge.toml") + .with_raw_content("[c8y.profiles.something]\n[c8y.profiles.other]"); + let completions = completion_names(&ttd).await; + assert_eq!(completions, ["other", "something"]); + } + + #[tokio::test] + async fn profile_completions_include_separate_mapper_config_profile_names() { + let ttd = TempTedgeDir::new(); + let c8y_profiles = ttd.dir("mappers").dir("c8y.d"); + c8y_profiles.file("profile1.toml").with_raw_content(""); + c8y_profiles.file("profile2.toml").with_raw_content(""); + let completions = completion_names(&ttd).await; + assert_eq!(completions, ["profile1", "profile2"]); + } + + /// Generates profile completions for the provided config dir, extracts just + /// the completion text portion, and sorts them to ensure the order is + /// stable + async fn completion_names(ttd: &TempTedgeDir) -> Vec { + let completions = profile_completions_for_config_dir(ttd.path()).await; + let mut completions = completions + .iter() + .map(|candidate| candidate.get_value().to_str().unwrap().to_owned()) + .collect::>(); + completions.sort(); + completions + } +} diff --git a/crates/core/tedge/src/cli/config/cli.rs b/crates/core/tedge/src/cli/config/cli.rs index 6700c7dfba3..df151d77005 100644 --- a/crates/core/tedge/src/cli/config/cli.rs +++ b/crates/core/tedge/src/cli/config/cli.rs @@ -3,9 +3,6 @@ use crate::cli::config::commands::*; use crate::command::*; use crate::ConfigError; use clap_complete::ArgValueCandidates; -use tedge_config::tedge_toml::mapper_config::AwsMapperSpecificConfig; -use tedge_config::tedge_toml::mapper_config::AzMapperSpecificConfig; -use tedge_config::tedge_toml::mapper_config::C8yMapperSpecificConfig; use tedge_config::tedge_toml::ProfileName; use tedge_config::tedge_toml::ReadableKey; use tedge_config::tedge_toml::WritableKey; @@ -187,61 +184,3 @@ impl BuildCommand for ConfigCmd { } } } - -pub async fn restrict_cloud_config_update( - cmd: &str, - key: &WritableKey, - tedge_config: &TEdgeConfig, -) -> anyhow::Result<()> { - use tedge_config::tedge_toml::ConfigDecision as CD; - let key = key.to_cow_str(); - let (cloud, config_source) = match key.split_once(".") { - None => unreachable!("Configuration keys always contain ."), - Some((cloud @ "c8y", rest)) => { - let profile = extract_profile_name(rest); - let source = tedge_config - .decide_config_source::(profile.as_ref()) - .await; - (cloud, source) - } - Some((cloud @ "az", rest)) => { - let profile = extract_profile_name(rest); - let source = tedge_config - .decide_config_source::(profile.as_ref()) - .await; - (cloud, source) - } - Some((cloud @ "aws", rest)) => { - let profile = extract_profile_name(rest); - let source = tedge_config - .decide_config_source::(profile.as_ref()) - .await; - (cloud, source) - } - // Not a cloud config, we don't need to worry about - _ => return Ok(()), - }; - - match config_source { - // Cloud config stored in tedge.toml - CD::LoadLegacy => Ok(()), - - // The cloud config has been migrated to new format - CD::LoadNew { path } | CD::NotFound { path } => { - Err(anyhow::anyhow!("`tedge config {cmd}` cannot be used to update {cloud} mapper config. Please directly edit {path} to update {key}")) - }, - - // Mappers directory exists, but we can't see inside it to check if the cloud is migrated - CD::PermissionError { mapper_config_dir, error } => { - let message = format!("Could not access {mapper_config_dir} to establish whether {cloud} mapper config is stored in `tedge.toml` or a separate file"); - let error = anyhow::Error::new(error); - Err(error.context(message)) - } - } -} - -fn extract_profile_name(partial_config_key: &str) -> Option { - let partial_config_key = partial_config_key.strip_prefix("profiles.")?; - let (profile, _rest) = partial_config_key.split_once(".")?; - Some(profile.parse().unwrap()) -} diff --git a/crates/core/tedge/src/cli/config/commands/add.rs b/crates/core/tedge/src/cli/config/commands/add.rs index 34ec8bd51a3..24d72fcb197 100644 --- a/crates/core/tedge/src/cli/config/commands/add.rs +++ b/crates/core/tedge/src/cli/config/commands/add.rs @@ -1,5 +1,4 @@ use crate::command::Command; -use crate::config::restrict_cloud_config_update; use crate::log::MaybeFancy; use tedge_config::tedge_toml::WritableKey; use tedge_config::TEdgeConfig; @@ -20,7 +19,6 @@ impl Command for AddConfigCommand { } async fn execute(&self, tedge_config: TEdgeConfig) -> Result<(), MaybeFancy> { - restrict_cloud_config_update("add", &self.key, &tedge_config).await?; tedge_config .update_toml(&|dto, reader| { dto.try_append_str(reader, &self.key, &self.value) diff --git a/crates/core/tedge/src/cli/config/commands/remove.rs b/crates/core/tedge/src/cli/config/commands/remove.rs index 8d80bf9a239..360510af03c 100644 --- a/crates/core/tedge/src/cli/config/commands/remove.rs +++ b/crates/core/tedge/src/cli/config/commands/remove.rs @@ -1,5 +1,4 @@ use crate::command::Command; -use crate::config::restrict_cloud_config_update; use crate::log::MaybeFancy; use tedge_config::tedge_toml::WritableKey; use tedge_config::TEdgeConfig; @@ -16,7 +15,6 @@ impl Command for RemoveConfigCommand { } async fn execute(&self, tedge_config: TEdgeConfig) -> Result<(), MaybeFancy> { - restrict_cloud_config_update("remove", &self.key, &tedge_config).await?; tedge_config .update_toml(&|dto, reader| { dto.try_remove_str(reader, &self.key, &self.value) diff --git a/crates/core/tedge/src/cli/config/commands/set.rs b/crates/core/tedge/src/cli/config/commands/set.rs index 7108bab1c09..c1b81fe7a90 100644 --- a/crates/core/tedge/src/cli/config/commands/set.rs +++ b/crates/core/tedge/src/cli/config/commands/set.rs @@ -1,5 +1,4 @@ use crate::command::Command; -use crate::config::restrict_cloud_config_update; use crate::log::MaybeFancy; use tedge_config::tedge_toml::WritableKey; use tedge_config::TEdgeConfig; @@ -20,7 +19,6 @@ impl Command for SetConfigCommand { } async fn execute(&self, tedge_config: TEdgeConfig) -> Result<(), MaybeFancy> { - restrict_cloud_config_update("set", &self.key, &tedge_config).await?; tedge_config .update_toml(&|dto, _reader| { dto.try_update_str(&self.key, &self.value) diff --git a/crates/core/tedge/src/cli/config/commands/unset.rs b/crates/core/tedge/src/cli/config/commands/unset.rs index ab337d72c4c..0604e09ce27 100644 --- a/crates/core/tedge/src/cli/config/commands/unset.rs +++ b/crates/core/tedge/src/cli/config/commands/unset.rs @@ -1,5 +1,4 @@ use crate::command::Command; -use crate::config::restrict_cloud_config_update; use crate::log::MaybeFancy; use tedge_config::tedge_toml::WritableKey; use tedge_config::TEdgeConfig; @@ -15,7 +14,6 @@ impl Command for UnsetConfigCommand { } async fn execute(&self, tedge_config: TEdgeConfig) -> Result<(), MaybeFancy> { - restrict_cloud_config_update("unset", &self.key, &tedge_config).await?; tedge_config .update_toml(&|dto, _reader| Ok(dto.try_unset_key(&self.key)?)) .await diff --git a/crates/core/tedge/src/cli/connect/command.rs b/crates/core/tedge/src/cli/connect/command.rs index 193be50744f..fb09a0b9b51 100644 --- a/crates/core/tedge/src/cli/connect/command.rs +++ b/crates/core/tedge/src/cli/connect/command.rs @@ -794,7 +794,11 @@ pub async fn bridge_config( bridge_keyfile: c8y_config.device.key_path.clone().into(), smartrest_templates: c8y_config.cloud_specific.smartrest.templates.clone(), smartrest_one_templates: c8y_config.cloud_specific.smartrest1.templates.clone(), - include_local_clean_session: c8y_config.bridge.include.local_cleansession, + include_local_clean_session: c8y_config + .cloud_specific + .bridge + .include + .local_cleansession, bridge_location, topic_prefix: c8y_config.bridge.topic_prefix.clone(), profile_name: profile.clone().map(Cow::into_owned), @@ -1253,9 +1257,9 @@ Each cloud profile requires either a unique URL or unique device ID, so it corre let err = validate_config(&config, &cloud).await.unwrap_err(); pretty_assertions::assert_eq!(err.to_string(), format!("You have matching URLs and device IDs for different profiles. -{path}/mappers/c8y.toml: url, {path}/mappers/c8y.d/new.toml: url are set to the same value, but so are device.id, device.id. +c8y.url, c8y.profiles.new.url are set to the same value, but so are c8y.device.id, c8y.profiles.new.device.id. -Each cloud profile requires either a unique URL or unique device ID, so it corresponds to a unique device in the associated cloud.", path = ttd.utf8_path())) +Each cloud profile requires either a unique URL or unique device ID, so it corresponds to a unique device in the associated cloud.")) } #[tokio::test] @@ -1274,9 +1278,9 @@ Each cloud profile requires either a unique URL or unique device ID, so it corre let err = validate_config(&config, &cloud).await.unwrap_err(); pretty_assertions::assert_eq!(err.to_string(), format!("You have matching URLs and device IDs for different profiles. -{path}/mappers/c8y.toml: url, {path}/mappers/c8y.d/new.toml: url are set to the same value, but so are {path}/mappers/c8y.toml: device.id, {path}/mappers/c8y.d/new.toml: device.id. +c8y.url, c8y.profiles.new.url are set to the same value, but so are c8y.device.id, c8y.profiles.new.device.id. -Each cloud profile requires either a unique URL or unique device ID, so it corresponds to a unique device in the associated cloud.", path = ttd.utf8_path())) +Each cloud profile requires either a unique URL or unique device ID, so it corresponds to a unique device in the associated cloud.")) } #[tokio::test] diff --git a/crates/core/tedge/tests/main.rs b/crates/core/tedge/tests/main.rs index 78fca31bd72..7f49efce4e5 100644 --- a/crates/core/tedge/tests/main.rs +++ b/crates/core/tedge/tests/main.rs @@ -403,63 +403,6 @@ mod tests { assert!(output_str.contains("Example")); } - // Tests for restrict_cloud_config_update behavior - #[test_case("set", "c8y.url", "new.example.com", "c8y", None; "set default c8y")] - #[test_case("unset", "c8y.url", "", "c8y", None; "unset default c8y")] - #[test_case("add", "c8y.smartrest.templates", "template1", "c8y", None; "add default c8y")] - #[test_case("remove", "c8y.smartrest.templates", "template1", "c8y", None; "remove default c8y")] - #[test_case("set", "az.url", "new.example.com", "az", None; "set default az")] - #[test_case("unset", "az.url", "", "az", None; "unset default az")] - #[test_case("set", "aws.url", "new.example.com", "aws", None; "set default aws")] - #[test_case("unset", "aws.url", "", "aws", None; "unset default aws")] - #[test_case("set", "c8y.profiles.prod.url", "new.example.com", "c8y", Some("prod"); "set profile c8y")] - #[test_case("set", "az.profiles.prod.url", "new.example.com", "az", Some("prod"); "set profile az")] - #[test_case("set", "aws.profiles.prod.url", "new.example.com", "aws", Some("prod"); "set profile aws")] - fn migrated_config_blocks_mutation_commands( - cmd: &str, - config_key: &str, - value: &str, - cloud: &str, - profile: Option<&str>, - ) -> Result<(), Box> { - let temp_dir = tempfile::tempdir()?; - - // Setup migrated config for the specific cloud being tested - match profile { - None => setup_migrated_default(&temp_dir, cloud), - Some(p) => setup_migrated_profile(&temp_dir, cloud, p), - } - - let test_home = temp_dir.path().to_str().unwrap(); - - // Build expected path in error message - let expected_path = match profile { - None => format!("{}/mappers/{}.toml", test_home, cloud), - Some(p) => format!("{}/mappers/{}.d/{}.toml", test_home, cloud, p), - }; - - // Attempt the command - let mut args = vec!["--config-dir", test_home, "config", cmd, config_key]; - if !value.is_empty() { - args.push(value); - } - - let mut command = tedge_command_with_test_home(args)?; - - // Verify it fails with appropriate error - command - .assert() - .failure() - .stderr(predicate::str::contains(format!("tedge config {cmd}"))) - .stderr(predicate::str::contains(format!( - "cannot be used to update {cloud} mapper config" - ))) - .stderr(predicate::str::contains(&expected_path)) - .stderr(predicate::str::contains(config_key)); - - Ok(()) - } - #[test_case("set", "c8y.url", "new.example.com", "c8y"; "set c8y")] #[test_case("unset", "c8y.url", "", "c8y"; "unset c8y")] #[test_case("add", "c8y.smartrest.templates", "template1", "c8y"; "add c8y")] @@ -535,153 +478,108 @@ mod tests { Ok(()) } - #[test] - fn c8y_migration_does_not_affect_az_and_aws() -> Result<(), Box> { + #[test_case::test_case("aws")] + #[test_case::test_case("c8y")] + #[test_case::test_case("az")] + fn migrated_configs_are_written_to_by_tedge_config_set( + cloud: &str, + ) -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; - // Setup: c8y migrated, az and aws in legacy - setup_migrated_default(&temp_dir, "c8y"); - let tedge_toml = temp_dir.path().join("tedge.toml"); - std::fs::write( - &tedge_toml, - "[az]\nurl = \"az.example.com\"\n[aws]\nurl = \"aws.example.com\"\n", - )?; - + setup_migrated_default(&temp_dir, cloud); let test_home = temp_dir.path().to_str().unwrap(); - // c8y set should fail - let mut set_c8y_cmd = tedge_command_with_test_home([ + let mut set_config_cmd = tedge_command_with_test_home([ "--config-dir", test_home, "config", "set", - "c8y.url", + &format!("{cloud}.url"), "new.example.com", ])?; - set_c8y_cmd.assert().failure(); + set_config_cmd.assert().success(); - // az set should succeed - let mut set_az_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home, - "config", - "set", - "az.url", - "new.az.example.com", - ])?; - set_az_cmd.assert().success(); - - // aws set should succeed - let mut set_aws_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home, - "config", - "set", - "aws.url", - "new.aws.example.com", - ])?; - set_aws_cmd.assert().success(); + let migrated_toml = + std::fs::read_to_string(temp_dir.path().join(format!("mappers/{cloud}.toml")))?; + assert_eq!(migrated_toml.trim(), "url = \"new.example.com\""); + // tedge.toml will be created when `tedge config set` runs + let tedge_toml = std::fs::read_to_string(temp_dir.path().join("tedge.toml"))?; + assert_eq!(tedge_toml.trim(), ""); Ok(()) } - #[test] - fn az_migration_does_not_affect_c8y_and_aws() -> Result<(), Box> { + #[test_case::test_case("aws")] + #[test_case::test_case("c8y")] + #[test_case::test_case("az")] + fn migrated_profiled_configs_can_be_created_by_tedge_config_set( + cloud: &str, + ) -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; - // Setup: az migrated, c8y and aws in legacy - setup_migrated_default(&temp_dir, "az"); - let tedge_toml = temp_dir.path().join("tedge.toml"); - std::fs::write( - &tedge_toml, - "[c8y]\nurl = \"c8y.example.com\"\n[aws]\nurl = \"aws.example.com\"\n", - )?; - + setup_migrated_default(&temp_dir, cloud); let test_home = temp_dir.path().to_str().unwrap(); - // az set should fail - let mut set_az_cmd = tedge_command_with_test_home([ + let mut set_config_command = tedge_command_with_test_home([ "--config-dir", test_home, "config", "set", - "az.url", + &format!("{cloud}.url"), "new.example.com", + "--profile", + "profile", ])?; - set_az_cmd.assert().failure(); - - // c8y set should succeed - let mut set_c8y_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home, - "config", - "set", - "c8y.url", - "new.c8y.example.com", - ])?; - set_c8y_cmd.assert().success(); + set_config_command.assert().success(); - // aws set should succeed - let mut set_aws_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home, - "config", - "set", - "aws.url", - "new.aws.example.com", - ])?; - set_aws_cmd.assert().success(); + let migrated_toml = std::fs::read_to_string( + temp_dir + .path() + .join(format!("mappers/{cloud}.d/profile.toml")), + ) + .unwrap(); + assert_eq!(migrated_toml.trim(), "url = \"new.example.com\""); + // tedge.toml will be created when `tedge config set` runs + let tedge_toml = std::fs::read_to_string(temp_dir.path().join("tedge.toml"))?; + assert_eq!(tedge_toml.trim(), ""); Ok(()) } - #[test] - fn aws_migration_does_not_affect_c8y_and_az() -> Result<(), Box> { + #[test_case::test_case("aws")] + #[test_case::test_case("c8y")] + #[test_case::test_case("az")] + fn migrated_profiled_configs_can_be_updated_by_tedge_config_set( + cloud: &str, + ) -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; - // Setup: aws migrated, c8y and az in legacy - setup_migrated_default(&temp_dir, "aws"); - let tedge_toml = temp_dir.path().join("tedge.toml"); - std::fs::write( - &tedge_toml, - "[c8y]\nurl = \"c8y.example.com\"\n[az]\nurl = \"az.example.com\"\n", - )?; - + setup_migrated_profile(&temp_dir, cloud, "profile"); let test_home = temp_dir.path().to_str().unwrap(); - // aws set should fail - let mut set_aws_cmd = tedge_command_with_test_home([ + let mut set_config_command = tedge_command_with_test_home([ "--config-dir", test_home, "config", "set", - "aws.url", + &format!("{cloud}.url"), "new.example.com", + "--profile", + "profile", ])?; - set_aws_cmd.assert().failure(); - - // c8y set should succeed - let mut set_c8y_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home, - "config", - "set", - "c8y.url", - "new.c8y.example.com", - ])?; - set_c8y_cmd.assert().success(); + set_config_command.assert().success(); - // az set should succeed - let mut set_az_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home, - "config", - "set", - "az.url", - "new.az.example.com", - ])?; - set_az_cmd.assert().success(); + let migrated_toml = std::fs::read_to_string( + temp_dir + .path() + .join(format!("mappers/{cloud}.d/profile.toml")), + ) + .unwrap(); + assert_eq!(migrated_toml.trim(), "url = \"new.example.com\""); + // tedge.toml will be created when `tedge config set` runs + let tedge_toml = std::fs::read_to_string(temp_dir.path().join("tedge.toml"))?; + assert_eq!(tedge_toml.trim(), ""); Ok(()) } diff --git a/crates/core/tedge_mapper/src/aws/mapper.rs b/crates/core/tedge_mapper/src/aws/mapper.rs index 455fd60187b..da31d146c81 100644 --- a/crates/core/tedge_mapper/src/aws/mapper.rs +++ b/crates/core/tedge_mapper/src/aws/mapper.rs @@ -82,10 +82,10 @@ impl TEdgeComponent for AwsMapper { } let clock = Box::new(WallClock); let aws_converter = AwsConverter::new( - aws_config.mapper.cloud_specific.timestamp, + aws_config.cloud_specific.mapper.timestamp, clock, mqtt_schema, - aws_config.mapper.cloud_specific.timestamp_format, + aws_config.cloud_specific.mapper.timestamp_format, prefix.value().clone(), aws_config.mapper.mqtt.max_payload_size.0, ); diff --git a/crates/core/tedge_mapper/src/az/mapper.rs b/crates/core/tedge_mapper/src/az/mapper.rs index 92bb7a0721c..834bbf7dfc1 100644 --- a/crates/core/tedge_mapper/src/az/mapper.rs +++ b/crates/core/tedge_mapper/src/az/mapper.rs @@ -91,10 +91,10 @@ impl TEdgeComponent for AzureMapper { } let mqtt_schema = MqttSchema::with_root(tedge_config.mqtt.topic_root.clone()); let az_converter = AzureConverter::new( - az_config.mapper.cloud_specific.timestamp, + az_config.cloud_specific.mapper.timestamp, Box::new(WallClock), mqtt_schema, - az_config.mapper.cloud_specific.timestamp_format, + az_config.cloud_specific.mapper.timestamp_format, prefix, az_config.mapper.mqtt.max_payload_size.0, ); diff --git a/plugins/tedge_file_log_plugin/src/main.rs b/plugins/tedge_file_log_plugin/src/main.rs index e5fc5fdeeaf..81fe30add71 100644 --- a/plugins/tedge_file_log_plugin/src/main.rs +++ b/plugins/tedge_file_log_plugin/src/main.rs @@ -3,10 +3,11 @@ use tedge_config::TEdgeConfig; use tedge_file_log_plugin::bin::FileLogCli; use tedge_file_log_plugin::bin::TEdgeConfigView; -fn main() -> anyhow::Result<()> { +#[tokio::main] +async fn main() -> anyhow::Result<()> { let cli = FileLogCli::parse(); - let tedge_config = TEdgeConfig::load_sync(&cli.common.config_dir)?; + let tedge_config = TEdgeConfig::load(&cli.common.config_dir).await?; let tmp_dir = tedge_config.tmp.path.as_path(); let view = TEdgeConfigView::new(tmp_dir);