edgehog_device_runtime/telemetry/status/
os_release.rs1use std::{collections::HashMap, io};
20
21use futures::TryFutureExt;
22use serde::Deserialize;
23use tracing::{debug, error};
24
25use crate::data::set_property;
26use crate::Client;
27
28const OS_INFO_INTERFACE: &str = "io.edgehog.devicemanager.OSInfo";
29
30const BASE_IMAGE_INTERFACE: &str = "io.edgehog.devicemanager.BaseImage";
31
32async fn try_read_file(path: &str) -> io::Result<Option<String>> {
33 match tokio::fs::read_to_string(path).await {
34 Ok(content) => Ok(Some(content)),
35 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
36 Err(err) => {
37 error!("couldn't read {path}: {err}");
38
39 Err(err)
40 }
41 }
42}
43
44fn split_key_value(line: &str) -> Option<(&str, &str)> {
45 line.split_once('=')
46 .map(|(k, v)| (k, v.trim_matches('"')))
47 .filter(|(k, v)| !k.is_empty() && !v.is_empty())
48}
49
50pub struct OsRelease {
51 pub os_info: OsInfo,
52 pub base_image: BaseImage,
53}
54
55impl OsRelease {
56 pub async fn read() -> Option<Self> {
57 let content = try_read_file("/etc/os-release")
58 .and_then(|file| async move {
59 if file.is_some() {
60 Ok(file)
61 } else {
62 try_read_file("/usr/lib/os-release").await
64 }
65 })
66 .await
67 .ok()
68 .flatten()?;
69
70 Some(Self::from(content.as_str()))
71 }
72
73 pub async fn send<C>(self, client: &mut C)
74 where
75 C: Client,
76 {
77 self.os_info.send(client).await;
78 self.base_image.send(client).await;
79 }
80}
81
82impl From<&str> for OsRelease {
83 fn from(s: &str) -> Self {
84 let lines: HashMap<&str, &str> = s.lines().filter_map(split_key_value).collect();
85
86 Self {
87 os_info: OsInfo::from(&lines),
88 base_image: BaseImage::from(&lines),
89 }
90 }
91}
92
93#[derive(Debug, Default, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct OsInfo {
97 pub os_name: Option<String>,
98 pub os_version: Option<String>,
99}
100
101impl OsInfo {
102 pub async fn send<C>(self, client: &mut C)
103 where
104 C: Client,
105 {
106 match self.os_name {
107 Some(name) => {
108 set_property(client, OS_INFO_INTERFACE, "/osName", name).await;
109 }
110 None => {
111 debug!("missing NAME in os-info");
112 }
113 }
114
115 match self.os_version {
116 Some(version) => {
117 set_property(client, OS_INFO_INTERFACE, "/osVersion", version).await;
118 }
119 None => {
120 debug!("missing VERSION_ID or BUILD_ID in os-info");
121 }
122 }
123 }
124}
125
126impl From<&HashMap<&str, &str>> for OsInfo {
127 fn from(value: &HashMap<&str, &str>) -> Self {
128 let name = value.get("NAME").map(|name| name.to_string());
129
130 let version = value
131 .get("VERSION_ID")
132 .or_else(|| value.get("BUILD_ID"))
133 .map(|version| version.to_string());
134
135 Self {
136 os_name: name,
137 os_version: version,
138 }
139 }
140}
141
142#[derive(Debug, Default)]
143pub struct BaseImage {
144 name: Option<String>,
145 version: Option<String>,
146 build_id: Option<String>,
147}
148
149impl BaseImage {
150 pub async fn send<C>(self, client: &mut C)
151 where
152 C: Client,
153 {
154 match self.name {
155 Some(name) => {
156 set_property(client, BASE_IMAGE_INTERFACE, "/name", name).await;
157 }
158 None => {
159 debug!("missing IMAGE_ID in os-info");
160 }
161 }
162
163 match self.version {
164 Some(version) => {
165 set_property(client, BASE_IMAGE_INTERFACE, "/version", version).await;
166 }
167 None => {
168 debug!("missing IMAGE_VERSION in os-info");
169 }
170 }
171
172 match self.build_id {
173 Some(build_id) => {
174 set_property(client, BASE_IMAGE_INTERFACE, "/buildId", build_id).await;
175 }
176 None => {
177 debug!("no build id set in IMAGE_VERSION");
178 }
179 }
180 }
181}
182
183impl From<&HashMap<&str, &str>> for BaseImage {
184 fn from(value: &HashMap<&str, &str>) -> Self {
185 let name = value.get("IMAGE_ID").map(|name| name.to_string());
186
187 let (version, build_id) = value
188 .get("IMAGE_VERSION")
189 .map(|version| match version.split_once('+') {
191 Some((version, build_id)) => {
192 (Some(version.to_string()), Some(build_id.to_string()))
193 }
194 None => (Some(version.to_string()), None),
195 })
196 .unwrap_or_default();
197
198 Self {
199 name,
200 version,
201 build_id,
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use astarte_device_sdk::store::SqliteStore;
209 use astarte_device_sdk::transport::mqtt::Mqtt;
210 use astarte_device_sdk::AstarteData;
211 use astarte_device_sdk_mock::MockDeviceClient;
212 use mockall::predicate;
213
214 use super::*;
215
216 #[test]
217 fn os_release_parsing() {
218 let file = r#"NAME="Arch Linux"
219PRETTY_NAME="Arch Linux"
220ID=arch
221BUILD_ID=rolling
222ANSI_COLOR="38;2;23;147;209"
223HOME_URL="https://archlinux.org/"
224DOCUMENTATION_URL="https://wiki.archlinux.org/"
225SUPPORT_URL="https://bbs.archlinux.org/"
226BUG_REPORT_URL="https://bugs.archlinux.org/"
227LOGO=archlinux-logo
228"#;
229
230 let data = OsRelease::from(file).os_info;
231 assert_eq!(data.os_name.unwrap(), "Arch Linux");
232 assert_eq!(data.os_version.unwrap(), "rolling");
233
234 let file = r#"PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
235NAME="Debian GNU/Linux"
236VERSION_ID="11"
237VERSION="11 (bullseye)"
238VERSION_CODENAME=bullseye
239ID=debian
240HOME_URL="https://www.debian.org/"
241SUPPORT_URL="https://www.debian.org/support"
242BUG_REPORT_URL="https://bugs.debian.org/""#;
243
244 let data = OsRelease::from(file).os_info;
245 assert_eq!(data.os_name.unwrap(), "Debian GNU/Linux");
246 assert_eq!(data.os_version.unwrap(), "11");
247 }
248
249 #[test]
250 fn os_release_parsing_with_middle_empty_line() {
251 let file = r#"NAME="Debian GNU/Linux"
252
253VERSION_ID="11""#;
254
255 let data = OsRelease::from(file).os_info;
256 assert_eq!(data.os_name.unwrap(), "Debian GNU/Linux");
257 assert_eq!(data.os_version.unwrap(), "11");
258 }
259
260 #[test]
261 fn os_release_with_only_name() {
262 let file = r#"NAME="Arch Linux"#;
263
264 let data = OsRelease::from(file).os_info;
265 assert_eq!(data.os_name.unwrap(), "Arch Linux");
266 assert!(data.os_version.is_none());
267 }
268
269 #[test]
270 fn os_release_malformed() {
271 let file = r#"NAME["Arch Linux"@@"#;
272
273 let data = OsRelease::from(file).os_info;
274 assert!(data.os_name.is_none());
275 assert!(data.os_version.is_none());
276 }
277
278 #[test]
279 fn parse_key_value_line_empty() {
280 let line = "";
281
282 let data = split_key_value(line);
283 assert!(data.is_none());
284 }
285
286 #[test]
287 fn parse_key_value_line_malformed() {
288 let line = r#"OS;"Arch"#;
289
290 let data = split_key_value(line);
291 assert!(data.is_none());
292 }
293
294 #[test]
295 fn parse_key_value_line_valid() {
296 let line = r#"OS="Arch"#;
297
298 let (key, value) = split_key_value(line).unwrap();
299 assert_eq!(key, "OS");
300 assert_eq!(value, "Arch");
301 }
302
303 #[tokio::test]
304 async fn should_send_os_info() {
305 let mut mock = MockDeviceClient::<Mqtt<SqliteStore>>::new();
306
307 mock.expect_set_property()
308 .once()
309 .with(
310 predicate::eq("io.edgehog.devicemanager.OSInfo"),
311 predicate::eq("/osName"),
312 predicate::eq(AstarteData::String("name".to_string())),
313 )
314 .returning(|_, _, _| Ok(()));
315
316 mock.expect_set_property()
317 .once()
318 .with(
319 predicate::eq("io.edgehog.devicemanager.OSInfo"),
320 predicate::eq("/osVersion"),
321 predicate::eq(AstarteData::String("version".to_string())),
322 )
323 .returning(|_, _, _| Ok(()));
324
325 OsInfo {
326 os_name: Some("name".to_string()),
327 os_version: Some("version".to_string()),
328 }
329 .send(&mut mock)
330 .await;
331 }
332
333 #[tokio::test]
334 #[cfg(target_os = "linux")]
335 async fn get_base_image_test() {
336 let result = OsRelease::read().await;
337 assert!(result.is_some());
338 }
339
340 #[test]
341 fn get_from_iter_empty_test() {
342 const OS_RELEASE: &str = r#"
343NAME="Ubuntu"
344VERSION="18.04.6 LTS (Bionic Beaver)"
345ID=ubuntu
346ID_LIKE=debian
347PRETTY_NAME="Ubuntu 18.04.6 LTS"
348VERSION_ID="18.04"
349HOME_URL="https://www.ubuntu.com/"
350SUPPORT_URL="https://help.ubuntu.com/"
351BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
352PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
353VERSION_CODENAME=bionic
354UBUNTU_CODENAME=bionic"#;
355
356 let base_image = OsRelease::from(OS_RELEASE).base_image;
357 assert!(base_image.name.is_none());
358 assert!(base_image.version.is_none());
359 assert!(base_image.build_id.is_none());
360 }
361
362 #[test]
363 fn get_from_iter_test() {
364 const OS_RELEASE: &str = r#"
365NAME="Ubuntu"
366VERSION="18.04.6 LTS (Bionic Beaver)"
367ID=ubuntu
368ID_LIKE=debian
369PRETTY_NAME="Ubuntu 18.04.6 LTS"
370VERSION_ID="18.04"
371HOME_URL="https://www.ubuntu.com/"
372SUPPORT_URL="https://help.ubuntu.com/"
373BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
374PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
375VERSION_CODENAME=bionic
376UBUNTU_CODENAME=bionic
377IMAGE_ID="testOs"
378IMAGE_VERSION="1.0.0+20220922""#;
379
380 let base_image = OsRelease::from(OS_RELEASE).base_image;
381 assert_eq!(base_image.name.unwrap(), "testOs");
382 assert_eq!(base_image.version.unwrap(), "1.0.0");
383 assert_eq!(base_image.build_id.unwrap(), "20220922");
384 }
385}