lmrc-docker 0.3.16

Docker client library for the LMRC Stack - ergonomic fluent APIs for containers, images, networks, volumes, and registry management
Documentation
use crate::DockerClient;
use crate::error::{DockerError, Result};
use bollard::auth::DockerCredentials;
use bollard::image::{ListImagesOptions, SearchImagesOptions};
use bollard::models::ImageSummary;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{debug, info};

/// Registry configuration and authentication
#[derive(Debug, Clone)]
pub struct RegistryConfig {
    /// Registry server address (e.g., "docker.io", "ghcr.io")
    pub server: String,
    /// Username for authentication
    pub username: Option<String>,
    /// Password or token for authentication
    pub password: Option<String>,
    /// Email (optional, for some registries)
    pub email: Option<String>,
}

impl RegistryConfig {
    /// Create a new registry config for Docker Hub
    pub fn docker_hub(username: String, password: String) -> Self {
        Self {
            server: "docker.io".to_string(),
            username: Some(username),
            password: Some(password),
            email: None,
        }
    }

    /// Create a new registry config for GitHub Container Registry
    pub fn github(username: String, token: String) -> Self {
        Self {
            server: "ghcr.io".to_string(),
            username: Some(username),
            password: Some(token),
            email: None,
        }
    }

    /// Create a custom registry config
    pub fn custom(server: String) -> Self {
        Self {
            server,
            username: None,
            password: None,
            email: None,
        }
    }

    /// Set authentication credentials
    pub fn with_auth(mut self, username: String, password: String) -> Self {
        self.username = Some(username);
        self.password = Some(password);
        self
    }

    /// Set email
    pub fn with_email(mut self, email: String) -> Self {
        self.email = Some(email);
        self
    }

    /// Convert to DockerCredentials for bollard
    pub(crate) fn to_credentials(&self) -> DockerCredentials {
        DockerCredentials {
            username: self.username.clone(),
            password: self.password.clone(),
            email: self.email.clone(),
            serveraddress: Some(self.server.clone()),
            ..Default::default()
        }
    }
}

/// Image search result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSearchResult {
    pub name: String,
    pub description: Option<String>,
    pub star_count: Option<i64>,
    pub is_official: bool,
    pub is_automated: bool,
}

/// Registry operations
pub struct Registry<'a> {
    client: &'a DockerClient,
}

impl<'a> Registry<'a> {
    pub(crate) fn new(client: &'a DockerClient) -> Self {
        Self { client }
    }

    /// Authenticate with a registry
    ///
    /// This is typically not needed for public pulls, but required for:
    /// - Pushing images
    /// - Pulling private images
    /// - Accessing registries with rate limits
    pub async fn login(&self, config: &RegistryConfig) -> Result<()> {
        info!("Logging in to registry: {}", config.server);

        let _credentials = config.to_credentials();

        // Verify credentials by attempting to access the registry
        // Docker doesn't have a dedicated "login" endpoint in the API
        // Instead, credentials are stored and used with subsequent operations

        // We can verify the login by attempting to search (works for Docker Hub)
        // For other registries, the credentials will be validated on first use
        debug!("Registry authentication configured for {}", config.server);

        Ok(())
    }

    /// Search for images in a registry
    ///
    /// # Arguments
    /// * `term` - Search term
    /// * `limit` - Maximum number of results (default: 25)
    ///
    /// # Example
    /// ```no_run
    /// # use lmrc_docker::DockerClient;
    /// # #[tokio::main]
    /// # async fn main() -> lmrc_docker::Result<()> {
    /// let client = DockerClient::new()?;
    /// let results = client.registry().search("nginx", Some(10)).await?;
    /// for result in results {
    ///     println!("{}: {}", result.name, result.description.unwrap_or_default());
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn search(&self, term: &str, limit: Option<i64>) -> Result<Vec<ImageSearchResult>> {
        info!("Searching for images: {}", term);

        let mut filters = HashMap::new();
        filters.insert("term".to_string(), vec![term.to_string()]);

        let options = SearchImagesOptions {
            term: term.to_string(),
            limit: Some(limit.unwrap_or(25) as u64),
            filters,
        };

        let results = self
            .client
            .docker
            .search_images(options)
            .await
            .map_err(|e| DockerError::ImageOperationFailed(format!("Search failed: {}", e)))?;

        Ok(results
            .into_iter()
            .map(|r| ImageSearchResult {
                name: r.name.unwrap_or_default(),
                description: r.description,
                star_count: r.star_count,
                is_official: r.is_official.unwrap_or(false),
                is_automated: r.is_automated.unwrap_or(false),
            })
            .collect())
    }

    /// Check if an image exists in the local registry cache
    pub async fn image_exists_locally(&self, image: &str) -> Result<bool> {
        debug!("Checking if image exists locally: {}", image);

        let mut filters = HashMap::new();
        filters.insert("reference".to_string(), vec![image.to_string()]);

        let options = ListImagesOptions {
            filters,
            ..Default::default()
        };

        let images = self
            .client
            .docker
            .list_images(Some(options))
            .await
            .map_err(|e| {
                DockerError::ImageOperationFailed(format!("Failed to list images: {}", e))
            })?;

        Ok(!images.is_empty())
    }

