systemprompt-cloud 0.79.0

Cloud API client, credentials, OAuth, and tenant management for systemprompt.io AI governance deployments. Remote sync and multi-tenant orchestration for the MCP governance pipeline.
Documentation
//! Docker CLI invocations behind a stubbable process-execution seam.
//!
//! [`DockerCli`] is the single place the platform shells out to `docker`. The
//! deploy-oriented operations (build, login, push) carry their own error
//! mapping; the raw [`DockerCli::output`] / [`DockerCli::status`] primitives
//! exist for callers that interpret exit codes and captured output themselves
//! (container inspection, compose lifecycle, `docker exec psql`). All process
//! execution flows through [`CommandRunner`], so tests can substitute a stub
//! instead of spawning real processes.
//!
//! `build_image` first proves the daemon is reachable through the `docker`
//! binary that `PATH` resolves. A wrapper shim ahead of the real binary (a
//! `credsStore` helper that is not installed, a desktop-app shim on a headless
//! box) fails `docker build` with a message that names neither, so the
//! preflight failure reports the resolved binary and the configured
//! credential store alongside the daemon's own error.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use std::io;
use std::path::Path;
use std::process::{ExitStatus, Output};

use crate::error::{CloudError, CloudResult};

mod command;

pub use command::{CommandRunner, CommandSpec, SystemCommandRunner};

fn configured_creds_store() -> String {
    let Some(config) = dirs::home_dir().map(|home| home.join(".docker").join("config.json")) else {
        return "unknown (no home directory)".to_owned();
    };
    let Ok(raw) = std::fs::read_to_string(&config) else {
        return format!("none ({} absent)", config.display());
    };
    match serde_json::from_str::<serde_json::Value>(&raw) {
        Ok(value) => value
            .get("credsStore")
            .and_then(serde_json::Value::as_str)
            .map_or_else(|| "none".to_owned(), ToOwned::to_owned),
        Err(e) => format!("unreadable ({}: {e})", config.display()),
    }
}

pub struct DockerCli {
    runner: Box<dyn CommandRunner>,
}

impl std::fmt::Debug for DockerCli {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DockerCli").finish_non_exhaustive()
    }
}

impl Default for DockerCli {
    fn default() -> Self {
        Self::new()
    }
}

impl DockerCli {
    #[must_use]
    pub fn new() -> Self {
        Self {
            runner: Box::new(SystemCommandRunner),
        }
    }

    #[must_use]
    pub const fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
        Self { runner }
    }

    pub fn preflight_daemon(&self) -> CloudResult<String> {
        let spec = CommandSpec::docker(["version", "--format", "{{.Server.Version}}"]);
        let outcome = self.runner.output(&spec);
        let failure = match outcome {
            Ok(output) if output.status.success() => {
                return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
            },
            Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_owned(),
            Err(e) => e.to_string(),
        };

        Err(CloudError::docker(format!(
            "Docker daemon unreachable via `{}`: {}\n  docker binary: {}\n  credsStore: {}",
            spec.rendered(),
            if failure.is_empty() {
                "no output".to_owned()
            } else {
                failure
            },
            self.resolved_docker_binary(),
            configured_creds_store(),
        )))
    }

    fn resolved_docker_binary(&self) -> String {
        let spec = if cfg!(windows) {
            CommandSpec {
                program: "where".to_owned(),
                args: vec!["docker".to_owned()],
                current_dir: None,
            }
        } else {
            CommandSpec {
                program: "sh".to_owned(),
                args: vec!["-c".to_owned(), "command -v docker".to_owned()],
                current_dir: None,
            }
        };
        match self.runner.output(&spec) {
            Ok(output) if output.status.success() => {
                let path = String::from_utf8_lossy(&output.stdout).trim().to_owned();
                if path.is_empty() {
                    "not on PATH".to_owned()
                } else {
                    path
                }
            },
            Ok(_) => "not on PATH".to_owned(),
            Err(e) => format!("unresolved ({e})"),
        }
    }

    pub fn build_image(
        &self,
        context_dir: &Path,
        dockerfile: &Path,
        image: &str,
    ) -> CloudResult<()> {
        self.preflight_daemon()?;

        let dockerfile_arg = dockerfile.to_string_lossy().into_owned();
        let mut spec = CommandSpec::docker([
            "build",
            "--no-cache",
            "-f",
            dockerfile_arg.as_str(),
            "-t",
            image,
            ".",
        ]);
        spec.current_dir = Some(context_dir.to_path_buf());

        let status = self.runner.status(&spec).map_err(|e| {
            CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
        })?;

        if !status.success() {
            return Err(CloudError::docker(format!(
                "Command failed: {}",
                spec.rendered()
            )));
        }

        Ok(())
    }

    pub fn login(&self, registry: &str, username: &str, token: &str) -> CloudResult<()> {
        let spec = CommandSpec::docker(["login", registry, "-u", username, "--password-stdin"]);

        let status = self
            .runner
            .status_with_stdin(&spec, token.as_bytes())
            .map_err(|e| {
                CloudError::docker_with(format!("failed to spawn `docker login {registry}`"), e)
            })?;

        if !status.success() {
            return Err(CloudError::docker("Docker login failed"));
        }

        Ok(())
    }

    pub fn push(&self, image: &str) -> CloudResult<()> {
        let spec = CommandSpec::docker(["push", image]);

        let status = self.runner.status(&spec).map_err(|e| {
            CloudError::docker_with(format!("failed to spawn `docker push {image}`"), e)
        })?;

        if !status.success() {
            return Err(CloudError::docker(format!(
                "Docker push failed for image: {}",
                image
            )));
        }

        Ok(())
    }

    pub fn repo_digest(&self, image: &str) -> CloudResult<String> {
        let spec = CommandSpec::docker([
            "image",
            "inspect",
            "--format",
            "{{json .RepoDigests}}",
            image,
        ]);
        let output = self.runner.output(&spec).map_err(|e| {
            CloudError::docker_with(format!("Failed to run: {}", spec.rendered()), e)
        })?;
        if !output.status.success() {
            return Err(CloudError::docker(format!(
                "Command failed: {}: {}",
                spec.rendered(),
                String::from_utf8_lossy(&output.stderr).trim()
            )));
        }
        let digests: Vec<String> = serde_json::from_slice(&output.stdout)?;
        select_repo_digest(image, &digests).ok_or_else(|| {
            CloudError::docker(format!(
                "Image {image} has no registry digest; push it before resolving the digest"
            ))
        })
    }

    pub fn output(&self, args: &[&str]) -> io::Result<Output> {
        self.runner
            .output(&CommandSpec::docker(args.iter().copied()))
    }

    pub fn status(&self, args: &[&str]) -> io::Result<ExitStatus> {
        self.runner
            .status(&CommandSpec::docker(args.iter().copied()))
    }
}

#[must_use]
pub fn select_repo_digest(image: &str, repo_digests: &[String]) -> Option<String> {
    let repository = image_repository(image);
    repo_digests.iter().find_map(|entry| {
        let (name, digest) = entry.split_once('@')?;
        (name == repository).then(|| digest.to_owned())
    })
}

#[must_use]
pub fn image_repository(image: &str) -> &str {
    let without_digest = image.split_once('@').map_or(image, |(name, _)| name);
    match without_digest.rsplit_once(':') {
        Some((name, tag)) if !tag.contains('/') => name,
        _ => without_digest,
    }
}