lmrc_postgres/
install.rs

1//! Advanced installation features
2//!
3//! This module provides comprehensive installation features including:
4//! - System requirements checking
5//! - Version availability verification
6//! - Progress reporting
7//! - Rollback on failure
8//! - Upgrade/downgrade support
9
10use crate::config::PostgresConfig;
11use crate::error::{Error, Result};
12use lmrc_ssh::SshClient;
13use tracing::{debug, info, warn};
14
15/// Platform information
16#[derive(Debug, Clone, PartialEq)]
17pub enum Platform {
18    /// Debian-based systems
19    Debian,
20    /// Ubuntu-based systems
21    Ubuntu,
22    /// RedHat-based systems
23    RedHat,
24    /// CentOS
25    CentOS,
26    /// Alpine Linux
27    Alpine,
28    /// Unknown platform
29    Unknown(String),
30}
31
32impl Platform {
33    /// Get platform name as string
34    pub fn as_str(&self) -> &str {
35        match self {
36            Platform::Debian => "Debian",
37            Platform::Ubuntu => "Ubuntu",
38            Platform::RedHat => "RedHat",
39            Platform::CentOS => "CentOS",
40            Platform::Alpine => "Alpine",
41            Platform::Unknown(s) => s,
42        }
43    }
44
45    /// Check if platform is supported
46    pub fn is_supported(&self) -> bool {
47        matches!(self, Platform::Debian | Platform::Ubuntu)
48    }
49}
50
51/// System requirements
52#[derive(Debug, Clone)]
53pub struct SystemRequirements {
54    /// Minimum RAM in MB
55    pub min_ram_mb: u64,
56    /// Minimum free disk space in MB
57    pub min_disk_mb: u64,
58    /// Minimum CPU cores
59    pub min_cpu_cores: u32,
60}
61
62impl Default for SystemRequirements {
63    fn default() -> Self {
64        Self {
65            min_ram_mb: 1024,  // 1GB RAM
66            min_disk_mb: 5120, // 5GB disk
67            min_cpu_cores: 1,
68        }
69    }
70}
71
72/// System information
73#[derive(Debug, Clone)]
74pub struct SystemInfo {
75    /// Platform
76    pub platform: Platform,
77    /// OS version
78    pub os_version: String,
79    /// Total RAM in MB
80    pub total_ram_mb: u64,
81    /// Free disk space in MB
82    pub free_disk_mb: u64,
83    /// Number of CPU cores
84    pub cpu_cores: u32,
85}
86
87/// Installation progress callback
88pub type ProgressCallback = Box<dyn Fn(InstallationStep, u8) + Send + Sync>;
89
90/// Installation step
91#[derive(Debug, Clone, PartialEq)]
92pub enum InstallationStep {
93    /// Checking system requirements
94    CheckingRequirements,
95    /// Detecting platform
96    DetectingPlatform,
97    /// Checking version availability
98    CheckingVersionAvailability,
99    /// Installing prerequisites
100    InstallingPrerequisites,
101    /// Adding repository
102    AddingRepository,
103    /// Updating package list
104    UpdatingPackages,
105    /// Installing PostgreSQL
106    InstallingPostgres,
107    /// Starting service
108    StartingService,
109    /// Verifying installation
110    VerifyingInstallation,
111    /// Installation complete
112    Complete,
113}
114
115impl InstallationStep {
116    /// Get step description
117    pub fn description(&self) -> &str {
118        match self {
119            Self::CheckingRequirements => "Checking system requirements",
120            Self::DetectingPlatform => "Detecting platform",
121            Self::CheckingVersionAvailability => "Checking PostgreSQL version availability",
122            Self::InstallingPrerequisites => "Installing prerequisites",
123            Self::AddingRepository => "Adding PostgreSQL repository",
124            Self::UpdatingPackages => "Updating package list",
125            Self::InstallingPostgres => "Installing PostgreSQL",
126            Self::StartingService => "Starting PostgreSQL service",
127            Self::VerifyingInstallation => "Verifying installation",
128            Self::Complete => "Installation complete",
129        }
130    }
131}
132
133/// Detect the operating system platform
134pub async fn detect_platform(ssh: &mut SshClient) -> Result<Platform> {
135    debug!("Detecting platform");
136
137    // Try to read /etc/os-release
138    let result = ssh.execute("cat /etc/os-release 2>/dev/null || cat /etc/lsb-release 2>/dev/null");
139
140    if let Ok(output) = result {
141        let content = output.stdout.to_lowercase();
142
143        if content.contains("ubuntu") {
144            return Ok(Platform::Ubuntu);
145        } else if content.contains("debian") {
146            return Ok(Platform::Debian);
147        } else if content.contains("rhel") || content.contains("red hat") {
148            return Ok(Platform::RedHat);
149        } else if content.contains("centos") {
150            return Ok(Platform::CentOS);
151        } else if content.contains("alpine") {
152            return Ok(Platform::Alpine);
153        }
154    }
155
156    // Fallback: check for specific files
157    if ssh.execute("test -f /etc/debian_version").is_ok() {
158        return Ok(Platform::Debian);
159    }
160
161    warn!("Could not detect platform, using Unknown");
162    Ok(Platform::Unknown("Unknown".to_string()))
163}
164
165/// Get system information
166pub async fn get_system_info(ssh: &mut SshClient) -> Result<SystemInfo> {
167    debug!("Gathering system information");
168
169    let platform = detect_platform(ssh).await?;
170
171    // Get OS version
172    let os_version = ssh
173        .execute("lsb_release -d 2>/dev/null | cut -f2 || cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'")
174        .map(|o| o.stdout.trim().to_string())
175        .unwrap_or_else(|_| "Unknown".to_string());
176
177    // Get total RAM in MB
178    let total_ram_mb = ssh
179        .execute("free -m | grep Mem: | awk '{print $2}'")
180        .ok()
181        .and_then(|o| o.stdout.trim().parse().ok())
182        .unwrap_or(0);
183
184    // Get free disk space in MB (root partition)
185    let free_disk_mb = ssh
186        .execute("df -m / | tail -1 | awk '{print $4}'")
187        .ok()
188        .and_then(|o| o.stdout.trim().parse().ok())
189        .unwrap_or(0);
190
191    // Get CPU cores
192    let cpu_cores = ssh
193        .execute("nproc")
194        .ok()
195        .and_then(|o| o.stdout.trim().parse().ok())
196        .unwrap_or(1);
197
198    Ok(SystemInfo {
199        platform,
200        os_version,
201        total_ram_mb,
202        free_disk_mb,
203        cpu_cores,
204    })
205}
206
207/// Check system requirements
208pub async fn check_requirements(
209    ssh: &mut SshClient,
210    requirements: &SystemRequirements,
211) -> Result<SystemInfo> {
212    info!("Checking system requirements");
213
214    let sys_info = get_system_info(ssh).await?;
215
216    debug!("System info: {:?}", sys_info);
217
218    // Check platform support
219    if !sys_info.platform.is_supported() {
220        return Err(Error::Installation(format!(
221            "Unsupported platform: {}. Currently only Debian and Ubuntu are supported.",
222            sys_info.platform.as_str()
223        )));
224    }
225
226    // Check RAM
227    if sys_info.total_ram_mb < requirements.min_ram_mb {
228        return Err(Error::Installation(format!(
229            "Insufficient RAM: {}MB available, {}MB required",
230            sys_info.total_ram_mb, requirements.min_ram_mb
231        )));
232    }
233
234    // Check disk space
235    if sys_info.free_disk_mb < requirements.min_disk_mb {
236        return Err(Error::Installation(format!(
237            "Insufficient disk space: {}MB available, {}MB required",
238            sys_info.free_disk_mb, requirements.min_disk_mb
239        )));
240    }
241
242    // Check CPU cores
243    if sys_info.cpu_cores < requirements.min_cpu_cores {
244        warn!(
245            "Low CPU cores: {} available, {} recommended",
246            sys_info.cpu_cores, requirements.min_cpu_cores
247        );
248    }
249
250    info!("✓ System requirements check passed");
251    Ok(sys_info)
252}
253
254/// Check if PostgreSQL version is available
255pub async fn check_version_available(ssh: &mut SshClient, version: &str) -> Result<bool> {
256    debug!("Checking if PostgreSQL {} is available", version);
257
258    // First ensure repository is added
259    let has_repo = ssh
260        .execute("test -f /etc/apt/sources.list.d/pgdg.list")
261        .is_ok();
262
263    if !has_repo {
264        // Add repository temporarily to check
265        ssh.execute("DEBIAN_FRONTEND=noninteractive apt-get update -y")
266            .ok();
267        ssh.execute("DEBIAN_FRONTEND=noninteractive apt-get install -y gnupg2 wget lsb-release")
268            .ok();
269        ssh.execute(
270            "wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -",
271        )
272        .ok();
273        ssh.execute(r#"echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list"#).ok();
274        ssh.execute("apt-get update -y").ok();
275    }
276
277    // Check if package is available
278    let package_name = format!("postgresql-{}", version);
279    let result = ssh.execute(&format!("apt-cache show {} 2>&1", package_name));
280
281    match result {
282        Ok(output) => {
283            let available = output
284                .stdout
285                .contains(&format!("Package: {}", package_name));
286            debug!("PostgreSQL {} available: {}", version, available);
287            Ok(available)
288        }
289        Err(_) => {
290            debug!("PostgreSQL {} not found in repositories", version);
291            Ok(false)
292        }
293    }
294}
295
296/// Upgrade PostgreSQL to a new version
297pub async fn upgrade(
298    ssh: &mut SshClient,
299    from_version: &str,
300    to_version: &str,
301    _config: &PostgresConfig,
302) -> Result<()> {
303    info!(
304        "Upgrading PostgreSQL from {} to {}",
305        from_version, to_version
306    );
307
308    // Check if target version is available
309    if !check_version_available(ssh, to_version).await? {
310        return Err(Error::Installation(format!(
311            "PostgreSQL version {} is not available",
312            to_version
313        )));
314    }
315
316    // Stop current service
317    info!("Stopping PostgreSQL {}", from_version);
318    ssh.execute("systemctl stop postgresql")
319        .map_err(|e| Error::ServiceError(format!("Failed to stop service: {}", e)))?;
320
321    // Install new version
322    info!("Installing PostgreSQL {}", to_version);
323    let install_cmd = format!(
324        "DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql-{}",
325        to_version
326    );
327    ssh.execute(&install_cmd)
328        .map_err(|e| Error::Installation(format!("Failed to install new version: {}", e)))?;
329
330    // Run pg_upgrade (simplified - in production this needs more careful handling)
331    info!("Note: Manual data migration may be required");
332    warn!(
333        "PostgreSQL {} installed. You may need to migrate data from version {}",
334        to_version, from_version
335    );
336
337    // Start new service
338    info!("Starting PostgreSQL {}", to_version);
339    ssh.execute("systemctl start postgresql")
340        .map_err(|e| Error::ServiceError(format!("Failed to start service: {}", e)))?;
341
342    info!("Upgrade to PostgreSQL {} completed", to_version);
343    info!("⚠️  Please verify your data and configuration");
344
345    Ok(())
346}
347
348/// Verify installation comprehensively
349pub async fn verify_installation(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
350    info!("Performing comprehensive installation verification");
351
352    // 1. Check PostgreSQL is installed
353    let psql_check = ssh.execute("which psql");
354    if psql_check.is_err() {
355        return Err(Error::Installation(
356            "PostgreSQL not found in PATH".to_string(),
357        ));
358    }
359
360    // 2. Check version
361    let version_output = ssh
362        .execute("psql --version")
363        .map_err(|e| Error::Installation(format!("Failed to get version: {}", e)))?;
364
365    if !version_output.stdout.contains(&config.version) {
366        return Err(Error::Installation(format!(
367            "Version mismatch: expected {}, got {}",
368            config.version, version_output.stdout
369        )));
370    }
371    info!("✓ PostgreSQL version {} confirmed", config.version);
372
373    // 3. Check service is running
374    let service_status = ssh
375        .execute("systemctl is-active postgresql")
376        .map_err(|e| Error::ServiceError(format!("Failed to check service: {}", e)))?;
377
378    if service_status.stdout.trim() != "active" {
379        return Err(Error::ServiceError("Service is not active".to_string()));
380    }
381    info!("✓ PostgreSQL service is running");
382
383    // 4. Check service is enabled
384    let service_enabled = ssh
385        .execute("systemctl is-enabled postgresql")
386        .map_err(|e| Error::ServiceError(format!("Failed to check if enabled: {}", e)))?;
387
388    if service_enabled.stdout.trim() != "enabled" {
389        warn!("PostgreSQL service is not enabled for auto-start");
390    } else {
391        info!("✓ PostgreSQL service is enabled");
392    }
393
394    // 5. Check PostgreSQL is listening
395    let listening = ssh
396        .execute("ss -tlnp | grep postgres || netstat -tlnp | grep postgres")
397        .is_ok();
398
399    if !listening {
400        warn!("PostgreSQL may not be listening on expected port");
401    } else {
402        info!("✓ PostgreSQL is listening for connections");
403    }
404
405    // 6. Check configuration files exist
406    let config_dir = config.config_dir();
407    let conf_exists = ssh
408        .execute(&format!("test -f {}/postgresql.conf", config_dir))
409        .is_ok();
410
411    if !conf_exists {
412        return Err(Error::Configuration(format!(
413            "Configuration file not found: {}/postgresql.conf",
414            config_dir
415        )));
416    }
417    info!("✓ Configuration files exist");
418
419    // 7. Check data directory
420    let data_dir_check = ssh
421        .execute(&format!("test -d /var/lib/postgresql/{}", config.version))
422        .is_ok();
423
424    if !data_dir_check {
425        warn!("Data directory may not exist");
426    } else {
427        info!("✓ Data directory exists");
428    }
429
430    info!("✅ Installation verification complete");
431    Ok(())
432}
433
434/// Rollback installation on failure
435pub async fn rollback_installation(ssh: &mut SshClient, version: &str) -> Result<()> {
436    warn!("Rolling back PostgreSQL {} installation", version);
437
438    // Stop service if running
439    let _ = ssh.execute("systemctl stop postgresql");
440
441    // Remove package
442    let remove_cmd = format!("apt-get remove -y postgresql-{}", version);
443    ssh.execute(&remove_cmd)
444        .map_err(|e| Error::Uninstallation(format!("Rollback failed: {}", e)))?;
445
446    // Clean up
447    let _ = ssh.execute("apt-get autoremove -y");
448
449    info!("Rollback completed");
450    Ok(())
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_platform_detection() {
459        let platform = Platform::Ubuntu;
460        assert_eq!(platform.as_str(), "Ubuntu");
461        assert!(platform.is_supported());
462
463        let unknown = Platform::Unknown("Custom".to_string());
464        assert_eq!(unknown.as_str(), "Custom");
465        assert!(!unknown.is_supported());
466    }
467
468    #[test]
469    fn test_system_requirements_default() {
470        let req = SystemRequirements::default();
471        assert_eq!(req.min_ram_mb, 1024);
472        assert_eq!(req.min_disk_mb, 5120);
473        assert_eq!(req.min_cpu_cores, 1);
474    }
475
476    #[test]
477    fn test_installation_step_description() {
478        let step = InstallationStep::CheckingRequirements;
479        assert_eq!(step.description(), "Checking system requirements");
480
481        let step = InstallationStep::InstallingPostgres;
482        assert_eq!(step.description(), "Installing PostgreSQL");
483    }
484}