camber-cli 0.2.0

CLI, project scaffolding, and config-driven proxy for Camber
Documentation
use std::process::Command;

use crate::resources::{CleanupCompletion, CleanupWitness, ExternalRun};
use crate::support::{FixtureError, run_command};

#[derive(Clone, Copy)]
pub enum ImagePresence {
    Absent,
    Present,
}

pub trait ImageOperations {
    fn remove(&mut self, image: &str) -> Result<(), FixtureError>;
    fn inspect(&mut self, image: &str) -> Result<ImagePresence, FixtureError>;
}

enum CleanupState {
    Armed,
    Attempted,
    Complete,
}

pub struct DockerImageGuard<O, W>
where
    O: ImageOperations,
    W: CleanupCompletion,
{
    run: ExternalRun,
    image: Box<str>,
    operations: O,
    witness: W,
    state: CleanupState,
}

impl<O, W> DockerImageGuard<O, W>
where
    O: ImageOperations,
    W: CleanupCompletion,
{
    pub fn new(run: ExternalRun, operations: O, witness: W) -> Self {
        let image = run.docker_image_tag();
        Self {
            run,
            image,
            operations,
            witness,
            state: CleanupState::Armed,
        }
    }

    fn image(&self) -> &str {
        &self.image
    }

    pub fn cleanup(&mut self) -> Result<(), FixtureError> {
        self.state = CleanupState::Attempted;
        let removal = self.operations.remove(&self.image);
        let inspection = self.operations.inspect(&self.image);

        let result = match inspection {
            Ok(ImagePresence::Absent) => self.witness.emit(self.run.run_id(), &self.image),
            Ok(ImagePresence::Present) => Err(image_remains_error(&self.image, removal.err())),
            Err(inspect_error) => Err(inspection_error(inspect_error, removal.err())),
        };
        self.state = match &result {
            Ok(()) => CleanupState::Complete,
            Err(_) => CleanupState::Armed,
        };
        result
    }
}

impl<O, W> Drop for DockerImageGuard<O, W>
where
    O: ImageOperations,
    W: CleanupCompletion,
{
    fn drop(&mut self) {
        match self.state {
            CleanupState::Armed => drop(self.cleanup()),
            CleanupState::Attempted | CleanupState::Complete => {}
        }
    }
}

fn image_remains_error(image: &str, removal_error: Option<FixtureError>) -> FixtureError {
    match removal_error {
        Some(error) => FixtureError::new(format!(
            "Docker image {image} remains after removal failed: {error}"
        )),
        None => FixtureError::new(format!(
            "Docker image inspect reports {image} still exists after removal"
        )),
    }
}

fn inspection_error(
    inspect_error: FixtureError,
    removal_error: Option<FixtureError>,
) -> FixtureError {
    match removal_error {
        Some(remove_error) => FixtureError::new(format!(
            "Docker image removal failed: {remove_error}; absence inspection failed: {inspect_error}"
        )),
        None => FixtureError::new(format!(
            "Docker image absence inspection failed: {inspect_error}"
        )),
    }
}

struct DockerOperations;

impl ImageOperations for DockerOperations {
    fn remove(&mut self, image: &str) -> Result<(), FixtureError> {
        let mut command = Command::new("docker");
        command.args(["image", "rm", "--force", image]);
        let output = run_command(command)?;

        match output.status.success() {
            true => Ok(()),
            false => Err(FixtureError::new(format!(
                "docker image rm failed: {}",
                String::from_utf8_lossy(&output.stderr)
            ))),
        }
    }

    fn inspect(&mut self, image: &str) -> Result<ImagePresence, FixtureError> {
        let mut command = Command::new("docker");
        command.args(["image", "inspect", image]);
        let output = run_command(command)?;

        match output.status.success() {
            true => Ok(ImagePresence::Present),
            false if inspection_reports_absence(&output.stderr) => Ok(ImagePresence::Absent),
            false => Err(FixtureError::new(format!(
                "docker image inspect did not prove absence: {}",
                String::from_utf8_lossy(&output.stderr)
            ))),
        }
    }
}

fn inspection_reports_absence(stderr: &[u8]) -> bool {
    let stderr = String::from_utf8_lossy(stderr).to_ascii_lowercase();
    stderr.contains("no such image") || stderr.contains("no such object")
}

fn project_root() -> Result<&'static std::path::Path, FixtureError> {
    std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .ok_or_else(|| FixtureError::new("camber-cli manifest has no parent"))?
        .parent()
        .ok_or_else(|| FixtureError::new("workspace root is absent"))
}

fn build_image(project_root: &std::path::Path, image: &str) -> Result<(), FixtureError> {
    match project_root.join("Dockerfile").exists() {
        true => {}
        false => return Err(FixtureError::new("Dockerfile not found at project root")),
    }

    let mut command = Command::new("docker");
    command
        .args(["build", "--tag", image, "."])
        .current_dir(project_root);
    let output = run_command(command)?;

    match output.status.success() {
        true => Ok(()),
        false => Err(FixtureError::new(format!(
            "docker build failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ))),
    }
}

pub fn run_dockerfile_build() -> Result<(), FixtureError> {
    let run = ExternalRun::from_environment()?;
    let witness = CleanupWitness::from_environment()?;
    let mut image = DockerImageGuard::new(run, DockerOperations, witness);
    let execution = project_root().and_then(|root| build_image(root, image.image()));

    image.cleanup()?;
    execution
}