Skip to main content

edgehog_device_runtime_containers/requests/
container.rs

1// This file is part of Edgehog.
2//
3// Copyright 2024 - 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
19//! Create image request
20
21use std::str::FromStr;
22
23use astarte_device_sdk::FromEvent;
24use bollard::models::RestartPolicyNameEnum;
25use tracing::{instrument, trace};
26
27use crate::{container::Binding, requests::BindingError};
28
29use super::{OptString, ReqUuid, VecReqUuid};
30
31/// couldn't parse restart policy {value}
32#[derive(Debug, thiserror::Error, displaydoc::Display, PartialEq)]
33pub struct RestartPolicyError {
34    value: String,
35}
36
37/// Request to pull a Docker Container.
38#[derive(Debug, Clone, FromEvent, PartialEq, Eq, PartialOrd, Ord)]
39#[from_event(
40    interface = "io.edgehog.devicemanager.apps.CreateContainerRequest",
41    path = "/container",
42    rename_all = "camelCase",
43    aggregation = "object"
44)]
45pub struct CreateContainer {
46    pub(crate) id: ReqUuid,
47    pub(crate) deployment_id: ReqUuid,
48    pub(crate) image_id: ReqUuid,
49    pub(crate) network_ids: VecReqUuid,
50    pub(crate) volume_ids: VecReqUuid,
51    pub(crate) device_mapping_ids: VecReqUuid,
52    pub(crate) hostname: String,
53    pub(crate) restart_policy: String,
54    pub(crate) env: Vec<String>,
55    pub(crate) binds: Vec<String>,
56    pub(crate) network_mode: String,
57    pub(crate) port_bindings: Vec<String>,
58    pub(crate) extra_hosts: Vec<String>,
59    pub(crate) cap_add: Vec<String>,
60    pub(crate) cap_drop: Vec<String>,
61    pub(crate) cpu_period: i64,
62    pub(crate) cpu_quota: i64,
63    pub(crate) cpu_realtime_period: i64,
64    pub(crate) cpu_realtime_runtime: i64,
65    pub(crate) memory: i64,
66    pub(crate) memory_reservation: i64,
67    pub(crate) memory_swap: i64,
68    pub(crate) memory_swappiness: i32,
69    pub(crate) volume_driver: OptString,
70    pub(crate) storage_opt: Vec<String>,
71    pub(crate) read_only_rootfs: bool,
72    pub(crate) tmpfs: Vec<String>,
73    pub(crate) privileged: bool,
74}
75
76#[derive(Debug, PartialEq, Eq)]
77pub(crate) struct ParsedBind<'a> {
78    port: u16,
79    proto: Option<&'a str>,
80    pub(crate) host: Binding<&'a str>,
81}
82
83impl<'a> ParsedBind<'a> {
84    fn new(
85        port: u16,
86        proto: Option<&'a str>,
87        host_ip: Option<&'a str>,
88        host_port: Option<u16>,
89    ) -> Self {
90        Self {
91            port,
92            proto,
93            host: Binding { host_ip, host_port },
94        }
95    }
96
97    pub(crate) fn id(&self) -> String {
98        let proto = self.proto.unwrap_or("tcp");
99
100        format!("{}/{}", self.port, proto)
101    }
102}
103
104/// Parses a binding in the form
105///
106/// ```plaintext
107/// [ip:[hostPort:]]containerPort[/protocol]
108/// ```
109#[instrument]
110pub(crate) fn parse_port_binding(input: &str) -> Result<ParsedBind<'_>, BindingError> {
111    let (host_ip, host_port, rest) = parse_host_ip_port(input)?;
112
113    let (container_port, protocol) = rest.split_once('/').map_or_else(
114        || {
115            trace!("container port {rest}");
116
117            (rest, None)
118        },
119        |(port, proto)| {
120            trace!("container port {port} and protocol {proto}");
121
122            (port, Some(proto))
123        },
124    );
125
126    let container_port = container_port.parse().map_err(|err| BindingError::Port {
127        binding: "container",
128        value: container_port.to_string(),
129        source: err,
130    })?;
131
132    Ok(ParsedBind::new(
133        container_port,
134        protocol,
135        host_ip,
136        host_port,
137    ))
138}
139
140#[instrument]
141fn parse_host_ip_port(input: &str) -> Result<(Option<&str>, Option<u16>, &str), BindingError> {
142    let Some((ip_or_port, rest)) = input.split_once(':') else {
143        trace!("missing host ip or port, returning rest: {input}");
144
145        return Ok((None, None, input));
146    };
147
148    match rest.split_once(':') {
149        Some((port, rest)) => {
150            let port: u16 = port.parse().map_err(|err| BindingError::Port {
151                binding: "host",
152                value: port.to_string(),
153                source: err,
154            })?;
155
156            trace!("found ip {ip_or_port} and port {port}");
157
158            Ok((Some(ip_or_port), Some(port), rest))
159        }
160        None => {
161            // Try to parse the ip as port
162            if let Ok(port) = ip_or_port.parse::<u16>() {
163                trace!("found port {port}");
164
165                Ok((None, Some(port), rest))
166            } else {
167                trace!("found ip {ip_or_port}");
168                Ok((Some(ip_or_port), None, rest))
169            }
170        }
171    }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub(crate) enum RestartPolicy {
176    Empty,
177    No,
178    Always,
179    UnlessStopped,
180    OnFailure,
181}
182
183impl FromStr for RestartPolicy {
184    type Err = RestartPolicyError;
185
186    fn from_str(s: &str) -> Result<Self, Self::Err> {
187        match s {
188            "" => Ok(RestartPolicy::Empty),
189            "no" => Ok(RestartPolicy::No),
190            "always" => Ok(RestartPolicy::Always),
191            "unless-stopped" => Ok(RestartPolicy::UnlessStopped),
192            "on-failure" => Ok(RestartPolicy::OnFailure),
193            _ => Err(RestartPolicyError {
194                value: s.to_string(),
195            }),
196        }
197    }
198}
199
200impl From<RestartPolicy> for RestartPolicyNameEnum {
201    fn from(value: RestartPolicy) -> Self {
202        match value {
203            RestartPolicy::Empty => RestartPolicyNameEnum::EMPTY,
204            RestartPolicy::No => RestartPolicyNameEnum::NO,
205            RestartPolicy::Always => RestartPolicyNameEnum::ALWAYS,
206            RestartPolicy::UnlessStopped => RestartPolicyNameEnum::UNLESS_STOPPED,
207            RestartPolicy::OnFailure => RestartPolicyNameEnum::ON_FAILURE,
208        }
209    }
210}
211
212#[cfg(test)]
213pub(crate) mod tests {
214
215    use std::fmt::Display;
216
217    use astarte_device_sdk::chrono::Utc;
218    use astarte_device_sdk::{AstarteData, DeviceEvent, Value};
219    use pretty_assertions::assert_eq;
220    use uuid::Uuid;
221
222    use super::*;
223
224    pub fn create_container_request_event<S: Display>(
225        id: impl Display,
226        deployment_id: impl Display,
227        image_id: impl Display,
228        image: &str,
229        network_ids: &[S],
230        device_mapping_ids: &[impl Display],
231    ) -> DeviceEvent {
232        let fields = [
233            ("id", AstarteData::String(id.to_string())),
234            (
235                "deploymentId",
236                AstarteData::String(deployment_id.to_string()),
237            ),
238            ("imageId", AstarteData::String(image_id.to_string())),
239            ("volumeIds", AstarteData::StringArray(vec![])),
240            (
241                "deviceMappingIds",
242                AstarteData::StringArray(
243                    device_mapping_ids.iter().map(|d| d.to_string()).collect(),
244                ),
245            ),
246            ("image", AstarteData::String(image.to_string())),
247            ("hostname", AstarteData::String("hostname".to_string())),
248            ("restartPolicy", AstarteData::String("no".to_string())),
249            ("env", AstarteData::StringArray(vec!["env".to_string()])),
250            ("binds", AstarteData::StringArray(vec!["binds".to_string()])),
251            ("networkMode", AstarteData::String("bridge".to_string())),
252            (
253                "networkIds",
254                AstarteData::StringArray(network_ids.iter().map(|s| s.to_string()).collect()),
255            ),
256            (
257                "portBindings",
258                AstarteData::StringArray(vec!["80:80".to_string()]),
259            ),
260            (
261                "extraHosts",
262                AstarteData::StringArray(vec!["host.docker.internal:host-gateway".to_string()]),
263            ),
264            (
265                "capAdd",
266                AstarteData::StringArray(vec!["CAP_CHOWN".to_string()]),
267            ),
268            (
269                "capDrop",
270                AstarteData::StringArray(vec!["CAP_KILL".to_string()]),
271            ),
272            ("privileged", AstarteData::Boolean(false)),
273            ("cpuPeriod", AstarteData::LongInteger(1000)),
274            ("cpuQuota", AstarteData::LongInteger(100)),
275            ("cpuRealtimePeriod", AstarteData::LongInteger(1000)),
276            ("cpuRealtimeRuntime", AstarteData::LongInteger(100)),
277            ("memory", AstarteData::LongInteger(4096)),
278            ("memoryReservation", AstarteData::LongInteger(1024)),
279            ("memorySwap", AstarteData::LongInteger(8192)),
280            ("memorySwappiness", AstarteData::Integer(50)),
281            ("volumeDriver", AstarteData::from("local")),
282            (
283                "storageOpt",
284                AstarteData::from(vec!["size=1024k".to_string()]),
285            ),
286            ("readOnlyRootfs", AstarteData::from(true)),
287            (
288                "tmpfs",
289                AstarteData::from(vec!["/run=rw,noexec,nosuid,size=65536k".to_string()]),
290            ),
291        ]
292        .into_iter()
293        .map(|(k, v)| (k.to_string(), v))
294        .collect();
295
296        DeviceEvent {
297            interface: "io.edgehog.devicemanager.apps.CreateContainerRequest".to_string(),
298            path: "/container".to_string(),
299            data: Value::Object {
300                data: fields,
301                timestamp: Utc::now(),
302            },
303        }
304    }
305
306    #[test]
307    fn create_container_request() {
308        let id = ReqUuid(Uuid::new_v4());
309        let deployment_id = ReqUuid(Uuid::new_v4());
310        let image_id = ReqUuid(Uuid::new_v4());
311        let network_ids = VecReqUuid(vec![ReqUuid(Uuid::new_v4())]);
312        let device_mapping_ids = VecReqUuid(vec![ReqUuid(Uuid::new_v4())]);
313        let event = create_container_request_event(
314            id,
315            deployment_id,
316            image_id,
317            "image",
318            &network_ids,
319            &device_mapping_ids,
320        );
321
322        let request = CreateContainer::from_event(event).unwrap();
323
324        let expect = CreateContainer {
325            id,
326            deployment_id,
327            image_id,
328            network_ids,
329            volume_ids: VecReqUuid(vec![]),
330            device_mapping_ids,
331            hostname: "hostname".to_string(),
332            restart_policy: "no".to_string(),
333            env: vec!["env".to_string()],
334            binds: vec!["binds".to_string()],
335            network_mode: "bridge".to_string(),
336            port_bindings: vec!["80:80".to_string()],
337            extra_hosts: vec!["host.docker.internal:host-gateway".to_string()],
338            cap_add: vec!["CAP_CHOWN".to_string()],
339            cap_drop: vec!["CAP_KILL".to_string()],
340            cpu_period: 1000,
341            cpu_quota: 100,
342            cpu_realtime_period: 1000,
343            cpu_realtime_runtime: 100,
344            memory: 4096,
345            memory_reservation: 1024,
346            memory_swap: 8192,
347            memory_swappiness: 50,
348            volume_driver: "local".to_string().into(),
349            storage_opt: vec!["size=1024k".to_string()],
350            read_only_rootfs: true,
351            tmpfs: vec!["/run=rw,noexec,nosuid,size=65536k".to_string()],
352            privileged: false,
353        };
354
355        assert_eq!(request, expect);
356    }
357
358    #[test]
359    fn should_parse_port_binding() {
360        let cases = [
361            // ip:[hostPort:]containerPort[/protocol]
362            (
363                "1.1.1.1:80:90/udp",
364                ParsedBind::new(90, Some("udp"), Some("1.1.1.1"), Some(80)),
365            ),
366            (
367                "1.1.1.1:90/udp",
368                ParsedBind::new(90, Some("udp"), Some("1.1.1.1"), None),
369            ),
370            (
371                "1.1.1.1:90",
372                ParsedBind::new(90, None, Some("1.1.1.1"), None),
373            ),
374            // [hostPort:]containerPort[/protocol]
375            (
376                "80:90/udp",
377                ParsedBind::new(90, Some("udp"), None, Some(80)),
378            ),
379            ("90/udp", ParsedBind::new(90, Some("udp"), None, None)),
380            ("90", ParsedBind::new(90, None, None, None)),
381        ];
382
383        for (case, expected) in cases {
384            let parsed = parse_port_binding(case).unwrap();
385
386            assert_eq!(parsed, expected, "failed to parse {case}");
387        }
388    }
389
390    #[test]
391    fn parse_restart_policy() {
392        let cases = [
393            ("", RestartPolicy::Empty),
394            ("no", RestartPolicy::No),
395            ("unless-stopped", RestartPolicy::UnlessStopped),
396            ("on-failure", RestartPolicy::OnFailure),
397            ("on-failure", RestartPolicy::OnFailure),
398        ];
399
400        for (case, exp) in cases {
401            let policy = RestartPolicy::from_str(case).unwrap();
402
403            assert_eq!(policy, exp);
404        }
405
406        let err = RestartPolicy::from_str("bar").unwrap_err();
407        assert_eq!(
408            err,
409            RestartPolicyError {
410                value: "bar".to_string()
411            }
412        );
413
414        let err = RestartPolicy::from_str("NO").unwrap_err();
415        assert_eq!(
416            err,
417            RestartPolicyError {
418                value: "NO".to_string()
419            }
420        );
421
422        let err = RestartPolicy::from_str("on_failure").unwrap_err();
423        assert_eq!(
424            err,
425            RestartPolicyError {
426                value: "on_failure".to_string()
427            }
428        );
429    }
430}