use serde_json::{Value, json};
use std::env;
use std::process::Output;
use tokio::process::Command;
use super::error::ProvisioningError;
use super::inspect_docker_runtime_access_current_user;
use super::types::{
InstallLocalProvisionDependenciesParams, LocalProvisionDependencyInstallResult,
LocalProvisionDependencyInstallStep, LocalProvisionDependencyStatus,
};
#[derive(Debug, Clone, Copy)]
enum PackageManager {
Apt,
Dnf,
Yum,
Apk,
Pacman,
Zypper,
}
impl PackageManager {
fn as_str(self) -> &'static str {
match self {
Self::Apt => "apt-get",
Self::Dnf => "dnf",
Self::Yum => "yum",
Self::Apk => "apk",
Self::Pacman => "pacman",
Self::Zypper => "zypper",
}
}
}
struct CommandSpec {
key: &'static str,
description: String,
program: String,
args: Vec<String>,
envs: Vec<(String, String)>,
}
pub async fn inspect_local_provisioning_dependencies() -> LocalProvisionDependencyStatus {
let package_manager_detected = detect_package_manager();
let package_manager = package_manager_detected.map(|manager| manager.as_str().to_string());
let running_as_root = detect_running_as_root().await;
let sudo_available = binary_on_path("sudo");
let sudo_non_interactive_available =
detect_sudo_non_interactive_available(running_as_root, sudo_available).await;
let systemctl_available = binary_on_path("systemctl");
let docker_binary_available = binary_on_path("docker");
let postgres_binary_available = binary_on_path("psql");
let docker_runtime_access = inspect_docker_runtime_access_current_user().await;
let docker_service_active = if systemctl_available && docker_binary_available {
service_active("docker").await
} else {
None
};
let postgres_service_active = if systemctl_available {
service_active("postgresql").await
} else {
None
};
let mut missing = Vec::new();
let mut notes = Vec::new();
if !cfg!(target_os = "linux") {
notes.push(
"Automatic dependency installation is only supported on Linux hosts.".to_string(),
);
}
if package_manager.is_none() && cfg!(target_os = "linux") {
notes.push("No supported package manager was detected on PATH.".to_string());
}
if !docker_binary_available {
missing.push("docker".to_string());
} else if !docker_runtime_access.available {
notes.push(format!(
"Docker runtime check: {}",
docker_runtime_access.detail
));
}
if !postgres_binary_available {
missing.push("postgres".to_string());
}
if !running_as_root && !sudo_available && cfg!(target_os = "linux") {
notes.push("Package installation requires root or passwordless sudo.".to_string());
} else if !running_as_root
&& sudo_available
&& !sudo_non_interactive_available
&& cfg!(target_os = "linux")
{
notes.push(
"sudo is installed but requires a password; non-interactive installs are unavailable."
.to_string(),
);
}
LocalProvisionDependencyStatus {
os_family: env::consts::OS.to_string(),
package_manager,
running_as_root,
sudo_available,
sudo_non_interactive_available,
systemctl_available,
can_attempt_install: compute_can_attempt_install(
cfg!(target_os = "linux"),
package_manager_detected.is_some(),
running_as_root,
sudo_non_interactive_available,
),
docker_binary_available,
docker_service_active,
docker_runtime_access,
postgres_binary_available,
postgres_service_active,
missing,
notes,
}
}
pub async fn install_local_provisioning_dependencies(
params: InstallLocalProvisionDependenciesParams,
) -> Result<LocalProvisionDependencyInstallResult, ProvisioningError> {
if !params.install_docker && !params.install_postgres {
return Err(ProvisioningError::InvalidInput(
"At least one install target must be enabled.".to_string(),
));
}
let before = inspect_local_provisioning_dependencies().await;
if before.os_family != "linux" {
return Err(ProvisioningError::Unavailable(
"Automatic dependency installation is only supported on Linux hosts.".to_string(),
));
}
let package_manager = detect_package_manager().ok_or_else(|| {
ProvisioningError::Unavailable(
"No supported package manager was detected on PATH.".to_string(),
)
})?;
let use_sudo_prefix = build_sudo_prefix(&before, params.use_sudo)?;
let mut steps = Vec::new();
let docker_missing = params.install_docker && !before.docker_binary_available;
let postgres_missing = params.install_postgres && !before.postgres_binary_available;
if !docker_missing {
steps.push(LocalProvisionDependencyInstallStep {
key: "docker_package_install".to_string(),
status: "skipped".to_string(),
description: "Docker is already available on PATH.".to_string(),
command: None,
detail: Some("Skipped Docker package installation.".to_string()),
output: None,
});
}
if !postgres_missing {
steps.push(LocalProvisionDependencyInstallStep {
key: "postgres_package_install".to_string(),
status: "skipped".to_string(),
description: "Postgres client tools are already available on PATH.".to_string(),
command: None,
detail: Some("Skipped Postgres package installation.".to_string()),
output: None,
});
}
let missing_packages = install_package_list(package_manager, docker_missing, postgres_missing);
if !missing_packages.is_empty() {
if let Some(command) = package_update_command(package_manager, &use_sudo_prefix) {
steps.push(run_install_step(command).await);
}
let install_command =
package_install_command(package_manager, &missing_packages, &use_sudo_prefix);
steps.push(run_install_step(install_command).await);
}
if params.start_services {
if params.install_docker {
run_optional_service_start(
"docker_service_start",
"Attempt to enable and start the Docker service.",
"docker",
&before,
&use_sudo_prefix,
&mut steps,
)
.await;
}
if params.install_postgres {
run_optional_service_start(
"postgres_service_start",
"Attempt to enable and start the PostgreSQL service.",
"postgresql",
&before,
&use_sudo_prefix,
&mut steps,
)
.await;
}
}
let after = inspect_local_provisioning_dependencies().await;
let overall_status = compute_overall_status(¶ms, &after, &steps);
Ok(LocalProvisionDependencyInstallResult {
requested: std::collections::HashMap::from([
(
"install_docker".to_string(),
Value::Bool(params.install_docker),
),
(
"install_postgres".to_string(),
Value::Bool(params.install_postgres),
),
(
"start_services".to_string(),
Value::Bool(params.start_services),
),
("use_sudo".to_string(), Value::Bool(params.use_sudo)),
(
"package_manager".to_string(),
json!(package_manager.as_str().to_string()),
),
]),
before,
after,
overall_status: overall_status.to_string(),
steps,
})
}
fn build_sudo_prefix(
status: &LocalProvisionDependencyStatus,
use_sudo: bool,
) -> Result<Vec<String>, ProvisioningError> {
if status.running_as_root {
return Ok(Vec::new());
}
if use_sudo && status.sudo_non_interactive_available {
return Ok(vec!["sudo".to_string(), "-n".to_string()]);
}
Err(ProvisioningError::Unavailable(
"Installing system packages requires root privileges or passwordless sudo.".to_string(),
))
}
fn compute_can_attempt_install(
is_linux: bool,
package_manager_detected: bool,
running_as_root: bool,
sudo_non_interactive_available: bool,
) -> bool {
is_linux && package_manager_detected && (running_as_root || sudo_non_interactive_available)
}
fn detect_package_manager() -> Option<PackageManager> {
[
("apt-get", PackageManager::Apt),
("dnf", PackageManager::Dnf),
("yum", PackageManager::Yum),
("apk", PackageManager::Apk),
("pacman", PackageManager::Pacman),
("zypper", PackageManager::Zypper),
]
.into_iter()
.find_map(|(binary, manager)| binary_on_path(binary).then_some(manager))
}
fn binary_on_path(binary: &str) -> bool {
let Some(path_value) = env::var_os("PATH") else {
return false;
};
env::split_paths(&path_value).any(|base| {
let direct = base.join(binary);
if direct.is_file() {
return true;
}
#[cfg(windows)]
{
let exe = base.join(format!("{binary}.exe"));
if exe.is_file() {
return true;
}
}
false
})
}
async fn detect_running_as_root() -> bool {
#[cfg(unix)]
{
let output = Command::new("id").arg("-u").output().await;
return output
.ok()
.filter(|result| result.status.success())
.map(|result| String::from_utf8_lossy(&result.stdout).trim() == "0")
.unwrap_or(false);
}
#[cfg(not(unix))]
{
false
}
}
async fn detect_sudo_non_interactive_available(
running_as_root: bool,
sudo_available: bool,
) -> bool {
if !cfg!(target_os = "linux") || running_as_root || !sudo_available {
return false;
}
Command::new("sudo")
.args(["-n", "true"])
.output()
.await
.map(|result| result.status.success())
.unwrap_or(false)
}
async fn service_active(service_name: &str) -> Option<bool> {
let output = Command::new("systemctl")
.args(["is-active", "--quiet", service_name])
.output()
.await
.ok()?;
Some(output.status.success())
}
fn install_package_list(
manager: PackageManager,
install_docker: bool,
install_postgres: bool,
) -> Vec<String> {
let mut packages = Vec::new();
if install_docker {
packages.extend(match manager {
PackageManager::Apt => vec!["docker.io"],
PackageManager::Dnf | PackageManager::Yum => vec!["docker"],
PackageManager::Apk => vec!["docker"],
PackageManager::Pacman => vec!["docker"],
PackageManager::Zypper => vec!["docker"],
});
}
if install_postgres {
packages.extend(match manager {
PackageManager::Apt => vec!["postgresql", "postgresql-contrib", "postgresql-client"],
PackageManager::Dnf | PackageManager::Yum => {
vec!["postgresql-server", "postgresql", "postgresql-contrib"]
}
PackageManager::Apk => vec!["postgresql", "postgresql-client"],
PackageManager::Pacman => vec!["postgresql"],
PackageManager::Zypper => vec!["postgresql-server", "postgresql"],
});
}
packages
.into_iter()
.map(str::to_string)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect()
}
fn package_update_command(manager: PackageManager, sudo_prefix: &[String]) -> Option<CommandSpec> {
match manager {
PackageManager::Apt => Some(compose_command(
"package_index_update",
"Refresh apt package metadata before installing provisioning dependencies.".to_string(),
sudo_prefix,
"apt-get",
vec!["update".to_string()],
vec![
("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string()),
("NEEDRESTART_MODE".to_string(), "a".to_string()),
],
)),
PackageManager::Pacman => Some(compose_command(
"package_index_update",
"Refresh pacman package metadata before installing provisioning dependencies."
.to_string(),
sudo_prefix,
"pacman",
vec!["-Sy".to_string()],
Vec::new(),
)),
_ => None,
}
}
fn package_install_command(
manager: PackageManager,
packages: &[String],
sudo_prefix: &[String],
) -> CommandSpec {
match manager {
PackageManager::Apt => compose_command(
"package_install",
format!(
"Install requested provisioning dependencies via apt-get: {}.",
packages.join(", ")
),
sudo_prefix,
"apt-get",
{
let mut args = vec![
"install".to_string(),
"-y".to_string(),
"--no-install-recommends".to_string(),
];
args.extend(packages.iter().cloned());
args
},
vec![
("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string()),
("NEEDRESTART_MODE".to_string(), "a".to_string()),
],
),
PackageManager::Dnf => compose_command(
"package_install",
format!(
"Install requested provisioning dependencies via dnf: {}.",
packages.join(", ")
),
sudo_prefix,
"dnf",
{
let mut args = vec!["install".to_string(), "-y".to_string()];
args.extend(packages.iter().cloned());
args
},
Vec::new(),
),
PackageManager::Yum => compose_command(
"package_install",
format!(
"Install requested provisioning dependencies via yum: {}.",
packages.join(", ")
),
sudo_prefix,
"yum",
{
let mut args = vec!["install".to_string(), "-y".to_string()];
args.extend(packages.iter().cloned());
args
},
Vec::new(),
),
PackageManager::Apk => compose_command(
"package_install",
format!(
"Install requested provisioning dependencies via apk: {}.",
packages.join(", ")
),
sudo_prefix,
"apk",
{
let mut args = vec!["add".to_string(), "--no-cache".to_string()];
args.extend(packages.iter().cloned());
args
},
Vec::new(),
),
PackageManager::Pacman => compose_command(
"package_install",
format!(
"Install requested provisioning dependencies via pacman: {}.",
packages.join(", ")
),
sudo_prefix,
"pacman",
{
let mut args = vec!["-S".to_string(), "--noconfirm".to_string()];
args.extend(packages.iter().cloned());
args
},
Vec::new(),
),
PackageManager::Zypper => compose_command(
"package_install",
format!(
"Install requested provisioning dependencies via zypper: {}.",
packages.join(", ")
),
sudo_prefix,
"zypper",
{
let mut args = vec![
"--non-interactive".to_string(),
"install".to_string(),
"-y".to_string(),
];
args.extend(packages.iter().cloned());
args
},
Vec::new(),
),
}
}
fn compose_command(
key: &'static str,
description: String,
sudo_prefix: &[String],
base_program: &str,
base_args: Vec<String>,
envs: Vec<(String, String)>,
) -> CommandSpec {
if sudo_prefix.is_empty() {
CommandSpec {
key,
description,
program: base_program.to_string(),
args: base_args,
envs,
}
} else {
let mut args = sudo_prefix.iter().skip(1).cloned().collect::<Vec<_>>();
args.push(base_program.to_string());
args.extend(base_args);
CommandSpec {
key,
description,
program: sudo_prefix[0].clone(),
args,
envs,
}
}
}
async fn run_install_step(command: CommandSpec) -> LocalProvisionDependencyInstallStep {
let command_line = build_command_string(&command.program, &command.args);
let output = execute_command(&command).await;
match output {
Ok(output) if output.status.success() => LocalProvisionDependencyInstallStep {
key: command.key.to_string(),
status: "success".to_string(),
description: command.description,
command: Some(command_line),
detail: Some("Command completed successfully.".to_string()),
output: trimmed_output(&output),
},
Ok(output) => {
let detail = trimmed_output(&output)
.unwrap_or_else(|| "Command exited without output.".to_string());
LocalProvisionDependencyInstallStep {
key: command.key.to_string(),
status: "failed".to_string(),
description: command.description,
command: Some(command_line),
detail: Some(detail.clone()),
output: Some(detail),
}
}
Err(err) => LocalProvisionDependencyInstallStep {
key: command.key.to_string(),
status: "failed".to_string(),
description: command.description,
command: Some(command_line),
detail: Some(format!("Failed to execute command: {err}")),
output: None,
},
}
}
async fn run_optional_service_start(
key: &'static str,
description: &str,
service_name: &str,
before: &LocalProvisionDependencyStatus,
sudo_prefix: &[String],
steps: &mut Vec<LocalProvisionDependencyInstallStep>,
) {
if !before.systemctl_available {
steps.push(LocalProvisionDependencyInstallStep {
key: key.to_string(),
status: "skipped".to_string(),
description: description.to_string(),
command: None,
detail: Some("systemctl is not available on this host.".to_string()),
output: None,
});
return;
}
let command = compose_command(
key,
description.to_string(),
sudo_prefix,
"systemctl",
vec![
"enable".to_string(),
"--now".to_string(),
service_name.to_string(),
],
Vec::new(),
);
let output = execute_command(&command).await;
match output {
Ok(result) if result.status.success() => steps.push(LocalProvisionDependencyInstallStep {
key: key.to_string(),
status: "success".to_string(),
description: description.to_string(),
command: Some(build_command_string(&command.program, &command.args)),
detail: Some(format!(
"Service '{}' is enabled and started.",
service_name
)),
output: trimmed_output(&result),
}),
Ok(result) => {
let detail = trimmed_output(&result)
.unwrap_or_else(|| "Command exited without output.".to_string());
steps.push(LocalProvisionDependencyInstallStep {
key: key.to_string(),
status: "failed".to_string(),
description: description.to_string(),
command: Some(build_command_string(&command.program, &command.args)),
detail: Some(detail.clone()),
output: Some(detail),
});
}
Err(err) => steps.push(LocalProvisionDependencyInstallStep {
key: key.to_string(),
status: "failed".to_string(),
description: description.to_string(),
command: Some(build_command_string(&command.program, &command.args)),
detail: Some(format!("Failed to execute service command: {err}")),
output: None,
}),
}
}
fn install_step_succeeded(steps: &[LocalProvisionDependencyInstallStep], key: &str) -> bool {
steps
.iter()
.rev()
.find(|step| step.key == key)
.map(|step| step.status == "success")
.unwrap_or(false)
}
fn target_ready(
binary_available: bool,
service_active: Option<bool>,
start_services: bool,
service_step_key: &str,
steps: &[LocalProvisionDependencyInstallStep],
) -> bool {
if !binary_available {
return false;
}
if !start_services {
return true;
}
service_active.unwrap_or(false) || install_step_succeeded(steps, service_step_key)
}
fn compute_overall_status(
params: &InstallLocalProvisionDependenciesParams,
after: &LocalProvisionDependencyStatus,
steps: &[LocalProvisionDependencyInstallStep],
) -> &'static str {
let mut requested_targets = 0usize;
let mut ready_targets = 0usize;
if params.install_docker {
requested_targets += 1;
if target_ready(
after.docker_binary_available,
after.docker_service_active,
params.start_services,
"docker_service_start",
steps,
) {
ready_targets += 1;
}
}
if params.install_postgres {
requested_targets += 1;
if target_ready(
after.postgres_binary_available,
after.postgres_service_active,
params.start_services,
"postgres_service_start",
steps,
) {
ready_targets += 1;
}
}
if ready_targets == requested_targets {
"success"
} else if ready_targets > 0 {
"partial"
} else {
"failed"
}
}
async fn execute_command(command: &CommandSpec) -> Result<Output, std::io::Error> {
let mut process = Command::new(&command.program);
process.args(&command.args);
for (key, value) in &command.envs {
process.env(key, value);
}
process.output().await
}
fn build_command_string(program: &str, args: &[String]) -> String {
let mut parts = vec![program.to_string()];
parts.extend(args.iter().cloned());
parts.join(" ")
}
fn trimmed_output(output: &Output) -> Option<String> {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let combined = if stderr.is_empty() {
stdout
} else if stdout.is_empty() {
stderr
} else {
format!("{stderr}\n{stdout}")
};
if combined.is_empty() {
return None;
}
let truncated = if combined.len() > 4000 {
format!("{}...", &combined[..4000])
} else {
combined
};
Some(truncated)
}
#[cfg(test)]
mod tests {
use super::{
PackageManager, compute_can_attempt_install, compute_overall_status, install_package_list,
};
use crate::provisioning::types::{
InstallLocalProvisionDependenciesParams, LocalProvisionDependencyInstallStep,
LocalProvisionDependencyStatus,
};
fn status(
docker_binary_available: bool,
docker_service_active: Option<bool>,
postgres_binary_available: bool,
postgres_service_active: Option<bool>,
) -> LocalProvisionDependencyStatus {
LocalProvisionDependencyStatus {
os_family: "linux".to_string(),
package_manager: Some("apt-get".to_string()),
running_as_root: false,
sudo_available: true,
sudo_non_interactive_available: true,
systemctl_available: true,
can_attempt_install: true,
docker_binary_available,
docker_runtime_access: crate::provisioning::DockerRuntimeAccessStatus {
available: docker_binary_available,
status: if docker_binary_available {
"ready".to_string()
} else {
"binary_missing".to_string()
},
detail: if docker_binary_available {
"Docker daemon is reachable by the current process user.".to_string()
} else {
"docker binary is not installed or not available in PATH".to_string()
},
hint: None,
},
docker_service_active,
postgres_binary_available,
postgres_service_active,
missing: Vec::new(),
notes: Vec::new(),
}
}
#[test]
fn install_package_list_collects_expected_apt_packages() {
let packages = install_package_list(PackageManager::Apt, true, true);
assert!(packages.iter().any(|pkg| pkg == "docker.io"));
assert!(packages.iter().any(|pkg| pkg == "postgresql"));
assert!(packages.iter().any(|pkg| pkg == "postgresql-client"));
}
#[test]
fn install_package_list_deduplicates_requested_packages() {
let packages = install_package_list(PackageManager::Pacman, true, true);
assert_eq!(
packages,
vec!["docker".to_string(), "postgresql".to_string()]
);
}
#[test]
fn can_attempt_install_requires_root_or_non_interactive_sudo() {
assert!(compute_can_attempt_install(true, true, true, false));
assert!(compute_can_attempt_install(true, true, false, true));
assert!(!compute_can_attempt_install(true, true, false, false));
assert!(!compute_can_attempt_install(true, false, true, true));
assert!(!compute_can_attempt_install(false, true, true, true));
}
#[test]
fn overall_status_is_partial_when_a_requested_service_start_fails() {
let params = InstallLocalProvisionDependenciesParams {
install_docker: true,
install_postgres: true,
start_services: true,
use_sudo: true,
};
let after = status(true, Some(false), true, Some(true));
let steps = vec![
LocalProvisionDependencyInstallStep {
key: "docker_service_start".to_string(),
status: "failed".to_string(),
description: "docker failed".to_string(),
command: None,
detail: None,
output: None,
},
LocalProvisionDependencyInstallStep {
key: "postgres_service_start".to_string(),
status: "success".to_string(),
description: "postgres started".to_string(),
command: None,
detail: None,
output: None,
},
];
assert_eq!(compute_overall_status(¶ms, &after, &steps), "partial");
}
#[test]
fn overall_status_is_success_when_all_requested_targets_are_ready() {
let params = InstallLocalProvisionDependenciesParams {
install_docker: true,
install_postgres: true,
start_services: true,
use_sudo: true,
};
let after = status(true, Some(true), true, Some(true));
let steps = vec![
LocalProvisionDependencyInstallStep {
key: "docker_service_start".to_string(),
status: "success".to_string(),
description: "docker started".to_string(),
command: None,
detail: None,
output: None,
},
LocalProvisionDependencyInstallStep {
key: "postgres_service_start".to_string(),
status: "success".to_string(),
description: "postgres started".to_string(),
command: None,
detail: None,
output: None,
},
];
assert_eq!(compute_overall_status(¶ms, &after, &steps), "success");
}
#[test]
fn overall_status_is_failed_when_requested_service_start_is_skipped() {
let params = InstallLocalProvisionDependenciesParams {
install_docker: true,
install_postgres: false,
start_services: true,
use_sudo: true,
};
let after = status(true, None, false, None);
let steps = vec![LocalProvisionDependencyInstallStep {
key: "docker_service_start".to_string(),
status: "skipped".to_string(),
description: "systemctl unavailable".to_string(),
command: None,
detail: None,
output: None,
}];
assert_eq!(compute_overall_status(¶ms, &after, &steps), "failed");
}
}