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_cmd = format!(
315 r#"echo "host all all {} md5" >> {}/pg_hba.conf"#,
316 config.listen_addresses, config_dir
317 );
318 ssh.execute(&hba_cmd)
319 .map_err(|e| Error::Configuration(format!("Failed to update pg_hba.conf: {}", e)))?;
320
321 info!("Restarting PostgreSQL to apply configuration");
323 ssh.execute("systemctl restart postgresql")
324 .map_err(|e| Error::ServiceError(format!("Failed to restart service: {}", e)))?;
325
326 tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
328 verify_service_running(ssh).await?;
329
330 info!("PostgreSQL server configured successfully");
331 Ok(())
332}
333
334pub async fn detect_diff(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigDiff> {
345 debug!("Detecting configuration differences");
346
347 let mut diff = ConfigDiff::new();
348 let config_file = config.postgresql_conf_path();
349
350 let get_config_value = |ssh: &mut SshClient, param: &str| -> Option<String> {
352 let cmd = format!(
353 r#"grep "^{} = " {} | sed "s/{} = //" | tr -d "'"#,
354 param, config_file, param
355 );
356 ssh.execute(&cmd).ok().and_then(|output| {
357 let val = output.stdout.trim();
358 if val.is_empty() {
359 None
360 } else {
361 Some(val.to_string())
362 }
363 })
364 };
365
366 macro_rules! check_param {
368 ($param_name:expr, $desired_value:expr) => {
369 if let Some(ref desired) = $desired_value {
370 let current = get_config_value(ssh, $param_name);
371 let desired_str = desired.to_string();
372 if current.as_deref() != Some(desired_str.as_str()) {
373 let change_type = if current.is_some() {
374 ChangeType::Modify
375 } else {
376 ChangeType::Add
377 };
378 diff.add_change(ConfigChange {
379 parameter: $param_name.to_string(),
380 current,
381 desired: desired_str,
382 change_type,
383 });
384 }
385 }
386 };
387 }
388
389 check_param!(
391 "max_connections",
392 config.max_connections.map(|v| v.to_string())
393 );
394 check_param!("shared_buffers", config.shared_buffers.clone());
395 check_param!("effective_cache_size", config.effective_cache_size.clone());
396 check_param!("work_mem", config.work_mem.clone());
397 check_param!("maintenance_work_mem", config.maintenance_work_mem.clone());
398 check_param!("wal_buffers", config.wal_buffers.clone());
399 check_param!(
400 "checkpoint_completion_target",
401 config.checkpoint_completion_target.map(|v| v.to_string())
402 );
403
404 let current_port = get_config_value(ssh, "port");
406 let desired_port = config.port.to_string();
407 if current_port.as_deref() != Some(desired_port.as_str()) {
408 let change_type = if current_port.is_some() {
409 ChangeType::Modify
410 } else {
411 ChangeType::Add
412 };
413 diff.add_change(ConfigChange {
414 parameter: "port".to_string(),
415 current: current_port,
416 desired: desired_port,
417 change_type,
418 });
419 }
420
421 let current_listen = get_config_value(ssh, "listen_addresses");
423 if current_listen.as_deref() != Some(&config.listen_addresses) {
424 let change_type = if current_listen.is_some() {
425 ChangeType::Modify
426 } else {
427 ChangeType::Add
428 };
429 diff.add_change(ConfigChange {
430 parameter: "listen_addresses".to_string(),
431 current: current_listen,
432 desired: config.listen_addresses.clone(),
433 change_type,
434 });
435 }
436
437 let current_ssl = get_config_value(ssh, "ssl");
439 let desired_ssl = if config.ssl { "on" } else { "off" }.to_string();
440 if current_ssl.as_deref() != Some(desired_ssl.as_str()) {
441 let change_type = if current_ssl.is_some() {
442 ChangeType::Modify
443 } else {
444 ChangeType::Add
445 };
446 diff.add_change(ConfigChange {
447 parameter: "ssl".to_string(),
448 current: current_ssl,
449 desired: desired_ssl,
450 change_type,
451 });
452 }
453
454 for (key, desired_value) in &config.extra_config {
456 let current = get_config_value(ssh, key);
457 if current.as_deref() != Some(desired_value.as_str()) {
458 let change_type = if current.is_some() {
459 ChangeType::Modify
460 } else {
461 ChangeType::Add
462 };
463 diff.add_change(ConfigChange {
464 parameter: key.clone(),
465 current,
466 desired: desired_value.clone(),
467 change_type,
468 });
469 }
470 }
471
472 debug!("Diff detection complete: {} changes", diff.len());
473 Ok(diff)
474}
475
476pub async fn test_connection(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
483 debug!("Testing database connection");
484
485 let test_cmd = format!(
486 r#"PGPASSWORD='{}' psql -U {} -h localhost -p {} -d {} -c '\l' > /dev/null 2>&1"#,
487 config.password, config.username, config.port, config.database_name
488 );
489
490 ssh.execute(&test_cmd)
491 .map_err(|e| Error::ConnectionTest(format!("Connection test failed: {}", e)))?;
492
493 info!("Database connection test successful");
494 Ok(())
495}
496
497async fn verify_service_running(ssh: &mut SshClient) -> Result<()> {
499 let status = ssh
500 .execute("systemctl is-active postgresql")
501 .map_err(|e| Error::ServiceError(format!("Failed to check service status: {}", e)))?;
502
503 if status.stdout.trim() != "active" {
504 return Err(Error::ServiceError(
505 "PostgreSQL service is not active".to_string(),
506 ));
507 }
508
509 debug!("PostgreSQL service is active");
510 Ok(())
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516
517 #[test]
521 fn test_config_paths() {
522 let config = PostgresConfig::builder()
523 .version("15")
524 .database_name("test")
525 .username("user")
526 .password("pass")
527 .build()
528 .unwrap();
529
530 assert_eq!(config.config_dir(), "/etc/postgresql/15/main");
531 assert_eq!(
532 config.postgresql_conf_path(),
533 "/etc/postgresql/15/main/postgresql.conf"
534 );
535 }
536}