1use crate::config::PostgresConfig;
7use crate::diff::{ChangeType, ConfigChange, ConfigDiff};
8use crate::error::{Error, Result};
9use lmrc_ssh::SshClient;
10use tracing::{debug, info};
11
12pub async fn is_installed(ssh: &mut SshClient) -> Result<bool> {
24 debug!("Checking if PostgreSQL is installed");
25
26 match ssh.execute("which psql") {
27 Ok(output) => {
28 let installed = !output.stdout.trim().is_empty();
29 debug!("PostgreSQL installed: {}", installed);
30 Ok(installed)
31 }
32 Err(_) => {
33 debug!("PostgreSQL not found");
34 Ok(false)
35 }
36 }
37}
38
39pub async fn get_installed_version(ssh: &mut SshClient) -> Result<Option<String>> {
50 debug!("Getting installed PostgreSQL version");
51
52 if !is_installed(ssh).await? {
53 return Ok(None);
54 }
55
56 let output = ssh
57 .execute("psql --version")
58 .map_err(|e| Error::ssh_execution(e.to_string(), "psql --version"))?;
59
60 let version = output
62 .stdout
63 .split_whitespace()
64 .nth(2)
65 .and_then(|v| v.split('.').next())
66 .map(|v| v.to_string());
67
68 debug!("Installed version: {:?}", version);
69 Ok(version)
70}
71
72pub async fn install(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
85 info!("Installing PostgreSQL {}", config.version);
86
87 if let Some(installed_version) = get_installed_version(ssh).await? {
89 if installed_version == config.version {
90 info!(
91 "PostgreSQL {} is already installed, skipping installation",
92 config.version
93 );
94 return Ok(());
95 } else {
96 return Err(Error::AlreadyInstalled(installed_version));
97 }
98 }
99
100 info!("Installing prerequisites");
102 ssh.execute("DEBIAN_FRONTEND=noninteractive apt-get update -y")
103 .map_err(|e| Error::Installation(format!("Failed to update package list: {}", e)))?;
104
105 ssh.execute("DEBIAN_FRONTEND=noninteractive apt-get install -y gnupg2 wget lsb-release")
106 .map_err(|e| Error::Installation(format!("Failed to install prerequisites: {}", e)))?;
107
108 info!("Adding PostgreSQL APT repository");
110 ssh.execute(
111 "wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -",
112 )
113 .map_err(|e| Error::Installation(format!("Failed to add GPG key: {}", e)))?;
114
115 ssh.execute(r#"echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list"#)
116 .map_err(|e| Error::Installation(format!("Failed to add repository: {}", e)))?;
117
118 info!("Updating package list");
120 ssh.execute("apt-get update -y")
121 .map_err(|e| Error::Installation(format!("Failed to update after adding repo: {}", e)))?;
122
123 info!("Installing PostgreSQL {}", config.version);
125 let install_cmd = format!(
126 "DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql-{}",
127 config.version
128 );
129 ssh.execute(&install_cmd)
130 .map_err(|e| Error::Installation(format!("Failed to install PostgreSQL: {}", e)))?;
131
132 info!("Starting PostgreSQL service");
134 ssh.execute("systemctl start postgresql")
135 .map_err(|e| Error::ServiceError(format!("Failed to start service: {}", e)))?;
136
137 ssh.execute("systemctl enable postgresql")
138 .map_err(|e| Error::ServiceError(format!("Failed to enable service: {}", e)))?;
139
140 tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
142
143 verify_service_running(ssh).await?;
145
146 info!("PostgreSQL {} installed successfully", config.version);
147 Ok(())
148}
149
150pub async fn uninstall(ssh: &mut SshClient, config: &PostgresConfig, purge: bool) -> Result<()> {
158 info!("Uninstalling PostgreSQL {}", config.version);
159
160 if !is_installed(ssh).await? {
161 info!("PostgreSQL is not installed, nothing to uninstall");
162 return Ok(());
163 }
164
165 info!("Stopping PostgreSQL service");
167 let _ = ssh.execute("systemctl stop postgresql");
168
169 let uninstall_cmd = if purge {
171 format!("apt-get purge -y postgresql-{}", config.version)
172 } else {
173 format!("apt-get remove -y postgresql-{}", config.version)
174 };
175
176 ssh.execute(&uninstall_cmd)
177 .map_err(|e| Error::Uninstallation(format!("Failed to uninstall: {}", e)))?;
178
179 if purge {
180 info!("Removing PostgreSQL data and configuration files");
181 let _ = ssh.execute(&format!("rm -rf /etc/postgresql/{}", config.version));
182 let _ = ssh.execute(&format!("rm -rf /var/lib/postgresql/{}", config.version));
183 }
184
185 let _ = ssh.execute("apt-get autoremove -y");
187
188 info!("PostgreSQL uninstalled successfully");
189 Ok(())
190}
191
192pub async fn configure_database(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
204 info!("Configuring database and user");
205
206 info!("Creating database {}", config.database_name);
208 let create_db_cmd = format!(
209 r#"sudo -u postgres psql -c "CREATE DATABASE {} ENCODING 'UTF8';" || true"#,
210 config.database_name
211 );
212 ssh.execute(&create_db_cmd)
213 .map_err(|e| Error::Configuration(format!("Failed to create database: {}", e)))?;
214
215 info!("Creating user {}", config.username);
217 let create_user_cmd = format!(
218 r#"sudo -u postgres psql -c "CREATE USER {} WITH PASSWORD '{}';" || true"#,
219 config.username, config.password
220 );
221 ssh.execute(&create_user_cmd)
222 .map_err(|e| Error::Configuration(format!("Failed to create user: {}", e)))?;
223
224 let update_password_cmd = format!(
226 r#"sudo -u postgres psql -c "ALTER USER {} WITH PASSWORD '{}';" || true"#,
227 config.username, config.password
228 );
229 ssh.execute(&update_password_cmd)
230 .map_err(|e| Error::Configuration(format!("Failed to update user password: {}", e)))?;
231
232 info!("Granting privileges");
234 let grant_cmd = format!(
235 r#"sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE {} TO {};" || true"#,
236 config.database_name, config.username
237 );
238 ssh.execute(&grant_cmd)
239 .map_err(|e| Error::Configuration(format!("Failed to grant privileges: {}", e)))?;
240
241 let grant_schema_cmd = format!(
243 r#"sudo -u postgres psql -d {} -c "GRANT ALL ON SCHEMA public TO {};" || true"#,
244 config.database_name, config.username
245 );
246 ssh.execute(&grant_schema_cmd)
247 .map_err(|e| Error::Configuration(format!("Failed to grant schema privileges: {}", e)))?;
248
249 let grant_default_cmd = format!(
251 r#"sudo -u postgres psql -d {} -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {};" || true"#,
252 config.database_name, config.username
253 );
254 ssh.execute(&grant_default_cmd)
255 .map_err(|e| Error::Configuration(format!("Failed to grant default privileges: {}", e)))?;
256
257 info!("Database and user configured successfully");
258 Ok(())
259}
260
261pub async fn configure_server(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
268 info!("Configuring PostgreSQL server");
269
270 let config_dir = config.config_dir();
271
272 info!("Configuring listen addresses");
274 let listen_cmd = format!(
275 r#"sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" {}/postgresql.conf"#,
276 config_dir
277 );
278 ssh.execute(&listen_cmd)
279 .map_err(|e| Error::Configuration(format!("Failed to update listen_addresses: {}", e)))?;
280
281 if config.port != 5432 {
283 let port_cmd = format!(
284 r#"sed -i "s/#port = 5432/port = {}/" {}/postgresql.conf"#,
285 config.port, config_dir
286 );
287 ssh.execute(&port_cmd)
288 .map_err(|e| Error::Configuration(format!("Failed to update port: {}", e)))?;
289 }
290
291 if let Some(max_conn) = config.max_connections {
293 let max_conn_cmd = format!(
294 r#"sed -i "s/max_connections = [0-9]\\+/max_connections = {}/" {}/postgresql.conf"#,
295 max_conn, config_dir
296 );
297 ssh.execute(&max_conn_cmd).map_err(|e| {
298 Error::Configuration(format!("Failed to update max_connections: {}", e))
299 })?;
300 }
301
302 if let Some(ref buffers) = config.shared_buffers {
304 let buffers_cmd = format!(
305 r#"sed -i "s/#shared_buffers = .*/shared_buffers = {}/" {}/postgresql.conf"#,
306 buffers, config_dir
307 );
308 ssh.execute(&buffers_cmd)
309 .map_err(|e| Error::Configuration(format!("Failed to update shared_buffers: {}", e)))?;
310 }
311
312 info!("Configuring remote access");
314 let hba_network = if config.listen_addresses == "0.0.0.0" || config.listen_addresses == "*" {
316 "0.0.0.0/0"
317 } else if config.listen_addresses.contains('/') {
318 config.listen_addresses.as_str()
319 } else {
320 &format!("{}/32", config.listen_addresses)
322 };
323
324 let hba_cmd = format!(
325 r#"echo "host all all {} md5" >> {}/pg_hba.conf"#,
326 hba_network, config_dir
327 );
328 ssh.execute(&hba_cmd)
329 .map_err(|e| Error::Configuration(format!("Failed to update pg_hba.conf: {}", e)))?;
330
331 info!("Restarting PostgreSQL to apply configuration");
333 ssh.execute("systemctl restart postgresql")
334 .map_err(|e| Error::ServiceError(format!("Failed to restart service: {}", e)))?;
335
336 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
338 verify_service_running(ssh).await?;
339
340 info!("PostgreSQL server configured successfully");
341 Ok(())
342}
343
344pub async fn detect_diff(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigDiff> {
355 debug!("Detecting configuration differences");
356
357 let mut diff = ConfigDiff::new();
358 let config_file = config.postgresql_conf_path();
359
360 let get_config_value = |ssh: &mut SshClient, param: &str| -> Option<String> {
362 let cmd = format!(
363 r#"grep "^{} = " {} | sed "s/{} = //" | tr -d "'"#,
364 param, config_file, param
365 );
366 ssh.execute(&cmd).ok().and_then(|output| {
367 let val = output.stdout.trim();
368 if val.is_empty() {
369 None
370 } else {
371 Some(val.to_string())
372 }
373 })
374 };
375
376 macro_rules! check_param {
378 ($param_name:expr, $desired_value:expr) => {
379 if let Some(ref desired) = $desired_value {
380 let current = get_config_value(ssh, $param_name);
381 let desired_str = desired.to_string();
382 if current.as_deref() != Some(desired_str.as_str()) {
383 let change_type = if current.is_some() {
384 ChangeType::Modify
385 } else {
386 ChangeType::Add
387 };
388 diff.add_change(ConfigChange {
389 parameter: $param_name.to_string(),
390 current,
391 desired: desired_str,
392 change_type,
393 });
394 }
395 }
396 };
397 }
398
399 check_param!(
401 "max_connections",
402 config.max_connections.map(|v| v.to_string())
403 );
404 check_param!("shared_buffers", config.shared_buffers.clone());
405 check_param!("effective_cache_size", config.effective_cache_size.clone());
406 check_param!("work_mem", config.work_mem.clone());
407 check_param!("maintenance_work_mem", config.maintenance_work_mem.clone());
408 check_param!("wal_buffers", config.wal_buffers.clone());
409 check_param!(
410 "checkpoint_completion_target",
411 config.checkpoint_completion_target.map(|v| v.to_string())
412 );
413
414 let current_port = get_config_value(ssh, "port");
416 let desired_port = config.port.to_string();
417 if current_port.as_deref() != Some(desired_port.as_str()) {
418 let change_type = if current_port.is_some() {
419 ChangeType::Modify
420 } else {
421 ChangeType::Add
422 };
423 diff.add_change(ConfigChange {
424 parameter: "port".to_string(),
425 current: current_port,
426 desired: desired_port,
427 change_type,
428 });
429 }
430
431 let current_listen = get_config_value(ssh, "listen_addresses");
433 if current_listen.as_deref() != Some(&config.listen_addresses) {
434 let change_type = if current_listen.is_some() {
435 ChangeType::Modify
436 } else {
437 ChangeType::Add
438 };
439 diff.add_change(ConfigChange {
440 parameter: "listen_addresses".to_string(),
441 current: current_listen,
442 desired: config.listen_addresses.clone(),
443 change_type,
444 });
445 }
446
447 let current_ssl = get_config_value(ssh, "ssl");
449 let desired_ssl = if config.ssl { "on" } else { "off" }.to_string();
450 if current_ssl.as_deref() != Some(desired_ssl.as_str()) {
451 let change_type = if current_ssl.is_some() {
452 ChangeType::Modify
453 } else {
454 ChangeType::Add
455 };
456 diff.add_change(ConfigChange {
457 parameter: "ssl".to_string(),
458 current: current_ssl,
459 desired: desired_ssl,
460 change_type,
461 });
462 }
463
464 for (key, desired_value) in &config.extra_config {
466 let current = get_config_value(ssh, key);
467 if current.as_deref() != Some(desired_value.as_str()) {
468 let change_type = if current.is_some() {
469 ChangeType::Modify
470 } else {
471 ChangeType::Add
472 };
473 diff.add_change(ConfigChange {
474 parameter: key.clone(),
475 current,
476 desired: desired_value.clone(),
477 change_type,
478 });
479 }
480 }
481
482 debug!("Diff detection complete: {} changes", diff.len());
483 Ok(diff)
484}
485
486pub async fn test_connection(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
493 debug!("Testing database connection");
494
495 let test_cmd = format!(
496 r#"PGPASSWORD='{}' psql -U {} -h localhost -p {} -d {} -c '\l' > /dev/null 2>&1"#,
497 config.password, config.username, config.port, config.database_name
498 );
499
500 ssh.execute(&test_cmd)
501 .map_err(|e| Error::ConnectionTest(format!("Connection test failed: {}", e)))?;
502
503 info!("Database connection test successful");
504 Ok(())
505}
506
507async fn verify_service_running(ssh: &mut SshClient) -> Result<()> {
509 let status = ssh
510 .execute("systemctl is-active postgresql")
511 .map_err(|e| Error::ServiceError(format!("Failed to check service status: {}", e)))?;
512
513 if status.stdout.trim() != "active" {
514 return Err(Error::ServiceError(
515 "PostgreSQL service is not active".to_string(),
516 ));
517 }
518
519 debug!("PostgreSQL service is active");
520 Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
531 fn test_config_paths() {
532 let config = PostgresConfig::builder()
533 .version("15")
534 .database_name("test")
535 .username("user")
536 .password("pass")
537 .build()
538 .unwrap();
539
540 assert_eq!(config.config_dir(), "/etc/postgresql/15/main");
541 assert_eq!(
542 config.postgresql_conf_path(),
543 "/etc/postgresql/15/main/postgresql.conf"
544 );
545 }
546}