Rust Programming

Building Command Line Tools

Project Manage Docker Containers using Docker Clients in Rust

In this guide, we demonstrate how to build a Rust CLI application to manage Docker containers using the Bollard library. We start by creating a new Rust project, adding the required dependencies, and then organizing the code into modular components for maintainability.


Setting Up the Project

First, create a new Rust project with Cargo and name it "mydocker". Open the project directory with your preferred code editor (such as Visual Studio Code). Cargo will automatically create the default project structure.

Next, update your Cargo.toml file by adding the following dependencies:

[package]
name = "mydocker"
version = "0.1.0"
edition = "2021"

[dependencies]
bollard = "0.18.1"                               # For Docker API integration
clap = { version = "4.5.21", features = ["derive"] }  # For building the CLI
tokio = { version = "1.41.1", features = ["full"] }   # Asynchronous runtime
futures-util = "0.3"                               # Utilities for async programming

After saving the file, build the project to ensure everything is set up correctly:

cargo build --quiet

The build should complete without errors.


Organizing the Project Structure

For a clean and maintainable codebase, we break our code into separate modules:

  • Main File: The entry point of the application.
  • CLI Module: Contains the command-line interface definitions.
  • Docker Module: Handles Docker API interactions.
  • (Optional) Utils Module: For helper functions (if needed).

Create the modules by executing the following commands:

touch src/cli.rs
touch src/docker.rs

Then, update src/main.rs to include these modules:

mod cli;
mod docker;

fn main() {
    println!("Hello, world!");
}

Build and run the project to verify the changes:

cargo run --quiet

Designing the CLI with Clap

We use the Clap crate to define the structure and behavior of our CLI. The intended commands for our application are:

  • List Containers:
    mydocker list containers
  • List Images:
    mydocker list images
  • Start a Container:
    mydocker start <container_id>
  • Stop a Container:
    mydocker stop <container_id>
  • Pull an Image:
    mydocker pull <image_name>

Create the CLI module by adding the following code to src/cli.rs:

use clap::{Parser, Subcommand};

/// The top-level CLI structure for mydocker
#[derive(Parser)]
#[command(name = "mydocker")]
#[command(about = "A minimal Docker CLI built with Rust")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

/// Primary commands for the application
#[derive(Subcommand)]
pub enum Command {
    /// List Docker resources
    List {
        /// Specify the subcommand for listing resources
        #[command(subcommand)]
        list_command: ListCommands,
    },
    /// Start a container
    Start {
        /// The container name or ID
        container_name: String,
    },
    /// Stop a container
    Stop {
        /// The container name or ID
        container_name: String,
    },
    /// Pull a Docker image from the registry
    Pull {
        /// The image name (e.g., nginx:latest)
        image_name: String,
    },
}

/// Subcommands for the list command
#[derive(Subcommand)]
pub enum ListCommands {
    /// List containers
    Containers {
        /// Include stopped containers (default is only running)
        #[arg(short, long)]
        all: bool,
    },
    /// List images
    Images,
}

This module defines a nested command structure where the main command "mydocker" branches into available subcommands.


Parsing and Handling CLI Commands

In the main file, update the main function to parse the CLI input and handle each command. For now, we're adding temporary command responses which will later integrate asynchronous Docker calls. Replace the contents of src/main.rs with the following code:

use clap::Parser;
use cli::{Cli, Command, ListCommands};

fn main() {
    // Parse CLI arguments
    let args: Cli = Cli::parse();

    // Temporary command handling before integrating Docker functionality
    match args.command {
        Command::List { list_command } => match list_command {
            ListCommands::Containers { all } => {
                println!("Listing {} containers...", if all { "all" } else { "running" });
            },
            ListCommands::Images => {
                println!("Printing images...");
            },
        },
        Command::Start { container_name } => {
            println!("Starting container: {}", container_name);
        },
        Command::Stop { container_name } => {
            println!("Stopping container: {}", container_name);
        },
        Command::Pull { image_name } => {
            println!("Pulling image: {}", image_name);
        },
    }
}

Verify the CLI help message with:

cargo run --quiet --help

Integrating Docker with Bollard

We now integrate the Bollard crate to interact with the Docker API. Create a Docker client wrapper in src/docker.rs that establishes a connection to the Docker daemon using a Unix socket. Note that you may need to adjust the socket path for your system.

Setting Up the Docker Client

Add the following code to src/docker.rs:

use bollard::Docker;
use bollard::API_DEFAULT_VERSION;

pub struct DockerClient {
    docker: Docker,
}

impl DockerClient {
    pub fn new() -> Self {
        let docker = Docker::connect_with_unix(
            "/Users/priyadav/.docker/run/docker.sock", // Adjust this path as necessary
            120, // Timeout in seconds
            API_DEFAULT_VERSION,
        )
        .expect("Failed to connect to Docker");
        Self { docker }
    }
}

Note

Verify your Docker socket path by running:

docker context inspect

Then, check the "Host" field under "Endpoints".


Implementing Docker Operations

We will now add asynchronous methods to handle various Docker operations. Each method interfaces with Bollard to perform Docker tasks.

List Containers

Append the following method in the impl DockerClient block:

use bollard::container::{ListContainersOptions, ContainerSummary};
use std::default::Default;

impl DockerClient {
    pub async fn list_containers(&self, all: bool) -> Result<Vec<ContainerSummary>, bollard::errors::Error> {
        let options: Option<ListContainersOptions<String>> = Some(ListContainersOptions {
            all,
            ..Default::default()
        });
        let containers = self.docker.list_containers(options).await?;
        Ok(containers)
    }
}

