Skip to main content

talos_api_rs/resources/
images.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Typed wrappers for Container Image APIs.
4//!
5//! Provides functionality to list and pull container images in the CRI.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use talos_api_rs::{ImageListRequest, ImagePullRequest, ContainerdNamespace};
11//!
12//! // List all images in the system namespace
13//! let list_req = ImageListRequest::new(ContainerdNamespace::System);
14//!
15//! // Pull a specific image into the CRI namespace
16//! let pull_req = ImagePullRequest::new("ghcr.io/siderolabs/kubelet:v1.30.0")
17//!     .with_namespace(ContainerdNamespace::Cri);
18//! ```
19
20use crate::api::generated::common::ContainerdNamespace as ProtoContainerdNamespace;
21use crate::api::generated::machine::{
22    ImageListRequest as ProtoImageListRequest, ImageListResponse as ProtoImageListResponse,
23    ImagePull as ProtoImagePull, ImagePullRequest as ProtoImagePullRequest,
24    ImagePullResponse as ProtoImagePullResponse,
25};
26
27// =============================================================================
28// ContainerdNamespace
29// =============================================================================
30
31/// Containerd namespace for image operations.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ContainerdNamespace {
34    /// Unknown namespace.
35    Unknown,
36    /// System namespace (talos system containers).
37    #[default]
38    System,
39    /// CRI namespace (Kubernetes workloads).
40    Cri,
41}
42
43impl ContainerdNamespace {
44    /// Convert to the protobuf enum value.
45    #[must_use]
46    pub fn as_proto_i32(&self) -> i32 {
47        match self {
48            Self::Unknown => ProtoContainerdNamespace::NsUnknown as i32,
49            Self::System => ProtoContainerdNamespace::NsSystem as i32,
50            Self::Cri => ProtoContainerdNamespace::NsCri as i32,
51        }
52    }
53}
54
55impl From<i32> for ContainerdNamespace {
56    fn from(value: i32) -> Self {
57        match value {
58            x if x == ProtoContainerdNamespace::NsSystem as i32 => Self::System,
59            x if x == ProtoContainerdNamespace::NsCri as i32 => Self::Cri,
60            _ => Self::Unknown,
61        }
62    }
63}
64
65// =============================================================================
66// ImageListRequest
67// =============================================================================
68
69/// Request to list container images.
70#[derive(Debug, Clone, Default)]
71pub struct ImageListRequest {
72    /// Containerd namespace to list images from.
73    pub namespace: ContainerdNamespace,
74}
75
76impl ImageListRequest {
77    /// Create a new request to list images in a specific namespace.
78    #[must_use]
79    pub fn new(namespace: ContainerdNamespace) -> Self {
80        Self { namespace }
81    }
82
83    /// Create a request to list system images.
84    #[must_use]
85    pub fn system() -> Self {
86        Self::new(ContainerdNamespace::System)
87    }
88
89    /// Create a request to list CRI images (Kubernetes workloads).
90    #[must_use]
91    pub fn cri() -> Self {
92        Self::new(ContainerdNamespace::Cri)
93    }
94}
95
96impl From<ImageListRequest> for ProtoImageListRequest {
97    fn from(req: ImageListRequest) -> Self {
98        Self {
99            namespace: req.namespace.as_proto_i32(),
100        }
101    }
102}
103
104// =============================================================================
105// ImageInfo
106// =============================================================================
107
108/// Information about a container image.
109#[derive(Debug, Clone)]
110pub struct ImageInfo {
111    /// Node that reported this image.
112    pub node: Option<String>,
113    /// Full image name (repository:tag or repository@digest).
114    pub name: String,
115    /// Image digest (sha256:...).
116    pub digest: String,
117    /// Image size in bytes.
118    pub size: i64,
119    /// When the image was created.
120    pub created_at: Option<prost_types::Timestamp>,
121}
122
123impl ImageInfo {
124    /// Get image size in megabytes.
125    #[must_use]
126    pub fn size_mb(&self) -> f64 {
127        self.size as f64 / 1_048_576.0
128    }
129
130    /// Get image size in a human-readable format.
131    #[must_use]
132    pub fn size_human(&self) -> String {
133        let size = self.size as f64;
134        if size < 1024.0 {
135            format!("{:.0} B", size)
136        } else if size < 1_048_576.0 {
137            format!("{:.1} KB", size / 1024.0)
138        } else if size < 1_073_741_824.0 {
139            format!("{:.1} MB", size / 1_048_576.0)
140        } else {
141            format!("{:.2} GB", size / 1_073_741_824.0)
142        }
143    }
144
145    /// Check if this is a digest-based reference (no tag).
146    #[must_use]
147    pub fn is_digest_reference(&self) -> bool {
148        self.name.contains('@')
149    }
150
151    /// Extract the repository name (without tag or digest).
152    #[must_use]
153    pub fn repository(&self) -> &str {
154        if let Some(pos) = self.name.find('@') {
155            &self.name[..pos]
156        } else if let Some(pos) = self.name.rfind(':') {
157            // Be careful not to split on port numbers
158            let before_colon = &self.name[..pos];
159            if before_colon.contains('/') || !before_colon.contains('.') {
160                &self.name[..pos]
161            } else {
162                &self.name
163            }
164        } else {
165            &self.name
166        }
167    }
168
169    /// Extract the tag (if present).
170    #[must_use]
171    pub fn tag(&self) -> Option<&str> {
172        if self.name.contains('@') {
173            return None;
174        }
175        if let Some(pos) = self.name.rfind(':') {
176            let before_colon = &self.name[..pos];
177            // Make sure it's not a port number
178            if before_colon.contains('/') || !before_colon.contains('.') {
179                return Some(&self.name[pos + 1..]);
180            }
181        }
182        None
183    }
184}
185
186impl From<ProtoImageListResponse> for ImageInfo {
187    fn from(proto: ProtoImageListResponse) -> Self {
188        Self {
189            node: proto.metadata.map(|m| m.hostname),
190            name: proto.name,
191            digest: proto.digest,
192            size: proto.size,
193            created_at: proto.created_at,
194        }
195    }
196}
197
198// =============================================================================
199// ImagePullRequest
200// =============================================================================
201
202/// Request to pull a container image.
203#[derive(Debug, Clone)]
204pub struct ImagePullRequest {
205    /// Containerd namespace to pull the image into.
206    pub namespace: ContainerdNamespace,
207    /// Image reference to pull (e.g., "docker.io/library/nginx:latest").
208    pub reference: String,
209}
210
211impl ImagePullRequest {
212    /// Create a new request to pull an image.
213    ///
214    /// Uses the system namespace by default.
215    #[must_use]
216    pub fn new(reference: impl Into<String>) -> Self {
217        Self {
218            namespace: ContainerdNamespace::System,
219            reference: reference.into(),
220        }
221    }
222
223    /// Set the namespace to pull the image into.
224    #[must_use]
225    pub fn with_namespace(mut self, namespace: ContainerdNamespace) -> Self {
226        self.namespace = namespace;
227        self
228    }
229
230    /// Pull into the CRI namespace (Kubernetes workloads).
231    #[must_use]
232    pub fn for_cri(mut self) -> Self {
233        self.namespace = ContainerdNamespace::Cri;
234        self
235    }
236}
237
238impl From<ImagePullRequest> for ProtoImagePullRequest {
239    fn from(req: ImagePullRequest) -> Self {
240        Self {
241            namespace: req.namespace.as_proto_i32(),
242            reference: req.reference,
243        }
244    }
245}
246
247// =============================================================================
248// ImagePullResult
249// =============================================================================
250
251/// Result from pulling an image.
252#[derive(Debug, Clone)]
253pub struct ImagePullResult {
254    /// Node that processed the pull request.
255    pub node: Option<String>,
256}
257
258impl From<ProtoImagePull> for ImagePullResult {
259    fn from(proto: ProtoImagePull) -> Self {
260        Self {
261            node: proto.metadata.map(|m| m.hostname),
262        }
263    }
264}
265
266/// Response from pulling an image (may contain multiple node results).
267#[derive(Debug, Clone)]
268pub struct ImagePullResponse {
269    /// Results from each node.
270    pub results: Vec<ImagePullResult>,
271}
272
273impl ImagePullResponse {
274    /// Check if the pull succeeded on all nodes.
275    #[must_use]
276    pub fn all_succeeded(&self) -> bool {
277        !self.results.is_empty()
278    }
279
280    /// Get the list of nodes that processed the request.
281    #[must_use]
282    pub fn nodes(&self) -> Vec<&str> {
283        self.results
284            .iter()
285            .filter_map(|r| r.node.as_deref())
286            .collect()
287    }
288}
289
290impl From<ProtoImagePullResponse> for ImagePullResponse {
291    fn from(proto: ProtoImagePullResponse) -> Self {
292        Self {
293            results: proto.messages.into_iter().map(Into::into).collect(),
294        }
295    }
296}
297
298// =============================================================================
299// Tests
300// =============================================================================
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_containerd_namespace_default() {
308        let ns = ContainerdNamespace::default();
309        assert_eq!(ns, ContainerdNamespace::System);
310    }
311
312    #[test]
313    fn test_containerd_namespace_from_i32() {
314        assert_eq!(ContainerdNamespace::from(0), ContainerdNamespace::Unknown);
315        assert_eq!(ContainerdNamespace::from(1), ContainerdNamespace::System);
316        assert_eq!(ContainerdNamespace::from(2), ContainerdNamespace::Cri);
317        assert_eq!(ContainerdNamespace::from(999), ContainerdNamespace::Unknown);
318    }
319
320    #[test]
321    fn test_image_list_request_constructors() {
322        let req = ImageListRequest::system();
323        assert_eq!(req.namespace, ContainerdNamespace::System);
324
325        let req = ImageListRequest::cri();
326        assert_eq!(req.namespace, ContainerdNamespace::Cri);
327    }
328
329    #[test]
330    fn test_image_list_request_to_proto() {
331        let req = ImageListRequest::cri();
332        let proto: ProtoImageListRequest = req.into();
333        assert_eq!(proto.namespace, ProtoContainerdNamespace::NsCri as i32);
334    }
335
336    #[test]
337    fn test_image_info_size_human() {
338        let info = ImageInfo {
339            node: None,
340            name: "test".to_string(),
341            digest: "sha256:abc".to_string(),
342            size: 500,
343            created_at: None,
344        };
345        assert_eq!(info.size_human(), "500 B");
346
347        let info = ImageInfo {
348            size: 2048,
349            ..info.clone()
350        };
351        assert_eq!(info.size_human(), "2.0 KB");
352
353        let info = ImageInfo {
354            size: 52_428_800,
355            ..info.clone()
356        };
357        assert_eq!(info.size_human(), "50.0 MB");
358
359        let info = ImageInfo {
360            size: 2_147_483_648,
361            ..info
362        };
363        assert_eq!(info.size_human(), "2.00 GB");
364    }
365
366    #[test]
367    fn test_image_info_repository_and_tag() {
368        // Standard image with tag
369        let info = ImageInfo {
370            node: None,
371            name: "docker.io/library/nginx:1.25".to_string(),
372            digest: "sha256:abc".to_string(),
373            size: 0,
374            created_at: None,
375        };
376        assert_eq!(info.repository(), "docker.io/library/nginx");
377        assert_eq!(info.tag(), Some("1.25"));
378        assert!(!info.is_digest_reference());
379
380        // Image with digest reference
381        let info = ImageInfo {
382            name: "ghcr.io/siderolabs/kubelet@sha256:abc123".to_string(),
383            ..info.clone()
384        };
385        assert_eq!(info.repository(), "ghcr.io/siderolabs/kubelet");
386        assert_eq!(info.tag(), None);
387        assert!(info.is_digest_reference());
388
389        // Image without tag (implicit :latest)
390        let info = ImageInfo {
391            name: "nginx".to_string(),
392            ..info
393        };
394        assert_eq!(info.repository(), "nginx");
395        assert_eq!(info.tag(), None);
396    }
397
398    #[test]
399    fn test_image_pull_request_builder() {
400        let req = ImagePullRequest::new("nginx:latest").for_cri();
401        assert_eq!(req.reference, "nginx:latest");
402        assert_eq!(req.namespace, ContainerdNamespace::Cri);
403
404        let req = ImagePullRequest::new("alpine:3.18").with_namespace(ContainerdNamespace::Cri);
405        assert_eq!(req.namespace, ContainerdNamespace::Cri);
406    }
407
408    #[test]
409    fn test_image_pull_request_to_proto() {
410        let req = ImagePullRequest::new("ghcr.io/test/image:v1").for_cri();
411        let proto: ProtoImagePullRequest = req.into();
412        assert_eq!(proto.reference, "ghcr.io/test/image:v1");
413        assert_eq!(proto.namespace, ProtoContainerdNamespace::NsCri as i32);
414    }
415
416    #[test]
417    fn test_image_pull_response_nodes() {
418        let response = ImagePullResponse {
419            results: vec![
420                ImagePullResult {
421                    node: Some("node1".to_string()),
422                },
423                ImagePullResult {
424                    node: Some("node2".to_string()),
425                },
426                ImagePullResult { node: None },
427            ],
428        };
429        assert!(response.all_succeeded());
430        assert_eq!(response.nodes(), vec!["node1", "node2"]);
431    }
432}