use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use std::process::Command;
use tracing::{debug, info, warn};
use crate::config::{ContainerCli, ContainerRuntime};
use super::method::{requires_buildkit, BuildMethod};
const DAEMON_VERSION: &str = "2";
pub(crate) fn compute_file_hash(path: &Path) -> Result<String> {
let contents = fs::read(path).context("Failed to read certificate file")?;
let mut hasher = Sha256::new();
hasher.update(&contents);
let result = hasher.finalize();
Ok(format!("{:x}", result))
}
fn compute_string_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
format!("{:x}", result)
}
fn generate_buildkit_config() -> Option<(String, String)> {
let insecure_registries =
super::env_var_non_empty("RISE_MANAGED_BUILDKIT_INSECURE_REGISTRIES")?;
let registries: Vec<&str> = insecure_registries
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if registries.is_empty() {
return None;
}
let mut config = String::new();
for registry in registries {
config.push_str(&format!(
"[registry.\"{}\"]\n http = true\n insecure = true\n\n",
registry
));
}
let hash = compute_string_hash(&config);
Some((config, hash))
}
fn write_buildkit_config() -> Result<Option<std::path::PathBuf>> {
let Some((config_content, _hash)) = generate_buildkit_config() else {
return Ok(None);
};
let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
let rise_dir = home_dir.join(".rise");
fs::create_dir_all(&rise_dir).context("Failed to create .rise directory")?;
let config_path = rise_dir.join("buildkitd.toml");
fs::write(&config_path, config_content).context("Failed to write buildkitd.toml")?;
Ok(Some(config_path))
}
fn network_exists(container_cli: &str, network_name: &str) -> bool {
let output = Command::new(container_cli)
.args(["network", "inspect", network_name])
.output();
matches!(output, Ok(output) if output.status.success())
}
fn create_network(container_cli: &str, network_name: &str) -> Result<()> {
if network_exists(container_cli, network_name) {
debug!("Network '{}' already exists", network_name);
return Ok(());
}
info!("Creating Docker network '{}'", network_name);
let status = Command::new(container_cli)
.args(["network", "create", network_name])
.status()
.context("Failed to create network")?;
if !status.success() {
bail!("Failed to create network '{}'", network_name);
}
info!("Network '{}' created successfully", network_name);
Ok(())
}
fn connect_to_network(container_cli: &str, network_name: &str, container_name: &str) -> Result<()> {
info!(
"Connecting container '{}' to network '{}'",
container_name, network_name
);
let status = Command::new(container_cli)
.args(["network", "connect", network_name, container_name])
.status()
.context("Failed to connect to network")?;
if !status.success() {
bail!(
"Failed to connect container '{}' to network '{}'",
container_name,
network_name
);
}
info!(
"Container '{}' connected to network '{}' successfully",
container_name, network_name
);
Ok(())
}
fn get_network_label_from_container(container_cli: &str, daemon_name: &str) -> Option<String> {
let output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{index .Config.Labels \"rise.network_name\"}}",
daemon_name,
])
.output()
.ok()?;
if output.status.success() {
let label_value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !label_value.is_empty() && label_value != "<no value>" {
return Some(label_value);
}
}
None
}
fn get_config_hash_label_from_container(container_cli: &str, daemon_name: &str) -> Option<String> {
let output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{index .Config.Labels \"rise.config_hash\"}}",
daemon_name,
])
.output()
.ok()?;
if output.status.success() {
let label_value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !label_value.is_empty() && label_value != "<no value>" {
return Some(label_value);
}
}
None
}
fn get_proxy_hash_label(container_cli: &str, daemon_name: &str) -> Option<String> {
let output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{index .Config.Labels \"rise.proxy_hash\"}}",
daemon_name,
])
.output()
.ok()?;
if output.status.success() {
let label_value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !label_value.is_empty() && label_value != "<no value>" {
return Some(label_value);
}
}
None
}
fn compute_proxy_hash(proxy_vars: &std::collections::HashMap<String, String>) -> Option<String> {
if proxy_vars.is_empty() {
return None;
}
let mut sorted_keys: Vec<&String> = proxy_vars.keys().collect();
sorted_keys.sort();
let mut input = String::new();
for key in &sorted_keys {
input.push_str(key);
input.push('=');
input.push_str(&proxy_vars[*key]);
input.push('\n');
}
Some(compute_string_hash(&input))
}
fn get_daemon_version_label(container_cli: &str, daemon_name: &str) -> Option<String> {
let output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{index .Config.Labels \"rise.daemon_version\"}}",
daemon_name,
])
.output()
.ok()?;
if output.status.success() {
let label_value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !label_value.is_empty() && label_value != "<no value>" {
return Some(label_value);
}
}
None
}
#[derive(Debug, PartialEq)]
enum DaemonState {
HasCert(String, Option<String>, Option<String>),
NoCert(Option<String>, Option<String>),
NotFound,
}
fn get_daemon_state(container_cli: &str, daemon_name: &str) -> DaemonState {
let inspect_status = Command::new(container_cli)
.args(["inspect", daemon_name])
.output();
let Ok(output) = inspect_status else {
return DaemonState::NotFound;
};
if !output.status.success() {
return DaemonState::NotFound;
}
let network_name = get_network_label_from_container(container_cli, daemon_name);
let config_hash = get_config_hash_label_from_container(container_cli, daemon_name);
let no_cert_output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{index .Config.Labels \"rise.no_ssl_cert\"}}",
daemon_name,
])
.output();
if let Ok(output) = no_cert_output {
if output.status.success() {
let label_value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if label_value == "true" {
return DaemonState::NoCert(network_name, config_hash);
}
}
}
let cert_hash_output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{index .Config.Labels \"rise.ssl_cert_hash\"}}",
daemon_name,
])
.output();
if let Ok(output) = cert_hash_output {
if output.status.success() {
let cert_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !cert_hash.is_empty() {
return DaemonState::HasCert(cert_hash, network_name, config_hash);
}
}
}
DaemonState::NoCert(network_name, config_hash)
}
fn stop_buildkit_daemon(container_cli: &ContainerCli, daemon_name: &str) -> Result<()> {
info!("Stopping existing BuildKit daemon '{}'", daemon_name);
let status = Command::new(container_cli.command())
.args(["rm", "-f", daemon_name])
.status()
.context("Failed to remove BuildKit daemon")?;
if !status.success() {
bail!("Failed to remove BuildKit daemon");
}
Ok(())
}
fn create_buildkit_daemon(
container_cli: &ContainerCli,
daemon_name: &str,
ssl_cert_file: Option<&Path>,
network_name: Option<&str>,
proxy_vars: &std::collections::HashMap<String, String>,
) -> Result<String> {
if let Some(cert_path) = ssl_cert_file {
info!(
"Creating managed BuildKit daemon '{}' with SSL certificate: {}",
daemon_name,
cert_path.display()
);
} else {
info!(
"Creating managed BuildKit daemon '{}' without SSL certificate",
daemon_name
);
}
if let Some(network) = network_name {
info!("BuildKit daemon will be connected to network '{}'", network);
}
let mut cmd = Command::new(container_cli.command());
cmd.args([
"run",
"--privileged",
"--name",
daemon_name,
"--rm",
"-d",
"--add-host",
"host.docker.internal:host-gateway",
"--label",
])
.arg(format!("rise.daemon_version={}", DAEMON_VERSION));
if container_cli.runtime() == ContainerRuntime::Podman {
debug!("Podman detected, adding --cgroupns=host for cgroup v2 compatibility");
cmd.arg("--cgroupns=host");
}
if let Some(cert_path) = ssl_cert_file {
let cert_path_abs = if cert_path.is_absolute() {
cert_path.to_path_buf()
} else {
std::env::current_dir()?.join(cert_path)
};
let cert_path_abs = cert_path_abs
.canonicalize()
.context("Failed to resolve SSL certificate path")?;
let cert_str = cert_path_abs
.to_str()
.context("SSL certificate path contains invalid UTF-8")?;
let cert_hash = compute_file_hash(&cert_path_abs)?;
cmd.arg("--label")
.arg(format!("rise.ssl_cert_file={}", cert_str))
.arg("--label")
.arg(format!("rise.ssl_cert_hash={}", cert_hash))
.arg("--volume")
.arg(format!(
"{}:/etc/ssl/certs/ca-certificates.crt:ro",
cert_str
));
} else {
cmd.arg("--label").arg("rise.no_ssl_cert=true");
}
if let Some(network) = network_name {
cmd.arg("--label")
.arg(format!("rise.network_name={}", network));
}
if let Some((_config_content, config_hash)) = generate_buildkit_config() {
let config_path = write_buildkit_config()?;
if let Some(config_file) = config_path {
let config_str = config_file
.to_str()
.context("Config file path contains invalid UTF-8")?;
info!(
"Mounting BuildKit config with insecure registries: {}",
config_str
);
cmd.arg("--label")
.arg(format!("rise.config_hash={}", config_hash))
.arg("--volume")
.arg(format!("{}:/etc/buildkit/buildkitd.toml:ro", config_str));
}
}
if !proxy_vars.is_empty() {
info!("Passing proxy environment variables to BuildKit daemon");
let mut sorted_keys: Vec<&String> = proxy_vars.keys().collect();
sorted_keys.sort();
let mut proxy_hash_input = String::new();
for key in &sorted_keys {
let value = &proxy_vars[*key];
cmd.arg("-e").arg(format!("{}={}", key, value));
proxy_hash_input.push_str(key);
proxy_hash_input.push('=');
proxy_hash_input.push_str(value);
proxy_hash_input.push('\n');
}
let proxy_hash = compute_string_hash(&proxy_hash_input);
cmd.arg("--label")
.arg(format!("rise.proxy_hash={}", proxy_hash));
}
cmd.arg("moby/buildkit");
let status = cmd.status().context("Failed to start BuildKit daemon")?;
if !status.success() {
bail!("Failed to create BuildKit daemon");
}
info!("BuildKit daemon '{}' created successfully", daemon_name);
if let Some(network) = network_name {
create_network(container_cli.command(), network)?;
connect_to_network(container_cli.command(), network, daemon_name)?;
}
Ok(format!("docker-container://{}", daemon_name))
}
pub(crate) fn ensure_managed_buildkit_daemon(
ssl_cert_file: Option<&Path>,
container_cli: &ContainerCli,
) -> Result<String> {
let daemon_name = "rise-buildkit";
let proxy_vars = super::proxy::read_and_transform_proxy_vars();
let network_name = super::env_var_non_empty("RISE_MANAGED_BUILDKIT_NETWORK_NAME");
let expected_config_hash = generate_buildkit_config().map(|(_, hash)| hash);
let expected_proxy_hash = compute_proxy_hash(&proxy_vars);
let current_state = get_daemon_state(container_cli.command(), daemon_name);
if current_state != DaemonState::NotFound {
let current_version = get_daemon_version_label(container_cli.command(), daemon_name);
if current_version.as_deref() != Some(DAEMON_VERSION) {
info!(
"BuildKit daemon version changed ({:?} -> {}), recreating",
current_version, DAEMON_VERSION
);
stop_buildkit_daemon(container_cli, daemon_name)?;
return create_buildkit_daemon(
container_cli,
daemon_name,
ssl_cert_file,
network_name.as_deref(),
&proxy_vars,
);
}
let current_proxy_hash = get_proxy_hash_label(container_cli.command(), daemon_name);
if current_proxy_hash != expected_proxy_hash {
info!("Proxy configuration has changed, recreating daemon");
stop_buildkit_daemon(container_cli, daemon_name)?;
return create_buildkit_daemon(
container_cli,
daemon_name,
ssl_cert_file,
network_name.as_deref(),
&proxy_vars,
);
}
}
match (ssl_cert_file, ¤t_state) {
(Some(cert_path), DaemonState::HasCert(current_hash, current_network, current_config)) => {
let cert_path_abs = cert_path
.canonicalize()
.context("Failed to resolve SSL certificate path")?;
let expected_hash = compute_file_hash(&cert_path_abs)?;
let network_changed = &network_name != current_network;
let config_changed = &expected_config_hash != current_config;
if current_hash == &expected_hash && !network_changed && !config_changed {
debug!(
"BuildKit daemon is up-to-date with current SSL_CERT_FILE, network, and config"
);
return Ok(format!("docker-container://{}", daemon_name));
}
if current_hash != &expected_hash {
info!("SSL certificate has changed (hash mismatch), recreating daemon");
} else if network_changed {
info!("Network configuration has changed, recreating daemon");
} else if config_changed {
info!("BuildKit config has changed (insecure registries), recreating daemon");
}
stop_buildkit_daemon(container_cli, daemon_name)?;
}
(Some(_), DaemonState::NoCert(current_network, current_config)) => {
let network_changed = &network_name != current_network;
let config_changed = &expected_config_hash != current_config;
if network_changed {
info!("Network configuration has changed, recreating daemon");
} else if config_changed {
info!("BuildKit config has changed, recreating daemon");
} else {
info!("SSL certificate now available, recreating daemon with certificate");
}
stop_buildkit_daemon(container_cli, daemon_name)?;
}
(None, DaemonState::NoCert(current_network, current_config)) => {
let network_changed = &network_name != current_network;
let config_changed = &expected_config_hash != current_config;
if !network_changed && !config_changed {
debug!("BuildKit daemon is up-to-date (no SSL certificate)");
return Ok(format!("docker-container://{}", daemon_name));
}
if network_changed {
info!("Network configuration has changed, recreating daemon");
} else if config_changed {
info!("BuildKit config has changed (insecure registries), recreating daemon");
}
stop_buildkit_daemon(container_cli, daemon_name)?;
}
(None, DaemonState::HasCert(_, current_network, current_config)) => {
let network_changed = &network_name != current_network;
let config_changed = &expected_config_hash != current_config;
if network_changed && config_changed {
info!("SSL certificate removed, network and config changed, recreating daemon");
} else if network_changed {
info!("SSL certificate removed and network changed, recreating daemon");
} else if config_changed {
info!("SSL certificate removed and config changed, recreating daemon");
} else {
info!("SSL certificate removed, recreating daemon without certificate");
}
stop_buildkit_daemon(container_cli, daemon_name)?;
}
(_, DaemonState::NotFound) => {
}
}
create_buildkit_daemon(
container_cli,
daemon_name,
ssl_cert_file,
network_name.as_deref(),
&proxy_vars,
)
}
pub(crate) fn ensure_buildx_builder(container_cli: &str, buildkit_host: &str) -> Result<String> {
let builder_name = "rise-buildkit";
let inspect_status = Command::new(container_cli)
.args(["buildx", "inspect", builder_name])
.output();
match inspect_status {
Ok(output) if output.status.success() => {
let inspect_output = String::from_utf8_lossy(&output.stdout);
if inspect_output.contains(buildkit_host) {
debug!(
"Buildx builder '{}' already exists with correct endpoint",
builder_name
);
return Ok(builder_name.to_string());
}
info!(
"Buildx builder '{}' exists but points to different endpoint, recreating",
builder_name
);
let _ = Command::new(container_cli)
.args(["buildx", "rm", builder_name])
.status();
}
_ => {
info!(
"Creating buildx builder '{}' for BuildKit daemon: {}",
builder_name, buildkit_host
);
}
}
let status = Command::new(container_cli)
.args(["buildx", "create", "--name", builder_name, buildkit_host])
.status()
.context("Failed to create buildx builder")?;
if !status.success() {
bail!("Failed to create buildx builder '{}'", builder_name);
}
info!("Buildx builder '{}' created successfully", builder_name);
Ok(builder_name.to_string())
}
pub(crate) fn resolve_host_gateway_ip(container_cli: &str, container_name: &str) -> Option<String> {
if let Some(ip) = resolve_from_etc_hosts(container_cli, container_name) {
debug!(
"Resolved host gateway IP for '{}' from /etc/hosts: {}",
container_name, ip
);
return Some(ip);
}
let output = Command::new(container_cli)
.args([
"inspect",
"--format",
"{{.NetworkSettings.Gateway}}",
container_name,
])
.output()
.ok()?;
if output.status.success() {
let ip = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !ip.is_empty() {
debug!(
"Resolved host gateway IP for '{}' from NetworkSettings.Gateway: {}",
container_name, ip
);
return Some(ip);
}
}
warn!(
"Failed to resolve host gateway IP for container '{}'",
container_name
);
None
}
fn resolve_from_etc_hosts(container_cli: &str, container_name: &str) -> Option<String> {
let output = Command::new(container_cli)
.args(["exec", container_name, "cat", "/etc/hosts"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let hosts = String::from_utf8_lossy(&output.stdout);
for line in hosts.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.contains("host.docker.internal") {
let ip = line.split_whitespace().next()?;
return Some(ip.to_string());
}
}
None
}
pub(crate) fn resolve_and_apply_host_gateway(
cmd: &mut std::process::Command,
container_cli: &str,
vars: &std::collections::HashMap<String, String>,
container_name: Option<&str>,
had_remote_host: bool,
) -> std::collections::HashMap<String, String> {
let gateway_ip = container_name.and_then(|name| resolve_host_gateway_ip(container_cli, name));
if had_remote_host && gateway_ip.is_none() && super::proxy::needs_host_gateway(vars) {
warn!(
"Proxy configuration references host.docker.internal but the host gateway IP \
could not be resolved for the remote BuildKit driver. Proxy routing may fail \
inside the build container."
);
}
super::proxy::apply_host_gateway(cmd, vars, gateway_ip.as_deref())
}
pub(crate) fn check_ssl_cert_and_warn(method: &BuildMethod, managed_buildkit: bool) {
if super::env_var_non_empty("SSL_CERT_FILE").is_some()
&& requires_buildkit(method)
&& !managed_buildkit
{
warn!(
"SSL_CERT_FILE is set but managed BuildKit daemon is disabled. \
Builds may fail with SSL certificate errors in corporate environments."
);
warn!("To enable automatic BuildKit daemon management:");
warn!(" rise build --managed-buildkit ...");
warn!("Or set environment variable:");
warn!(" export RISE_MANAGED_BUILDKIT=true");
}
}