List Images

Similarly, add a method to list Docker images:

use bollard::image::{ListImagesOptions, ImageSummary};

impl DockerClient {
    pub async fn list_images(&self) -> Result<Vec<ImageSummary>, bollard::errors::Error> {
        let options: Option<ListImagesOptions<String>> = Some(ListImagesOptions {
            all: true,
            ..Default::default()
        });
        let images = self.docker.list_images(options).await?;
        Ok(images)
    }
}

Start a Container

To start a container, include the following method:

use bollard::container::StartContainerOptions;

impl DockerClient {
    pub async fn start_container(&self, container_name: &str) -> Result<(), bollard::errors::Error> {
        self.docker
            .start_container(container_name, None::<StartContainerOptions<String>>)
            .await?;
        Ok(())
    }
}

Stop a Container

To stop a running container, add this method:

use bollard::container::StopContainerOptions;

impl DockerClient {
    pub async fn stop_container(&self, container_name: &str) -> Result<(), bollard::errors::Error> {
        let options: Option<StopContainerOptions> = Some(StopContainerOptions { t: 30 });
        self.docker.stop_container(container_name, options).await?;
        Ok(())
    }
}

Pull an Image

Finally, to pull a Docker image from a registry:

use bollard::image::{CreateImageOptions, CreateImageInfo};
use futures_util::stream::TryStreamExt;

impl DockerClient {
    pub async fn pull_image(&self, image_name: &str) -> Result<(), bollard::errors::Error> {
        let options: Option<CreateImageOptions<&str>> = Some(CreateImageOptions {
            from_image: image_name,
            ..Default::default()
        });
        let mut stream = self.docker.create_image(options, None, None);
        while let Some(msg) = stream.try_next().await? {
            if let Some(status) = msg.status {
                println!("{}", status);
            }
        }
        Ok(())
    }
}

Updating the Main Function for Async Docker Calls

Since Docker operations are asynchronous, update the main function to utilize Tokio as the async runtime. Replace the contents of src/main.rs with the following code:

use clap::Parser;
use cli::{Cli, Command, ListCommands};
use docker::DockerClient;

#[tokio::main]
async fn main() {
    // Parse the CLI input
    let args: Cli = Cli::parse();
    let docker_client = DockerClient::new();

    // Route and handle commands with asynchronous Docker calls
    match args.command {
        Command::List { list_command } => match list_command {
            ListCommands::Containers { all } => {
                println!("Printing containers:");
                match docker_client.list_containers(all).await {
                    Ok(containers) => {
                        for container in containers {
                            println!(
                                "{}\t{}\t{}",
                                container.id.unwrap_or_default(),
                                container.names.unwrap_or_default().join(","),
                                container.status.unwrap_or_default()
                            );
                        }
                    },
                    Err(e) => eprintln!("Error listing containers: {}", e),
                }
            },
            ListCommands::Images => {
                println!("Printing images:");
                match docker_client.list_images().await {
                    Ok(images) => {
                        for image in images {
                            println!("{}\t{}", image.id, image.repo_tags.join(","));
                        }
                    },
                    Err(e) => eprintln!("Error listing images: {}", e),
                }
            },
        },
        Command::Start { container_name } => {
            println!("Starting container: {}", container_name);
            match docker_client.start_container(&container_name).await {
                Ok(_) => println!("Container started successfully"),
                Err(e) => eprintln!("Error starting container: {}", e),
            }
        },
        Command::Stop { container_name } => {
            println!("Stopping container: {}", container_name);
            match docker_client.stop_container(&container_name).await {
                Ok(_) => println!("Container stopped successfully"),
                Err(e) => eprintln!("Error stopping container: {}", e),
            }
        },
        Command::Pull { image_name } => {
            println!("Pulling image: {}", image_name);
            match docker_client.pull_image(&image_name).await {
                Ok(_) => println!("Image pulled successfully"),
                Err(e) => eprintln!("Error pulling image: {}", e),
            }
        },
    }
}

Testing the Application

Below are some commands to test the functionality of your CLI tool:

Listing Containers and Images

  • List Running Containers:

    cargo run --quiet -- list containers
    
  • List All Containers (including stopped ones):

    cargo run --quiet -- list containers --all
    
  • List Images:

    cargo run --quiet -- list images
    

Starting and Stopping a Container

  • Start a Container:

    cargo run --quiet -- start <container_name>
    

    Warning

    If the container does not exist, you will receive an error similar to:

    Error starting container: Docker responded with status code 404: No such container: <container_name>

  • Stop a Container:

    cargo run --quiet -- stop <container_name>
    

Pulling an Image

  • Pull an Image (e.g., PostgreSQL):

    cargo run --quiet -- pull postgres:latest
    

During the pull process, you will see real-time status messages from Docker (e.g., "Downloading...", "Download complete", etc.). After completion, the message "Image pulled successfully" confirms a successful operation.


Summary

In this article, we built a Rust-based Docker CLI tool that:

  • Lists Docker containers and images.
  • Starts and stops containers.
  • Pulls images from a Docker registry.

We leveraged Cargo for project management, Clap for CLI parsing, Tokio for asynchronous execution, and Bollard for Docker API integration. The modular design of this application makes it easy to extend the tool with additional functionalities in the future.

Happy coding!

Watch Video

Watch video content

Previous
Parsing command line args with clap