Skip to main content

edgehog_device_runtime/telemetry/status/
os_release.rs

1// This file is part of Edgehog.
2//
3// Copyright 2022 - 2025 SECO Mind Srl
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9//    http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16//
17// SPDX-License-Identifier: Apache-2.0
18
19use 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                    // Only read this if the one in /etc doesn't exist
63                    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/// get structured data for `io.edgehog.devicemanager.OSInfo` interface
94#[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            // cursed some mapping
190            .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}