diff --git a/Cargo.lock b/Cargo.lock index e593ba8b373..52079b66e4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,12 +1042,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.3" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core 0.20.3", - "darling_macro 0.20.3", + "darling_core 0.20.10", + "darling_macro 0.20.10", ] [[package]] @@ -1066,15 +1066,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.3" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", - "strsim 0.10.0", + "strsim 0.11.1", "syn 2.0.77", ] @@ -1091,11 +1091,11 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.3" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core 0.20.3", + "darling_core 0.20.10", "quote", "syn 2.0.77", ] @@ -4134,6 +4134,7 @@ version = "1.4.2" dependencies = [ "anyhow", "camino", + "certificate", "clap", "clap_complete", "doku", @@ -4152,7 +4153,7 @@ dependencies = [ name = "tedge_config_macros-impl" version = "1.4.2" dependencies = [ - "darling 0.20.3", + "darling 0.20.10", "heck 0.4.1", "itertools 0.13.0", "pretty_assertions", diff --git a/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs b/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs index d04398cf5a6..7af2434c837 100644 --- a/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs +++ b/crates/common/tedge_config/src/tedge_config_cli/tedge_config.rs @@ -154,15 +154,8 @@ define_tedge_config! { device: { /// Identifier of the device within the fleet. It must be globally /// unique and is derived from the device certificate. - #[tedge_config(readonly( - write_error = "\ - The device id is read from the device certificate and cannot be set directly.\n\ - To set 'device.id' to some , you can use `tedge cert create --device-id `.", - function = "device_id", - ))] + #[tedge_config(reader(function = "device_id", private))] #[tedge_config(example = "Raspberrypi-4d18303a-6d3a-11eb-b1a6-175f6bb72665")] - #[tedge_config(note = "This setting is derived from the device certificate and is therefore read only.")] - #[tedge_config(reader(private))] #[doku(as = "String")] id: Result, @@ -215,14 +208,9 @@ define_tedge_config! { device: { /// Identifier of the device within the fleet. It must be globally /// unique and is derived from the device certificate. - #[tedge_config(readonly( - write_error = "\ - The device id is read from the device certificate and cannot be set directly.\n\ - To set 'device.id' to some , you can use `tedge cert create --device-id `.", - function = "c8y_device_id", - ))] + #[tedge_config(reader(function = "c8y_device_id"))] + #[tedge_config(default(from_optional_key = "device.id"))] #[tedge_config(example = "Raspberrypi-4d18303a-6d3a-11eb-b1a6-175f6bb72665")] - #[tedge_config(note = "This setting is derived from the device certificate and is therefore read only.")] #[doku(as = "String")] id: Result, @@ -411,14 +399,9 @@ define_tedge_config! { device: { /// Identifier of the device within the fleet. It must be globally /// unique and is derived from the device certificate. - #[tedge_config(readonly( - write_error = "\ - The device id is read from the device certificate and cannot be set directly.\n\ - To set 'device.id' to some , you can use `tedge cert create --device-id `.", - function = "az_device_id", - ))] + #[tedge_config(reader(function = "az_device_id"))] + #[tedge_config(default(from_optional_key = "device.id"))] #[tedge_config(example = "Raspberrypi-4d18303a-6d3a-11eb-b1a6-175f6bb72665")] - #[tedge_config(note = "This setting is derived from the device certificate and is therefore read only.")] #[doku(as = "String")] id: Result, @@ -481,14 +464,9 @@ define_tedge_config! { device: { /// Identifier of the device within the fleet. It must be globally /// unique and is derived from the device certificate. - #[tedge_config(readonly( - write_error = "\ - The device id is read from the device certificate and cannot be set directly.\n\ - To set 'device.id' to some , you can use `tedge cert create --device-id `.", - function = "aws_device_id", - ))] + #[tedge_config(reader(function = "aws_device_id"))] + #[tedge_config(default(from_optional_key = "device.id"))] #[tedge_config(example = "Raspberrypi-4d18303a-6d3a-11eb-b1a6-175f6bb72665")] - #[tedge_config(note = "This setting is derived from the device certificate and is therefore read only.")] #[doku(as = "String")] id: Result, @@ -929,6 +907,15 @@ impl TEdgeConfigReader { Some(Cloud::Aws(profile)) => &self.aws.try_get(profile)?.device.cert_path, }) } + + pub fn device_id<'a>(&self, cloud: Option>>) -> Result<&str, ReadError> { + Ok(match cloud.map(<_>::into) { + None => self.device.id()?, + Some(Cloud::C8y(profile)) => self.c8y.try_get(profile)?.device.id()?, + Some(Cloud::Az(profile)) => self.az.try_get(profile)?.device.id()?, + Some(Cloud::Aws(profile)) => self.aws.try_get(profile)?.device.id()?, + }) + } } #[derive(Debug, PartialEq, Eq, Clone)] @@ -1019,27 +1006,77 @@ fn default_http_bind_address(dto: &TEdgeConfigDto) -> IpAddr { fn device_id_from_cert(cert_path: &Utf8Path) -> Result { let pem = PemCertificate::from_pem_file(cert_path) - .map_err(|err| cert_error_into_config_error(ReadOnlyKey::DeviceId.to_cow_str(), err))?; + .map_err(|err| cert_error_into_config_error(ReadableKey::DeviceId.to_cow_str(), err))?; let device_id = pem .subject_common_name() - .map_err(|err| cert_error_into_config_error(ReadOnlyKey::DeviceId.to_cow_str(), err))?; + .map_err(|err| cert_error_into_config_error(ReadableKey::DeviceId.to_cow_str(), err))?; Ok(device_id) } -fn device_id(device: &TEdgeConfigReaderDevice) -> Result { - device_id_from_cert(&device.cert_path) +fn device_id( + device: &TEdgeConfigReaderDevice, + dto_value: &OptionalConfig, +) -> Result { + match dto_value.or_none() { + Some(id) => Ok(id.clone()), + None => device_id_from_cert(&device.cert_path), + } } -fn c8y_device_id(device: &TEdgeConfigReaderC8yDevice) -> Result { - device_id_from_cert(&device.cert_path) +fn c8y_device_id( + c8y_device: &TEdgeConfigReaderC8yDevice, + dto_value: &OptionalConfig, +) -> Result { + match dto_value.or_none() { + Some(id) => Ok(id.clone()), + None => device_id_from_cert(&c8y_device.cert_path), + } } -fn az_device_id(device: &TEdgeConfigReaderAzDevice) -> Result { - device_id_from_cert(&device.cert_path) +fn az_device_id( + az_device: &TEdgeConfigReaderAzDevice, + dto_value: &OptionalConfig, +) -> Result { + match dto_value.or_none() { + Some(id) => Ok(id.clone()), + None => device_id_from_cert(&az_device.cert_path), + } } -fn aws_device_id(device: &TEdgeConfigReaderAwsDevice) -> Result { - device_id_from_cert(&device.cert_path) +fn aws_device_id( + aws_device: &TEdgeConfigReaderAwsDevice, + dto_value: &OptionalConfig, +) -> Result { + match dto_value.or_none() { + Some(id) => Ok(id.clone()), + None => device_id_from_cert(&aws_device.cert_path), + } +} + +pub fn explicit_device_id( + config_location: &TEdgeConfigLocation, + cloud: &Option, +) -> Option { + let dto = config_location.load_dto_from_toml_and_env().ok()?; + + match cloud { + None => dto.device.id.clone(), + Some(Cloud::C8y(profile)) => { + let key = profile.map(|name| name.to_string()); + let c8y_dto = dto.c8y.try_get(key.as_deref(), "c8y").ok()?; + c8y_dto.device.id.clone() + } + Some(Cloud::Az(profile)) => { + let key = profile.map(|name| name.to_string()); + let az_dto = dto.az.try_get(key.as_deref(), "az").ok()?; + az_dto.device.id.clone() + } + Some(Cloud::Aws(profile)) => { + let key = profile.map(|name| name.to_string()); + let aws_dto = dto.aws.try_get(key.as_deref(), "aws").ok()?; + aws_dto.device.id.clone() + } + } } fn cert_error_into_config_error(key: Cow<'static, str>, err: CertificateError) -> ReadError { diff --git a/crates/common/tedge_config/src/tedge_config_cli/tedge_config_location.rs b/crates/common/tedge_config/src/tedge_config_cli/tedge_config_location.rs index 8248288f172..9b48b437853 100644 --- a/crates/common/tedge_config/src/tedge_config_cli/tedge_config_location.rs +++ b/crates/common/tedge_config/src/tedge_config_cli/tedge_config_location.rs @@ -102,6 +102,10 @@ impl TEdgeConfigLocation { Ok(TEdgeConfig::from_dto(&dto, self)) } + pub fn load_dto_from_toml_and_env(&self) -> Result { + self.load_dto::(self.toml_path()) + } + fn load_dto( &self, path: &Utf8Path, diff --git a/crates/common/tedge_config_macros/Cargo.toml b/crates/common/tedge_config_macros/Cargo.toml index 2d68a9bdc8c..b8f816beeb2 100644 --- a/crates/common/tedge_config_macros/Cargo.toml +++ b/crates/common/tedge_config_macros/Cargo.toml @@ -22,6 +22,7 @@ tracing = { workspace = true } url = { workspace = true } [dev-dependencies] +certificate = { workspace = true, features = ["reqwest"] } clap = { workspace = true } serde = { workspace = true, features = ["rc"] } serde_json = { workspace = true } diff --git a/crates/common/tedge_config_macros/examples/macro.rs b/crates/common/tedge_config_macros/examples/macro.rs index 6162aa735f1..dc283385fa5 100644 --- a/crates/common/tedge_config_macros/examples/macro.rs +++ b/crates/common/tedge_config_macros/examples/macro.rs @@ -189,7 +189,7 @@ define_tedge_config! { } } -fn device_id(_reader: &TEdgeConfigReaderDevice) -> Result { +fn device_id(_reader: &TEdgeConfigReaderDevice, _: &()) -> Result { Ok("dummy-device-id".to_owned()) } diff --git a/crates/common/tedge_config_macros/examples/multi.rs b/crates/common/tedge_config_macros/examples/multi.rs index 7414c39ed98..71d0ae90e8b 100644 --- a/crates/common/tedge_config_macros/examples/multi.rs +++ b/crates/common/tedge_config_macros/examples/multi.rs @@ -111,14 +111,14 @@ fn main() { keys, [ "c8y.http", - "c8y.smartrest.use_operation_id", - "c8y.url", "c8y.profiles.cloud.http", "c8y.profiles.cloud.smartrest.use_operation_id", "c8y.profiles.cloud.url", "c8y.profiles.edge.http", "c8y.profiles.edge.smartrest.use_operation_id", - "c8y.profiles.edge.url" + "c8y.profiles.edge.url", + "c8y.smartrest.use_operation_id", + "c8y.url", ] ); } diff --git a/crates/common/tedge_config_macros/examples/reader_function.rs b/crates/common/tedge_config_macros/examples/reader_function.rs new file mode 100644 index 00000000000..3d15e8618f9 --- /dev/null +++ b/crates/common/tedge_config_macros/examples/reader_function.rs @@ -0,0 +1,148 @@ +use camino::Utf8Path; +use camino::Utf8PathBuf; +use certificate::CertificateError; +use certificate::PemCertificate; +use std::borrow::Cow; +use tedge_config_macros::*; + +#[derive(thiserror::Error, Debug)] +pub enum ReadError { + #[error(transparent)] + ConfigNotSet(#[from] ConfigNotSet), + + #[error(transparent)] + Multi(#[from] MultiError), + + #[error("Config value {key}, cannot be read: {message} ")] + ReadOnlyNotFound { + key: Cow<'static, str>, + message: &'static str, + }, + + #[error("Derivation for `{key}` failed: {cause}")] + DerivationFailed { + key: Cow<'static, str>, + cause: String, + }, +} + +pub trait AppendRemoveItem { + type Item; + + fn append(current_value: Option, new_value: Self::Item) -> Option; + + fn remove(current_value: Option, remove_value: Self::Item) -> Option; +} + +impl AppendRemoveItem for T { + type Item = T; + + fn append(_current_value: Option, _new_value: Self::Item) -> Option { + unimplemented!() + } + + fn remove(_current_value: Option, _remove_value: Self::Item) -> Option { + unimplemented!() + } +} + +define_tedge_config! { + device: { + #[tedge_config(reader(function = "device_id"))] + #[doku(as = "String")] + id: Result, + + #[doku(as = "String")] + cert_path: Utf8PathBuf, + }, + + #[tedge_config(multi)] + c8y: { + device: { + #[tedge_config(reader(function = "c8y_device_id"))] + #[tedge_config(default(from_optional_key = "device.id"))] + #[doku(as = "String")] + id: Result, + + #[doku(as = "String")] + #[tedge_config(default(from_optional_key = "device.cert_path"))] + cert_path: Utf8PathBuf, + } + }, +} + +fn device_id( + device: &TEdgeConfigReaderDevice, + dto_value: &OptionalConfig, +) -> Result { + match dto_value.or_none() { + Some(dto_value) => Ok(dto_value.to_owned()), + None => { + let cert = device.cert_path.or_config_not_set()?; + device_id_from_cert(cert) + } + } +} + +fn c8y_device_id( + c8y_device: &TEdgeConfigReaderC8yDevice, + dto_value: &OptionalConfig, +) -> Result { + match dto_value.or_none() { + Some(dto_value) => Ok(dto_value.to_owned()), + None => { + let cert = c8y_device.cert_path.or_config_not_set()?; + device_id_from_cert(cert) + } + } +} + +fn device_id_from_cert(cert_path: &Utf8Path) -> Result { + let pem = PemCertificate::from_pem_file(cert_path) + .map_err(|err| cert_error_into_config_error(ReadableKey::DeviceId.to_cow_str(), err))?; + let device_id = pem + .subject_common_name() + .map_err(|err| cert_error_into_config_error(ReadableKey::DeviceId.to_cow_str(), err))?; + Ok(device_id) +} + +fn cert_error_into_config_error(key: Cow<'static, str>, err: CertificateError) -> ReadError { + match &err { + CertificateError::IoError { error, .. } => match error.kind() { + std::io::ErrorKind::NotFound => ReadError::ReadOnlyNotFound { + key, + message: concat!( + "The device id is read from the device certificate.\n", + "To set 'device.id' to some , you can use `tedge cert create --device-id `.", + ), + }, + _ => ReadError::DerivationFailed { + key, + cause: format!("{}", err), + }, + }, + _ => ReadError::DerivationFailed { + key, + cause: format!("{}", err), + }, + } +} + +fn read_config(toml: &str) -> TEdgeConfigReader { + let c8y_dto = toml::from_str(toml).unwrap(); + TEdgeConfigReader::from_dto(&c8y_dto, &TEdgeConfigLocation) +} + +fn main() { + let config = read_config("device.id = \"test-device-id\""); + let c8y = config.c8y.try_get::<&str>(None).unwrap(); + + assert_eq!(config.device.id().unwrap(), "test-device-id"); + assert_eq!(c8y.device.id().unwrap(), "test-device-id"); + + let config = read_config("device.id = \"test-device-id\"\nc8y.device.id = \"c8y-device-id\""); + let c8y = config.c8y.try_get::<&str>(None).unwrap(); + + assert_eq!(config.device.id().unwrap(), "test-device-id"); + assert_eq!(c8y.device.id().unwrap(), "c8y-device-id"); +} diff --git a/crates/common/tedge_config_macros/impl/src/dto.rs b/crates/common/tedge_config_macros/impl/src/dto.rs index 4fbe7e661f3..7c49e7a6095 100644 --- a/crates/common/tedge_config_macros/impl/src/dto.rs +++ b/crates/common/tedge_config_macros/impl/src/dto.rs @@ -4,6 +4,7 @@ use quote::quote_spanned; use syn::parse_quote_spanned; use syn::spanned::Spanned; +use crate::error::extract_type_from_result; use crate::input::FieldOrGroup; use crate::prefixed_type_name; @@ -21,7 +22,17 @@ pub fn generate( for item in items { match item { FieldOrGroup::Field(field) => { - if !field.dto().skip && field.read_only().is_none() { + if field.reader_function().is_some() { + let ty = match extract_type_from_result(field.ty()) { + Some((ok, _err)) => ok, + None => field.ty(), + }; + idents.push(field.ident()); + tys.push(parse_quote_spanned!(ty.span() => Option<#ty>)); + sub_dtos.push(None); + preserved_attrs.push(field.attrs().iter().filter(is_preserved).collect()); + extra_attrs.push(quote! {}); + } else if !field.dto().skip && field.read_only().is_none() { idents.push(field.ident()); tys.push({ let ty = field.ty(); @@ -106,7 +117,11 @@ fn is_preserved(attr: &&syn::Attribute) -> bool { #[cfg(test)] mod tests { + use proc_macro2::Span; use syn::parse_quote; + use syn::Ident; + use syn::Item; + use syn::ItemStruct; use super::*; @@ -137,4 +152,135 @@ mod tests { #[unknown_attribute = "some value"] ))) } + + #[test] + fn dto_is_generated() { + let input: crate::input::Configuration = parse_quote!( + c8y: { + url: String, + }, + sudo: { + enable: bool, + }, + ); + + let generated = generate_test_dto(&input); + let expected = parse_quote! { + #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[non_exhaustive] + pub struct TEdgeConfigDto { + #[serde(default)] + #[serde(skip_serializing_if = "TEdgeConfigDtoC8y::is_default")] + pub c8y: TEdgeConfigDtoC8y, + #[serde(default)] + #[serde(skip_serializing_if = "TEdgeConfigDtoSudo::is_default")] + pub sudo: TEdgeConfigDtoSudo, + } + + impl TEdgeConfigDto { + #[allow(unused)] + fn is_default(&self) -> bool { + self == &Self::default() + } + } + + #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[non_exhaustive] + pub struct TEdgeConfigDtoC8y { + pub url: Option, + } + + impl TEdgeConfigDtoC8y { + #[allow(unused)] + fn is_default(&self) -> bool { + self == &Self::default() + } + } + + #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[non_exhaustive] + pub struct TEdgeConfigDtoSudo { + pub enable: Option, + } + + impl TEdgeConfigDtoSudo { + #[allow(unused)] + fn is_default(&self) -> bool { + self == &Self::default() + } + } + }; + + assert_eq(&generated, &expected); + } + + #[test] + fn ok_type_is_extracted_from_reader_function_if_relevant() { + let input: crate::input::Configuration = parse_quote!( + device: { + #[tedge_config(reader(function = "c8y_device_id"))] + id: Result, + } + ); + + let mut generated = generate_test_dto(&input); + generated + .items + .retain(only_struct_named("TEdgeConfigDtoDevice")); + + let expected = parse_quote! { + #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[non_exhaustive] + pub struct TEdgeConfigDtoDevice { + pub id: Option, + } + }; + + assert_eq(&generated, &expected); + } + + #[test] + fn reader_function_type_is_used_verbatim_in_dto_if_not_result() { + let input: crate::input::Configuration = parse_quote!( + device: { + #[tedge_config(reader(function = "c8y_device_id"))] + id: String, + } + ); + + let mut generated = generate_test_dto(&input); + generated + .items + .retain(only_struct_named("TEdgeConfigDtoDevice")); + + let expected = parse_quote! { + #[derive(Debug, Default, ::serde::Deserialize, ::serde::Serialize, PartialEq)] + #[non_exhaustive] + pub struct TEdgeConfigDtoDevice { + pub id: Option, + } + }; + + assert_eq(&generated, &expected); + } + + fn generate_test_dto(input: &crate::input::Configuration) -> syn::File { + let tokens = super::generate( + Ident::new("TEdgeConfigDto", Span::call_site()), + &input.groups, + "", + ); + syn::parse2(tokens).unwrap() + } + + fn assert_eq(actual: &syn::File, expected: &syn::File) { + pretty_assertions::assert_eq!( + prettyplease::unparse(actual), + prettyplease::unparse(expected), + ) + } + + fn only_struct_named(target: &str) -> impl Fn(&Item) -> bool + '_ { + move |i| matches!(i, Item::Struct(ItemStruct { ident, .. }) if ident == target) + } } diff --git a/crates/common/tedge_config_macros/impl/src/input/parse.rs b/crates/common/tedge_config_macros/impl/src/input/parse.rs index 1e0a1e8dfa2..2ca31d1a79c 100644 --- a/crates/common/tedge_config_macros/impl/src/input/parse.rs +++ b/crates/common/tedge_config_macros/impl/src/input/parse.rs @@ -3,10 +3,6 @@ //! This is designed to take a [proc_macro2::TokenStream] and turn it into //! something useful with the aid of [syn]. -// FIXME: if let can be simplified with `.unwrap_or_default()` -// for all `#[darling(default)]` -#![allow(clippy::manual_unwrap_or_default)] - use darling::util::SpannedValue; use darling::FromAttributes; use darling::FromField; @@ -198,6 +194,7 @@ pub struct FieldDtoSettings { pub struct ReaderSettings { #[darling(default)] pub private: bool, + pub function: Option, #[darling(default)] pub skip: bool, } diff --git a/crates/common/tedge_config_macros/impl/src/input/validate.rs b/crates/common/tedge_config_macros/impl/src/input/validate.rs index 460debd9115..7923837f1a0 100644 --- a/crates/common/tedge_config_macros/impl/src/input/validate.rs +++ b/crates/common/tedge_config_macros/impl/src/input/validate.rs @@ -13,6 +13,7 @@ use syn::Meta; use syn::MetaNameValue; use crate::error::combine_errors; +use crate::error::extract_type_from_result; use crate::optional_error::OptionalError; use crate::optional_error::SynResultExt; use crate::reader::PathItem; @@ -242,6 +243,40 @@ impl ReadOnlyField { } } +impl ReadWriteField { + pub fn lazy_reader_name(&self, parents: &[PathItem]) -> syn::Ident { + format_ident!( + "LazyReader{}{}", + parents + .iter() + .filter_map(PathItem::as_static) + .map(|p| p.to_string().to_upper_camel_case()) + .collect::>() + .join(""), + self.rename() + .map(<_>::to_owned) + .unwrap_or_else(|| self.ident.to_string()) + .to_upper_camel_case() + ) + } + + pub fn parent_name(&self, parents: &[PathItem]) -> syn::Ident { + format_ident!( + "TEdgeConfigReader{}", + parents + .iter() + .filter_map(PathItem::as_static) + .map(|p| p.to_string().to_upper_camel_case()) + .collect::>() + .join(""), + ) + } + + pub fn rename(&self) -> Option<&str> { + Some(self.rename.as_ref()?.as_str()) + } +} + #[derive(Debug)] pub struct ReadWriteField { pub attrs: Vec, @@ -336,6 +371,21 @@ impl ConfigurableField { } } + pub fn reader_function(&self) -> Option<(&syn::Path, &ReadWriteField)> { + match self { + Self::ReadWrite( + field @ ReadWriteField { + reader: + ReaderSettings { + function: Some(f), .. + }, + .. + }, + ) => Some((f, field)), + _ => None, + } + } + pub fn deprecated_keys(&self) -> impl Iterator { let keys = match self { Self::ReadOnly(field) => &field.deprecated_keys, @@ -392,6 +442,14 @@ impl TryFrom for ConfigurableField { .push(parse_quote_spanned!(span=> #[serde(rename = #literal)])) } + if value.reader.function.is_some() && value.from.is_none() { + value.from = Some( + extract_type_from_result(&value.ty) + .map_or(&value.ty, |tys| tys.0) + .clone(), + ); + } + for name in value.deprecated_names { let name_str = name.as_str(); if name.contains('.') { diff --git a/crates/common/tedge_config_macros/impl/src/query.rs b/crates/common/tedge_config_macros/impl/src/query.rs index 43cba62aa05..1c53491db6c 100644 --- a/crates/common/tedge_config_macros/impl/src/query.rs +++ b/crates/common/tedge_config_macros/impl/src/query.rs @@ -674,7 +674,7 @@ fn generate_string_readers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { parent_segments.remove(parent_segments.len() - 1); let to_string = quote_spanned!(field.ty().span()=> .to_string()); let match_variant = configuration_key.match_read_write; - if field.read_only().is_some() { + if field.read_only().is_some() || field.reader_function().is_some() { if extract_type_from_result(field.ty()).is_some() { parse_quote! { ReadableKey::#match_variant => Ok(self.#(#segments).*()?#to_string), @@ -730,16 +730,21 @@ fn generate_string_writers(paths: &[VecDeque<&FieldOrGroup>]) -> TokenStream { .unwrap(); let match_variant = configuration_key.match_read_write; - let ty = field.ty(); + let ty = if field.reader_function().is_some() { + extract_type_from_result(field.ty()).map(|tys| tys.0).unwrap_or(field.ty()) + } else { + field.ty() + }; + let parse_as = field.from().unwrap_or(field.ty()); let parse = quote_spanned! {parse_as.span()=> parse::<#parse_as>() }; let convert_to_field_ty = quote_spanned! {ty.span()=> map(<#ty>::from)}; - let current_value = if field.read_only().is_some() { + let current_value = if field.read_only().is_some() || field.reader_function().is_some() { if extract_type_from_result(field.ty()).is_some() { - quote_spanned! {ty.span()=> reader.#(#read_segments).*.try_read(reader).ok()} + quote_spanned! {ty.span()=> reader.#(#read_segments).*().ok().cloned()} } else { - quote_spanned! {ty.span()=> Some(reader.#(#read_segments).*.read(reader))} + quote_spanned! {ty.span()=> Some(reader.#(#read_segments).*())} } } else if field.has_guaranteed_default() { quote_spanned! {ty.span()=> Some(reader.#(#read_segments).*.to_owned())} @@ -1515,6 +1520,47 @@ mod tests { ) } + #[test] + fn impl_try_append_calls_method_for_current_value() { + let input: crate::input::Configuration = parse_quote!( + #[tedge_config(multi)] + c8y: { + device: { + #[tedge_config(reader(function = "device_id"))] + id: String, + }, + } + ); + let paths = configuration_paths_from(&input.groups); + let writers = generate_string_writers(&paths); + let impl_dto_block = syn::parse2(writers).unwrap(); + let impl_dto_block = retain_fn(impl_dto_block, "try_append_str"); + + let expected = parse_quote! { + impl TEdgeConfigDto { + pub fn try_append_str(&mut self, reader: &TEdgeConfigReader, key: &WritableKey, value: &str) -> Result<(), WriteError> { + match key { + WritableKey::C8yDeviceId(key0) => { + self.c8y.try_get_mut(key0.as_deref(), "c8y")?.device.id = ::append( + Some(reader.c8y.try_get(key0.as_deref())?.device.id()), + value + .parse::() + .map(::from) + .map_err(|e| WriteError::ParseValue(Box::new(e)))?, + ); + } + }; + Ok(()) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&parse_quote!(#impl_dto_block)), + prettyplease::unparse(&expected) + ) + } + fn keys_enum_impl_block(config_keys: &(Vec, Vec)) -> ItemImpl { let generated = keys_enum(parse_quote!(ReadableKey), config_keys, "DOC FRAGMENT"); let generated_file: syn::File = syn::parse2(generated).unwrap(); diff --git a/crates/common/tedge_config_macros/impl/src/reader.rs b/crates/common/tedge_config_macros/impl/src/reader.rs index 782cad20c8b..820c166b86c 100644 --- a/crates/common/tedge_config_macros/impl/src/reader.rs +++ b/crates/common/tedge_config_macros/impl/src/reader.rs @@ -57,27 +57,55 @@ fn generate_structs( let ty = field.ty(); attrs.push(field.attrs().to_vec()); idents.push(field.ident()); - if field.is_optional() { + if let Some((function, rw_field)) = field.reader_function() { + let name = rw_field.lazy_reader_name(&parents); + let parent_ty = rw_field.parent_name(&parents); + tys.push(parse_quote_spanned!(ty.span()=> #name)); + let dto_ty: syn::Type = match extract_type_from_result(&rw_field.ty) { + Some((ok, _err)) => parse_quote!(OptionalConfig<#ok>), + None => { + let ty = &rw_field.ty; + parse_quote!(OptionalConfig<#ty>) + } + }; + lazy_readers.push(( + name, + &rw_field.ty, + function, + parent_ty, + rw_field.ident.clone(), + dto_ty.clone(), + visibility(field), + )); + vis.push(parse_quote!()); + } else if field.is_optional() { tys.push(parse_quote_spanned!(ty.span()=> OptionalConfig<#ty>)); - } else if let Some(field) = field.read_only() { - let name = field.lazy_reader_name(&parents); - let parent_ty = field.parent_name(&parents); - tys.push(parse_quote_spanned!(field.ty.span()=> #name)); + vis.push(match field.reader().private { + true => parse_quote!(), + false => parse_quote!(pub), + }); + } else if let Some(ro_field) = field.read_only() { + let name = ro_field.lazy_reader_name(&parents); + let parent_ty = ro_field.parent_name(&parents); + tys.push(parse_quote_spanned!(ro_field.ty.span()=> #name)); lazy_readers.push(( name, - &field.ty, - &field.readonly.function, + &ro_field.ty, + &ro_field.readonly.function, parent_ty, - field.ident.clone(), + ro_field.ident.clone(), + parse_quote!(()), + visibility(field), )); + vis.push(parse_quote!()); } else { tys.push(ty.to_owned()); + vis.push(match field.reader().private { + true => parse_quote!(), + false => parse_quote!(pub), + }); } sub_readers.push(None); - vis.push(match field.reader().private { - true => parse_quote!(), - false => parse_quote!(pub), - }); } FieldOrGroup::Multi(group) if !group.reader.skip => { let sub_reader_name = prefixed_type_name(name, group); @@ -125,36 +153,37 @@ fn generate_structs( } } - let lazy_reader_impls = - lazy_readers - .iter() - .map(|(name, ty, function, parent_ty, id)| -> syn::ItemImpl { - if let Some((ok, err)) = extract_type_from_result(ty) { - parse_quote_spanned! {name.span()=> - impl #parent_ty { - pub fn #id(&self) -> Result<&#ok, #err> { - self.#id.0.get_or_try_init(|| #function(self)) - } + let lazy_reader_impls = lazy_readers.iter().map( + |(_, ty, function, parent_ty, id, _dto_ty, vis)| -> syn::ItemImpl { + if let Some((ok, err)) = extract_type_from_result(ty) { + parse_quote_spanned! {function.span()=> + impl #parent_ty { + #vis fn #id(&self) -> Result<&#ok, #err> { + self.#id.0.get_or_try_init(|| #function(self, &self.#id.1)) } } - } else { - parse_quote_spanned! {name.span()=> - impl #parent_ty { - pub fn #id(&self) -> &#ty { - self.#id.0.get_or_init(|| #function(self)) - } + } + } else { + parse_quote_spanned! {function.span()=> + impl #parent_ty { + #vis fn #id(&self) -> &#ty { + self.#id.0.get_or_init(|| #function(self, &self.#id.1)) } } } - }); + } + }, + ); - let (lr_names, lr_tys): (Vec<_>, Vec<_>) = lazy_readers + let (lr_names, lr_tys, lr_dto_tys): (Vec<_>, Vec<_>, Vec<_>) = lazy_readers .iter() - .map(|(name, ty, _, _, _)| match extract_type_from_result(ty) { - Some((ok, _err)) => (name, ok), - None => (name, *ty), - }) - .unzip(); + .map( + |(name, ty, _, _, _, dto_ty, _)| match extract_type_from_result(ty) { + Some((ok, _err)) => (name, ok, dto_ty), + None => (name, *ty, dto_ty), + }, + ) + .multiunzip(); let doc_comment_attr = (!doc_comment.is_empty()).then(|| quote_spanned!(name.span()=> #[doc = #doc_comment])); @@ -170,9 +199,9 @@ fn generate_structs( } #( - #[derive(::serde::Serialize, Clone, Debug, Default)] - #[serde(into = "()")] - pub struct #lr_names(::once_cell::sync::OnceCell<#lr_tys>); + #[derive(::serde::Serialize, Clone, Debug)] + #[serde(into = "()")] // Just a hack to support serialization, required for doku + pub struct #lr_names(::once_cell::sync::OnceCell<#lr_tys>, #lr_dto_tys); impl From<#lr_names> for () { fn from(_: #lr_names) {} @@ -185,6 +214,14 @@ fn generate_structs( }) } +fn visibility(field: &ConfigurableField) -> syn::Visibility { + if field.reader().private { + parse_quote!() + } else { + parse_quote!(pub) + } +} + fn find_field<'a>( mut fields: &'a [FieldOrGroup], key: &Punctuated, @@ -306,8 +343,8 @@ fn reader_value_for_field<'a>( parse_quote!(ReadableKey::#ident(#(#args.map(<_>::to_owned)),*).to_cow_str()) }; let read_path = read_field(parents); - match &rw_field.default { - FieldDefault::None => quote! { + let value = match &rw_field.default { + FieldDefault::None => quote_spanned! {rw_field.ident.span()=> match &dto.#(#read_path).*.#name { None => OptionalConfig::Empty(#key), Some(value) => OptionalConfig::Present { value: value.clone(), key: #key }, @@ -345,15 +382,22 @@ fn reader_value_for_field<'a>( observed_keys, )?; - let (default, value) = - if matches!(&rw_field.default, FieldDefault::FromOptionalKey(_)) { - ( - quote!(#default.map(|v| v.into())), - quote!(OptionalConfig::Present { value: value.clone(), key: #key }), - ) - } else { - (quote!(#default.into()), quote!(value.clone())) - }; + let (default, value) = if rw_field.reader.function.is_some() { + ( + quote_spanned!(default_key.span()=> #default.1.into()), + quote_spanned!(rw_field.ident.span()=> OptionalConfig::Present { value: value.clone(), key: #key }), + ) + } else if matches!(&rw_field.default, FieldDefault::FromOptionalKey(_)) { + ( + quote_spanned!(default_key.span()=> #default.map(|v| v.into())), + quote_spanned!(rw_field.ident.span()=> OptionalConfig::Present { value: value.clone(), key: #key }), + ) + } else { + ( + quote_spanned!(default_key.span()=> #default.into()), + quote_spanned!(rw_field.ident.span()=> value.clone()), + ) + }; quote_spanned! {name.span()=> match &dto.#(#read_path).*.#name { @@ -386,12 +430,20 @@ fn reader_value_for_field<'a>( Some(value) => value.clone(), } }, + }; + if field.reader_function().is_some() { + let name = rw_field.lazy_reader_name(parents); + quote_spanned! {rw_field.ident.span()=> + #name(<_>::default(), #value) + } + } else { + value } } ConfigurableField::ReadOnly(field) => { let name = field.lazy_reader_name(parents); - quote! { - #name::default() + quote_spanned! {field.ident.span()=> + #name(<_>::default(), ()) } } }) @@ -501,7 +553,7 @@ fn generate_conversions( FieldOrGroup::Field(field) => { let name = field.ident(); let value = reader_value_for_field(field, &parents, root_fields, Vec::new())?; - field_conversions.push(quote!(#name: #value)); + field_conversions.push(quote_spanned!(name.span()=> #name: #value)); } FieldOrGroup::Group(group) if !group.reader.skip => { let sub_reader_name = prefixed_type_name(name, group); @@ -521,7 +573,7 @@ fn generate_conversions( }) .collect(); field_conversions.push( - quote!(#name: #sub_reader_name::from_dto(dto, location, #(#extra_call_args),*)), + quote_spanned!(name.span()=> #name: #sub_reader_name::from_dto(dto, location, #(#extra_call_args),*)), ); let sub_conversions = generate_conversions(&sub_reader_name, &group.contents, parents, root_fields)?; @@ -557,7 +609,7 @@ fn generate_conversions( .intersperse(".".to_owned()) .collect::(); let new_arg2 = extra_call_args.last().unwrap().clone(); - field_conversions.push(quote!(#name: dto.#(#read_path).*.map_keys(|#new_arg2| #sub_reader_name::from_dto(dto, location, #(#extra_call_args),*), #parent_key))); + field_conversions.push(quote_spanned!(name.span()=> #name: dto.#(#read_path).*.map_keys(|#new_arg2| #sub_reader_name::from_dto(dto, location, #(#extra_call_args),*), #parent_key))); parents.push(new_arg); let sub_conversions = generate_conversions(&sub_reader_name, &group.contents, parents, root_fields)?; @@ -569,7 +621,7 @@ fn generate_conversions( } } - Ok(quote! { + Ok(quote_spanned! {name.span()=> impl #name { #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] #[automatically_derived] @@ -589,6 +641,9 @@ fn generate_conversions( mod tests { use super::*; use syn::parse_quote; + use syn::Item; + use syn::ItemImpl; + use syn::ItemStruct; #[test] fn from_optional_key_reuses_multi_fields() { @@ -725,4 +780,279 @@ mod tests { prettyplease::unparse(&expected) ) } + + #[test] + fn generate_structs_generates_getter_for_readonly_value() { + let input: crate::input::Configuration = parse_quote!( + device: { + #[tedge_config(readonly( + write_error = "\ + The device id is read from the device certificate and cannot be set directly.\n\ + To set 'device.id' to some , you can use `tedge cert create --device-id `.", + function = "device_id", + ))] + id: String, + }, + ); + let actual = generate_structs( + &parse_quote!(TEdgeConfigReader), + &input.groups, + Vec::new(), + "", + ) + .unwrap(); + let file: syn::File = syn::parse2(actual).unwrap(); + + let expected = parse_quote! { + #[derive(::doku::Document, ::serde::Serialize, Debug, Clone)] + #[non_exhaustive] + pub struct TEdgeConfigReader { + pub device: TEdgeConfigReaderDevice, + } + #[derive(::doku::Document, ::serde::Serialize, Debug, Clone)] + #[non_exhaustive] + pub struct TEdgeConfigReaderDevice { + id: LazyReaderDeviceId, + } + #[derive(::serde::Serialize, Clone, Debug)] + #[serde(into = "()")] + pub struct LazyReaderDeviceId(::once_cell::sync::OnceCell, ()); + impl From for () { + fn from(_: LazyReaderDeviceId) {} + } + impl TEdgeConfigReaderDevice { + pub fn id(&self) -> &String { + self.id.0.get_or_init(|| device_id(self, &self.id.1)) + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&file), + prettyplease::unparse(&expected) + ) + } + + #[test] + fn generate_structs_generates_getter_for_reader_function_value() { + let input: crate::input::Configuration = parse_quote!( + device: { + #[tedge_config(reader(function = "device_id", private))] + id: String, + }, + ); + let actual = generate_structs( + &parse_quote!(TEdgeConfigReader), + &input.groups, + Vec::new(), + "", + ) + .unwrap(); + let mut file: syn::File = syn::parse2(actual).unwrap(); + let target: syn::Type = parse_quote!(TEdgeConfigReaderDevice); + file.items + .retain(|i| matches!(i, Item::Impl(ItemImpl { self_ty, .. }) if **self_ty == target)); + + let expected = parse_quote! { + impl TEdgeConfigReaderDevice { + fn id(&self) -> &String { + self.id.0.get_or_init(|| device_id(self, &self.id.1)) + } + } + }; + + 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!( + test: { + #[tedge_config(reader(function = "device_id"))] + read_via_function: String, + #[tedge_config(readonly(write_error = "TODO", function="device_id"))] + readonly: String, + #[tedge_config(default(value = "test"))] + with_default: String, + optional: String, + }, + ); + let actual = generate_structs( + &parse_quote!(TEdgeConfigReader), + &input.groups, + Vec::new(), + "", + ) + .unwrap(); + let mut file: syn::File = syn::parse2(actual).unwrap(); + file.items.retain(|s| matches!(s, Item::Struct(ItemStruct { ident, ..}) if ident == "TEdgeConfigReaderTest")); + + let expected = parse_quote! { + #[derive(::doku::Document, ::serde::Serialize, Debug, Clone)] + #[non_exhaustive] + pub struct TEdgeConfigReaderTest { + read_via_function: LazyReaderTestReadViaFunction, + readonly: LazyReaderTestReadonly, + pub with_default: String, + pub optional: OptionalConfig, + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&file), + prettyplease::unparse(&expected) + ) + } + + #[test] + fn default_values_do_stuff() { + let input: crate::input::Configuration = parse_quote!( + c8y: { + #[tedge_config(default(from_optional_key = "c8y.url"))] + http: String, + url: String, + }, + ); + let actual = generate_conversions( + &parse_quote!(TEdgeConfigReader), + &input.groups, + Vec::new(), + &input.groups, + ) + .unwrap(); + let file: syn::File = syn::parse2(actual).unwrap(); + + let expected = parse_quote! { + impl TEdgeConfigReader { + #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] + #[automatically_derived] + /// Converts the provided [TEdgeConfigDto] into a reader + pub fn from_dto(dto: &TEdgeConfigDto, location: &TEdgeConfigLocation) -> Self { + Self { + c8y: TEdgeConfigReaderC8y::from_dto(dto, location), + } + } + } + impl TEdgeConfigReaderC8y { + #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] + #[automatically_derived] + /// Converts the provided [TEdgeConfigDto] into a reader + pub fn from_dto(dto: &TEdgeConfigDto, location: &TEdgeConfigLocation) -> Self { + Self { + http: match &dto.c8y.http { + Some(value) => { + OptionalConfig::Present { + value: value.clone(), + key: ReadableKey::C8yHttp.to_cow_str(), + } + } + None => { + match &dto.c8y.url { + None => OptionalConfig::Empty(ReadableKey::C8yUrl.to_cow_str()), + Some(value) => { + OptionalConfig::Present { + value: value.clone(), + key: ReadableKey::C8yUrl.to_cow_str(), + } + } + } + .map(|v| v.into()) + } + }, + url: match &dto.c8y.url { + None => OptionalConfig::Empty(ReadableKey::C8yUrl.to_cow_str()), + Some(value) => { + OptionalConfig::Present { + value: value.clone(), + key: ReadableKey::C8yUrl.to_cow_str(), + } + } + }, + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&file), + prettyplease::unparse(&expected) + ) + } + + #[test] + fn default_values_do_stuff2() { + let input: crate::input::Configuration = parse_quote!( + device: { + #[tedge_config(reader(function = "device_id"))] + id: Result, + }, + c8y: { + device: { + #[tedge_config(default(from_optional_key = "device.id"))] + #[tedge_config(reader(function = "c8y_device_id"))] + id: Result, + }, + }, + ); + 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!(TEdgeConfigReaderC8yDevice); + file.items + .retain(|i| matches!(i, Item::Impl(ItemImpl { self_ty, ..}) if **self_ty == target)); + + let expected = parse_quote! { + impl TEdgeConfigReaderC8yDevice { + #[allow(unused, clippy::clone_on_copy, clippy::useless_conversion)] + #[automatically_derived] + /// Converts the provided [TEdgeConfigDto] into a reader + pub fn from_dto(dto: &TEdgeConfigDto, location: &TEdgeConfigLocation) -> Self { + Self { + id: LazyReaderC8yDeviceId( + <_>::default(), + match &dto.c8y.device.id { + Some(value) => { + OptionalConfig::Present { + value: value.clone(), + key: ReadableKey::C8yDeviceId.to_cow_str(), + } + } + None => { + LazyReaderDeviceId( + <_>::default(), + match &dto.device.id { + None => { + OptionalConfig::Empty(ReadableKey::DeviceId.to_cow_str()) + } + Some(value) => { + OptionalConfig::Present { + value: value.clone(), + key: ReadableKey::DeviceId.to_cow_str(), + } + } + }, + ) + .1 + .into() + } + }, + ), + } + } + } + }; + + pretty_assertions::assert_eq!( + prettyplease::unparse(&file), + prettyplease::unparse(&expected) + ) + } } diff --git a/crates/common/tedge_config_macros/src/define_tedge_config_docs.md b/crates/common/tedge_config_macros/src/define_tedge_config_docs.md index 922b468573f..2ffa1ba8e6a 100644 --- a/crates/common/tedge_config_macros/src/define_tedge_config_docs.md +++ b/crates/common/tedge_config_macros/src/define_tedge_config_docs.md @@ -935,10 +935,18 @@ define_tedge_config! { ))] #[doku(as = "String")] id: Result, + + #[tedge_config(reader(function = "try_read_device_id_with_dto"))] + #[doku(as = "String")] + dto_backed_id: Result, } } -fn try_read_device_id(_reader: &TEdgeConfigReaderDevice) -> Result { +fn try_read_device_id(_reader: &TEdgeConfigReaderDevice, _: &()) -> Result { + unimplemented!() +} + +fn try_read_device_id_with_dto(_reader: &TEdgeConfigReaderDevice, _dto_value: &OptionalConfig) -> Result { unimplemented!() } ``` diff --git a/crates/core/tedge/src/cli/certificate/cli.rs b/crates/core/tedge/src/cli/certificate/cli.rs index c8540af1935..b3d7274352d 100644 --- a/crates/core/tedge/src/cli/certificate/cli.rs +++ b/crates/core/tedge/src/cli/certificate/cli.rs @@ -1,12 +1,3 @@ -use crate::cli::common::Cloud; -use crate::cli::common::CloudArg; -use camino::Utf8PathBuf; -use clap::ValueHint; -use tedge_config::OptionalConfigError; -use tedge_config::ProfileName; -use tedge_config::ReadError; -use tedge_config::TEdgeConfig; - use super::create::CreateCertCmd; use super::create_csr::CreateCsrCmd; use super::remove::RemoveCertCmd; @@ -14,6 +5,17 @@ use super::renew::RenewCertCmd; use super::show::ShowCertCmd; use super::upload::*; +use anyhow::anyhow; +use camino::Utf8PathBuf; +use clap::ValueHint; +use tedge_config::explicit_device_id; +use tedge_config::OptionalConfigError; +use tedge_config::ProfileName; +use tedge_config::TEdgeConfig; +use tedge_config::TEdgeConfigLocation; + +use crate::cli::common::Cloud; +use crate::cli::common::CloudArg; use crate::command::BuildCommand; use crate::command::BuildContext; use crate::command::Command; @@ -24,8 +26,8 @@ pub enum TEdgeCertCli { /// Create a self-signed device certificate Create { /// The device identifier to be used as the common name for the certificate - #[clap(long = "device-id")] - id: String, + #[clap(long = "device-id", global = true)] + id: Option, #[clap(subcommand)] cloud: Option, @@ -80,12 +82,14 @@ impl BuildCommand for TEdgeCertCli { let cmd = match self { TEdgeCertCli::Create { id, cloud } => { let cloud: Option = cloud.map(<_>::try_into).transpose()?; + let cmd = CreateCertCmd { - id, + id: get_device_id(id, &config, &context.config_location, &cloud)?, cert_path: config.device_cert_path(cloud.as_ref())?.to_owned(), key_path: config.device_key_path(cloud.as_ref())?.to_owned(), user: user.to_owned(), group: group.to_owned(), + config_location: context.config_location, }; cmd.into_boxed() } @@ -97,15 +101,8 @@ impl BuildCommand for TEdgeCertCli { } => { let cloud: Option = cloud.map(<_>::try_into).transpose()?; - // Use the current device id if no id is provided - let id = if let Some(id) = id { - id - } else { - get_device_id_from_config(&config, &cloud)? - }; - let cmd = CreateCsrCmd { - id, + id: get_device_id(id, &config, &context.config_location, &cloud)?, key_path: config.device_key_path(cloud.as_ref())?.to_owned(), // Use output file instead of csr_path from tedge config if provided csr_path: output_path.unwrap_or_else(|| config.device.csr_path.clone()), @@ -193,16 +190,203 @@ pub enum UploadCertCli { }, } -fn get_device_id_from_config( +/// Returns the device ID from the config if no ID is provided by CLI +fn get_device_id( + id: Option, config: &TEdgeConfig, + config_location: &TEdgeConfigLocation, cloud: &Option, -) -> Result { - let id = match cloud { - None => config.device.id(), - Some(Cloud::C8y(profile)) => config.c8y.try_get(profile.as_deref())?.device.id(), - Some(Cloud::Azure(profile)) => config.az.try_get(profile.as_deref())?.device.id(), - Some(Cloud::Aws(profile)) => config.aws.try_get(profile.as_deref())?.device.id(), - }? - .to_owned(); - Ok(id) +) -> Result { + match (id, config.device_id(cloud.as_ref()).ok()) { + (None, None) => Err(anyhow!( + "No device ID is provided. Use `--device-id ` option to specify the device ID." + )), + (None, Some(config_id)) => Ok(config_id.into()), + (Some(input_id), None) => Ok(input_id), + (Some(input_id), Some(config_id)) if input_id == config_id => Ok(input_id), + (Some(input_id), Some(_config_id)) => { + match explicit_device_id(config_location, &cloud.as_ref().map(Into::into)) { + None => { + // the cloud profile doesn't have its own device.id explicitly, so using the input id is fine + Ok(input_id) + } + Some(explicit_id) => { + Err(anyhow!( + "`--device-id` option conflicts with tedge config settings.\n\ + Configured value: '{explicit_id}', but input: '{input_id}'\n\n\ + Please either update the configuration using `tedge config set `\n\ + or provide the correct value with the `--device-id` option." + )) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tedge_config::TEdgeConfigLocation; + use tedge_test_utils::fs::TempTedgeDir; + use test_case::test_case; + + #[test_case( + None, + None, + toml::toml!{ + [device] + id = "test" + }, + "test" + )] + #[test_case( + None, + Some(CloudArg::C8y{ profile: None }), + toml::toml!{ + [device] + id = "test" + }, + "test" + )] + #[test_case( + None, + Some(CloudArg::C8y{ profile: Some("foo".parse().unwrap()) }), + toml::toml!{ + [device] + id = "test" + [c8y.profiles.foo.device] + }, + "test" + )] + #[test_case( + None, + Some(CloudArg::C8y{ profile: None }), + toml::toml!{ + [device] + id = "test" + [c8y.device] + id = "c8y-test" + [c8y.profiles.foo.device] + id = "c8y-foo-test" + }, + "c8y-test" + )] + #[test_case( + None, + Some(CloudArg::C8y{ profile: Some("foo".parse().unwrap()) }), + toml::toml!{ + [device] + id = "test" + [c8y.device] + id = "c8y-test" + [c8y.profiles.foo.device] + id = "c8y-foo-test" + }, + "c8y-foo-test" + )] + #[test_case( + Some("input"), + None, + toml::toml!{ + [device] + }, + "input" + )] + #[test_case( + Some("input"), + None, + toml::toml!{ + [device] + id = "input" + }, + "input" + )] + #[test_case( + Some("input"), + Some(CloudArg::C8y{ profile: None }), + toml::toml!{ + [device] + id = "test" + }, + "input" + )] + #[test_case( + Some("input"), + Some(CloudArg::C8y{ profile: None }), + toml::toml!{ + [c8y.device] + id = "input" + }, + "input" + )] + #[test_case( + Some("input"), + Some(CloudArg::C8y{ profile: Some("foo".parse().unwrap()) }), + toml::toml!{ + [c8y.profiles.foo.device] + id = "input" + }, + "input" + )] + fn validate_get_device_id_returns_ok( + input_id: Option<&str>, + cloud_arg: Option, + toml: toml::Table, + expected: &str, + ) { + let cloud: Option = cloud_arg.map(<_>::try_into).transpose().unwrap(); + let ttd = TempTedgeDir::new(); + ttd.file("tedge.toml").with_toml_content(toml); + let location = TEdgeConfigLocation::from_custom_root(ttd.path()); + let reader = location.load().unwrap(); + let id = input_id.map(|s| s.to_string()); + let result = get_device_id(id, &reader, &location, &cloud); + assert_eq!(result.unwrap().as_str(), expected); + } + + #[test_case( + None, + None, + toml::toml!{ + [device] + } + )] + #[test_case( + Some("input"), + None, + toml::toml!{ + [device] + id = "test" + } + )] + #[test_case( + Some("input"), + Some(CloudArg::C8y{ profile: None }), + toml::toml!{ + [c8y.device] + id = "c8y-test" + } + )] + #[test_case( + Some("input"), + Some(CloudArg::C8y{ profile: Some("foo".parse().unwrap()) }), + toml::toml!{ + [c8y.profiles.foo.device] + id = "c8y-foo-test" + } + )] + fn validate_get_device_id_returns_err( + input_id: Option<&str>, + cloud_arg: Option, + toml: toml::Table, + ) { + let cloud: Option = cloud_arg.map(<_>::try_into).transpose().unwrap(); + let ttd = TempTedgeDir::new(); + ttd.file("tedge.toml").with_toml_content(toml); + let location = TEdgeConfigLocation::from_custom_root(ttd.path()); + let reader = location.load().unwrap(); + let id = input_id.map(|s| s.to_string()); + let result = get_device_id(id, &reader, &location, &cloud); + assert!(result.is_err()); + } } diff --git a/crates/core/tedge/src/cli/certificate/create.rs b/crates/core/tedge/src/cli/certificate/create.rs index 7deaa148122..127d8c29f3f 100644 --- a/crates/core/tedge/src/cli/certificate/create.rs +++ b/crates/core/tedge/src/cli/certificate/create.rs @@ -1,4 +1,5 @@ use super::error::CertError; +use crate::cli::certificate::show::ShowCertCmd; use crate::command::Command; use crate::log::MaybeFancy; use camino::Utf8PathBuf; @@ -10,6 +11,7 @@ use std::fs::File; use std::fs::OpenOptions; use std::io::prelude::*; use std::path::Path; +use tedge_config::TEdgeConfigLocation; use tedge_utils::paths::set_permission; use tedge_utils::paths::validate_parent_dir_exists; @@ -27,6 +29,9 @@ pub struct CreateCertCmd { /// The owner of the private key pub user: String, pub group: String, + + /// The tedge.toml file location, required to access to TEdgeConfigDto + pub config_location: TEdgeConfigLocation, } impl Command for CreateCertCmd { @@ -37,7 +42,11 @@ impl Command for CreateCertCmd { fn execute(&self) -> Result<(), MaybeFancy> { let config = NewCertificateConfig::default(); self.create_test_certificate(&config)?; - eprintln!("Certificate was successfully created"); + eprintln!("Certificate was successfully created\n"); + let show_cert_cmd = ShowCertCmd { + cert_path: self.cert_path.clone(), + }; + show_cert_cmd.execute()?; Ok(()) } } @@ -185,6 +194,7 @@ mod tests { key_path: key_path.clone(), user: "mosquitto".to_string(), group: "mosquitto".to_string(), + config_location: TEdgeConfigLocation::from_custom_root(dir.path()), }; assert_matches!( @@ -214,6 +224,7 @@ mod tests { key_path: key_path.clone(), user: "mosquitto".to_string(), group: "mosquitto".to_string(), + config_location: TEdgeConfigLocation::from_custom_root(dir.path()), }; assert!(cmd @@ -237,6 +248,7 @@ mod tests { key_path, user: "mosquitto".to_string(), group: "mosquitto".to_string(), + config_location: TEdgeConfigLocation::from_custom_root(dir.path()), }; let cert_error = cmd @@ -257,6 +269,7 @@ mod tests { key_path, user: "mosquitto".to_string(), group: "mosquitto".to_string(), + config_location: TEdgeConfigLocation::from_custom_root(dir.path()), }; let cert_error = cmd diff --git a/crates/core/tedge/src/cli/certificate/create_csr.rs b/crates/core/tedge/src/cli/certificate/create_csr.rs index 73c9468075e..07fce8d8e1a 100644 --- a/crates/core/tedge/src/cli/certificate/create_csr.rs +++ b/crates/core/tedge/src/cli/certificate/create_csr.rs @@ -71,6 +71,7 @@ mod tests { use crate::CreateCertCmd; use assert_matches::assert_matches; use std::path::Path; + use tedge_config::TEdgeConfigLocation; use tempfile::*; use x509_parser::der_parser::asn1_rs::FromDer; use x509_parser::nom::AsBytes; @@ -113,6 +114,7 @@ mod tests { key_path: key_path.clone(), user: "mosquitto".to_string(), group: "mosquitto".to_string(), + config_location: TEdgeConfigLocation::from_custom_root(dir.path()), }; // create private key and public cert with standard command diff --git a/crates/core/tedge/src/cli/certificate/renew.rs b/crates/core/tedge/src/cli/certificate/renew.rs index 6763b79b90c..5016f0458aa 100644 --- a/crates/core/tedge/src/cli/certificate/renew.rs +++ b/crates/core/tedge/src/cli/certificate/renew.rs @@ -59,6 +59,7 @@ mod tests { use std::path::Path; use std::thread::sleep; use std::time::Duration; + use tedge_config::TEdgeConfigLocation; use tempfile::*; #[test] @@ -73,6 +74,7 @@ mod tests { key_path: key_path.clone(), user: "mosquitto".to_string(), group: "mosquitto".to_string(), + config_location: TEdgeConfigLocation::from_custom_root(dir.path()), }; // First create both cert and key diff --git a/crates/core/tedge/src/cli/config/cli.rs b/crates/core/tedge/src/cli/config/cli.rs index be3044740fc..cdb7212f640 100644 --- a/crates/core/tedge/src/cli/config/cli.rs +++ b/crates/core/tedge/src/cli/config/cli.rs @@ -113,6 +113,7 @@ pub enum ConfigCmd { }, } +#[macro_export] macro_rules! try_with_profile { ($key:ident, $profile:ident) => {{ use anyhow::Context; diff --git a/crates/core/tedge/src/cli/config/commands/list.rs b/crates/core/tedge/src/cli/config/commands/list.rs index b82bd60c6d2..e0f76db71cb 100644 --- a/crates/core/tedge/src/cli/config/commands/list.rs +++ b/crates/core/tedge/src/cli/config/commands/list.rs @@ -30,21 +30,13 @@ impl Command for ListConfigCommand { } } -fn should_hide(key: &str) -> bool { - (key.starts_with("c8y") || key.starts_with("az") || key.starts_with("aws")) - && key.contains(".device.") -} - fn print_config_list( config: &TEdgeConfig, all: bool, filter: Option<&str>, ) -> Result<(), anyhow::Error> { let mut keys_without_values = Vec::new(); - for config_key in config - .readable_keys() - .filter(|key| !should_hide(&key.to_cow_str())) - { + for config_key in config.readable_keys() { if !key_matches_filter(&config_key.to_cow_str(), filter) { continue; } @@ -75,9 +67,6 @@ fn print_config_doc(filter: Option<&str>) { .unwrap_or_default(); for (key, ty) in READABLE_KEYS.iter() { - if should_hide(key) { - continue; - } if !key_matches_filter(key, filter) { continue; } diff --git a/crates/core/tedge/src/cli/connect/command.rs b/crates/core/tedge/src/cli/connect/command.rs index baa9ecdd458..3cb9aa63c3e 100644 --- a/crates/core/tedge/src/cli/connect/command.rs +++ b/crates/core/tedge/src/cli/connect/command.rs @@ -4,6 +4,7 @@ use crate::bridge::c8y::BridgeConfigC8yParams; use crate::bridge::BridgeConfig; use crate::bridge::BridgeLocation; use crate::bridge::CommonMosquittoConfig; +use crate::bridge::TEDGE_BRIDGE_CONF_DIR_PATH; use crate::cli::common::Cloud; use crate::cli::common::MaybeBorrowedCloud; use crate::cli::connect::jwt_token::*; @@ -20,7 +21,9 @@ use anyhow::bail; use c8y_api::http_proxy::read_c8y_credentials; use c8y_api::smartrest::message::get_smartrest_template_id; use c8y_api::smartrest::message_ids::JWT_TOKEN; +use camino::Utf8Path; use camino::Utf8PathBuf; +use certificate::PemCertificate; use mqtt_channel::Topic; use rumqttc::Event; use rumqttc::Incoming; @@ -51,8 +54,6 @@ use tracing::warn; use which::which; use yansi::Paint as _; -use crate::bridge::TEDGE_BRIDGE_CONF_DIR_PATH; - pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const CONNECTION_TIMEOUT: Duration = Duration::from_secs(60); const MOSQUITTO_RESTART_TIMEOUT_SECONDS: u64 = 20; @@ -337,6 +338,18 @@ fn validate_config(config: &TEdgeConfig, cloud: &MaybeBorrowedCloud<'_>) -> anyh Ok(()) } +fn validate_device_matches_cert_cn(id: &str, cert: &Utf8Path) -> anyhow::Result<()> { + let pem = PemCertificate::from_pem_file(cert)?; + let cn = pem.subject_common_name()?; + if id == cn { + Ok(()) + } else { + Err(anyhow::anyhow!(format!( + "device.id '{id}' mismatches to the device certificate's CN '{cn}'" + ))) + } +} + fn disallow_matching_url_device_id( config: &TEdgeConfig, url: fn(Option) -> ReadableKey, @@ -362,21 +375,19 @@ fn disallow_matching_url_device_id( .map(|&(k, _)| format!("{}", url(k.clone()).yellow().bold())) .collect::>() .join(", "); - // TODO re-enable this logic once multiple device IDs are properly supported - // let device_id_keys: String = matches - // .iter() - // .map(|(_, key)| format!("{}", key.yellow().bold())) - // .collect::>() - // .join(", "); - // bail!( - // "You have matching URLs and device IDs for different profiles. - - // {url_keys} are set to the same value, but so are {device_id_keys}. - - // Each cloud profile requires either a unique URL or unique device ID, \ - // so it corresponds to a unique device in the associated cloud." - // ); - bail!("The configurations: {url_keys} should be set to different values before connecting, but are currently set to the same value"); + let device_id_keys: String = matches + .iter() + .map(|(_, key)| format!("{}", key.yellow().bold())) + .collect::>() + .join(", "); + bail!( + "You have matching URLs and device IDs for different profiles. + +{url_keys} are set to the same value, but so are {device_id_keys}. + +Each cloud profile requires either a unique URL or unique device ID, \ +so it corresponds to a unique device in the associated cloud." + ); } } Ok(()) @@ -438,6 +449,8 @@ pub fn bridge_config( match cloud { MaybeBorrowedCloud::Azure(profile) => { let az_config = config.az.try_get(profile.as_deref())?; + validate_device_matches_cert_cn(az_config.device.id()?, az_config.device_cert_path())?; + let params = BridgeConfigAzureParams { mqtt_host: HostPort::::try_from( az_config.url.or_config_not_set()?.as_str(), @@ -458,6 +471,11 @@ pub fn bridge_config( } MaybeBorrowedCloud::Aws(profile) => { let aws_config = config.aws.try_get(profile.as_deref())?; + validate_device_matches_cert_cn( + aws_config.device.id()?, + aws_config.device_cert_path(), + )?; + let params = BridgeConfigAwsParams { mqtt_host: HostPort::::try_from( aws_config.url.or_config_not_set()?.as_str(), @@ -481,7 +499,13 @@ pub fn bridge_config( let (remote_username, remote_password) = match c8y_config.auth_method.to_type(&c8y_config.credentials_path) { - AuthType::Certificate => (None, None), + AuthType::Certificate => { + validate_device_matches_cert_cn( + c8y_config.device.id()?, + c8y_config.device_cert_path(), + )?; + (None, None) + } AuthType::Basic => { let (username, password) = read_c8y_credentials(&c8y_config.credentials_path)?; @@ -1248,8 +1272,10 @@ mod tests { mod validate_config { use super::super::validate_config; use super::Cloud; + use crate::bridge_config; use tedge_config::TEdgeConfigLocation; use tedge_test_utils::fs::TempTedgeDir; + use test_case::test_case; #[test] fn allows_default_config() { @@ -1294,8 +1320,7 @@ mod tests { let config = loc.load().unwrap(); let err = validate_config(&config, &cloud).unwrap_err(); - // TODO change me to assert eq once device IDs are properly supported - assert_ne!(err.to_string(), "You have matching URLs and device IDs for different profiles. + assert_eq!(err.to_string(), "You have matching URLs and device IDs for different profiles. c8y.url, c8y.profiles.new.url are set to the same value, but so are c8y.device.id, c8y.profiles.new.device.id. @@ -1347,6 +1372,11 @@ Each cloud profile requires either a unique URL or unique device ID, so it corre .unwrap(); dto.try_update_str(&"c8y.profiles.new.url".parse().unwrap(), "example.com") .unwrap(); + dto.try_update_str( + &"c8y.profiles.new.device.id".parse().unwrap(), + "test-device", + ) + .unwrap(); dto.try_update_str( &"c8y.profiles.new.device.cert_path".parse().unwrap(), &cert_path.display().to_string(), @@ -1389,6 +1419,11 @@ Each cloud profile requires either a unique URL or unique device ID, so it corre .unwrap(); dto.try_update_str(&"c8y.profiles.diff_id.url".parse().unwrap(), "example.com") .unwrap(); + dto.try_update_str( + &"c8y.profiles.diff_id.device.id".parse().unwrap(), + "test-device-second", + ) + .unwrap(); dto.try_update_str( &"c8y.profiles.diff_id.device.cert_path".parse().unwrap(), &cert_path.display().to_string(), @@ -1551,5 +1586,56 @@ Each cloud profile requires either a unique URL or unique device ID, so it corre validate_config(&config, &cloud).unwrap(); } + + #[test_case(Cloud::c8y(None), "c8y.", "")] + #[test_case(Cloud::c8y(Some("foo".parse().unwrap())), "c8y.profiles.foo.", "c8y.profiles.foo.")] + #[test_case(Cloud::az(None), "az.", "")] + #[test_case(Cloud::az(Some("foo".parse().unwrap())), "az.profiles.foo.", "az.profiles.foo.")] + #[test_case(Cloud::aws(None), "aws.", "")] + #[test_case(Cloud::aws(Some("foo".parse().unwrap())), "aws.profiles.foo.", "aws.profiles.foo.")] + fn rejects_device_id_mismatches_cert_cn( + cloud: Cloud, + url_prefix: &str, + device_prefix: &str, + ) { + let ttd = TempTedgeDir::new(); + let cert = rcgen::generate_simple_self_signed(["test-device".into()]).unwrap(); + let mut cert_path = ttd.path().to_owned(); + cert_path.push("test.crt"); + let mut key_path = ttd.path().to_owned(); + key_path.push("test.key"); + std::fs::write(&cert_path, cert.serialize_pem().unwrap()).unwrap(); + std::fs::write(&key_path, cert.serialize_private_key_pem()).unwrap(); + let loc = TEdgeConfigLocation::from_custom_root(ttd.path()); + loc.update_toml(&|dto, _| { + dto.try_update_str( + &format!("{url_prefix}url").parse().unwrap(), + "latest.example.com", + ) + .unwrap(); + dto.try_update_str( + &format!("{device_prefix}device.id").parse().unwrap(), + "custom-device", + ) + .unwrap(); + dto.try_update_str( + &format!("{device_prefix}device.cert_path").parse().unwrap(), + &cert_path.display().to_string(), + ) + .unwrap(); + dto.try_update_str( + &format!("{device_prefix}device.key_path").parse().unwrap(), + &key_path.display().to_string(), + ) + .unwrap(); + Ok(()) + }) + .unwrap(); + let config = loc.load().unwrap(); + + let err = bridge_config(&config, &cloud).unwrap_err(); + eprintln!("err={err}"); + assert!(err.to_string().contains("mismatch")); + } } } diff --git a/crates/core/tedge/src/cli/upload/mod.rs b/crates/core/tedge/src/cli/upload/mod.rs index fcf8fa916cf..080f8401ee9 100644 --- a/crates/core/tedge/src/cli/upload/mod.rs +++ b/crates/core/tedge/src/cli/upload/mod.rs @@ -79,8 +79,9 @@ impl BuildCommand for UploadCmd { let identity = config.http.client.auth.identity()?; let cloud_root_certs = config.cloud_root_certs(); let c8y = C8yEndPoint::local_proxy(&config, profile.as_deref())?; + let c8y_config = config.c8y.try_get(profile.as_deref())?; let device_id = match device_id { - None => config.device.id()?.clone(), + None => c8y_config.device.id()?.clone(), Some(device_id) => device_id, }; let text = text.unwrap_or_else(|| format!("Uploaded file: {file:?}")); diff --git a/crates/core/tedge/tests/main.rs b/crates/core/tedge/tests/main.rs index 04773a3c8ed..f3cdba99258 100644 --- a/crates/core/tedge/tests/main.rs +++ b/crates/core/tedge/tests/main.rs @@ -85,7 +85,7 @@ mod tests { // The remove command can be run when there is no certificate remove_cmd.assert().success(); - // We start we no certificate, hence no device id + // We start with no certificate, hence no device id get_device_id_cmd .assert() .failure() @@ -127,60 +127,12 @@ mod tests { .failure() .stderr(predicate::str::contains("device.id")); - // The a new certificate can then be created. + // A new certificate can then be created. create_cmd.assert().success(); Ok(()) } - #[test] - fn run_config_set_get_unset_read_only_key() -> Result<(), Box> { - let temp_dir = tempfile::tempdir().unwrap(); - let temp_dir_path = temp_dir.path(); - let test_home_str = temp_dir_path.to_str().unwrap(); - - let device_id = "test"; - - // allowed to get read-only key by CLI - let mut get_device_id_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home_str, - "config", - "get", - "device.id", - ])?; - - get_device_id_cmd - .assert() - .failure() - .stderr(predicate::str::contains("device.id")); - - // forbidden to set read-only key by CLI - let mut set_device_id_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home_str, - "config", - "set", - "device.id", - device_id, - ])?; - - set_device_id_cmd.assert().failure(); - - // forbidden to unset read-only key by CLI - let mut unset_device_id_cmd = tedge_command_with_test_home([ - "--config-dir", - test_home_str, - "config", - "unset", - "device.id", - ])?; - - unset_device_id_cmd.assert().failure(); - - Ok(()) - } - // #[test_case(config key, config value, expected unset value)] #[test_case( "c8y.url", diff --git a/docs/src/operate/c8y/smartrest-one.md b/docs/src/operate/c8y/smartrest-one.md new file mode 100644 index 00000000000..82a8f779753 --- /dev/null +++ b/docs/src/operate/c8y/smartrest-one.md @@ -0,0 +1,73 @@ +--- +title: SmartREST 1.0 and basic auth +tags: [Operate, Cumulocity] +description: Establishing connection with Basic auth and using SmartREST 1.0 +--- + +%%te%% supports basic authentication and [SmartREST 1.0.](https://cumulocity.com/docs/smartrest/smartrest-one/) +This page explains how to use basic authentication and SmartREST 1.0 with Cumulocity. + +:::important +It is highly recommended to use certificate-based authentication and SmartREST 2.0. +This guide is intended for users who must use the SmartREST 1.0 for specific reasons. +::: + +## Setting up basic authentication + +To use SmartREST 1.0, the authentication mode must be set to `basic` using the `tedge config` CLI tool: + +```sh +sudo tedge config set c8y.auth_mode basic +``` + +Next, provide credentials (username/password) in a credential file formatted as follows. +The default location of the credentials file is `/etc/tedge/credentials.toml`: + +```toml +[c8y] +username = "t5678/octocat" +password = "abcd1234" +``` + +If needed, you can specify a custom location for the credentials file using the `tedge config` CLI tool: + +```sh +sudo tedge config set c8y.credentials_path +``` + +## Configuring the device ID + +The device ID must be explicitly set. This ID will be used as the external ID of the device in your Cumulocity tenant. + +```sh +sudo tedge config set device.id +``` + +## Adding SmartREST 1.0 template to %%te%% config + +You need to specify all the external IDs of the SmartREST 1.0 templates you plan to use. +This can be achieved by providing a comma-separated list of the external IDs: + +```sh +sudo tedge config set c8y.smartrest1.templates template-1,template-2 +``` + +Alternatively, you can add the template IDs one by one: + +```sh +sudo tedge config add c8y.smartrest1.templates template-1 +sudo tedge config add c8y.smartrest1.templates template-2 +``` + +## Connecting to Cumulocity + +Before connecting to Cumulocity, ensure the following prerequisites are met: + +- The device has already been registered in Cumulocity using the [Bulk Registration API](https://cumulocity.com/docs/device-management-application/registering-devices/#bulk-device-registration). +- The SmartREST 1.0 templates have been registered in your Cumulocity tenant. + +Once these steps are complete, you can connect to Cumulocity: + +```sh +sudo tedge connect c8y +``` diff --git a/tests/RobotFramework/tests/cumulocity/smartrest_one/smartrest_one.robot b/tests/RobotFramework/tests/cumulocity/smartrest_one/smartrest_one.robot index 027d988bf45..53088c72048 100644 --- a/tests/RobotFramework/tests/cumulocity/smartrest_one/smartrest_one.robot +++ b/tests/RobotFramework/tests/cumulocity/smartrest_one/smartrest_one.robot @@ -31,6 +31,12 @@ Register and Use SmartREST 1.0. Templates [Arguments] ${use_builtin_bridge} Custom Setup use_builtin_bridge=${use_builtin_bridge} + # device.id should be set by tedge config and confirm the test doesn't use certificate + Execute Command tedge config set device.id ${DEVICE_SN} + Execute Command tedge cert remove + File Should Not Exist /etc/tedge/device-certs/tedge-certificate.pem + File Should Not Exist /etc/tedge/device-certs/tedge-private-key.pem + ${TEMPLATE_XID}= Get Random Name prefix=TST_Template Set Test Variable $TEMPLATE_XID Execute Command tedge config set c8y.smartrest1.templates "${TEMPLATE_XID}" diff --git a/tests/RobotFramework/tests/tedge/tedge_cert_create.robot b/tests/RobotFramework/tests/tedge/tedge_cert_create.robot new file mode 100644 index 00000000000..d4556b82919 --- /dev/null +++ b/tests/RobotFramework/tests/tedge/tedge_cert_create.robot @@ -0,0 +1,133 @@ +*** Settings *** +Resource ../../resources/common.resource +Library ThinEdgeIO + +Suite Teardown Get Logs +Test Setup Custom Setup + +Test Tags theme:cli + + +*** Test Cases *** +Run tedge cert create + # device.id doesn't exist yet + Execute Command tedge config get device.id exp_exit_code=1 + + Execute Command tedge cert create --device-id ${DEVICE_SN} + File Should Exist /etc/tedge/device-certs/tedge-certificate.pem + File Should Exist /etc/tedge/device-certs/tedge-private-key.pem + + ${subject}= Execute Command openssl x509 -noout -subject -in /etc/tedge/device-certs/tedge-certificate.pem + Should Match Regexp ${subject} pattern=^subject=CN = ${DEVICE_SN},.+$ + + # device.id is read from the cert's CN + ${device_id}= Execute Command tedge config get device.id strip=${True} + Should Be Equal ${device_id} ${DEVICE_SN} + + # Remove the cert and key + Execute Command tedge cert remove + File Should Not Exist /etc/tedge/device-certs/tedge-certificate.pem + File Should Not Exist /etc/tedge/device-certs/tedge-private-key.pem + Execute Command tedge config get device.id exp_exit_code=1 + + # New cert/key can be also created without --device-id option if device.id is set in config + Execute Command tedge config set device.id ${DEVICE_SN}-two + Execute Command tedge cert create + File Should Exist /etc/tedge/device-certs/tedge-certificate.pem + File Should Exist /etc/tedge/device-certs/tedge-private-key.pem + ${subject_two}= Execute Command + ... openssl x509 -noout -subject -in /etc/tedge/device-certs/tedge-certificate.pem + Should Match Regexp ${subject_two} pattern=^subject=CN = ${DEVICE_SN}-two,.+$ + +Run tedge cert create with cloud profile + Set Test Variable $SECOND_DEVICE_SN ${device_sn}-second + Set Test Variable $THIRD_DEVICE_SN ${device_sn}-third + + Execute Command + ... tedge config set c8y.device.cert_path --profile second /etc/tedge/device-certs/tedge-certificate@second.pem + Execute Command + ... tedge config set c8y.device.key_path --profile second /etc/tedge/device-certs/tedge-private-key@second.pem + + Execute Command tedge cert create --device-id ${SECOND_DEVICE_SN} c8y --profile second + File Should Exist /etc/tedge/device-certs/tedge-certificate@second.pem + File Should Exist /etc/tedge/device-certs/tedge-private-key@second.pem + + ${subject}= Execute Command + ... openssl x509 -noout -subject -in /etc/tedge/device-certs/tedge-certificate@second.pem + Should Match Regexp ${subject} pattern=^subject=CN = ${SECOND_DEVICE_SN},.+$ + + # c8y.profiles.second.device.id is read from the cert's CN of the cloud profile "second" + ${config_output}= Execute Command + ... tedge config get c8y.device.id --profile second + ... strip=${True} + Should Be Equal ${config_output} ${SECOND_DEVICE_SN} + + # Using another cloud profile. c8y.profiles.third.device.id is set by tedge config set + Execute Command tedge config set c8y.device.id --profile third ${THIRD_DEVICE_SN} + Execute Command + ... tedge config set c8y.device.cert_path --profile third /etc/tedge/device-certs/tedge-certificate@third.pem + Execute Command + ... tedge config set c8y.device.key_path --profile third /etc/tedge/device-certs/tedge-private-key@third.pem + + Execute Command tedge cert create c8y --profile third + + File Should Exist /etc/tedge/device-certs/tedge-certificate@third.pem + File Should Exist /etc/tedge/device-certs/tedge-private-key@third.pem + ${subject}= Execute Command + ... openssl x509 -noout -subject -in /etc/tedge/device-certs/tedge-certificate@third.pem + Should Match Regexp ${subject} pattern=^subject=CN = ${THIRD_DEVICE_SN},.+$ + +Device ID derivation + ${output}= Execute Command tedge cert create --device-id input + Should Contain ${output} CN=input + Execute Command tedge cert remove + + Execute Command tedge config set device.id testid + # Mismached device id returns error + Execute Command tedge cert create --device-id different exp_exit_code=1 + # Matched device id passes + ${output}= Execute Command tedge cert create --device-id testid + Should Contain ${output} CN=testid + Execute Command tedge cert remove + + # The generic device.id is used as "default" value if cloud profile doesn't have it explicitly + ${output}= Execute Command tedge cert create c8y + Should Contain ${output} CN=testid + Execute Command tedge cert remove c8y + + Execute Command tedge config set c8y.url example.com --profile foo + ${output}= Execute Command tedge cert create c8y --profile foo + Should Contain ${output} CN=testid + Execute Command tedge cert remove c8y --profile foo + + # input value is used if cloud profile doesn't have explicit device id + ${output}= Execute Command tedge cert create c8y --device-id input + Should Contain ${output} CN=input + Execute Command tedge cert remove c8y + + ${output}= Execute Command tedge cert create c8y --device-id input --profile foo + Should Contain ${output} CN=input + Execute Command tedge cert remove c8y --profile foo + + # the value from cloud profile is used over the "default" value from the generic device.id + Execute Command tedge config set c8y.device.id c8y-testid + ${output}= Execute Command tedge cert create c8y + Should Contain ${output} CN=c8y-testid + Execute Command tedge cert remove c8y + # Mismatched device id returns error + Execute Command tedge cert create c8y --device-id different exp_exit_code=1 + + Execute Command tedge config set c8y.device.id c8y-foo-testid --profile foo + ${output}= Execute Command tedge cert create c8y --profile foo + Should Contain ${output} CN=c8y-foo-testid + Execute Command tedge cert remove c8y --profile foo + # Mismatched device id returns error + Execute Command tedge cert create c8y --device-id different --profile foo exp_exit_code=1 + + +*** Keywords *** +Custom Setup + ${device_sn}= Setup skip_bootstrap=${True} + Execute Command ./bootstrap.sh --no-bootstrap --no-connect + + Set Test Variable $DEVICE_SN ${device_sn} diff --git a/tests/RobotFramework/tests/tedge_connect/tedge_connect_cn_validation.robot b/tests/RobotFramework/tests/tedge_connect/tedge_connect_cn_validation.robot new file mode 100644 index 00000000000..b28a3f67762 --- /dev/null +++ b/tests/RobotFramework/tests/tedge_connect/tedge_connect_cn_validation.robot @@ -0,0 +1,34 @@ +*** Settings *** +Resource ../../resources/common.resource +Library Cumulocity +Library ThinEdgeIO +Library ../../.venv/lib/python3.11/site-packages/robot/libraries/String.py + +Suite Teardown Get Logs +Test Setup Custom Setup + +Test Tags theme:c8y theme:cli + + +*** Test Cases *** +Certificate's CN must match device.id + ${cert_output}= Execute Command tedge cert show + Should Contain ${cert_output} ${DEVICE_SN} + + Execute Command tedge config set device.id foo + ${output}= Execute Command tedge connect c8y + ... exp_exit_code=1 + ... stdout=False + ... stderr=True + ... timeout=0 + ... strip=True + Should Be Equal + ... ${output} + ... error: device.id 'foo' mismatches to the device certificate's CN '${DEVICE_SN}' + + +*** Keywords *** +Custom Setup + ${device_sn}= Setup skip_bootstrap=${True} + Execute Command ./bootstrap.sh --no-connect + Set Test Variable $DEVICE_SN ${device_sn}