diff --git a/src/cli.rs b/src/cli.rs index 61890e43..c7d68a46 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,8 +10,7 @@ use clap::{ArgMatches, Clap, FromArgMatches}; use crate as deploy; -use self::deploy::{DeployFlake, ParseFlakeError}; -use futures_util::stream::{StreamExt, TryStreamExt}; +use self::deploy::{data, settings, flake}; use log::{debug, error, info, warn}; use serde::Serialize; use std::process::Stdio; @@ -107,160 +106,6 @@ async fn test_flake_support() -> Result { .success()) } -#[derive(Error, Debug)] -pub enum CheckDeploymentError { - #[error("Failed to execute Nix checking command: {0}")] - NixCheck(#[from] std::io::Error), - #[error("Nix checking command resulted in a bad exit code: {0:?}")] - NixCheckExit(Option), -} - -async fn check_deployment( - supports_flakes: bool, - repo: &str, - extra_build_args: &[String], -) -> Result<(), CheckDeploymentError> { - info!("Running checks for flake in {}", repo); - - let mut check_command = match supports_flakes { - true => Command::new("nix"), - false => Command::new("nix-build"), - }; - - if supports_flakes { - check_command.arg("flake").arg("check").arg(repo); - } else { - check_command.arg("-E") - .arg("--no-out-link") - .arg(format!("let r = import {}/.; x = (if builtins.isFunction r then (r {{}}) else r); in if x ? checks then x.checks.${{builtins.currentSystem}} else {{}}", repo)); - } - - for extra_arg in extra_build_args { - check_command.arg(extra_arg); - } - - let check_status = check_command.status().await?; - - match check_status.code() { - Some(0) => (), - a => return Err(CheckDeploymentError::NixCheckExit(a)), - }; - - Ok(()) -} - -#[derive(Error, Debug)] -pub enum GetDeploymentDataError { - #[error("Failed to execute nix eval command: {0}")] - NixEval(std::io::Error), - #[error("Failed to read output from evaluation: {0}")] - NixEvalOut(std::io::Error), - #[error("Evaluation resulted in a bad exit code: {0:?}")] - NixEvalExit(Option), - #[error("Error converting evaluation output to utf8: {0}")] - DecodeUtf8(#[from] std::string::FromUtf8Error), - #[error("Error decoding the JSON from evaluation: {0}")] - DecodeJson(#[from] serde_json::error::Error), - #[error("Impossible happened: profile is set but node is not")] - ProfileNoNode, -} - -/// Evaluates the Nix in the given `repo` and return the processed Data from it -async fn get_deployment_data( - supports_flakes: bool, - flakes: &[deploy::DeployFlake<'_>], - extra_build_args: &[String], -) -> Result, GetDeploymentDataError> { - futures_util::stream::iter(flakes).then(|flake| async move { - - info!("Evaluating flake in {}", flake.repo); - - let mut c = if supports_flakes { - Command::new("nix") - } else { - Command::new("nix-instantiate") - }; - - if supports_flakes { - c.arg("eval") - .arg("--json") - .arg(format!("{}#deploy", flake.repo)) - // We use --apply instead of --expr so that we don't have to deal with builtins.getFlake - .arg("--apply"); - match (&flake.node, &flake.profile) { - (Some(node), Some(profile)) => { - // Ignore all nodes and all profiles but the one we're evaluating - c.arg(format!( - r#" - deploy: - (deploy // {{ - nodes = {{ - "{0}" = deploy.nodes."{0}" // {{ - profiles = {{ - inherit (deploy.nodes."{0}".profiles) "{1}"; - }}; - }}; - }}; - }}) - "#, - node, profile - )) - } - (Some(node), None) => { - // Ignore all nodes but the one we're evaluating - c.arg(format!( - r#" - deploy: - (deploy // {{ - nodes = {{ - inherit (deploy.nodes) "{}"; - }}; - }}) - "#, - node - )) - } - (None, None) => { - // We need to evaluate all profiles of all nodes anyway, so just do it strictly - c.arg("deploy: deploy") - } - (None, Some(_)) => return Err(GetDeploymentDataError::ProfileNoNode), - } - } else { - c - .arg("--strict") - .arg("--read-write-mode") - .arg("--json") - .arg("--eval") - .arg("-E") - .arg(format!("let r = import {}/.; in if builtins.isFunction r then (r {{}}).deploy else r.deploy", flake.repo)) - }; - - for extra_arg in extra_build_args { - c.arg(extra_arg); - } - - let build_child = c - .stdout(Stdio::piped()) - .spawn() - .map_err(GetDeploymentDataError::NixEval)?; - - let build_output = build_child - .wait_with_output() - .await - .map_err(GetDeploymentDataError::NixEvalOut)?; - - match build_output.status.code() { - Some(0) => (), - a => return Err(GetDeploymentDataError::NixEvalExit(a)), - }; - - let data_json = String::from_utf8(build_output.stdout)?; - - Ok(serde_json::from_str(&data_json)?) -}).try_collect().await -} - #[derive(Serialize)] struct PromptPart<'a> { user: &'a str, @@ -272,9 +117,9 @@ struct PromptPart<'a> { fn print_deployment( parts: &[( - &deploy::DeployFlake<'_>, - deploy::DeployData, - deploy::DeployDefs, + &data::Target, + data::DeployData, + data::DeployDefs, )], ) -> Result<(), toml::ser::Error> { let mut part_map: HashMap> = HashMap::new(); @@ -315,9 +160,9 @@ pub enum PromptDeploymentError { fn prompt_deployment( parts: &[( - &deploy::DeployFlake<'_>, - deploy::DeployData, - deploy::DeployDefs, + &data::Target, + data::DeployData, + data::DeployDefs, )], ) -> Result<(), PromptDeploymentError> { print_deployment(parts)?; @@ -378,7 +223,7 @@ pub enum RunDeployError { #[error("Profile was provided without a node name")] ProfileWithoutNode, #[error("Error processing deployment definitions: {0}")] - DeployDataDefs(#[from] deploy::DeployDataDefsError), + InvalidDeployDataDefs(#[from] data::DeployDataDefsError), #[error("Failed to make printable TOML of deployment: {0}")] TomlFormat(#[from] toml::ser::Error), #[error("{0}")] @@ -388,19 +233,19 @@ pub enum RunDeployError { } type ToDeploy<'a> = Vec<( - &'a deploy::DeployFlake<'a>, - &'a deploy::data::Data, - (&'a str, &'a deploy::data::Node), - (&'a str, &'a deploy::data::Profile), + &'a data::Target, + &'a settings::Root, + (&'a str, &'a settings::Node), + (&'a str, &'a settings::Profile), )>; async fn run_deploy( - deploy_flakes: Vec>, - data: Vec, + deploy_targets: Vec, + data: Vec, supports_flakes: bool, check_sigs: bool, interactive: bool, - cmd_overrides: &deploy::CmdOverrides, + cmd_overrides: &data::CmdOverrides, keep_result: bool, result_path: Option<&str>, extra_build_args: &[String], @@ -409,11 +254,11 @@ async fn run_deploy( log_dir: &Option, rollback_succeeded: bool, ) -> Result<(), RunDeployError> { - let to_deploy: ToDeploy = deploy_flakes + let to_deploy: ToDeploy = deploy_targets .iter() .zip(&data) - .map(|(deploy_flake, data)| { - let to_deploys: ToDeploy = match (&deploy_flake.node, &deploy_flake.profile) { + .map(|(deploy_target, data)| { + let to_deploys: ToDeploy = match (&deploy_target.node, &deploy_target.profile) { (Some(node_name), Some(profile_name)) => { let node = match data.nodes.get(node_name) { Some(x) => x, @@ -425,7 +270,7 @@ async fn run_deploy( }; vec![( - deploy_flake, + deploy_target, data, (node_name.as_str(), node), (profile_name.as_str(), profile), @@ -437,7 +282,7 @@ async fn run_deploy( None => return Err(RunDeployError::NodeNotFound(node_name.clone())), }; - let mut profiles_list: Vec<(&str, &deploy::data::Profile)> = Vec::new(); + let mut profiles_list: Vec<(&str, &settings::Profile)> = Vec::new(); for profile_name in [ node.node_settings.profiles_order.iter().collect(), @@ -459,14 +304,14 @@ async fn run_deploy( profiles_list .into_iter() - .map(|x| (deploy_flake, data, (node_name.as_str(), node), x)) + .map(|x| (deploy_target, data, (node_name.as_str(), node), x)) .collect() } (None, None) => { let mut l = Vec::new(); for (node_name, node) in &data.nodes { - let mut profiles_list: Vec<(&str, &deploy::data::Profile)> = Vec::new(); + let mut profiles_list: Vec<(&str, &settings::Profile)> = Vec::new(); for profile_name in [ node.node_settings.profiles_order.iter().collect(), @@ -490,7 +335,7 @@ async fn run_deploy( let ll: ToDeploy = profiles_list .into_iter() - .map(|x| (deploy_flake, data, (node_name.as_str(), node), x)) + .map(|x| (deploy_target, data, (node_name.as_str(), node), x)) .collect(); l.extend(ll); @@ -508,13 +353,13 @@ async fn run_deploy( .collect(); let mut parts: Vec<( - &deploy::DeployFlake<'_>, - deploy::DeployData, - deploy::DeployDefs, + &data::Target, + data::DeployData, + data::DeployDefs, )> = Vec::new(); - for (deploy_flake, data, (node_name, node), (profile_name, profile)) in to_deploy { - let deploy_data = deploy::make_deploy_data( + for (deploy_target, data, (node_name, node), (profile_name, profile)) in to_deploy { + let deploy_data = data::make_deploy_data( &data.generic_settings, node, node_name, @@ -527,7 +372,7 @@ async fn run_deploy( let deploy_defs = deploy_data.defs()?; - parts.push((deploy_flake, deploy_data, deploy_defs)); + parts.push((deploy_target, deploy_data, deploy_defs)); } if interactive { @@ -536,11 +381,11 @@ async fn run_deploy( print_deployment(&parts[..])?; } - for (deploy_flake, deploy_data, deploy_defs) in &parts { + for (deploy_target, deploy_data, deploy_defs) in &parts { deploy::push::push_profile(deploy::push::PushProfileData { supports_flakes, check_sigs, - repo: deploy_flake.repo, + repo: &deploy_target.repo, deploy_data, deploy_defs, keep_result, @@ -550,7 +395,7 @@ async fn run_deploy( .await?; } - let mut succeeded: Vec<(&deploy::DeployData, &deploy::DeployDefs)> = vec![]; + let mut succeeded: Vec<(&data::DeployData, &data::DeployDefs)> = vec![]; // Run all deployments // In case of an error rollback any previoulsy made deployment. @@ -591,11 +436,11 @@ pub enum RunError { #[error("Failed to test for flake support: {0}")] FlakeTest(std::io::Error), #[error("Failed to check deployment: {0}")] - CheckDeployment(#[from] CheckDeploymentError), + CheckDeployment(#[from] flake::CheckDeploymentError), #[error("Failed to evaluate deployment data: {0}")] - GetDeploymentData(#[from] GetDeploymentDataError), + GetDeploymentData(#[from] flake::GetDeploymentDataError), #[error("Error parsing flake: {0}")] - ParseFlake(#[from] deploy::ParseFlakeError), + ParseFlake(#[from] data::ParseTargetError), #[error("Error initiating logger: {0}")] Logger(#[from] flexi_logger::FlexiLoggerError), #[error("{0}")] @@ -619,12 +464,12 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> { .targets .unwrap_or_else(|| vec![opts.clone().target.unwrap_or_else(|| ".".to_string())]); - let deploy_flakes: Vec = deploys + let deploy_targets: Vec = deploys .iter() - .map(|f| deploy::parse_flake(f.as_str())) - .collect::, ParseFlakeError>>()?; + .map(|f| f.parse::()) + .collect::, data::ParseTargetError>>()?; - let cmd_overrides = deploy::CmdOverrides { + let cmd_overrides = data::CmdOverrides { ssh_user: opts.ssh_user, profile_user: opts.profile_user, ssh_opts: opts.ssh_opts, @@ -644,14 +489,14 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> { } if !opts.skip_checks { - for deploy_flake in &deploy_flakes { - check_deployment(supports_flakes, deploy_flake.repo, &opts.extra_build_args).await?; + for deploy_target in deploy_targets.iter() { + flake::check_deployment(supports_flakes, &deploy_target.repo, &opts.extra_build_args).await?; } } let result_path = opts.result_path.as_deref(); - let data = get_deployment_data(supports_flakes, &deploy_flakes, &opts.extra_build_args).await?; + let data = flake::get_deployment_data(supports_flakes, &deploy_targets, &opts.extra_build_args).await?; run_deploy( - deploy_flakes, + deploy_targets, data, supports_flakes, opts.checksigs, diff --git a/src/data.rs b/src/data.rs index 6fe7f75f..9de663ef 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,73 +1,303 @@ // SPDX-FileCopyrightText: 2020 Serokell +// SPDX-FileCopyrightText: 2021 Yannik Sander // // SPDX-License-Identifier: MPL-2.0 +use rnix::{types::*, SyntaxKind::*}; use merge::Merge; -use serde::Deserialize; -use std::collections::HashMap; +use thiserror::Error; -#[derive(Deserialize, Debug, Clone, Merge)] -pub struct GenericSettings { - #[serde(rename(deserialize = "sshUser"))] +use crate::settings; + +#[derive(PartialEq, Debug)] +pub struct Target { + pub repo: String, + pub node: Option, + pub profile: Option, +} + +#[derive(Error, Debug)] +pub enum ParseTargetError { + #[error("The given path was too long, did you mean to put something in quotes?")] + PathTooLong, + #[error("Unrecognized node or token encountered")] + Unrecognized, +} +impl std::str::FromStr for Target { + type Err = ParseTargetError; + + fn from_str(s: &str) -> Result { + let flake_fragment_start = s.find('#'); + let (repo, maybe_fragment) = match flake_fragment_start { + Some(i) => (s[..i].to_string(), Some(&s[i + 1..])), + None => (s.to_string(), None), + }; + + let mut node: Option = None; + let mut profile: Option = None; + + if let Some(fragment) = maybe_fragment { + let ast = rnix::parse(fragment); + + let first_child = match ast.root().node().first_child() { + Some(x) => x, + None => { + return Ok(Target { + repo, + node: None, + profile: None, + }) + } + }; + + let mut node_over = false; + + for entry in first_child.children_with_tokens() { + let x: Option = match (entry.kind(), node_over) { + (TOKEN_DOT, false) => { + node_over = true; + None + } + (TOKEN_DOT, true) => { + return Err(ParseTargetError::PathTooLong); + } + (NODE_IDENT, _) => Some(entry.into_node().unwrap().text().to_string()), + (TOKEN_IDENT, _) => Some(entry.into_token().unwrap().text().to_string()), + (NODE_STRING, _) => { + let c = entry + .into_node() + .unwrap() + .children_with_tokens() + .nth(1) + .unwrap(); + + Some(c.into_token().unwrap().text().to_string()) + } + _ => return Err(ParseTargetError::Unrecognized), + }; + + if !node_over { + node = x; + } else { + profile = x; + } + } + } + + Ok(Target { + repo, + node, + profile, + }) + } +} + +#[test] +fn test_deploy_target_from_str() { + assert_eq!( + "../deploy/examples/system".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: None, + profile: None, + } + ); + + assert_eq!( + "../deploy/examples/system#".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: None, + profile: None, + } + ); + + assert_eq!( + "../deploy/examples/system#computer.\"something.nix\"".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: Some("computer".to_string()), + profile: Some("something.nix".to_string()), + } + ); + + assert_eq!( + "../deploy/examples/system#\"example.com\".system".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: Some("example.com".to_string()), + profile: Some("system".to_string()), + } + ); + + assert_eq!( + "../deploy/examples/system#example".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: Some("example".to_string()), + profile: None + } + ); + + assert_eq!( + "../deploy/examples/system#example.system".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: Some("example".to_string()), + profile: Some("system".to_string()) + } + ); + + assert_eq!( + "../deploy/examples/system".parse::().unwrap(), + Target { + repo: "../deploy/examples/system".to_string(), + node: None, + profile: None, + } + ); +} + +#[derive(Debug)] +pub struct CmdOverrides { pub ssh_user: Option, - pub user: Option, - #[serde( - skip_serializing_if = "Vec::is_empty", - default, - rename(deserialize = "sshOpts") - )] - #[merge(strategy = merge::vec::append)] - pub ssh_opts: Vec, - #[serde(rename(deserialize = "fastConnection"))] + pub profile_user: Option, + pub ssh_opts: Option, pub fast_connection: Option, - #[serde(rename(deserialize = "autoRollback"))] pub auto_rollback: Option, - #[serde(rename(deserialize = "confirmTimeout"))] - pub confirm_timeout: Option, - #[serde(rename(deserialize = "tempPath"))] - pub temp_path: Option, - #[serde(rename(deserialize = "magicRollback"))] + pub hostname: Option, pub magic_rollback: Option, + pub temp_path: Option, + pub confirm_timeout: Option, + pub dry_activate: bool, } -#[derive(Deserialize, Debug, Clone)] -pub struct NodeSettings { - pub hostname: String, - pub profiles: HashMap, - #[serde( - skip_serializing_if = "Vec::is_empty", - default, - rename(deserialize = "profilesOrder") - )] - pub profiles_order: Vec, +#[derive(Debug, Clone)] +pub struct DeployData<'a> { + pub node_name: &'a str, + pub node: &'a settings::Node, + pub profile_name: &'a str, + pub profile: &'a settings::Profile, + + pub cmd_overrides: &'a CmdOverrides, + + pub merged_settings: settings::GenericSettings, + + pub debug_logs: bool, + pub log_dir: Option<&'a str>, } -#[derive(Deserialize, Debug, Clone)] -pub struct ProfileSettings { - pub path: String, - #[serde(rename(deserialize = "profilePath"))] - pub profile_path: Option, +#[derive(Debug)] +pub struct DeployDefs { + pub ssh_user: String, + pub profile_user: String, + pub profile_path: String, + pub sudo: Option, } -#[derive(Deserialize, Debug, Clone)] -pub struct Profile { - #[serde(flatten)] - pub profile_settings: ProfileSettings, - #[serde(flatten)] - pub generic_settings: GenericSettings, +#[derive(Error, Debug)] +pub enum DeployDataDefsError { + #[error("Neither `user` nor `sshUser` are set for profile {0} of node {1}")] + NoProfileUser(String, String), } -#[derive(Deserialize, Debug, Clone)] -pub struct Node { - #[serde(flatten)] - pub generic_settings: GenericSettings, - #[serde(flatten)] - pub node_settings: NodeSettings, +impl<'a> DeployData<'a> { + pub fn defs(&'a self) -> Result { + let ssh_user = match self.merged_settings.ssh_user { + Some(ref u) => u.clone(), + None => whoami::username(), + }; + + let profile_user = self.get_profile_user()?; + + let profile_path = self.get_profile_path()?; + + let sudo: Option = match self.merged_settings.user { + Some(ref user) if user != &ssh_user => Some(format!("sudo -u {}", user)), + _ => None, + }; + + Ok(DeployDefs { + ssh_user, + profile_user, + profile_path, + sudo, + }) + } + + pub fn get_profile_path(&'a self) -> Result { + let profile_user = self.get_profile_user()?; + let profile_path = match self.profile.profile_settings.profile_path { + None => match &profile_user[..] { + "root" => format!("/nix/var/nix/profiles/{}", self.profile_name), + _ => format!( + "/nix/var/nix/profiles/per-user/{}/{}", + profile_user, self.profile_name + ), + }, + Some(ref x) => x.clone(), + }; + Ok(profile_path) + } + + pub fn get_profile_user(&'a self) -> Result { + let profile_user = match self.merged_settings.user { + Some(ref x) => x.clone(), + None => match self.merged_settings.ssh_user { + Some(ref x) => x.clone(), + None => { + return Err(DeployDataDefsError::NoProfileUser( + self.profile_name.to_owned(), + self.node_name.to_owned(), + )) + } + }, + }; + Ok(profile_user) + } } -#[derive(Deserialize, Debug, Clone)] -pub struct Data { - #[serde(flatten)] - pub generic_settings: GenericSettings, - pub nodes: HashMap, +pub fn make_deploy_data<'a, 's>( + top_settings: &'s settings::GenericSettings, + node: &'a settings::Node, + node_name: &'a str, + profile: &'a settings::Profile, + profile_name: &'a str, + cmd_overrides: &'a CmdOverrides, + debug_logs: bool, + log_dir: Option<&'a str>, +) -> DeployData<'a> { + let mut merged_settings = profile.generic_settings.clone(); + merged_settings.merge(node.generic_settings.clone()); + merged_settings.merge(top_settings.clone()); + + if cmd_overrides.ssh_user.is_some() { + merged_settings.ssh_user = cmd_overrides.ssh_user.clone(); + } + if cmd_overrides.profile_user.is_some() { + merged_settings.user = cmd_overrides.profile_user.clone(); + } + if let Some(ref ssh_opts) = cmd_overrides.ssh_opts { + merged_settings.ssh_opts = ssh_opts.split(' ').map(|x| x.to_owned()).collect(); + } + if let Some(fast_connection) = cmd_overrides.fast_connection { + merged_settings.fast_connection = Some(fast_connection); + } + if let Some(auto_rollback) = cmd_overrides.auto_rollback { + merged_settings.auto_rollback = Some(auto_rollback); + } + if let Some(magic_rollback) = cmd_overrides.magic_rollback { + merged_settings.magic_rollback = Some(magic_rollback); + } + + DeployData { + node_name, + node, + profile_name, + profile, + cmd_overrides, + merged_settings, + debug_logs, + log_dir, + } } diff --git a/src/deploy.rs b/src/deploy.rs index f8fc2f90..7c1048ea 100644 --- a/src/deploy.rs +++ b/src/deploy.rs @@ -9,7 +9,7 @@ use std::borrow::Cow; use thiserror::Error; use tokio::process::Command; -use crate::DeployDataDefsError; +use crate::data; struct ActivateCommandData<'a> { sudo: &'a Option, @@ -207,8 +207,8 @@ pub enum ConfirmProfileError { } pub async fn confirm_profile( - deploy_data: &super::DeployData<'_>, - deploy_defs: &super::DeployDefs, + deploy_data: &data::DeployData<'_>, + deploy_defs: &data::DeployDefs, temp_path: Cow<'_, str>, ssh_addr: &str, ) -> Result<(), ConfirmProfileError> { @@ -267,8 +267,8 @@ pub enum DeployProfileError { } pub async fn deploy_profile( - deploy_data: &super::DeployData<'_>, - deploy_defs: &super::DeployDefs, + deploy_data: &data::DeployData<'_>, + deploy_defs: &data::DeployDefs, dry_activate: bool, ) -> Result<(), DeployProfileError> { if !dry_activate { @@ -415,11 +415,11 @@ pub enum RevokeProfileError { SSHRevokeExit(Option), #[error("Deployment data invalid: {0}")] - InvalidDeployDataDefs(#[from] DeployDataDefsError), + InvalidDeployDataDefs(#[from] data::DeployDataDefsError), } pub async fn revoke( - deploy_data: &crate::DeployData<'_>, - deploy_defs: &crate::DeployDefs, + deploy_data: &data::DeployData<'_>, + deploy_defs: &data::DeployDefs, ) -> Result<(), RevokeProfileError> { let self_revoke_command = build_revoke_command(&RevokeCommandData { sudo: &deploy_defs.sudo, diff --git a/src/flake.rs b/src/flake.rs new file mode 100644 index 00000000..22b6de23 --- /dev/null +++ b/src/flake.rs @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2020 Serokell +// SPDX-FileCopyrightText: 2021 Yannik Sander +// +// SPDX-License-Identifier: MPL-2.0 + +use crate as deploy; + +use self::deploy::{data, settings}; +use log::{error, info}; +use std::process::Stdio; +use futures_util::stream::{StreamExt, TryStreamExt}; +use thiserror::Error; +use tokio::process::Command; + +#[derive(Error, Debug)] +pub enum CheckDeploymentError { + #[error("Failed to execute Nix checking command: {0}")] + NixCheck(#[from] std::io::Error), + #[error("Nix checking command resulted in a bad exit code: {0:?}")] + NixCheckExit(Option), +} + +pub async fn check_deployment( + supports_flakes: bool, + repo: &str, + extra_build_args: &[String], +) -> Result<(), CheckDeploymentError> { + info!("Running checks for flake in {}", repo); + + let mut check_command = match supports_flakes { + true => Command::new("nix"), + false => Command::new("nix-build"), + }; + + if supports_flakes { + check_command.arg("flake").arg("check").arg(repo); + } else { + check_command.arg("-E") + .arg("--no-out-link") + .arg(format!("let r = import {}/.; x = (if builtins.isFunction r then (r {{}}) else r); in if x ? checks then x.checks.${{builtins.currentSystem}} else {{}}", repo)); + }; + + for extra_arg in extra_build_args { + check_command.arg(extra_arg); + } + + let check_status = check_command.status().await?; + + match check_status.code() { + Some(0) => (), + a => return Err(CheckDeploymentError::NixCheckExit(a)), + }; + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum GetDeploymentDataError { + #[error("Failed to execute nix eval command: {0}")] + NixEval(std::io::Error), + #[error("Failed to read output from evaluation: {0}")] + NixEvalOut(std::io::Error), + #[error("Evaluation resulted in a bad exit code: {0:?}")] + NixEvalExit(Option), + #[error("Error converting evaluation output to utf8: {0}")] + DecodeUtf8(#[from] std::string::FromUtf8Error), + #[error("Error decoding the JSON from evaluation: {0}")] + DecodeJson(#[from] serde_json::error::Error), + #[error("Impossible happened: profile is set but node is not")] + ProfileNoNode, +} + +/// Evaluates the Nix in the given `repo` and return the processed Data from it +pub async fn get_deployment_data( + supports_flakes: bool, + flakes: &[data::Target], + extra_build_args: &[String], +) -> Result, GetDeploymentDataError> { + futures_util::stream::iter(flakes).then(|flake| async move { + + info!("Evaluating flake in {}", flake.repo); + + let mut c = if supports_flakes { + Command::new("nix") + } else { + Command::new("nix-instantiate") + }; + + if supports_flakes { + c.arg("eval") + .arg("--json") + .arg(format!("{}#deploy", flake.repo)) + // We use --apply instead of --expr so that we don't have to deal with builtins.getFlake + .arg("--apply"); + match (&flake.node, &flake.profile) { + (Some(node), Some(profile)) => { + // Ignore all nodes and all profiles but the one we're evaluating + c.arg(format!( + r#" + deploy: + (deploy // {{ + nodes = {{ + "{0}" = deploy.nodes."{0}" // {{ + profiles = {{ + inherit (deploy.nodes."{0}".profiles) "{1}"; + }}; + }}; + }}; + }}) + "#, + node, profile + )) + } + (Some(node), None) => { + // Ignore all nodes but the one we're evaluating + c.arg(format!( + r#" + deploy: + (deploy // {{ + nodes = {{ + inherit (deploy.nodes) "{}"; + }}; + }}) + "#, + node + )) + } + (None, None) => { + // We need to evaluate all profiles of all nodes anyway, so just do it strictly + c.arg("deploy: deploy") + } + (None, Some(_)) => return Err(GetDeploymentDataError::ProfileNoNode), + } + } else { + c + .arg("--strict") + .arg("--read-write-mode") + .arg("--json") + .arg("--eval") + .arg("-E") + .arg(format!("let r = import {}/.; in if builtins.isFunction r then (r {{}}).deploy else r.deploy", flake.repo)) + }; + + for extra_arg in extra_build_args { + c.arg(extra_arg); + } + + let build_child = c + .stdout(Stdio::piped()) + .spawn() + .map_err(GetDeploymentDataError::NixEval)?; + + let build_output = build_child + .wait_with_output() + .await + .map_err(GetDeploymentDataError::NixEvalOut)?; + + match build_output.status.code() { + Some(0) => (), + a => return Err(GetDeploymentDataError::NixEvalExit(a)), + }; + + let data_json = String::from_utf8(build_output.stdout)?; + + Ok(serde_json::from_str(&data_json)?) +}).try_collect().await +} diff --git a/src/lib.rs b/src/lib.rs index 981ec1ed..e530a8b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,12 +4,6 @@ // // SPDX-License-Identifier: MPL-2.0 -use rnix::{types::*, SyntaxKind::*}; - -use merge::Merge; - -use thiserror::Error; - use flexi_logger::*; pub fn make_lock_path(temp_path: &str, closure: &str) -> String { @@ -145,296 +139,9 @@ pub fn init_logger( Ok(()) } +pub mod settings; pub mod data; +pub mod flake; pub mod deploy; pub mod push; pub mod cli; - -#[derive(Debug)] -pub struct CmdOverrides { - pub ssh_user: Option, - pub profile_user: Option, - pub ssh_opts: Option, - pub fast_connection: Option, - pub auto_rollback: Option, - pub hostname: Option, - pub magic_rollback: Option, - pub temp_path: Option, - pub confirm_timeout: Option, - pub dry_activate: bool, -} - -#[derive(PartialEq, Debug)] -pub struct DeployFlake<'a> { - pub repo: &'a str, - pub node: Option, - pub profile: Option, -} - -#[derive(Error, Debug)] -pub enum ParseFlakeError { - #[error("The given path was too long, did you mean to put something in quotes?")] - PathTooLong, - #[error("Unrecognized node or token encountered")] - Unrecognized, -} -pub fn parse_flake(flake: &str) -> Result { - let flake_fragment_start = flake.find('#'); - let (repo, maybe_fragment) = match flake_fragment_start { - Some(s) => (&flake[..s], Some(&flake[s + 1..])), - None => (flake, None), - }; - - let mut node: Option = None; - let mut profile: Option = None; - - if let Some(fragment) = maybe_fragment { - let ast = rnix::parse(fragment); - - let first_child = match ast.root().node().first_child() { - Some(x) => x, - None => { - return Ok(DeployFlake { - repo, - node: None, - profile: None, - }) - } - }; - - let mut node_over = false; - - for entry in first_child.children_with_tokens() { - let x: Option = match (entry.kind(), node_over) { - (TOKEN_DOT, false) => { - node_over = true; - None - } - (TOKEN_DOT, true) => { - return Err(ParseFlakeError::PathTooLong); - } - (NODE_IDENT, _) => Some(entry.into_node().unwrap().text().to_string()), - (TOKEN_IDENT, _) => Some(entry.into_token().unwrap().text().to_string()), - (NODE_STRING, _) => { - let c = entry - .into_node() - .unwrap() - .children_with_tokens() - .nth(1) - .unwrap(); - - Some(c.into_token().unwrap().text().to_string()) - } - _ => return Err(ParseFlakeError::Unrecognized), - }; - - if !node_over { - node = x; - } else { - profile = x; - } - } - } - - Ok(DeployFlake { - repo, - node, - profile, - }) -} - -#[test] -fn test_parse_flake() { - assert_eq!( - parse_flake("../deploy/examples/system").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: None, - profile: None, - } - ); - - assert_eq!( - parse_flake("../deploy/examples/system#").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: None, - profile: None, - } - ); - - assert_eq!( - parse_flake("../deploy/examples/system#computer.\"something.nix\"").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: Some("computer".to_string()), - profile: Some("something.nix".to_string()), - } - ); - - assert_eq!( - parse_flake("../deploy/examples/system#\"example.com\".system").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: Some("example.com".to_string()), - profile: Some("system".to_string()), - } - ); - - assert_eq!( - parse_flake("../deploy/examples/system#example").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: Some("example".to_string()), - profile: None - } - ); - - assert_eq!( - parse_flake("../deploy/examples/system#example.system").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: Some("example".to_string()), - profile: Some("system".to_string()) - } - ); - - assert_eq!( - parse_flake("../deploy/examples/system").unwrap(), - DeployFlake { - repo: "../deploy/examples/system", - node: None, - profile: None, - } - ); -} - -#[derive(Debug, Clone)] -pub struct DeployData<'a> { - pub node_name: &'a str, - pub node: &'a data::Node, - pub profile_name: &'a str, - pub profile: &'a data::Profile, - - pub cmd_overrides: &'a CmdOverrides, - - pub merged_settings: data::GenericSettings, - - pub debug_logs: bool, - pub log_dir: Option<&'a str>, -} - -#[derive(Debug)] -pub struct DeployDefs { - pub ssh_user: String, - pub profile_user: String, - pub profile_path: String, - pub sudo: Option, -} - -#[derive(Error, Debug)] -pub enum DeployDataDefsError { - #[error("Neither `user` nor `sshUser` are set for profile {0} of node {1}")] - NoProfileUser(String, String), -} - -impl<'a> DeployData<'a> { - pub fn defs(&'a self) -> Result { - let ssh_user = match self.merged_settings.ssh_user { - Some(ref u) => u.clone(), - None => whoami::username(), - }; - - let profile_user = self.get_profile_user()?; - - let profile_path = self.get_profile_path()?; - - let sudo: Option = match self.merged_settings.user { - Some(ref user) if user != &ssh_user => Some(format!("sudo -u {}", user)), - _ => None, - }; - - Ok(DeployDefs { - ssh_user, - profile_user, - profile_path, - sudo, - }) - } - - fn get_profile_path(&'a self) -> Result { - let profile_user = self.get_profile_user()?; - let profile_path = match self.profile.profile_settings.profile_path { - None => match &profile_user[..] { - "root" => format!("/nix/var/nix/profiles/{}", self.profile_name), - _ => format!( - "/nix/var/nix/profiles/per-user/{}/{}", - profile_user, self.profile_name - ), - }, - Some(ref x) => x.clone(), - }; - Ok(profile_path) - } - - fn get_profile_user(&'a self) -> Result { - let profile_user = match self.merged_settings.user { - Some(ref x) => x.clone(), - None => match self.merged_settings.ssh_user { - Some(ref x) => x.clone(), - None => { - return Err(DeployDataDefsError::NoProfileUser( - self.profile_name.to_owned(), - self.node_name.to_owned(), - )) - } - }, - }; - Ok(profile_user) - } -} - -pub fn make_deploy_data<'a, 's>( - top_settings: &'s data::GenericSettings, - node: &'a data::Node, - node_name: &'a str, - profile: &'a data::Profile, - profile_name: &'a str, - cmd_overrides: &'a CmdOverrides, - debug_logs: bool, - log_dir: Option<&'a str>, -) -> DeployData<'a> { - let mut merged_settings = profile.generic_settings.clone(); - merged_settings.merge(node.generic_settings.clone()); - merged_settings.merge(top_settings.clone()); - - if cmd_overrides.ssh_user.is_some() { - merged_settings.ssh_user = cmd_overrides.ssh_user.clone(); - } - if cmd_overrides.profile_user.is_some() { - merged_settings.user = cmd_overrides.profile_user.clone(); - } - if let Some(ref ssh_opts) = cmd_overrides.ssh_opts { - merged_settings.ssh_opts = ssh_opts.split(' ').map(|x| x.to_owned()).collect(); - } - if let Some(fast_connection) = cmd_overrides.fast_connection { - merged_settings.fast_connection = Some(fast_connection); - } - if let Some(auto_rollback) = cmd_overrides.auto_rollback { - merged_settings.auto_rollback = Some(auto_rollback); - } - if let Some(magic_rollback) = cmd_overrides.magic_rollback { - merged_settings.magic_rollback = Some(magic_rollback); - } - - DeployData { - node_name, - node, - profile_name, - profile, - cmd_overrides, - merged_settings, - debug_logs, - log_dir, - } -} diff --git a/src/push.rs b/src/push.rs index 69eba0db..ee55a123 100644 --- a/src/push.rs +++ b/src/push.rs @@ -9,6 +9,8 @@ use std::process::Stdio; use thiserror::Error; use tokio::process::Command; +use crate::data; + #[derive(Error, Debug)] pub enum PushProfileError { #[error("Failed to run Nix show-derivation command: {0}")] @@ -47,8 +49,8 @@ pub struct PushProfileData<'a> { pub supports_flakes: bool, pub check_sigs: bool, pub repo: &'a str, - pub deploy_data: &'a super::DeployData<'a>, - pub deploy_defs: &'a super::DeployDefs, + pub deploy_data: &'a data::DeployData<'a>, + pub deploy_defs: &'a data::DeployDefs, pub keep_result: bool, pub result_path: Option<&'a str>, pub extra_build_args: &'a [String], diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 00000000..9ce50a0f --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2020 Serokell +// +// SPDX-License-Identifier: MPL-2.0 + +use merge::Merge; +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Deserialize, Debug, Clone, Merge)] +pub struct GenericSettings { + #[serde(rename(deserialize = "sshUser"))] + pub ssh_user: Option, + pub user: Option, + #[serde( + skip_serializing_if = "Vec::is_empty", + default, + rename(deserialize = "sshOpts") + )] + #[merge(strategy = merge::vec::append)] + pub ssh_opts: Vec, + #[serde(rename(deserialize = "fastConnection"))] + pub fast_connection: Option, + #[serde(rename(deserialize = "autoRollback"))] + pub auto_rollback: Option, + #[serde(rename(deserialize = "confirmTimeout"))] + pub confirm_timeout: Option, + #[serde(rename(deserialize = "tempPath"))] + pub temp_path: Option, + #[serde(rename(deserialize = "magicRollback"))] + pub magic_rollback: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct NodeSettings { + pub hostname: String, + pub profiles: HashMap, + #[serde( + skip_serializing_if = "Vec::is_empty", + default, + rename(deserialize = "profilesOrder") + )] + pub profiles_order: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ProfileSettings { + pub path: String, + #[serde(rename(deserialize = "profilePath"))] + pub profile_path: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Profile { + #[serde(flatten)] + pub profile_settings: ProfileSettings, + #[serde(flatten)] + pub generic_settings: GenericSettings, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Node { + #[serde(flatten)] + pub generic_settings: GenericSettings, + #[serde(flatten)] + pub node_settings: NodeSettings, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Root { + #[serde(flatten)] + pub generic_settings: GenericSettings, + pub nodes: HashMap, +}