Skip to content

Commit a78ef0d

Browse files
authored
Merge pull request #3902 from jarhodes314/feat/mapper-config-migration-tool
feat: Support automated config migration for separate mapper config
2 parents 3b252a2 + 451a648 commit a78ef0d

File tree

23 files changed

+2349
-132
lines changed

23 files changed

+2349
-132
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/common/tedge_config/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ clap_complete = { workspace = true }
2121
doku = { workspace = true }
2222
figment = { workspace = true, features = ["env", "toml"] }
2323
futures = { workspace = true }
24+
glob = { workspace = true }
2425
humantime = { workspace = true }
2526
mqtt_channel = { workspace = true }
2627
once_cell = { workspace = true }

crates/common/tedge_config/src/lib.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ pub use tedge_toml::tedge_config_location::*;
1717
pub use camino::Utf8Path as Path;
1818
pub use camino::Utf8PathBuf as PathBuf;
1919
pub use certificate::CertificateError;
20+
use models::CloudType;
2021
use std::path::Path as StdPath;
22+
use strum::IntoEnumIterator;
2123
pub use tedge_config_macros::all_or_nothing;
2224
pub use tedge_config_macros::OptionalConfig;
2325

@@ -27,13 +29,36 @@ impl TEdgeConfig {
2729
config_location.load().await
2830
}
2931

32+
/// Load [TEdgeConfig], using a separate mapper config file as the default
33+
/// behaviour if no clouds are already configured
34+
///
35+
/// As of 2026-01-05, this is only used for testing how the new default will
36+
/// work before we fully adopt the new format. When we do this, we should
37+
/// probably also include `tedge config upgrade` commands in the
38+
/// relevant package postinstall scripts.
39+
#[cfg(feature = "test")]
40+
pub async fn load_prefer_separate_mapper_config(
41+
config_dir: impl AsRef<StdPath>,
42+
) -> Result<Self, TEdgeConfigError> {
43+
let mut config_location = TEdgeConfigLocation::from_custom_root(config_dir.as_ref());
44+
config_location.default_to_mapper_config_dir();
45+
config_location.load().await
46+
}
47+
3048
pub async fn update_toml(
3149
self,
3250
update: &impl Fn(&mut TEdgeConfigDto, &TEdgeConfigReader) -> ConfigSettingResult<()>,
3351
) -> Result<(), TEdgeConfigError> {
3452
self.location().update_toml(update).await
3553
}
3654

55+
pub async fn migrate_mapper_configs(self) -> Result<(), TEdgeConfigError> {
56+
for cloud_type in CloudType::iter() {
57+
self.location().migrate_mapper_config(cloud_type).await?;
58+
}
59+
Ok(())
60+
}
61+
3762
#[cfg(feature = "test")]
3863
/// A test only method designed for injecting configuration into tests
3964
///

crates/common/tedge_config/src/tedge_toml/models/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub use topic_prefix::TopicPrefix;
5555
serde::Serialize,
5656
serde::Deserialize,
5757
strum::EnumIter,
58+
strum::AsRefStr,
5859
)]
5960
#[serde(rename_all = "lowercase")]
6061
#[strum(serialize_all = "lowercase")]

crates/common/tedge_config/src/tedge_toml/tedge_config.rs

Lines changed: 70 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
mod version;
22
use futures::Stream;
33
use reqwest::NoProxy;
4-
use serde::Deserialize;
54
use version::TEdgeTomlVersion;
65

76
mod append_remove;
@@ -62,7 +61,6 @@ use std::net::Ipv4Addr;
6261
use std::num::NonZeroU16;
6362
use std::path::PathBuf;
6463
use std::sync::Arc;
65-
use strum::IntoEnumIterator;
6664
use tedge_api::mqtt_topics::EntityTopicId;
6765
pub use tedge_config_macros::ConfigNotSet;
6866
pub use tedge_config_macros::MultiError;
@@ -104,16 +102,18 @@ impl std::ops::Deref for TEdgeConfig {
104102
}
105103
}
106104