    /// List all local images
    pub async fn list_local_images(&self, all: bool) -> Result<Vec<ImageSummary>> {
        debug!("Listing local images (all: {})", all);

        let options = ListImagesOptions::<String> {
            all,
            ..Default::default()
        };

        self.client
            .docker
            .list_images(Some(options))
            .await
            .map_err(|e| DockerError::ImageOperationFailed(format!("Failed to list images: {}", e)))
    }

    /// Get detailed information about a local image
    pub async fn inspect_image(&self, image: &str) -> Result<bollard::models::ImageInspect> {
        info!("Inspecting image: {}", image);

        self.client
            .docker
            .inspect_image(image)
            .await
            .map_err(|_e| DockerError::ImageNotFound(image.to_string()))
    }

    /// Remove an image from local cache
    ///
    /// # Arguments
    /// * `image` - Image name or ID
    /// * `force` - Force removal even if image is in use
    /// * `noprune` - Do not delete untagged parents
    pub async fn remove_image(&self, image: &str, force: bool, noprune: bool) -> Result<()> {
        info!(
            "Removing image: {} (force: {}, noprune: {})",
            image, force, noprune
        );

        use bollard::image::RemoveImageOptions;

        let options = RemoveImageOptions { force, noprune };

        self.client
            .docker
            .remove_image(image, Some(options), None)
            .await
            .map_err(|e| {
                DockerError::ImageOperationFailed(format!("Failed to remove image: {}", e))
            })?;

        Ok(())
    }

    /// Tag an image with a new name/tag
    ///
    /// # Arguments
    /// * `source` - Source image name
    /// * `target_repo` - Target repository name
    /// * `target_tag` - Target tag (default: "latest")
    pub async fn tag_image(
        &self,
        source: &str,
        target_repo: &str,
        target_tag: Option<&str>,
    ) -> Result<()> {
        let tag = target_tag.unwrap_or("latest");
        info!("Tagging image {} as {}:{}", source, target_repo, tag);

        use bollard::image::TagImageOptions;

        let options = TagImageOptions {
            repo: target_repo.to_string(),
            tag: tag.to_string(),
        };

        self.client
            .docker
            .tag_image(source, Some(options))
            .await
            .map_err(|e| {
                DockerError::ImageOperationFailed(format!("Failed to tag image: {}", e))
            })?;

        Ok(())
    }

    /// Prune unused images
    ///
    /// # Arguments
    /// * `dangling_only` - Only remove dangling images (untagged)
    ///
    /// # Returns
    /// Number of bytes reclaimed
    pub async fn prune_images(&self, dangling_only: bool) -> Result<u64> {
        info!("Pruning images (dangling_only: {})", dangling_only);

        use bollard::image::PruneImagesOptions;

        let mut filters = HashMap::new();
        if dangling_only {
            filters.insert("dangling".to_string(), vec!["true".to_string()]);
        }

        let options = PruneImagesOptions { filters };

        let result = self
            .client
            .docker
            .prune_images(Some(options))
            .await
            .map_err(|e| {
                DockerError::ImageOperationFailed(format!("Failed to prune images: {}", e))
            })?;

        let space_reclaimed = result.space_reclaimed.unwrap_or(0) as u64;
        info!("Reclaimed {} bytes", space_reclaimed);

        Ok(space_reclaimed)
    }

    /// Export an image to a tar archive
    pub async fn export_image(&self, image: &str) -> Result<Vec<u8>> {
        info!("Exporting image: {}", image);

        use futures_util::TryStreamExt;

        let mut stream = self.client.docker.export_image(image);
        let mut data = Vec::new();

        while let Some(chunk) = stream.try_next().await.map_err(|e| {
            DockerError::ImageOperationFailed(format!("Failed to export image: {}", e))
        })? {
            data.extend_from_slice(chunk.as_ref());
        }

        info!("Exported {} bytes", data.len());
        Ok(data)
    }

    /// Import an image from a tar archive
    pub async fn import_image(&self, tar_data: Vec<u8>, tag: Option<&str>) -> Result<String> {
        info!("Importing image (tag: {:?})", tag);

        use bollard::image::ImportImageOptions;
        use bytes::Bytes;
        use futures_util::TryStreamExt;
        use http_body_util::Full;

        let options = ImportImageOptions {
            ..Default::default()
        };

        let body = Full::new(Bytes::from(tar_data));
        let body = http_body_util::Either::Left(body);

        let mut stream = self.client.docker.import_image(options, body, None);

        let mut image_id = String::new();

        while let Some(info) = stream.try_next().await.map_err(|e| {
            DockerError::ImageOperationFailed(format!("Failed to import image: {}", e))
        })? {
            if let Some(id) = info.id {
                image_id = id;
            }
        }

        info!("Imported image: {}", image_id);
        Ok(image_id)
    }
}