topgrade 17.4.0

Upgrade all the things
use std::fmt::{Display, Formatter};
use std::io;
use std::io::Write;
use std::path::Path;

use color_eyre::eyre::Context;
use color_eyre::eyre::Result;
use color_eyre::eyre::{OptionExt, bail};
use tracing::{debug, error, warn};
use wildmatch::WildMatch;

use crate::command::CommandExt;
use crate::error::{SkipStep, TopgradeError};
use crate::terminal::print_separator;
use crate::{execution_context::ExecutionContext, utils::require};
use rust_i18n::t;

// A string found in the output of docker for containers that weren't found in
// the docker registry. We use this to gracefully handle and skip containers
// that cannot be pulled, likely because they don't exist in the registry in
// the first place. This happens e.g. when the user tags an image locally
// themselves or when using docker-compose.
const NONEXISTENT_REPO: &str = "repository does not exist";

// A string found in the output of docker when Docker Desktop is not running.
const DOCKER_NOT_RUNNING: &str = "We recommend to activate the WSL integration in Docker Desktop settings.";

/// Uniquely identifies a `Container`.
#[derive(Debug)]
struct Container {
    /// `Repository` and `Tag`
    ///
    /// format: `Repository:Tag`, e.g., `nixos/nix:latest`.
    repo_tag: String,
    /// Platform
    ///
    /// format: `OS/Architecture`, e.g., `linux/amd64`.
    platform: String,
}

impl Container {
    /// Construct a new `Container`.
    fn new(repo_tag: String, platform: String) -> Self {
        Self { repo_tag, platform }
    }
}

impl Display for Container {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        // e.g., "`fedora:latest` for `linux/amd64`"
        write!(
            f,
            "{}",
            t!(
                "`{repo_tag}` for `{platform}`",
                repo_tag = self.repo_tag,
                platform = self.platform
            )
        )
    }
}

/// Returns a Vector of all containers, with Strings in the format
/// "REGISTRY/[PATH/]CONTAINER_NAME:TAG"
///
/// Containers specified in `ignored_containers` will be filtered out.
fn list_containers(ctx: &ExecutionContext, crt: &Path) -> Result<Vec<Container>> {
    let ignored_containers = ctx.config().containers_ignored_tags().map(|patterns| {
        patterns
            .iter()
            .map(|pattern| WildMatch::new(pattern))
            .collect::<Vec<WildMatch>>()
    });

    debug!(
        "Querying '{} image ls --format \"{{{{.Repository}}}}:{{{{.Tag}}}}/{{{{.ID}}}}\"' for containers",
        crt.display()
    );

    let sudo = if ctx.config().containers_use_sudo() {
        Some(ctx.require_sudo()?)
    } else {
        None
    };

    let output = if let Some(sudo) = sudo {
        sudo.execute(ctx, crt)?
            .always()
            .args(["image", "ls", "--format", "{{.Repository}}:{{.Tag}} {{.ID}}"])
            .output_checked_utf8()?
    } else {
        ctx.execute(crt)
            .always()
            .args(["image", "ls", "--format", "{{.Repository}}:{{.Tag}} {{.ID}}"])
            .output_checked_utf8()?
    };

    let mut retval = vec![];
    for line in output.stdout.lines() {
        if line.starts_with("localhost") {
            // Don't know how to update self-built containers
            debug!("Skipping self-built container '{}'", line);
            continue;
        }

        if line.contains("<none>") {
            // Bogus/dangling container or intermediate layer
            debug!("Skipping bogus container '{}'", line);
            continue;
        }

        if line.starts_with("vsc-") {
            debug!("Skipping visual studio code dev container '{}'", line);
            continue;
        }

        debug!("Using container '{}'", line);

        // line is of format: `Repository:Tag ImageID`, e.g., `nixos/nix:latest d80fea9c32b4`
        let split_res = line.split(' ').collect::<Vec<&str>>();
        if split_res.len() != 2 {
            bail!(format!(
                "Got erroneous output from `{} image ls --format \"{{.Repository}}:{{.Tag}} {{.ID}}\"; Expected line to split into 2 parts",
                crt.display()
            ));
        }
        let (repo_tag, image_id) = (split_res[0], split_res[1]);

        if let Some(ref ignored_containers) = ignored_containers
            && ignored_containers.iter().any(|pattern| pattern.matches(repo_tag))
        {
            debug!("Skipping ignored container '{}'", line);
            continue;
        }

        debug!(
            "Querying '{} image inspect --format \"{{{{.Os}}}}/{{{{.Architecture}}}}\"' for container {}",
            crt.display(),
            image_id
        );
        let inspect_output = if let Some(sudo) = sudo {
            sudo.execute(ctx, crt)?
                .always()
                .args(["image", "inspect", image_id, "--format", "{{.Os}}/{{.Architecture}}"])
                .output_checked_utf8()?
        } else {
            ctx.execute(crt)
                .always()
                .args(["image", "inspect", image_id, "--format", "{{.Os}}/{{.Architecture}}"])
                .output_checked_utf8()?
        };
        let mut platform = inspect_output.stdout;
        // truncate the tailing new line character
        platform.truncate(platform.len() - 1);
        if !platform.contains('/') {
            bail!(format!(
                "Got erroneous output from `{} image ls --format \"{{.Repository}}:{{.Tag}} {{.ID}}\"; Expected platform to contain '/'",
                crt.display()
            ));
        }

        retval.push(Container::new(repo_tag.to_string(), platform));
    }

    Ok(retval)
}