107-
async fn read_file_if_exists(path: &Utf8Path) -> anyhow::Result<Option<String>> {
105+
async fn read_file_if_exists(
106+
path: &Utf8Path,
107+
config_dir: &Utf8Path,
108+
) -> anyhow::Result<Option<String>> {
108109
match tokio::fs::read_to_string(path).await {
109110
Ok(contents) => Ok(Some(contents)),
110111
Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
111112
Err(e) => {
112-
let dir = path.parent().unwrap();
113113
// If the error is actually with the mappers directory as a whole,
114114
// feed that back to the user
115-
if let Err(dir_error) = tokio::fs::read_dir(dir).await {
116-
Err(dir_error).context(format!("failed to read {dir}"))
115+
if let Err(dir_error) = tokio::fs::read_dir(config_dir).await {
116+
Err(dir_error).context(format!("failed to read {config_dir}"))
117117
} else {
118118
Err(e).context(format!("failed to read mapper configuration from {path}"))
119119
}
@@ -132,34 +132,41 @@ impl TEdgeConfigDto {
132132
use futures::StreamExt;
133133
use futures::TryStreamExt;
134134

135-
let mappers_dir = location.tedge_config_root_path().join("mappers");
135+
let mappers_dir = location.mappers_config_dir();
136136
let all_profiles = location.mapper_config_profiles::<T>().await;
137137
let ty = T::expected_cloud_type();
138138
if let Some(profiles) = all_profiles {
139+
let config_paths = location.config_path::<T>();
140+
let default_profile_path = config_paths.path_for(None::<&ProfileName>);
141+
139142
if !dto.is_default() {
140-
tracing::warn!("{ty} configuration found in `tedge.toml`, but this will be ignored in favour of configuration in {mappers_dir}/{ty}.toml and {mappers_dir}/{ty}.d")
143+
let wildcard_profile_path = config_paths.path_for(Some("*"));
144+
tracing::warn!("{ty} configuration found in `tedge.toml`, but this will be ignored in favour of configuration in {default_profile_path} and {wildcard_profile_path}")
141145
}
142-
let toml_path = mappers_dir.join(format!("{ty}.toml"));
143-
let default_profile_toml = read_file_if_exists(&toml_path).await?;
146+
147+
let default_profile_toml =
148+
read_file_if_exists(&default_profile_path, &config_paths.base_dir).await?;
144149
let mut default_profile_config: T::CloudDto = default_profile_toml.map_or_else(
145150
|| Ok(<_>::default()),
146151
|toml| {
147152
toml::from_str(&toml).with_context(|| {
148-
format!("failed to deserialise mapper config in {toml_path}")
153+
format!("failed to deserialise mapper config in {default_profile_path}")
149154
})
150155
},
151156
)?;
152-
default_profile_config.set_mapper_config_dir(mappers_dir.clone());
157+
default_profile_config.set_mappers_root_dir(mappers_dir.clone());
158+
default_profile_config.set_mapper_config_file(default_profile_path);
153159
dto.non_profile = default_profile_config;
154160

155-
dto.profiles = profiles
161+
dto.profiles = futures::stream::iter(profiles)
156162
.filter_map(futures::future::ready)
157163
.then(|profile| async {
158-
let toml_path = mappers_dir.join(format!("{ty}.d/{profile}.toml"));
164+
let toml_path = config_paths.path_for(Some(&profile));
159165
let profile_toml = tokio::fs::read_to_string(&toml_path).await?;
160166
let mut profiled_config: T::CloudDto = toml::from_str(&profile_toml)
161167
.context("failed to deserialise mapper config")?;
162-
profiled_config.set_mapper_config_dir(mappers_dir.clone());
168+
profiled_config.set_mappers_root_dir(mappers_dir.clone());
169+
profiled_config.set_mapper_config_file(toml_path);
163170
Ok::<_, anyhow::Error>((profile, profiled_config))
164171
})
165172
.try_collect()
@@ -227,10 +234,6 @@ impl TEdgeConfig {
227234
)?)
228235
}
229236

230-
pub fn profiled_config_directories(&self) -> impl Iterator<Item = Utf8PathBuf> + use<'_> {
231-
CloudType::iter().map(|ty| self.location.mappers_config_dir().join(format!("{ty}.d")))
232-
}
233-
234237
fn all_profiles<'a, T>(&'a self) -> Box<dyn Iterator<Item = Option<ProfileName>> + 'a>
235238
where
236239
T: ExpectedCloudType,
@@ -421,6 +424,12 @@ impl CloudConfig for DynCloudConfig<'_> {
421424
Self::Borrow(config) => config.key_pin(),
422425
}
423426
}
427+
fn mapper_config_location(&self) -> &Utf8Path {
428+
match self {
429+
Self::Arc(config) => config.mapper_config_location(),
430+
Self::Borrow(config) => config.mapper_config_location(),
431+
}
432+
}
424433
}
425434

