Skip to main content

edgehog_device_runtime_containers/store/
image.rs

1// This file is part of Edgehog.
2//
3// Copyright 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 diesel::{delete, insert_or_ignore_into, ExpressionMethods, OptionalExtension, RunQueryDsl};
20use diesel::{update, QueryDsl};
21use edgehog_store::conversions::SqlUuid;
22use edgehog_store::db::HandleError;
23use edgehog_store::models::containers::image::ImageStatus;
24use edgehog_store::models::QueryModel;
25use edgehog_store::{
26    models::containers::{container::ContainerMissingImage, image::Image},
27    schema::containers::{container_missing_images, containers, images},
28};
29use tracing::instrument;
30use uuid::Uuid;
31
32use crate::docker::image::Image as ContainerImage;
33use crate::requests::image::CreateImage;
34use crate::resource::image::ImageResource;
35
36use super::{Result, StateStore};
37
38impl StateStore {
39    /// Stores the image received from the CreateRequest
40    #[instrument(skip_all, fields(%image.id))]
41    pub(crate) async fn create_image(&self, image: CreateImage) -> Result<()> {
42        let image = Image::from(image);
43
44        self.handle
45            .for_write(move |writer| {
46                insert_or_ignore_into(images::table)
47                    .values(&image)
48                    .execute(writer)?;
49
50                update(containers::table)
51                    .set(containers::image_id.eq(image.id))
52                    .filter(
53                        containers::id.eq_any(
54                            ContainerMissingImage::find_by_image(&image.id)
55                                .select(container_missing_images::container_id),
56                        ),
57                    )
58                    .execute(writer)?;
59
60                delete(ContainerMissingImage::find_by_image(&image.id)).execute(writer)?;
61
62                Ok(())
63            })
64            .await?;
65
66        Ok(())
67    }
68
69    /// Updates the status of a image
70    #[instrument(skip(self))]
71    pub(crate) async fn update_image_status(&self, id: Uuid, status: ImageStatus) -> Result<()> {
72        self.handle
73            .for_write(move |writer| {
74                let updated = update(Image::find_id(&SqlUuid::new(id)))
75                    .set(images::status.eq(status))
76                    .execute(writer)?;
77
78                HandleError::check_modified(updated, 1)?;
79
80                Ok(())
81            })
82            .await?;
83
84        Ok(())
85    }
86
87    /// Updates the local_id of a image
88    #[instrument(skip(self))]
89    pub(crate) async fn update_image_local_id(
90        &self,
91        id: Uuid,
92        local_id: Option<String>,
93    ) -> Result<()> {
94        self.handle
95            .for_write(move |writer| {
96                let updated = update(Image::find_id(&SqlUuid::new(id)))
97                    .set(images::local_id.eq(local_id))
98                    .execute(writer)?;
99
100                HandleError::check_modified(updated, 1)?;
101
102                Ok(())
103            })
104            .await?;
105
106        Ok(())
107    }
108
109    /// Delete the [`Image`] with the give [id](Uuid).
110    #[instrument(skip(self))]
111    pub(crate) async fn delete_image(&self, id: Uuid) -> Result<()> {
112        self.handle
113            .for_write(move |writer| {
114                let updated = delete(Image::find_id(&SqlUuid::new(id))).execute(writer)?;
115
116                HandleError::check_modified(updated, 1)?;
117
118                Ok(())
119            })
120            .await?;
121
122        Ok(())
123    }
124
125    /// Fetches an image by id
126    #[instrument(skip(self))]
127    pub(crate) async fn find_image(&self, id: Uuid) -> Result<Option<ImageResource>> {
128        let image = self
129            .handle
130            .for_read(move |reader| {
131                let image: Option<Image> =
132                    Image::find_id(&SqlUuid::new(id)).first(reader).optional()?;
133
134                Ok(image)
135            })
136            .await?
137            .map(|img| ImageResource::new(ContainerImage::from(img)));
138
139        Ok(image)
140    }
141
142    /// Fetches the images that need to be published
143    #[instrument(skip(self))]
144    pub(crate) async fn load_images_to_publish(&self) -> Result<Vec<SqlUuid>> {
145        let image = self
146            .handle
147            .for_read(move |reader| {
148                let images = images::table
149                    .select(images::id)
150                    .filter(images::status.eq(ImageStatus::Received))
151                    .load::<SqlUuid>(reader)?;
152
153                Ok(images)
154            })
155            .await?;
156
157        Ok(image)
158    }
159
160    /// Finds the unique id of the image with the given local id
161    ///
162    /// Returns the id of the image and the reference.
163    #[instrument(skip(self))]
164    pub(crate) async fn find_image_by_local_id(
165        &self,
166        local_id: String,
167    ) -> Result<Option<(Uuid, String)>> {
168        let id = self
169            .handle
170            .for_read(|reader| {
171                images::table
172                    .filter(images::local_id.eq(local_id))
173                    .select((images::id, images::reference))
174                    .first::<(SqlUuid, String)>(reader)
175                    .map(|(id, reference)| (*id, reference))
176                    .optional()
177                    .map_err(HandleError::Query)
178            })
179            .await?;
180
181        Ok(id)
182    }
183}
184
185impl From<CreateImage> for Image {
186    fn from(
187        CreateImage {
188            id,
189            deployment_id: _,
190            reference,
191            registry_auth,
192        }: CreateImage,
193    ) -> Self {
194        let registry_auth = (!registry_auth.is_empty()).then_some(registry_auth);
195
196        Self {
197            id: SqlUuid::new(id),
198            local_id: None,
199            status: ImageStatus::default(),
200            reference,
201            registry_auth,
202        }
203    }
204}
205
206impl From<Image> for ContainerImage {
207    fn from(value: Image) -> Self {
208        Self::new(value.local_id, value.reference, value.registry_auth)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use crate::requests::ReqUuid;
215
216    use super::*;
217
218    use edgehog_store::db;
219    use pretty_assertions::assert_eq;
220    use tempfile::TempDir;
221
222    async fn find_image(store: &StateStore, id: Uuid) -> Option<Image> {
223        store
224            .handle
225            .for_read(move |reader| {
226                Image::find_id(&SqlUuid::new(id))
227                    .first::<Image>(reader)
228                    .optional()
229                    .map_err(HandleError::Query)
230            })
231            .await
232            .unwrap()
233    }
234
235    #[tokio::test]
236    async fn should_store() {
237        let tmp = TempDir::with_prefix("store_image").unwrap();
238        let db_file = tmp.path().join("state.db");
239        let db_file = db_file.to_str().unwrap();
240
241        let handle = db::Handle::open(db_file).await.unwrap();
242        let store = StateStore::new(handle);
243
244        let image_id = Uuid::new_v4();
245        let deployment_id = Uuid::new_v4();
246        let image = CreateImage {
247            id: ReqUuid(image_id),
248            deployment_id: ReqUuid(deployment_id),
249            reference: "postgres:15".to_string(),
250            registry_auth: String::new(),
251        };
252        store.create_image(image).await.unwrap();
253
254        let res = find_image(&store, image_id).await.unwrap();
255
256        let exp = Image {
257            id: SqlUuid::new(image_id),
258            local_id: None,
259            status: ImageStatus::Received,
260            reference: "postgres:15".to_string(),
261            registry_auth: None,
262        };
263
264        assert_eq!(res, exp)
265    }
266
267    #[tokio::test]
268    async fn should_update() {
269        let tmp = TempDir::with_prefix("update_image").unwrap();
270        let db_file = tmp.path().join("state.db");
271        let db_file = db_file.to_str().unwrap();
272
273        let handle = db::Handle::open(db_file).await.unwrap();
274        let store = StateStore::new(handle);
275
276        let image_id = Uuid::new_v4();
277        let deployment_id = Uuid::new_v4();
278        let image = CreateImage {
279            id: ReqUuid(image_id),
280            deployment_id: ReqUuid(deployment_id),
281            reference: "postgres:15".to_string(),
282            registry_auth: String::new(),
283        };
284        store.create_image(image).await.unwrap();
285        store
286            .update_image_status(image_id, ImageStatus::Published)
287            .await
288            .unwrap();
289        let local_id = Uuid::new_v4().to_string();
290        store
291            .update_image_local_id(image_id, Some(local_id.clone()))
292            .await
293            .unwrap();
294
295        let res = find_image(&store, image_id).await.unwrap();
296
297        let exp = Image {
298            id: SqlUuid::new(image_id),
299            local_id: Some(local_id),
300            status: ImageStatus::Published,
301            reference: "postgres:15".to_string(),
302            registry_auth: None,
303        };
304
305        assert_eq!(res, exp)
306    }
307
308    #[tokio::test]
309    async fn find_by_local_id() {
310        let tmp = TempDir::with_prefix("find_by_local_id").unwrap();
311        let db_file = tmp.path().join("state.db");
312        let db_file = db_file.to_str().unwrap();
313
314        let handle = db::Handle::open(db_file).await.unwrap();
315        let store = StateStore::new(handle);
316
317        let image_id = Uuid::new_v4();
318        let deployment_id = Uuid::new_v4();
319        let reference = "postgres:15".to_string();
320        let image = CreateImage {
321            id: ReqUuid(image_id),
322            deployment_id: ReqUuid(deployment_id),
323            reference: reference.clone(),
324            registry_auth: String::new(),
325        };
326        store.create_image(image).await.unwrap();
327        let local_id = Uuid::new_v4().to_string();
328        store
329            .update_image_local_id(image_id, Some(local_id.clone()))
330            .await
331            .unwrap();
332
333        let res = store
334            .find_image_by_local_id(local_id)
335            .await
336            .unwrap()
337            .unwrap();
338
339        assert_eq!(res, (image_id, reference))
340    }
341}