microsandbox_core/management/
db.rs

1//! Database management for Microsandbox.
2//!
3//! This module provides database functionality for Microsandbox, managing both sandbox
4//! and OCI (Open Container Initiative) related data. It handles database initialization,
5//! migrations, and operations for storing and retrieving container images, layers,
6//! and sandbox configurations.
7
8use std::path::Path;
9
10use chrono::{DateTime, NaiveDateTime, Utc};
11use oci_spec::image::{ImageConfiguration, ImageIndex, ImageManifest, MediaType, Platform};
12use sqlx::{migrate::Migrator, sqlite::SqlitePoolOptions, Pool, Row, Sqlite};
13use tokio::fs;
14
15use crate::{
16    models::{Config, Image, Index, Layer, Manifest, Sandbox},
17    runtime::SANDBOX_STATUS_RUNNING,
18    MicrosandboxResult,
19};
20
21//--------------------------------------------------------------------------------------------------
22// Constants
23//--------------------------------------------------------------------------------------------------
24
25/// Migrator for the sandbox database
26pub static SANDBOX_DB_MIGRATOR: Migrator = sqlx::migrate!("lib/migrations/sandbox");
27
28/// Migrator for the OCI database
29pub static OCI_DB_MIGRATOR: Migrator = sqlx::migrate!("lib/migrations/oci");
30
31//--------------------------------------------------------------------------------------------------
32// Functions
33//--------------------------------------------------------------------------------------------------
34
35/// Initializes a new SQLite database if it doesn't already exist at the specified path.
36///
37/// ## Arguments
38///
39/// * `db_path` - Path where the SQLite database file should be created
40/// * `migrator` - SQLx migrator containing database schema migrations to run
41pub async fn initialize(
42    db_path: impl AsRef<Path>,
43    migrator: &Migrator,
44) -> MicrosandboxResult<Pool<Sqlite>> {
45    let db_path = db_path.as_ref();
46
47    // Ensure parent directory exists
48    if let Some(parent) = db_path.parent() {
49        fs::create_dir_all(parent).await?;
50    }
51
52    // Create an empty database file if it doesn't exist
53    if !db_path.exists() {
54        fs::File::create(&db_path).await?;
55    }
56
57    // Create database connection pool
58    let pool = SqlitePoolOptions::new()
59        .max_connections(5)
60        .connect(&format!("sqlite://{}?mode=rwc", db_path.display()))
61        .await?;
62
63    // Run migrations
64    migrator.run(&pool).await?;
65
66    Ok(pool)
67}
68
69/// Creates and returns a connection pool for SQLite database operations.
70///
71/// This function initializes a new SQLite connection pool with specified configuration parameters
72/// for managing database connections efficiently. The pool is configured with a maximum of 5
73/// concurrent connections.
74pub async fn get_pool(db_path: impl AsRef<Path>) -> MicrosandboxResult<Pool<Sqlite>> {
75    let db_path = db_path.as_ref();
76    let pool = SqlitePoolOptions::new()
77        .max_connections(5)
78        .connect(&format!("sqlite://{}?mode=rwc", db_path.display()))
79        .await?;
80
81    Ok(pool)
82}
83
84/// Gets an existing database connection pool or creates a new one if the database doesn't exist.
85///
86/// This function combines database initialization and pool creation into a single operation.
87/// If the database doesn't exist, it will be created and migrations will be run before
88/// returning the connection pool.
89///
90/// ## Arguments
91///
92/// * `db_path` - Path to the SQLite database file
93/// * `migrator` - SQLx migrator containing database schema migrations to run
94pub async fn get_or_create_pool(
95    db_path: impl AsRef<Path>,
96    migrator: &Migrator,
97) -> MicrosandboxResult<Pool<Sqlite>> {
98    // Initialize the database if it doesn't exist
99    initialize(&db_path, migrator).await
100}
101
102//--------------------------------------------------------------------------------------------------
103// Functions: Sandboxes
104//--------------------------------------------------------------------------------------------------
105
106/// Saves or updates a sandbox in the database and returns its ID.
107/// If a sandbox with the same name and config_file exists, it will be updated.
108/// Otherwise, a new sandbox record will be created.
109pub(crate) async fn save_or_update_sandbox(
110    pool: &Pool<Sqlite>,
111    name: &str,
112    config_file: &str,
113    config_last_modified: &DateTime<Utc>,
114    status: &str,
115    supervisor_pid: u32,
116    microvm_pid: u32,
117    rootfs_paths: &str,
118    group_id: Option<u32>,
119    group_ip: Option<String>,
120) -> MicrosandboxResult<i64> {
121    let sandbox = Sandbox {
122        id: 0,
123        name: name.to_string(),
124        config_file: config_file.to_string(),
125        config_last_modified: config_last_modified.clone(),
126        status: status.to_string(),
127        supervisor_pid,
128        microvm_pid,
129        rootfs_paths: rootfs_paths.to_string(),
130        group_id,
131        group_ip,
132        created_at: Utc::now(),
133        modified_at: Utc::now(),
134    };
135
136    // Try to update first
137    let update_result = sqlx::query(
138        r#"
139        UPDATE sandboxes
140        SET config_last_modified = ?,
141            status = ?,
142            supervisor_pid = ?,
143            microvm_pid = ?,
144            rootfs_paths = ?,
145            group_id = ?,
146            group_ip = ?,
147            modified_at = CURRENT_TIMESTAMP
148        WHERE name = ? AND config_file = ?
149        RETURNING id
150        "#,
151    )
152    .bind(&sandbox.config_last_modified.to_rfc3339())
153    .bind(&sandbox.status)
154    .bind(&sandbox.supervisor_pid)
155    .bind(&sandbox.microvm_pid)
156    .bind(&sandbox.rootfs_paths)
157    .bind(&sandbox.group_id)
158    .bind(&sandbox.group_ip)
159    .bind(&sandbox.name)
160    .bind(&sandbox.config_file)
161    .fetch_optional(pool)
162    .await?;
163
164    if let Some(record) = update_result {
165        tracing::debug!("updated existing sandbox record");
166        Ok(record.get::<i64, _>("id"))
167    } else {
168        // If no record was updated, insert a new one
169        tracing::debug!("creating new sandbox record");
170        let record = sqlx::query(
171            r#"
172            INSERT INTO sandboxes (
173                name, config_file, config_last_modified,
174                status, supervisor_pid, microvm_pid, rootfs_paths,
175                group_id, group_ip
176            )
177            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
178            RETURNING id
179            "#,
180        )
181        .bind(sandbox.name)
182        .bind(sandbox.config_file)
183        .bind(sandbox.config_last_modified.to_rfc3339())
184        .bind(sandbox.status)
185        .bind(sandbox.supervisor_pid)
186        .bind(sandbox.microvm_pid)
187        .bind(sandbox.rootfs_paths)
188        .bind(sandbox.group_id)
189        .bind(sandbox.group_ip)
190        .fetch_one(pool)
191        .await?;
192
193        Ok(record.get::<i64, _>("id"))
194    }
195}
196
197pub(crate) async fn get_sandbox(
198    pool: &Pool<Sqlite>,
199    name: &str,
200    config_file: &str,
201) -> MicrosandboxResult<Option<Sandbox>> {
202    let record = sqlx::query(
203        r#"
204        SELECT id, name, config_file, config_last_modified, status,
205               supervisor_pid, microvm_pid, rootfs_paths,
206               group_id, group_ip, created_at, modified_at
207        FROM sandboxes
208        WHERE name = ? AND config_file = ?
209        "#,
210    )
211    .bind(name)
212    .bind(config_file)
213    .fetch_optional(pool)
214    .await?;
215
216    Ok(record.map(|row| Sandbox {
217        id: row.get("id"),
218        name: row.get("name"),
219        config_file: row.get("config_file"),
220        config_last_modified: row
221            .get::<String, _>("config_last_modified")
222            .parse::<DateTime<Utc>>()
223            .unwrap(),
224        status: row.get("status"),
225        supervisor_pid: row.get("supervisor_pid"),
226        microvm_pid: row.get("microvm_pid"),
227        rootfs_paths: row.get("rootfs_paths"),
228        group_id: row.get("group_id"),
229        group_ip: row.get("group_ip"),
230        created_at: parse_sqlite_datetime(&row.get::<String, _>("created_at")),
231        modified_at: parse_sqlite_datetime(&row.get::<String, _>("modified_at")),
232    }))
233}
234
235/// Updates the status of a sandbox identified by name and config file
236pub(crate) async fn update_sandbox_status(
237    pool: &Pool<Sqlite>,
238    name: &str,
239    config_file: &str,
240    status: &str,
241) -> MicrosandboxResult<()> {
242    sqlx::query(
243        r#"
244        UPDATE sandboxes
245        SET status = ?,
246            modified_at = CURRENT_TIMESTAMP
247        WHERE name = ? AND config_file = ?
248        "#,
249    )
250    .bind(status)
251    .bind(name)
252    .bind(config_file)
253    .execute(pool)
254    .await?;
255
256    Ok(())
257}
258
259/// Gets all sandboxes associated with a specific config file
260pub(crate) async fn get_running_config_sandboxes(
261    pool: &Pool<Sqlite>,
262    config_file: &str,
263) -> MicrosandboxResult<Vec<Sandbox>> {
264    let records = sqlx::query(
265        r#"
266        SELECT id, name, config_file, config_last_modified, status,
267               supervisor_pid, microvm_pid, rootfs_paths,
268               group_id, group_ip, created_at, modified_at
269        FROM sandboxes
270        WHERE config_file = ? AND status = ?
271        ORDER BY created_at DESC
272        "#,
273    )
274    .bind(config_file)
275    .bind(SANDBOX_STATUS_RUNNING)
276    .fetch_all(pool)
277    .await?;
278
279    Ok(records
280        .into_iter()
281        .map(|row| Sandbox {
282            id: row.get("id"),
283            name: row.get("name"),
284            config_file: row.get("config_file"),
285            config_last_modified: row
286                .get::<String, _>("config_last_modified")
287                .parse::<DateTime<Utc>>()
288                .unwrap(),
289            status: row.get("status"),
290            supervisor_pid: row.get("supervisor_pid"),
291            microvm_pid: row.get("microvm_pid"),
292            rootfs_paths: row.get("rootfs_paths"),
293            group_id: row.get("group_id"),
294            group_ip: row.get("group_ip"),
295            created_at: parse_sqlite_datetime(&row.get::<String, _>("created_at")),
296            modified_at: parse_sqlite_datetime(&row.get::<String, _>("modified_at")),
297        })
298        .collect())
299}
300
301/// Deletes a sandbox from the database by name and config file.
302pub(crate) async fn delete_sandbox(
303    pool: &Pool<Sqlite>,
304    name: &str,
305    config_file: &str,
306) -> MicrosandboxResult<()> {
307    sqlx::query(
308        r#"
309        DELETE FROM sandboxes
310        WHERE name = ? AND config_file = ?
311        "#,
312    )
313    .bind(name)
314    .bind(config_file)
315    .execute(pool)
316    .await?;
317
318    Ok(())
319}
320
321//--------------------------------------------------------------------------------------------------
322// Functions: Images
323//--------------------------------------------------------------------------------------------------
324
325/// Saves an image to the database and returns its ID
326pub(crate) async fn save_image(
327    pool: &Pool<Sqlite>,
328    reference: &str,
329    size_bytes: i64,
330) -> MicrosandboxResult<i64> {
331    let image = Image {
332        id: 0, // Will be set by the database
333        reference: reference.to_string(),
334        size_bytes,
335        last_used_at: Some(Utc::now()),
336        created_at: Utc::now(),
337        modified_at: Utc::now(),
338    };
339
340    let record = sqlx::query(
341        r#"
342        INSERT INTO images (reference, size_bytes, last_used_at)
343        VALUES (?, ?, CURRENT_TIMESTAMP)
344        RETURNING id
345        "#,
346    )
347    .bind(&image.reference)
348    .bind(image.size_bytes)
349    .fetch_one(pool)
350    .await?;
351
352    Ok(record.get::<i64, _>("id"))
353}
354
355/// Saves an image index to the database and returns its ID
356pub(crate) async fn save_index(
357    pool: &Pool<Sqlite>,
358    image_id: i64,
359    index: &ImageIndex,
360    platform: Option<&Platform>,
361) -> MicrosandboxResult<i64> {
362    let index_model = Index {
363        id: 0, // Will be set by the database
364        image_id,
365        schema_version: index.schema_version() as i64,
366        media_type: index
367            .media_type()
368            .as_ref()
369            .map(|mt| mt.to_string())
370            .unwrap_or_else(|| MediaType::ImageIndex.to_string()),
371        platform_os: platform.map(|p| p.os().to_string()),
372        platform_arch: platform.map(|p| p.architecture().to_string()),
373        platform_variant: platform.and_then(|p| p.variant().as_ref().map(|v| v.to_string())),
374        annotations_json: index
375            .annotations()
376            .as_ref()
377            .map(|a| serde_json::to_string(a).unwrap_or_default()),
378        created_at: Utc::now(),
379        modified_at: Utc::now(),
380    };
381
382    let record = sqlx::query(
383        r#"
384        INSERT INTO indexes (
385            image_id, schema_version, media_type,
386            platform_os, platform_arch, platform_variant,
387            annotations_json
388        )
389        VALUES (?, ?, ?, ?, ?, ?, ?)
390        RETURNING id
391        "#,
392    )
393    .bind(index_model.image_id)
394    .bind(index_model.schema_version)
395    .bind(&index_model.media_type)
396    .bind(&index_model.platform_os)
397    .bind(&index_model.platform_arch)
398    .bind(&index_model.platform_variant)
399    .bind(&index_model.annotations_json)
400    .fetch_one(pool)
401    .await?;
402
403    Ok(record.get::<i64, _>("id"))
404}
405
406/// Saves an image manifest to the database and returns its ID
407pub(crate) async fn save_manifest(
408    pool: &Pool<Sqlite>,
409    image_id: i64,
410    index_id: Option<i64>,
411    manifest: &ImageManifest,
412) -> MicrosandboxResult<i64> {
413    let manifest_model = Manifest {
414        id: 0, // Will be set by the database
415        index_id,
416        image_id,
417        schema_version: manifest.schema_version() as i64,
418        media_type: manifest
419            .media_type()
420            .as_ref()
421            .map(|mt| mt.to_string())
422            .unwrap_or_else(|| MediaType::ImageManifest.to_string()),
423        annotations_json: manifest
424            .annotations()
425            .as_ref()
426            .map(|a| serde_json::to_string(a).unwrap_or_default()),
427        created_at: Utc::now(),
428        modified_at: Utc::now(),
429    };
430
431    let record = sqlx::query(
432        r#"
433        INSERT INTO manifests (
434            index_id, image_id, schema_version,
435            media_type, annotations_json
436        )
437        VALUES (?, ?, ?, ?, ?)
438        RETURNING id
439        "#,
440    )
441    .bind(manifest_model.index_id)
442    .bind(manifest_model.image_id)
443    .bind(manifest_model.schema_version)
444    .bind(&manifest_model.media_type)
445    .bind(&manifest_model.annotations_json)
446    .fetch_one(pool)
447    .await?;
448
449    Ok(record.get::<i64, _>("id"))
450}
451
452/// Saves an image configuration to the database
453pub(crate) async fn save_config(
454    pool: &Pool<Sqlite>,
455    manifest_id: i64,
456    config: &ImageConfiguration,
457) -> MicrosandboxResult<i64> {
458    let config_model = Config {
459        id: 0, // Will be set by the database
460        manifest_id,
461        media_type: MediaType::ImageConfig.to_string(),
462        created: config
463            .created()
464            .as_ref()
465            .map(|dt| dt.parse::<DateTime<Utc>>().unwrap()),
466        architecture: config.architecture().to_string(),
467        os: config.os().to_string(),
468        os_variant: config.os_version().as_ref().map(|s| s.to_string()),
469        config_env_json: config
470            .config()
471            .as_ref()
472            .map(|c| serde_json::to_string(c.env()).unwrap_or_default()),
473        config_cmd_json: config
474            .config()
475            .as_ref()
476            .map(|c| serde_json::to_string(c.cmd()).unwrap_or_default()),
477        config_working_dir: config
478            .config()
479            .as_ref()
480            .and_then(|c| c.working_dir().as_ref().map(String::from)),
481        config_entrypoint_json: config
482            .config()
483            .as_ref()
484            .map(|c| serde_json::to_string(c.entrypoint()).unwrap_or_default()),
485        config_volumes_json: config
486            .config()
487            .as_ref()
488            .map(|c| serde_json::to_string(c.volumes()).unwrap_or_default()),
489        config_exposed_ports_json: config
490            .config()
491            .as_ref()
492            .map(|c| serde_json::to_string(c.exposed_ports()).unwrap_or_default()),
493        config_user: config
494            .config()
495            .as_ref()
496            .and_then(|c| c.user().as_ref().map(String::from)),
497        rootfs_type: config.rootfs().typ().to_string(),
498        rootfs_diff_ids_json: Some(
499            serde_json::to_string(&config.rootfs().diff_ids()).unwrap_or_default(),
500        ),
501        history_json: Some(serde_json::to_string(config.history()).unwrap_or_default()),
502        created_at: Utc::now(),
503        modified_at: Utc::now(),
504    };
505
506    let record = sqlx::query(
507        r#"
508        INSERT INTO configs (
509            manifest_id, media_type, created, architecture,
510            os, os_variant, config_env_json, config_cmd_json,
511            config_working_dir, config_entrypoint_json,
512            config_volumes_json, config_exposed_ports_json,
513            config_user, rootfs_type, rootfs_diff_ids_json,
514            history_json
515        )
516        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
517        RETURNING id
518        "#,
519    )
520    .bind(config_model.manifest_id)
521    .bind(&config_model.media_type)
522    .bind(config_model.created.map(|dt| dt.to_rfc3339()))
523    .bind(&config_model.architecture)
524    .bind(&config_model.os)
525    .bind(&config_model.os_variant)
526    .bind(&config_model.config_env_json)
527    .bind(&config_model.config_cmd_json)
528    .bind(&config_model.config_working_dir)
529    .bind(&config_model.config_entrypoint_json)
530    .bind(&config_model.config_volumes_json)
531    .bind(&config_model.config_exposed_ports_json)
532    .bind(&config_model.config_user)
533    .bind(&config_model.rootfs_type)
534    .bind(&config_model.rootfs_diff_ids_json)
535    .bind(&config_model.history_json)
536    .fetch_one(pool)
537    .await?;
538
539    Ok(record.get::<i64, _>("id"))
540}
541
542/// Saves an image layer to the database
543pub(crate) async fn save_layer(
544    pool: &Pool<Sqlite>,
545    media_type: &str,
546    digest: &str,
547    size_bytes: i64,
548    diff_id: &str,
549) -> MicrosandboxResult<i64> {
550    let layer_model = Layer {
551        id: 0, // Will be set by the database
552        media_type: media_type.to_string(),
553        digest: digest.to_string(),
554        diff_id: diff_id.to_string(),
555        size_bytes,
556        created_at: Utc::now(),
557        modified_at: Utc::now(),
558    };
559
560    let record = sqlx::query(
561        r#"
562        INSERT INTO layers (
563            media_type, digest, size_bytes, diff_id
564        )
565        VALUES (?, ?, ?, ?)
566        RETURNING id
567        "#,
568    )
569    .bind(&layer_model.media_type)
570    .bind(&layer_model.digest)
571    .bind(layer_model.size_bytes)
572    .bind(&layer_model.diff_id)
573    .fetch_one(pool)
574    .await?;
575
576    Ok(record.get::<i64, _>("id"))
577}
578
579/// Saves or updates a layer in the database.
580/// If the layer exists, it updates the size_bytes and other fields.
581/// If it doesn't exist, creates a new record.
582pub(crate) async fn save_or_update_layer(
583    pool: &Pool<Sqlite>,
584    media_type: &str,
585    digest: &str,
586    size_bytes: i64,
587    diff_id: &str,
588) -> MicrosandboxResult<i64> {
589    let layer_model = Layer {
590        id: 0, // Will be set by the database
591        media_type: media_type.to_string(),
592        digest: digest.to_string(),
593        diff_id: diff_id.to_string(),
594        size_bytes,
595        created_at: Utc::now(),
596        modified_at: Utc::now(),
597    };
598
599    // Try to update first
600    let update_result = sqlx::query(
601        r#"
602        UPDATE layers
603        SET media_type = ?,
604            size_bytes = ?,
605            diff_id = ?,
606            modified_at = CURRENT_TIMESTAMP
607        WHERE digest = ?
608        RETURNING id
609        "#,
610    )
611    .bind(&layer_model.media_type)
612    .bind(layer_model.size_bytes)
613    .bind(&layer_model.diff_id)
614    .bind(&layer_model.digest)
615    .fetch_optional(pool)
616    .await?;
617
618    if let Some(record) = update_result {
619        Ok(record.get::<i64, _>("id"))
620    } else {
621        // If no record was updated, insert a new one
622        save_layer(pool, media_type, digest, size_bytes, diff_id).await
623    }
624}
625
626/// Associates a layer with a manifest in the manifest_layers join table
627pub(crate) async fn save_manifest_layer(
628    pool: &Pool<Sqlite>,
629    manifest_id: i64,
630    layer_id: i64,
631) -> MicrosandboxResult<i64> {
632    let record = sqlx::query(
633        r#"
634        INSERT INTO manifest_layers (manifest_id, layer_id)
635        VALUES (?, ?)
636        ON CONFLICT (manifest_id, layer_id) DO NOTHING
637        RETURNING id
638        "#,
639    )
640    .bind(manifest_id)
641    .bind(layer_id)
642    .fetch_optional(pool)
643    .await?;
644
645    if let Some(record) = record {
646        Ok(record.get::<i64, _>("id"))
647    } else {
648        // If no record was inserted (because it already exists), fetch the existing ID
649        let record = sqlx::query(
650            r#"
651            SELECT id FROM manifest_layers
652            WHERE manifest_id = ? AND layer_id = ?
653            "#,
654        )
655        .bind(manifest_id)
656        .bind(layer_id)
657        .fetch_one(pool)
658        .await?;
659
660        Ok(record.get::<i64, _>("id"))
661    }
662}
663
664/// Gets all layers for an image from the database.
665pub async fn get_image_layers(
666    pool: &Pool<Sqlite>,
667    reference: &str,
668) -> MicrosandboxResult<Vec<Layer>> {
669    let records = sqlx::query(
670        r#"
671        SELECT l.id, l.media_type, l.digest,
672               l.diff_id, l.size_bytes, l.created_at, l.modified_at
673        FROM layers l
674        JOIN manifest_layers ml ON l.id = ml.layer_id
675        JOIN manifests m ON ml.manifest_id = m.id
676        JOIN images i ON m.image_id = i.id
677        WHERE i.reference = ?
678        ORDER BY l.id ASC
679        "#,
680    )
681    .bind(reference)
682    .fetch_all(pool)
683    .await?;
684
685    Ok(records
686        .into_iter()
687        .map(|row| Layer {
688            id: row.get("id"),
689            media_type: row.get("media_type"),
690            digest: row.get("digest"),
691            diff_id: row.get("diff_id"),
692            size_bytes: row.get("size_bytes"),
693            created_at: parse_sqlite_datetime(&row.get::<String, _>("created_at")),
694            modified_at: parse_sqlite_datetime(&row.get::<String, _>("modified_at")),
695        })
696        .collect())
697}
698
699/// Checks if an image exists in the database.
700pub(crate) async fn image_exists(pool: &Pool<Sqlite>, reference: &str) -> MicrosandboxResult<bool> {
701    let record = sqlx::query(
702        r#"
703        SELECT COUNT(*) as count
704        FROM images
705        WHERE reference = ?
706        "#,
707    )
708    .bind(reference)
709    .fetch_one(pool)
710    .await?;
711
712    Ok(record.get::<i64, _>("count") > 0)
713}
714
715/// Gets the configuration for an image from the database.
716///
717/// This function retrieves the configuration details for a specified image reference.
718/// It includes information like architecture, OS, environment variables, command,
719/// working directory, and other container configuration metadata.
720///
721/// ## Arguments
722///
723/// * `pool` - SQLite connection pool
724/// * `reference` - OCI image reference string (e.g., "ubuntu:latest")
725///
726/// ## Returns
727///
728/// Returns a `MicrosandboxResult` containing either the image `Config` or an error
729pub(crate) async fn get_image_config(
730    pool: &Pool<Sqlite>,
731    reference: &str,
732) -> MicrosandboxResult<Option<Config>> {
733    let record = sqlx::query(
734        r#"
735        SELECT c.id, c.manifest_id, c.media_type, c.created, c.architecture,
736               c.os, c.os_variant, c.config_env_json, c.config_cmd_json,
737               c.config_working_dir, c.config_entrypoint_json,
738               c.config_volumes_json, c.config_exposed_ports_json,
739               c.config_user, c.rootfs_type, c.rootfs_diff_ids_json,
740               c.history_json, c.created_at, c.modified_at
741        FROM configs c
742        JOIN manifests m ON c.manifest_id = m.id
743        JOIN images i ON m.image_id = i.id
744        WHERE i.reference = ?
745        LIMIT 1
746        "#,
747    )
748    .bind(reference)
749    .fetch_optional(pool)
750    .await?;
751
752    Ok(record.map(|row| Config {
753        id: row.get("id"),
754        manifest_id: row.get("manifest_id"),
755        media_type: row.get("media_type"),
756        created: row
757            .get::<Option<String>, _>("created")
758            .map(|dt| dt.parse::<DateTime<Utc>>().unwrap()),
759        architecture: row.get("architecture"),
760        os: row.get("os"),
761        os_variant: row.get("os_variant"),
762        config_env_json: null_to_none(row.get("config_env_json")),
763        config_cmd_json: null_to_none(row.get("config_cmd_json")),
764        config_working_dir: row.get("config_working_dir"),
765        config_entrypoint_json: null_to_none(row.get("config_entrypoint_json")),
766        config_volumes_json: null_to_none(row.get("config_volumes_json")),
767        config_exposed_ports_json: null_to_none(row.get("config_exposed_ports_json")),
768        config_user: row.get("config_user"),
769        rootfs_type: row.get("rootfs_type"),
770        rootfs_diff_ids_json: row.get("rootfs_diff_ids_json"),
771        history_json: null_to_none(row.get("history_json")),
772        created_at: parse_sqlite_datetime(&row.get::<String, _>("created_at")),
773        modified_at: parse_sqlite_datetime(&row.get::<String, _>("modified_at")),
774    }))
775}
776
777/// Saves or updates an image in the database.
778/// If the image exists, it updates the size_bytes and last_used_at.
779/// If it doesn't exist, creates a new record.
780pub(crate) async fn save_or_update_image(
781    pool: &Pool<Sqlite>,
782    reference: &str,
783    size_bytes: i64,
784) -> MicrosandboxResult<i64> {
785    // Try to update first
786    let update_result = sqlx::query(
787        r#"
788        UPDATE images
789        SET size_bytes = ?, last_used_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP
790        WHERE reference = ?
791        RETURNING id, reference, size_bytes, last_used_at, created_at, modified_at
792        "#,
793    )
794    .bind(size_bytes)
795    .bind(reference)
796    .fetch_optional(pool)
797    .await?;
798
799    if let Some(record) = update_result {
800        Ok(record.get::<i64, _>("id"))
801    } else {
802        // If no record was updated, insert a new one
803        save_image(pool, reference, size_bytes).await
804    }
805}
806
807/// Gets layers from the database by their digest values.
808///
809/// This function retrieves layer information for a list of digest values without
810/// requiring a manifest relationship. This is useful for checking if specific layers
811/// exist in the database before trying to download them.
812///
813/// ## Arguments
814///
815/// * `pool` - SQLite connection pool
816/// * `digests` - List of layer digest strings to search for
817///
818/// ## Returns
819///
820/// Returns a `MicrosandboxResult` containing a vector of `Layer` objects that match the provided digests
821pub(crate) async fn get_layers_by_digest(
822    pool: &Pool<Sqlite>,
823    digests: &[String],
824) -> MicrosandboxResult<Vec<Layer>> {
825    if digests.is_empty() {
826        return Ok(Vec::new());
827    }
828
829    // Create placeholders for the IN clause (?,?,?)
830    let placeholders = (0..digests.len())
831        .map(|_| "?")
832        .collect::<Vec<_>>()
833        .join(",");
834
835    let query = format!(
836        r#"
837        SELECT id, media_type, digest, diff_id, size_bytes, created_at, modified_at
838        FROM layers
839        WHERE digest IN ({})
840        "#,
841        placeholders
842    );
843
844    // Build the query with the dynamic number of parameters
845    let mut query_builder = sqlx::query(&query);
846    for digest in digests {
847        query_builder = query_builder.bind(digest);
848    }
849
850    let records = query_builder.fetch_all(pool).await?;
851
852    Ok(records
853        .into_iter()
854        .map(|row| Layer {
855            id: row.get("id"),
856            media_type: row.get("media_type"),
857            digest: row.get("digest"),
858            diff_id: row.get("diff_id"),
859            size_bytes: row.get("size_bytes"),
860            created_at: parse_sqlite_datetime(&row.get::<String, _>("created_at")),
861            modified_at: parse_sqlite_datetime(&row.get::<String, _>("modified_at")),
862        })
863        .collect())
864}
865
866/// Gets all layer digests for an image manifest from the database.
867///
868/// This function retrieves just the digest strings for all layers associated with a specific
869/// image reference. This is useful for checking if layers exist without needing the full layer details.
870///
871/// ## Arguments
872///
873/// * `pool` - SQLite connection pool
874/// * `reference` - OCI image reference string (e.g., "ubuntu:latest")
875///
876/// ## Returns
877///
878/// Returns a `MicrosandboxResult` containing a vector of layer digest strings
879pub(crate) async fn get_image_layer_digests(
880    pool: &Pool<Sqlite>,
881    reference: &str,
882) -> MicrosandboxResult<Vec<String>> {
883    let records = sqlx::query(
884        r#"
885        SELECT l.digest
886        FROM layers l
887        JOIN manifest_layers ml ON l.id = ml.layer_id
888        JOIN manifests m ON ml.manifest_id = m.id
889        JOIN images i ON m.image_id = i.id
890        WHERE i.reference = ?
891        ORDER BY l.id ASC
892        "#,
893    )
894    .bind(reference)
895    .fetch_all(pool)
896    .await?;
897
898    Ok(records
899        .into_iter()
900        .map(|row| row.get::<String, _>("digest"))
901        .collect())
902}
903
904//--------------------------------------------------------------------------------------------------
905// Tests
906//--------------------------------------------------------------------------------------------------
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use sqlx::Row;
912    use tempfile::tempdir;
913
914    #[tokio::test]
915    async fn test_init_sandbox_db() -> MicrosandboxResult<()> {
916        // Create temporary directory
917        let temp_dir = tempdir()?;
918        let db_path = temp_dir.path().join("test_sandbox.db");
919
920        // Initialize database
921        initialize(&db_path, &SANDBOX_DB_MIGRATOR).await?;
922
923        // Test database connection
924        let pool = get_pool(&db_path).await?;
925
926        // Verify tables exist by querying them
927        let tables = sqlx::query("SELECT name FROM sqlite_master WHERE type='table'")
928            .fetch_all(&pool)
929            .await?;
930
931        let table_names: Vec<String> = tables
932            .iter()
933            .map(|row| row.get::<String, _>("name"))
934            .collect();
935
936        assert!(
937            table_names.contains(&"sandboxes".to_string()),
938            "sandboxes table not found"
939        );
940        assert!(
941            table_names.contains(&"groups".to_string()),
942            "groups table not found"
943        );
944        assert!(
945            table_names.contains(&"sandbox_metrics".to_string()),
946            "sandbox_metrics table not found"
947        );
948
949        Ok(())
950    }
951
952    #[tokio::test]
953    async fn test_init_oci_db() -> MicrosandboxResult<()> {
954        // Create temporary directory
955        let temp_dir = tempdir()?;
956        let db_path = temp_dir.path().join("test_oci.db");
957
958        // Initialize database
959        initialize(&db_path, &OCI_DB_MIGRATOR).await?;
960
961        // Test database connection
962        let pool = get_pool(&db_path).await?;
963
964        // Verify tables exist by querying them
965        let tables = sqlx::query("SELECT name FROM sqlite_master WHERE type='table'")
966            .fetch_all(&pool)
967            .await?;
968
969        let table_names: Vec<String> = tables
970            .iter()
971            .map(|row| row.get::<String, _>("name"))
972            .collect();
973
974        assert!(
975            table_names.contains(&"images".to_string()),
976            "images table not found"
977        );
978        assert!(
979            table_names.contains(&"indexes".to_string()),
980            "indexes table not found"
981        );
982        assert!(
983            table_names.contains(&"manifests".to_string()),
984            "manifests table not found"
985        );
986        assert!(
987            table_names.contains(&"configs".to_string()),
988            "configs table not found"
989        );
990        assert!(
991            table_names.contains(&"layers".to_string()),
992            "layers table not found"
993        );
994
995        Ok(())
996    }
997}
998
999//--------------------------------------------------------------------------------------------------
1000// Functions: Helpers
1001//--------------------------------------------------------------------------------------------------
1002
1003/// Parses a SQLite datetime string (in "YYYY-MM-DD HH:MM:SS" format) to a DateTime<Utc>.
1004fn parse_sqlite_datetime(s: &str) -> DateTime<Utc> {
1005    let naive_dt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
1006        .unwrap_or_else(|e| panic!("Failed to parse datetime string '{}': {:?}", s, e));
1007    DateTime::from_naive_utc_and_offset(naive_dt, Utc)
1008}
1009
1010/// Sometimes the json columns in the database can have literal "null" values.
1011/// This function converts those to None.
1012fn null_to_none(value: Option<String>) -> Option<String> {
1013    value.filter(|v| v != "null")
1014}