426435
/// The keys that can be read from the configuration
@@ -439,12 +448,6 @@ pub static READABLE_KEYS: Lazy<Vec<(Cow<'static, str>, doku::Type)>> = Lazy::new
439448
struct_field_paths(None, &fields)
440449
});
441450

442-
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, doku::Document, serde::Serialize)]
443-
pub enum MapperConfigLocation {
444-
TedgeToml,
445-
SeparateFile(#[doku(as = "String")] camino::Utf8PathBuf),
446-
}
447-
448451
define_tedge_config! {
449452
#[tedge_config(reader(skip))]
450453
config: {
@@ -576,6 +579,10 @@ define_tedge_config! {
576579
#[serde(skip)]
577580
mapper_config_dir: Utf8PathBuf,
578581

582+
#[tedge_config(reader(skip))]
583+
#[serde(skip)]
584+
mapper_config_file: Utf8PathBuf,
585+
579586
/// Endpoint URL of Cumulocity tenant
580587
#[tedge_config(example = "your-tenant.cumulocity.com")]
581588
// Config consumers should use `c8y.http`/`c8y.mqtt` as appropriate, hence this field is private
@@ -820,6 +827,10 @@ define_tedge_config! {
820827
#[serde(skip)]
821828
mapper_config_dir: Utf8PathBuf,
822829

830+
#[tedge_config(reader(skip))]
831+
#[serde(skip)]
832+
mapper_config_file: Utf8PathBuf,
833+
823834
/// Endpoint URL of Azure IoT tenant
824835
#[tedge_config(example = "myazure.azure-devices.net")]
825836
url: ConnectUrl,
@@ -911,6 +922,10 @@ define_tedge_config! {
911922
#[serde(skip)]
912923
mapper_config_dir: Utf8PathBuf,
913924

925+
#[tedge_config(reader(skip))]
926+
#[serde(skip)]
927+
mapper_config_file: Utf8PathBuf,
928+
914929
/// Endpoint URL of AWS IoT tenant
915930
#[tedge_config(example = "your-endpoint.amazonaws.com")]
916931
url: ConnectUrl,
@@ -1411,6 +1426,7 @@ pub trait CloudConfig {
14111426
fn root_cert_path(&self) -> &Utf8Path;
14121427
fn key_uri(&self) -> Option<Arc<str>>;
14131428
fn key_pin(&self) -> Option<Arc<str>>;
1429+
fn mapper_config_location(&self) -> &Utf8Path;
14141430
}
14151431

14161432
impl<T: SpecialisedCloudConfig> CloudConfig for MapperConfig<T> {
@@ -1433,6 +1449,10 @@ impl<T: SpecialisedCloudConfig> CloudConfig for MapperConfig<T> {
14331449
fn key_pin(&self) -> Option<Arc<str>> {
14341450
self.device.key_pin.clone()
14351451
}
1452+
1453+
fn mapper_config_location(&self) -> &Utf8Path {
1454+
&self.location
1455+
}
14361456
}
14371457

14381458
fn c8y_topic_prefix() -> TopicPrefix {
@@ -1816,7 +1836,8 @@ mod tests {
18161836
async fn mapper_config_reads_non_profiled_mapper() {
18171837
let ttd = TempTedgeDir::new();
18181838
ttd.dir("mappers")
1819-
.file("c8y.toml")
1839+
.dir("c8y")
1840+
.file("tedge.toml")
18201841
.with_toml_content(toml::toml! {
18211842
url = "example.com"
18221843

@@ -1838,8 +1859,8 @@ mod tests {
18381859
async fn mapper_config_reads_profiled_mapper() {
18391860
let ttd = TempTedgeDir::new();
18401861
ttd.dir("mappers")
1841-
.dir("c8y.d")
1842-
.file("myprofile.toml")
1862+
.dir("c8y.myprofile")
1863+
.file("tedge.toml")
18431864
.with_toml_content(toml::toml! {
18441865
url = "example.com"
18451866

@@ -1861,8 +1882,8 @@ mod tests {
18611882
async fn mapper_config_fails_if_profile_is_not_migrated_but_directory_exists() {
18621883
let ttd = TempTedgeDir::new();
18631884
ttd.dir("mappers")
1864-
.dir("c8y.d")
1865-
.file("myprofile.toml")
1885+
.dir("c8y.myprofile")
1886+
.file("tedge.toml")
18661887
.with_toml_content(toml::toml! {
18671888
url = "example.com"
18681889

@@ -1882,7 +1903,8 @@ mod tests {
18821903
async fn mapper_config_fails_if_profile_is_not_migrated_but_default_profile_is() {
18831904
let ttd = TempTedgeDir::new();
18841905
ttd.dir("mappers")
1885-
.file("c8y.toml")
1906+
.dir("c8y")
1907+
.file("tedge.toml")
18861908
.with_toml_content(toml::toml! {
18871909
url = "example.com"
18881910

@@ -1936,7 +1958,7 @@ mod tests {
19361958
async fn mapper_config_falls_back_to_tedge_toml_config_if_only_another_cloud_is_using_new_format(
19371959
) {
19381960
let ttd = TempTedgeDir::new();
1939-
ttd.dir("mappers").file("c8y.toml");
1961+
ttd.dir("mappers").dir("c8y").file("tedge.toml");
19401962
ttd.file("tedge.toml").with_toml_content(toml::toml! {
19411963
az.url = "az.url"
19421964
});
@@ -1987,7 +2009,8 @@ mod tests {
19872009
async fn all_mapper_configs_succeeds_when_profile_directory_does_not_exist() {
19882010
let ttd = TempTedgeDir::new();
19892011
ttd.dir("mappers")
1990-
.file("c8y.toml")
2012+
.dir("c8y")
2013+
.file("tedge.toml")
19912014
.with_toml_content(toml::toml! {
19922015
url = "example.com"
19932016
});
@@ -2007,18 +2030,21 @@ mod tests {
20072030
async fn all_mapper_configs_includes_both_default_and_profiles_when_directory_exists() {
20082031
let ttd = TempTedgeDir::new();
20092032
let mappers_dir = ttd.dir("mappers");
2010-
mappers_dir.file("c8y.toml").with_toml_content(toml::toml! {
2011-
url = "default.example.com"
2012-
});
20132033
mappers_dir
2014-
.dir("c8y.d")
2015-
.file("profile1.toml")
2034+
.dir("c8y")
2035+
.file("tedge.toml")
2036+
.with_toml_content(toml::toml! {
2037+
url = "default.example.com"
2038+
});
2039+
mappers_dir
2040+
.dir("c8y.profile1")
2041+
.file("tedge.toml")
20162042
.with_toml_content(toml::toml! {
20172043
url = "profile1.example.com"
20182044
});
20192045
mappers_dir
2020-
.dir("c8y.d")
2021-
.file("profile2.toml")
2046+
.dir("c8y.profile2")
2047+
.file("tedge.toml")
20222048
.with_toml_content(toml::toml! {
20232049
url = "profile2.example.com"
20242050
});
@@ -2049,13 +2075,15 @@ mod tests {
20492075
let ttd = TempTedgeDir::new();
20502076
// C8y mapper with no profile directory
20512077
ttd.dir("mappers")
2052-
.file("c8y.toml")
2078+
.dir("c8y")
2079+
.file("tedge.toml")
20532080
.with_toml_content(toml::toml! {
20542081
url = "c8y.example.com"
20552082
});
20562083
// Az mapper with no profile directory
20572084
ttd.dir("mappers")
2058-
.file("az.toml")
2085+
.dir("az")
2086+
.file("tedge.toml")
20592087
.with_toml_content(toml::toml! {
20602088
url = "az.example.com"
20612089
});

0 commit comments

Comments
 (0)