pub fn run_containers(ctx: &ExecutionContext) -> Result<()> {
    // Check what runtime is specified in the config
    let container_runtime = ctx.config().containers_runtime().to_string();
    let use_sudo = ctx.config().containers_use_sudo();
    let crt = require(container_runtime)?;
    debug!("Using container runtime '{}'", crt.display());

    print_separator(t!("Containers"));

    let sudo = if use_sudo { Some(ctx.require_sudo()?) } else { None };

    let output = if let Some(sudo) = sudo {
        sudo.execute(ctx, &crt)?
            .always()
            .arg("--help")
            .output_checked_with(|_| Ok(()))?
    } else {
        ctx.execute(&crt)
            .always()
            .arg("--help")
            .output_checked_with(|_| Ok(()))?
    };

    let status_code = output
        .status
        .code()
        .ok_or_eyre("Couldn't get status code (terminated by signal)")?;
    let stdout = std::str::from_utf8(&output.stdout).wrap_err("Expected output to be valid UTF-8")?;
    if stdout.contains(DOCKER_NOT_RUNNING) && status_code == 1 {
        // Write the output
        io::stdout().write_all(&output.stdout)?;
        io::stderr().write_all(&output.stderr)?;
        // Don't crash, but don't be silent either.
        // This can happen in other ways than Docker Desktop not running, but even in those cases
        //  we don't want to crash, since the containers step is enabled by default.
        warn!(
            "{} seems to be non-functional right now (see above). Is WSL integration enabled for Docker Desktop? Is Docker Desktop running?",
            crt.display()
        );
        return Err(SkipStep(format!(
            "{} seems to be non-functional right now. Possibly WSL integration is not enabled for Docker Desktop, or Docker Desktop is not running.",
            crt.display()
        )).into());
    } else if !output.status.success() {
        // Write the output
        io::stdout().write_all(&output.stdout)?;
        io::stderr().write_all(&output.stderr)?;
        // If we saw the message, but the code is not 1 (e.g. 0, or a non-1 failure), crash, as we expect a 1.
        // If we did not see the message, it's broken in some way we do not understand.
        bail!(
            "{0} seems to be non-functional (`{0} --help` returned non-zero exit code {1})",
            crt.display(),
            status_code,
        );
    }

    let containers = list_containers(ctx, &crt).context("Failed to list Docker containers")?;
    debug!("Containers to inspect: {:?}", containers);

    for container in &containers {
        debug!("Pulling container '{}'", container);
        let mut args = vec!["pull", container.repo_tag.as_str()];
        if container.platform.as_str() != "/" {
            args.push("--platform");
            args.push(container.platform.as_str());
        }

        let mut exec = if let Some(sudo) = sudo {
            sudo.execute(ctx, &crt)?
        } else {
            ctx.execute(&crt)
        };

        if let Err(e) = exec.args(&args).status_checked() {
            error!("Pulling container '{}' failed: {}", container, e);

            // Find out if this is 'skippable'
            // This is necessary e.g. for docker, because unlike podman docker doesn't tell from
            // which repository a container originates (such as `docker.io`). This has the
            // practical consequence that all containers, whether self-built, created by
            // docker-compose or pulled from the docker hub, look exactly the same to us. We can
            // only find out what went wrong by manually parsing the output of the command...
            if match exec.output_checked_utf8() {
                Ok(s) => s.stdout.contains(NONEXISTENT_REPO) || s.stderr.contains(NONEXISTENT_REPO),
                Err(e) => match e.downcast_ref::<TopgradeError>() {
                    Some(TopgradeError::ProcessFailedWithOutput(_, _, stderr)) => stderr.contains(NONEXISTENT_REPO),
                    _ => false,
                },
            } {
                warn!("Skipping unknown container '{}'", container);
                continue;
            }

            return Err(e);
        }
    }

    if ctx.config().containers_system_prune() {
        // Run system prune to clean up unused containers, networks, and build cache
        if let Some(sudo) = sudo {
            sudo.execute(ctx, &crt)?
                .args(["system", "prune", "--force"])
                .status_checked()?
        } else {
            ctx.execute(&crt)
                .args(["system", "prune", "--force"])
                .status_checked()?
        }
    // Only run `image prune` if we don't run `system prune`
    } else if ctx.config().cleanup() {
        // Remove dangling images
        debug!("Removing dangling images");
        if let Some(sudo) = sudo {
            sudo.execute(ctx, &crt)?
                .args(["image", "prune", "-f"])
                .status_checked()?
        } else {
            ctx.execute(&crt).args(["image", "prune", "-f"]).status_checked()?
        }
    }

    Ok(())
}