deploy.rs
1 // SPDX-FileCopyrightText: 2020 Serokell <https://serokell.io/> 2 // SPDX-FileCopyrightText: 2020 Andreas Fuchs <asf@boinkor.net> 3 // SPDX-FileCopyrightText: 2021 Yannik Sander <contact@ysndr.de> 4 // 5 // SPDX-License-Identifier: MPL-2.0 6 7 use log::{debug, info, trace}; 8 use std::path::Path; 9 use thiserror::Error; 10 use tokio::{io::AsyncWriteExt, process::Command}; 11 12 use crate::{DeployDataDefsError, DeployDefs, ProfileInfo}; 13 14 struct ActivateCommandData<'a> { 15 sudo: &'a Option<String>, 16 profile_info: &'a ProfileInfo, 17 closure: &'a str, 18 auto_rollback: bool, 19 temp_path: &'a Path, 20 confirm_timeout: u16, 21 magic_rollback: bool, 22 debug_logs: bool, 23 log_dir: Option<&'a str>, 24 dry_activate: bool, 25 boot: bool, 26 } 27 28 fn build_activate_command(data: &ActivateCommandData) -> String { 29 let mut self_activate_command = format!("{}/activate-rs", data.closure); 30 31 if data.debug_logs { 32 self_activate_command = format!("{} --debug-logs", self_activate_command); 33 } 34 35 if let Some(log_dir) = data.log_dir { 36 self_activate_command = format!("{} --log-dir {}", self_activate_command, log_dir); 37 } 38 39 self_activate_command = format!( 40 "{} activate '{}' {} --temp-path '{}'", 41 self_activate_command, 42 data.closure, 43 match data.profile_info { 44 ProfileInfo::ProfilePath { profile_path } => 45 format!("--profile-path '{}'", profile_path), 46 ProfileInfo::ProfileUserAndName { 47 profile_user, 48 profile_name, 49 } => format!( 50 "--profile-user {} --profile-name {}", 51 profile_user, profile_name 52 ), 53 }, 54 data.temp_path.display() 55 ); 56 57 self_activate_command = format!( 58 "{} --confirm-timeout {}", 59 self_activate_command, data.confirm_timeout 60 ); 61 62 if data.magic_rollback { 63 self_activate_command = format!("{} --magic-rollback", self_activate_command); 64 } 65 66 if data.auto_rollback { 67 self_activate_command = format!("{} --auto-rollback", self_activate_command); 68 } 69 70 if data.dry_activate { 71 self_activate_command = format!("{} --dry-activate", self_activate_command); 72 } 73 74 if data.boot { 75 self_activate_command = format!("{} --boot", self_activate_command); 76 } 77 78 if let Some(sudo_cmd) = &data.sudo { 79 self_activate_command = format!("{} {}", sudo_cmd, self_activate_command); 80 } 81 82 self_activate_command 83 } 84 85 #[test] 86 fn test_activation_command_builder() { 87 let sudo = Some("sudo -u test".to_string()); 88 let profile_info = &ProfileInfo::ProfilePath { 89 profile_path: "/blah/profiles/test".to_string(), 90 }; 91 let closure = "/nix/store/blah/etc"; 92 let auto_rollback = true; 93 let dry_activate = false; 94 let boot = false; 95 let temp_path = Path::new("/tmp"); 96 let confirm_timeout = 30; 97 let magic_rollback = true; 98 let debug_logs = true; 99 let log_dir = Some("/tmp/something.txt"); 100 101 assert_eq!( 102 build_activate_command(&ActivateCommandData { 103 sudo: &sudo, 104 profile_info, 105 closure, 106 auto_rollback, 107 temp_path, 108 confirm_timeout, 109 magic_rollback, 110 debug_logs, 111 log_dir, 112 dry_activate, 113 boot, 114 }), 115 "sudo -u test /nix/store/blah/etc/activate-rs --debug-logs --log-dir /tmp/something.txt activate '/nix/store/blah/etc' --profile-path '/blah/profiles/test' --temp-path '/tmp' --confirm-timeout 30 --magic-rollback --auto-rollback" 116 .to_string(), 117 ); 118 } 119 120 struct WaitCommandData<'a> { 121 sudo: &'a Option<String>, 122 closure: &'a str, 123 temp_path: &'a Path, 124 activation_timeout: Option<u16>, 125 debug_logs: bool, 126 log_dir: Option<&'a str>, 127 } 128 129 fn build_wait_command(data: &WaitCommandData) -> String { 130 let mut self_activate_command = format!("{}/activate-rs", data.closure); 131 132 if data.debug_logs { 133 self_activate_command = format!("{} --debug-logs", self_activate_command); 134 } 135 136 if let Some(log_dir) = data.log_dir { 137 self_activate_command = format!("{} --log-dir {}", self_activate_command, log_dir); 138 } 139 140 self_activate_command = format!( 141 "{} wait '{}' --temp-path '{}'", 142 self_activate_command, 143 data.closure, 144 data.temp_path.display(), 145 ); 146 if let Some(activation_timeout) = data.activation_timeout { 147 self_activate_command = format!("{} --activation-timeout {}", self_activate_command, activation_timeout); 148 } 149 150 if let Some(sudo_cmd) = &data.sudo { 151 self_activate_command = format!("{} {}", sudo_cmd, self_activate_command); 152 } 153 154 self_activate_command 155 } 156 157 #[test] 158 fn test_wait_command_builder() { 159 let sudo = Some("sudo -u test".to_string()); 160 let closure = "/nix/store/blah/etc"; 161 let temp_path = Path::new("/tmp"); 162 let activation_timeout = Some(600); 163 let debug_logs = true; 164 let log_dir = Some("/tmp/something.txt"); 165 166 assert_eq!( 167 build_wait_command(&WaitCommandData { 168 sudo: &sudo, 169 closure, 170 temp_path, 171 activation_timeout, 172 debug_logs, 173 log_dir 174 }), 175 "sudo -u test /nix/store/blah/etc/activate-rs --debug-logs --log-dir /tmp/something.txt wait '/nix/store/blah/etc' --temp-path '/tmp' --activation-timeout 600" 176 .to_string(), 177 ); 178 } 179 180 struct RevokeCommandData<'a> { 181 sudo: &'a Option<String>, 182 closure: &'a str, 183 profile_info: ProfileInfo, 184 debug_logs: bool, 185 log_dir: Option<&'a str>, 186 } 187 188 fn build_revoke_command(data: &RevokeCommandData) -> String { 189 let mut self_activate_command = format!("{}/activate-rs", data.closure); 190 191 if data.debug_logs { 192 self_activate_command = format!("{} --debug-logs", self_activate_command); 193 } 194 195 if let Some(log_dir) = data.log_dir { 196 self_activate_command = format!("{} --log-dir {}", self_activate_command, log_dir); 197 } 198 199 self_activate_command = format!( 200 "{} revoke {}", 201 self_activate_command, 202 match &data.profile_info { 203 ProfileInfo::ProfilePath { profile_path } => 204 format!("--profile-path '{}'", profile_path), 205 ProfileInfo::ProfileUserAndName { 206 profile_user, 207 profile_name, 208 } => format!( 209 "--profile-user {} --profile-name {}", 210 profile_user, profile_name 211 ), 212 } 213 ); 214 215 if let Some(sudo_cmd) = &data.sudo { 216 self_activate_command = format!("{} {}", sudo_cmd, self_activate_command); 217 } 218 219 self_activate_command 220 } 221 222 #[test] 223 fn test_revoke_command_builder() { 224 let sudo = Some("sudo -u test".to_string()); 225 let closure = "/nix/store/blah/etc"; 226 let profile_info = ProfileInfo::ProfilePath { 227 profile_path: "/nix/var/nix/per-user/user/profile".to_string(), 228 }; 229 let debug_logs = true; 230 let log_dir = Some("/tmp/something.txt"); 231 232 assert_eq!( 233 build_revoke_command(&RevokeCommandData { 234 sudo: &sudo, 235 closure, 236 profile_info, 237 debug_logs, 238 log_dir 239 }), 240 "sudo -u test /nix/store/blah/etc/activate-rs --debug-logs --log-dir /tmp/something.txt revoke --profile-path '/nix/var/nix/per-user/user/profile'" 241 .to_string(), 242 ); 243 } 244 245 async fn handle_sudo_stdin(ssh_activate_child: &mut tokio::process::Child, deploy_defs: &DeployDefs) -> Result<(), std::io::Error> { 246 match ssh_activate_child.stdin.as_mut() { 247 Some(stdin) => { 248 let _ = stdin.write_all(format!("{}\n",deploy_defs.sudo_password.clone().unwrap_or("".to_string())).as_bytes()).await; 249 Ok(()) 250 } 251 None => { 252 Err( 253 std::io::Error::new( 254 std::io::ErrorKind::Other, 255 "Failed to open stdin for sudo command", 256 ) 257 ) 258 } 259 } 260 } 261 262 #[derive(Error, Debug)] 263 pub enum ConfirmProfileError { 264 #[error("Failed to run confirmation command over SSH (the server should roll back): {0}")] 265 SSHConfirm(std::io::Error), 266 #[error( 267 "Confirming activation over SSH resulted in a bad exit code (the server should roll back): {0:?}" 268 )] 269 SSHConfirmExit(Option<i32>), 270 } 271 272 pub async fn confirm_profile( 273 deploy_data: &super::DeployData<'_>, 274 deploy_defs: &super::DeployDefs, 275 temp_path: &Path, 276 ssh_addr: &str, 277 ) -> Result<(), ConfirmProfileError> { 278 let mut ssh_confirm_command = Command::new("ssh"); 279 ssh_confirm_command 280 .arg(ssh_addr) 281 .stdin(std::process::Stdio::piped()); 282 283 for ssh_opt in &deploy_data.merged_settings.ssh_opts { 284 ssh_confirm_command.arg(ssh_opt); 285 } 286 287 let lock_path = super::make_lock_path(temp_path, &deploy_data.profile.profile_settings.path); 288 289 let mut confirm_command = format!("rm {}", lock_path.display()); 290 if let Some(sudo_cmd) = &deploy_defs.sudo { 291 confirm_command = format!("{} {}", sudo_cmd, confirm_command); 292 } 293 294 debug!( 295 "Attempting to run command to confirm deployment: {}", 296 confirm_command 297 ); 298 299 let mut ssh_confirm_child = ssh_confirm_command 300 .arg(confirm_command) 301 .spawn() 302 .map_err(ConfirmProfileError::SSHConfirm)?; 303 304 if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { 305 trace!("[confirm] Piping in sudo password"); 306 handle_sudo_stdin(&mut ssh_confirm_child, deploy_defs) 307 .await 308 .map_err(ConfirmProfileError::SSHConfirm)?; 309 } 310 311 let ssh_confirm_exit_status = ssh_confirm_child 312 .wait() 313 .await 314 .map_err(ConfirmProfileError::SSHConfirm)?; 315 316 match ssh_confirm_exit_status.code() { 317 Some(0) => (), 318 a => return Err(ConfirmProfileError::SSHConfirmExit(a)), 319 }; 320 321 info!("Deployment confirmed."); 322 323 Ok(()) 324 } 325 326 #[derive(Error, Debug)] 327 pub enum DeployProfileError { 328 #[error("Failed to spawn activation command over SSH: {0}")] 329 SSHSpawnActivate(std::io::Error), 330 331 #[error("Failed to run activation command over SSH: {0}")] 332 SSHActivate(std::io::Error), 333 #[error("Activating over SSH resulted in a bad exit code: {0:?}")] 334 SSHActivateExit(Option<i32>), 335 #[error("Activating over SSH resulted in a bad exit code: {0:?}")] 336 SSHActivateTimeout(tokio::sync::oneshot::error::RecvError), 337 338 #[error("Failed to run wait command over SSH: {0}")] 339 SSHWait(std::io::Error), 340 #[error("Waiting over SSH resulted in a bad exit code: {0:?}")] 341 SSHWaitExit(Option<i32>), 342 343 #[error("Failed to pipe to child stdin: {0}")] 344 SSHActivatePipe(std::io::Error), 345 346 #[error("Error confirming deployment: {0}")] 347 Confirm(#[from] ConfirmProfileError), 348 #[error("Deployment data invalid: {0}")] 349 InvalidDeployDataDefs(#[from] DeployDataDefsError), 350 } 351 352 pub async fn deploy_profile( 353 deploy_data: &super::DeployData<'_>, 354 deploy_defs: &super::DeployDefs, 355 dry_activate: bool, 356 boot: bool, 357 ) -> Result<(), DeployProfileError> { 358 if !dry_activate { 359 info!( 360 "Activating profile `{}` for node `{}`", 361 deploy_data.profile_name, deploy_data.node_name 362 ); 363 } 364 365 let temp_path: &Path = match &deploy_data.merged_settings.temp_path { 366 Some(x) => x, 367 None => Path::new("/tmp"), 368 }; 369 370 let confirm_timeout = deploy_data.merged_settings.confirm_timeout.unwrap_or(30); 371 372 let activation_timeout = deploy_data.merged_settings.activation_timeout; 373 374 let magic_rollback = deploy_data.merged_settings.magic_rollback.unwrap_or(true); 375 376 let auto_rollback = deploy_data.merged_settings.auto_rollback.unwrap_or(true); 377 378 let self_activate_command = build_activate_command(&ActivateCommandData { 379 sudo: &deploy_defs.sudo, 380 profile_info: &deploy_data.get_profile_info()?, 381 closure: &deploy_data.profile.profile_settings.path, 382 auto_rollback, 383 temp_path: temp_path, 384 confirm_timeout, 385 magic_rollback, 386 debug_logs: deploy_data.debug_logs, 387 log_dir: deploy_data.log_dir, 388 dry_activate, 389 boot, 390 }); 391 392 debug!("Constructed activation command: {}", self_activate_command); 393 394 let hostname = match deploy_data.cmd_overrides.hostname { 395 Some(ref x) => x, 396 None => &deploy_data.node.node_settings.hostname, 397 }; 398 399 let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname); 400 401 let mut ssh_activate_command = Command::new("ssh"); 402 ssh_activate_command 403 .arg(&ssh_addr) 404 .stdin(std::process::Stdio::piped()); 405 406 for ssh_opt in &deploy_data.merged_settings.ssh_opts { 407 ssh_activate_command.arg(&ssh_opt); 408 } 409 410 if !magic_rollback || dry_activate || boot { 411 let mut ssh_activate_child = ssh_activate_command 412 .arg(self_activate_command) 413 .spawn() 414 .map_err(DeployProfileError::SSHSpawnActivate)?; 415 416 if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { 417 trace!("[activate] Piping in sudo password"); 418 handle_sudo_stdin(&mut ssh_activate_child, deploy_defs) 419 .await 420 .map_err(DeployProfileError::SSHActivatePipe)?; 421 } 422 423 let ssh_activate_exit_status = ssh_activate_child 424 .wait() 425 .await 426 .map_err(DeployProfileError::SSHActivate)?; 427 428 match ssh_activate_exit_status.code() { 429 Some(0) => (), 430 a => return Err(DeployProfileError::SSHActivateExit(a)), 431 }; 432 433 if dry_activate { 434 info!("Completed dry-activate!"); 435 } else if boot { 436 info!("Success activating for next boot, done!"); 437 } else { 438 info!("Success activating, done!"); 439 } 440 } else { 441 let self_wait_command = build_wait_command(&WaitCommandData { 442 sudo: &deploy_defs.sudo, 443 closure: &deploy_data.profile.profile_settings.path, 444 temp_path: temp_path, 445 activation_timeout: activation_timeout, 446 debug_logs: deploy_data.debug_logs, 447 log_dir: deploy_data.log_dir, 448 }); 449 450 debug!("Constructed wait command: {}", self_wait_command); 451 452 let mut ssh_activate_child = ssh_activate_command 453 .arg(self_activate_command) 454 .spawn() 455 .map_err(DeployProfileError::SSHSpawnActivate)?; 456 457 if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { 458 trace!("[activate] Piping in sudo password"); 459 handle_sudo_stdin(&mut ssh_activate_child, deploy_defs) 460 .await 461 .map_err(DeployProfileError::SSHActivatePipe)?; 462 } 463 464 info!("Creating activation waiter"); 465 466 let mut ssh_wait_command = Command::new("ssh"); 467 ssh_wait_command 468 .arg(&ssh_addr) 469 .stdin(std::process::Stdio::piped()); 470 471 for ssh_opt in &deploy_data.merged_settings.ssh_opts { 472 ssh_wait_command.arg(ssh_opt); 473 } 474 475 let (send_activate, recv_activate) = tokio::sync::oneshot::channel(); 476 let (send_activated, recv_activated) = tokio::sync::oneshot::channel(); 477 478 let thread = tokio::spawn(async move { 479 let o = ssh_activate_child.wait_with_output().await; 480 481 let maybe_err = match o { 482 Err(x) => Some(DeployProfileError::SSHActivate(x)), 483 Ok(ref x) => match x.status.code() { 484 Some(0) => None, 485 a => Some(DeployProfileError::SSHActivateExit(a)), 486 }, 487 }; 488 489 if let Some(err) = maybe_err { 490 send_activate.send(err).unwrap(); 491 } 492 493 send_activated.send(()).unwrap(); 494 }); 495 496 let mut ssh_wait_child = ssh_wait_command 497 .arg(self_wait_command) 498 .spawn() 499 .map_err(DeployProfileError::SSHWait)?; 500 501 if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { 502 trace!("[wait] Piping in sudo password"); 503 handle_sudo_stdin(&mut ssh_wait_child, deploy_defs) 504 .await 505 .map_err(DeployProfileError::SSHActivatePipe)?; 506 } 507 508 tokio::select! { 509 x = ssh_wait_child.wait() => { 510 debug!("Wait command ended"); 511 match x.map_err(DeployProfileError::SSHWait)?.code() { 512 Some(0) => (), 513 a => return Err(DeployProfileError::SSHWaitExit(a)), 514 }; 515 }, 516 x = recv_activate => { 517 debug!("Activate command exited with an error"); 518 return Err(x.unwrap()); 519 }, 520 } 521 522 info!("Success activating, attempting to confirm activation"); 523 524 let c = confirm_profile(deploy_data, deploy_defs, temp_path, &ssh_addr).await; 525 recv_activated.await.map_err(|x| DeployProfileError::SSHActivateTimeout(x))?; 526 c?; 527 528 thread 529 .await 530 .map_err(|x| DeployProfileError::SSHActivate(x.into()))?; 531 } 532 533 Ok(()) 534 } 535 536 #[derive(Error, Debug)] 537 pub enum RevokeProfileError { 538 #[error("Failed to spawn revocation command over SSH: {0}")] 539 SSHSpawnRevoke(std::io::Error), 540 541 #[error("Error revoking deployment: {0}")] 542 SSHRevoke(std::io::Error), 543 #[error("Revoking over SSH resulted in a bad exit code: {0:?}")] 544 SSHRevokeExit(Option<i32>), 545 546 #[error("Deployment data invalid: {0}")] 547 InvalidDeployDataDefs(#[from] DeployDataDefsError), 548 } 549 pub async fn revoke( 550 deploy_data: &crate::DeployData<'_>, 551 deploy_defs: &crate::DeployDefs, 552 ) -> Result<(), RevokeProfileError> { 553 let self_revoke_command = build_revoke_command(&RevokeCommandData { 554 sudo: &deploy_defs.sudo, 555 closure: &deploy_data.profile.profile_settings.path, 556 profile_info: deploy_data.get_profile_info()?, 557 debug_logs: deploy_data.debug_logs, 558 log_dir: deploy_data.log_dir, 559 }); 560 561 debug!("Constructed revoke command: {}", self_revoke_command); 562 563 let hostname = match deploy_data.cmd_overrides.hostname { 564 Some(ref x) => x, 565 None => &deploy_data.node.node_settings.hostname, 566 }; 567 568 let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname); 569 570 let mut ssh_activate_command = Command::new("ssh"); 571 ssh_activate_command 572 .arg(&ssh_addr) 573 .stdin(std::process::Stdio::piped()); 574 575 for ssh_opt in &deploy_data.merged_settings.ssh_opts { 576 ssh_activate_command.arg(&ssh_opt); 577 } 578 579 let mut ssh_revoke_child = ssh_activate_command 580 .arg(self_revoke_command) 581 .spawn() 582 .map_err(RevokeProfileError::SSHSpawnRevoke)?; 583 584 if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) { 585 trace!("[revoke] Piping in sudo password"); 586 handle_sudo_stdin(&mut ssh_revoke_child, deploy_defs) 587 .await 588 .map_err(RevokeProfileError::SSHRevoke)?; 589 } 590 591 let result = ssh_revoke_child.wait_with_output().await; 592 593 match result { 594 Err(x) => Err(RevokeProfileError::SSHRevoke(x)), 595 Ok(ref x) => match x.status.code() { 596 Some(0) => Ok(()), 597 a => Err(RevokeProfileError::SSHRevokeExit(a)), 598 }, 599 } 600 }