// SPDX-FileCopyrightText: 2020 Serokell
// SPDX-FileCopyrightText: 2020 Andreas Fuchs
// SPDX-FileCopyrightText: 2021 Yannik Sander
//
// SPDX-License-Identifier: MPL-2.0
use signal_hook::{consts::signal::SIGHUP, iterator::Signals};
use clap::Clap;
use tokio::fs;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::timeout;
use std::time::Duration;
use std::env;
use std::path::{Path, PathBuf};
use notify::{recommended_watcher, RecommendedWatcher, RecursiveMode, Watcher};
use thiserror::Error;
use log::{debug, error, info, warn};
/// Remote activation utility for deploy-rs
#[derive(Clap, Debug)]
#[clap(version = "1.0", author = "Serokell ")]
struct Opts {
/// Print debug logs to output
#[clap(short, long)]
debug_logs: bool,
/// Directory to print logs to
#[clap(long)]
log_dir: Option,
#[clap(subcommand)]
subcmd: SubCommand,
}
#[derive(Clap, Debug)]
enum SubCommand {
Activate(ActivateOpts),
Wait(WaitOpts),
Revoke(RevokeOpts),
}
/// Activate a profile
#[derive(Clap, Debug)]
#[clap(group(
clap::ArgGroup::new("profile")
.required(true)
.multiple(false)
.args(&["profile-path","profile-user"])
))]
struct ActivateOpts {
/// The closure to activate
closure: String,
/// The profile path to install into
#[clap(long)]
profile_path: Option,
/// The profile user if explicit profile path is not specified
#[clap(long, requires = "profile-name")]
profile_user: Option,
/// The profile name
#[clap(long, requires = "profile-user")]
profile_name: Option,
/// Maximum time to wait for confirmation after activation
#[clap(long)]
confirm_timeout: u16,
/// Wait for confirmation after deployment and rollback if not confirmed
#[clap(long)]
magic_rollback: bool,
/// Auto rollback if failure
#[clap(long)]
auto_rollback: bool,
/// Show what will be activated on the machines
#[clap(long)]
dry_activate: bool,
/// Don't activate, but update the boot loader to boot into the new profile
#[clap(long)]
boot: bool,
/// Path for any temporary files that may be needed during activation
#[clap(long)]
temp_path: PathBuf,
}
/// Wait for profile activation
#[derive(Clap, Debug)]
struct WaitOpts {
/// The closure to wait for
closure: String,
/// Path for any temporary files that may be needed during activation
#[clap(long)]
temp_path: PathBuf,
/// Timeout to wait for activation
#[clap(long)]
activation_timeout: Option,
}
/// Revoke profile activation
#[derive(Clap, Debug)]
struct RevokeOpts {
/// The profile path to install into
#[clap(long)]
profile_path: Option,
/// The profile user if explicit profile path is not specified
#[clap(long, requires = "profile-name")]
profile_user: Option,
/// The profile name
#[clap(long, requires = "profile-user")]
profile_name: Option,
}
#[derive(Error, Debug)]
pub enum DeactivateError {
#[error("Failed to execute the rollback command: {0}")]
Rollback(std::io::Error),
#[error("The rollback resulted in a bad exit code: {0:?}")]
RollbackExit(Option),
#[error("Failed to run command for listing generations: {0}")]
ListGen(std::io::Error),
#[error("Command for listing generations resulted in a bad exit code: {0:?}")]
ListGenExit(Option),
#[error("Error converting generation list output to utf8: {0}")]
DecodeListGenUtf8(std::string::FromUtf8Error),
#[error("Failed to run command for deleting generation: {0}")]
DeleteGen(std::io::Error),
#[error("Command for deleting generations resulted in a bad exit code: {0:?}")]
DeleteGenExit(Option),
#[error("Failed to run command for re-activating the last generation: {0}")]
Reactivate(std::io::Error),
#[error("Command for re-activating the last generation resulted in a bad exit code: {0:?}")]
ReactivateExit(Option),
}
pub async fn deactivate(profile_path: &str) -> Result<(), DeactivateError> {
warn!("De-activating due to error");
let nix_env_rollback_exit_status = Command::new("nix-env")
.arg("-p")
.arg(&profile_path)
.arg("--rollback")
.status()
.await
.map_err(DeactivateError::Rollback)?;
match nix_env_rollback_exit_status.code() {
Some(0) => (),
a => return Err(DeactivateError::RollbackExit(a)),
};
debug!("Listing generations");
let nix_env_list_generations_out = Command::new("nix-env")
.arg("-p")
.arg(&profile_path)
.arg("--list-generations")
.output()
.await
.map_err(DeactivateError::ListGen)?;
match nix_env_list_generations_out.status.code() {
Some(0) => (),
a => return Err(DeactivateError::ListGenExit(a)),
};
let generations_list = String::from_utf8(nix_env_list_generations_out.stdout)
.map_err(DeactivateError::DecodeListGenUtf8)?;
let last_generation_line = generations_list
.lines()
.last()
.expect("Expected to find a generation in list");
let last_generation_id = last_generation_line
.split_whitespace()
.next()
.expect("Expected to get ID from generation entry");
debug!("Removing generation entry {}", last_generation_line);
warn!("Removing generation by ID {}", last_generation_id);
let nix_env_delete_generation_exit_status = Command::new("nix-env")
.arg("-p")
.arg(&profile_path)
.arg("--delete-generations")
.arg(last_generation_id)
.status()
.await
.map_err(DeactivateError::DeleteGen)?;
match nix_env_delete_generation_exit_status.code() {
Some(0) => (),
a => return Err(DeactivateError::DeleteGenExit(a)),
};
info!("Attempting to re-activate the last generation");
let re_activate_exit_status = Command::new(format!("{}/deploy-rs-activate", profile_path))
.env("PROFILE", &profile_path)
.current_dir(&profile_path)
.status()
.await
.map_err(DeactivateError::Reactivate)?;
match re_activate_exit_status.code() {
Some(0) => (),
a => return Err(DeactivateError::ReactivateExit(a)),
};
Ok(())
}
#[derive(Error, Debug)]
pub enum ActivationConfirmationError {
#[error("Failed to create activation confirmation directory: {0}")]
CreateConfirmDir(std::io::Error),
#[error("Failed to create activation confirmation file: {0}")]
CreateConfirmFile(std::io::Error),
#[error("Could not watch for activation sentinel: {0}")]
Watcher(#[from] notify::Error),
}
#[derive(Error, Debug)]
pub enum DangerZoneError {
#[error("Timeout elapsed for confirmation")]
TimesUp,
#[error("inotify stream ended without activation confirmation")]
NoConfirmation,
#[error("inotify encountered an error: {0}")]
Watch(notify::Error),
}
async fn danger_zone(
mut events: mpsc::Receiver>,
confirm_timeout: u16,
) -> Result<(), DangerZoneError> {
info!("Waiting for confirmation event...");
match timeout(Duration::from_secs(confirm_timeout as u64), events.recv()).await {
Ok(Some(Ok(()))) => Ok(()),
Ok(Some(Err(e))) => Err(DangerZoneError::Watch(e)),
Ok(None) => Err(DangerZoneError::NoConfirmation),
Err(_) => Err(DangerZoneError::TimesUp),
}
}
pub async fn activation_confirmation(
profile_path: String,
temp_path: PathBuf,
confirm_timeout: u16,
closure: String,
) -> Result<(), ActivationConfirmationError> {
let lock_path = deploy::make_lock_path(&temp_path, &closure);
debug!("Ensuring parent directory exists for canary file");
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(ActivationConfirmationError::CreateConfirmDir)?;
}
debug!("Creating canary file");
fs::File::create(&lock_path)
.await
.map_err(ActivationConfirmationError::CreateConfirmFile)?;
debug!("Creating notify watcher");
let (deleted, done) = mpsc::channel(1);
let mut watcher: RecommendedWatcher =
recommended_watcher(move |res: Result| {
let send_result = match res {
Ok(e) if e.kind == notify::EventKind::Remove(notify::event::RemoveKind::File) => {
debug!("Got worthy removal event, sending on channel");
deleted.try_send(Ok(()))
}
Err(e) => {
debug!("Got error waiting for removal event, sending on channel");
deleted.try_send(Err(e))
}
Ok(_) => Ok(()), // ignore non-removal events
};
if let Err(e) = send_result {
error!("Could not send file system event to watcher: {}", e);
}
})?;
watcher.watch(&lock_path, RecursiveMode::NonRecursive)?;
if let Err(err) = danger_zone(done, confirm_timeout).await {
error!("Error waiting for confirmation event: {}", err);
if let Err(err) = deactivate(&profile_path).await {
error!(
"Error de-activating due to another error waiting for confirmation, oh no...: {}",
err
);
}
}
Ok(())
}
#[derive(Error, Debug)]
pub enum WaitError {
#[error("Error creating watcher for activation: {0}")]
Watcher(#[from] notify::Error),
#[error("Error waiting for activation: {0}")]
Waiting(#[from] DangerZoneError),
}
pub async fn wait(temp_path: PathBuf, closure: String, activation_timeout: Option) -> Result<(), WaitError> {
let lock_path = deploy::make_lock_path(&temp_path, &closure);
let (created, done) = mpsc::channel(1);
let mut watcher: RecommendedWatcher = {
// TODO: fix wasteful clone
let lock_path = lock_path.clone();
recommended_watcher(move |res: Result| {
let send_result = match res {
Ok(e) if e.kind == notify::EventKind::Create(notify::event::CreateKind::File) => {
match &e.paths[..] {
[x] => match lock_path.canonicalize() {
// 'lock_path' may not exist yet when some other files are created in 'temp_path'
// x is already supposed to be canonical path
Ok(lock_path) if x == &lock_path => created.try_send(Ok(())),
_ => Ok(()),
},
_ => Ok(()),
}
}
Err(e) => created.try_send(Err(e)),
Ok(_) => Ok(()), // ignore non-removal events
};
if let Err(e) = send_result {
error!("Could not send file system event to watcher: {}", e);
}
})?
};
watcher.watch(&temp_path, RecursiveMode::NonRecursive)?;
// Avoid a potential race condition by checking for existence after watcher creation
if fs::metadata(&lock_path).await.is_ok() {
watcher.unwatch(&temp_path)?;
return Ok(());
}
danger_zone(done, activation_timeout.unwrap_or(240)).await?;
info!("Found canary file, done waiting!");
Ok(())
}
#[derive(Error, Debug)]
pub enum ActivateError {
#[error("Failed to execute the command for setting profile: {0}")]
SetProfile(std::io::Error),
#[error("The command for setting profile resulted in a bad exit code: {0:?}")]
SetProfileExit(Option),
#[error("Failed to execute the activation script: {0}")]
RunActivate(std::io::Error),
#[error("The activation script resulted in a bad exit code: {0:?}")]
RunActivateExit(Option),
#[error("There was an error de-activating after an error was encountered: {0}")]
Deactivate(#[from] DeactivateError),
#[error("Failed to get activation confirmation: {0}")]
ActivationConfirmation(#[from] ActivationConfirmationError),
}
pub async fn activate(
profile_path: String,
closure: String,
auto_rollback: bool,
temp_path: PathBuf,
confirm_timeout: u16,
magic_rollback: bool,
dry_activate: bool,
boot: bool,
) -> Result<(), ActivateError> {
if !dry_activate {
info!("Activating profile");
let nix_env_set_exit_status = Command::new("nix-env")
.arg("-p")
.arg(&profile_path)
.arg("--set")
.arg(&closure)
.status()
.await
.map_err(ActivateError::SetProfile)?;
match nix_env_set_exit_status.code() {
Some(0) => (),
a => {
if auto_rollback && !dry_activate {
deactivate(&profile_path).await?;
}
return Err(ActivateError::SetProfileExit(a));
}
};
}
debug!("Running activation script");
let activation_location = if dry_activate {
&closure
} else {
&profile_path
};
let activate_status = match Command::new(format!("{}/deploy-rs-activate", activation_location))
.env("PROFILE", activation_location)
.env("DRY_ACTIVATE", if dry_activate { "1" } else { "0" })
.env("BOOT", if boot { "1" } else { "0" })
.current_dir(activation_location)
.status()
.await
.map_err(ActivateError::RunActivate)
{
Ok(x) => x,
Err(e) => {
if auto_rollback && !dry_activate {
deactivate(&profile_path).await?;
}
return Err(e);
}
};
if !dry_activate {
match activate_status.code() {
Some(0) => (),
a => {
if auto_rollback {
deactivate(&profile_path).await?;
}
return Err(ActivateError::RunActivateExit(a));
}
};
if !dry_activate {
info!("Activation succeeded!");
}
if magic_rollback && !boot {
info!("Magic rollback is enabled, setting up confirmation hook...");
match activation_confirmation(profile_path.clone(), temp_path, confirm_timeout, closure)
.await
{
Ok(()) => {}
Err(err) => {
deactivate(&profile_path).await?;
return Err(ActivateError::ActivationConfirmation(err));
}
};
}
}
Ok(())
}
async fn revoke(profile_path: String) -> Result<(), DeactivateError> {
deactivate(profile_path.as_str()).await?;
Ok(())
}
#[derive(Error, Debug)]
pub enum GetProfilePathError {
#[error("Failed to deduce HOME directory for user {0}")]
NoUserHome(String),
}
fn get_profile_path(
profile_path: Option,
profile_user: Option,
profile_name: Option,
) -> Result {
match (profile_path, profile_user, profile_name) {
(Some(profile_path), None, None) => Ok(profile_path),
(None, Some(profile_user), Some(profile_name)) => {
let nix_state_dir = env::var("NIX_STATE_DIR").unwrap_or("/nix/var/nix".to_string());
// As per https://nixos.org/manual/nix/stable/command-ref/files/profiles#profiles
match &profile_user[..] {
"root" => {
match &profile_name[..] {
// NixOS system profile belongs to the root user, but isn't stored in the 'per-user/root'
"system" => Ok(format!("{}/profiles/system", nix_state_dir)),
_ => Ok(format!(
"{}/profiles/per-user/root/{}",
nix_state_dir, profile_name
)),
}
}
_ => {
let old_user_profiles_dir =
format!("{}/profiles/per-user/{}", nix_state_dir, profile_user);
// To stay backward compatible
if Path::new(&old_user_profiles_dir).exists() {
Ok(format!("{}/{}", old_user_profiles_dir, profile_name))
} else {
// https://github.com/NixOS/nix/blob/2.17.0/src/libstore/profiles.cc#L308
// This is basically the equivalent of calling 'dirs::state_dir()'.
// However, this function returns 'None' on macOS, while nix will actually
// check env variables, so we imitate nix implementation below instead of
// using 'dirs::state_dir()' directly.
let state_dir = env::var("XDG_STATE_HOME").or_else(|_| {
dirs::home_dir()
.map(|h| {
format!("{}/.local/state", h.as_path().display().to_string())
})
.ok_or(GetProfilePathError::NoUserHome(profile_user))
})?;
Ok(format!("{}/nix/profiles/{}", state_dir, profile_name))
}
}
}
}
_ => panic!("impossible"),
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
// Ensure that this process stays alive after the SSH connection dies
let mut signals = Signals::new(&[SIGHUP])?;
std::thread::spawn(move || {
for _ in signals.forever() {
println!("Received SIGHUP - ignoring...");
}
});
let opts: Opts = Opts::parse();
deploy::init_logger(
opts.debug_logs,
opts.log_dir.as_deref(),
&match opts.subcmd {
SubCommand::Activate(_) => deploy::LoggerType::Activate,
SubCommand::Wait(_) => deploy::LoggerType::Wait,
SubCommand::Revoke(_) => deploy::LoggerType::Revoke,
},
)?;
let r = match opts.subcmd {
SubCommand::Activate(activate_opts) => activate(
get_profile_path(
activate_opts.profile_path,
activate_opts.profile_user,
activate_opts.profile_name,
)?,
activate_opts.closure,
activate_opts.auto_rollback,
activate_opts.temp_path,
activate_opts.confirm_timeout,
activate_opts.magic_rollback,
activate_opts.dry_activate,
activate_opts.boot,
)
.await
.map_err(|x| Box::new(x) as Box),
SubCommand::Wait(wait_opts) => wait(wait_opts.temp_path, wait_opts.closure, wait_opts.activation_timeout)
.await
.map_err(|x| Box::new(x) as Box),
SubCommand::Revoke(revoke_opts) => revoke(get_profile_path(
revoke_opts.profile_path,
revoke_opts.profile_user,
revoke_opts.profile_name,
)?)
.await
.map_err(|x| Box::new(x) as Box),
};
match r {
Ok(()) => (),
Err(err) => {
error!("{}", err);
std::process::exit(1)
}
}
Ok(())
}