/ src / deploy.rs
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  }