Added support for run profiles
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::ffi::{OsStr};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn preview_command(cmd: &Command) -> String {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for (k, vopt) in cmd.get_envs() {
|
||||||
|
match vopt {
|
||||||
|
Some(v) => parts.push(format!(
|
||||||
|
"{}={}",
|
||||||
|
sh_escape(k),
|
||||||
|
sh_escape(v),
|
||||||
|
)),
|
||||||
|
None => parts.push(format!("unset {}", sh_escape(k))), // env removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(sh_escape(cmd.get_program()).into_owned());
|
||||||
|
parts.extend(cmd.get_args().map(|a| sh_escape(a).into_owned()));
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// On Windows, emulate `cmd.exe` style env setting
|
||||||
|
// (This is for display; Command doesn’t invoke cmd.exe.)
|
||||||
|
let mut env_bits = Vec::new();
|
||||||
|
for (k, vopt) in cmd.get_envs() {
|
||||||
|
match vopt {
|
||||||
|
Some(v) => env_bits.push(format!("set {}={}", ps_quote(k), ps_quote(v))),
|
||||||
|
None => env_bits.push(format!("set {}=", ps_quote(k))), // unset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_bits.is_empty() {
|
||||||
|
parts.push(env_bits.join(" && "));
|
||||||
|
parts.push("&&".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program + args (quote per CreateProcess rules)
|
||||||
|
parts.push(win_quote(cmd.get_program()));
|
||||||
|
parts.extend(cmd.get_args().map(win_quote));
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn sh_escape(s: &OsStr) -> Cow<'_, str> {
|
||||||
|
let s = s.to_string_lossy();
|
||||||
|
if s.is_empty() || s.chars().any(|c| c.is_whitespace() || "!\"#$&'()*;<>?`\\|[]{}".contains(c))
|
||||||
|
{
|
||||||
|
let mut out = String::from("'");
|
||||||
|
for ch in s.chars() {
|
||||||
|
if ch == '\'' {
|
||||||
|
out.push_str("'\\''");
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\'');
|
||||||
|
Cow::Owned(out)
|
||||||
|
} else {
|
||||||
|
Cow::Owned(s.into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn win_quote(s: &OsStr) -> String {
|
||||||
|
// Quote per Windows argv rules (CreateProcess / CommandLineToArgvW).
|
||||||
|
// Wrap in "..." and escape internal " with backslashes.
|
||||||
|
let s = s.to_string_lossy();
|
||||||
|
if !s.contains([' ', '\t', '"', '\\']) {
|
||||||
|
return s.into_owned();
|
||||||
|
}
|
||||||
|
let mut out = String::from("\"");
|
||||||
|
let mut backslashes = 0;
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => backslashes += 1,
|
||||||
|
'"' => {
|
||||||
|
out.extend(std::iter::repeat('\\').take(backslashes * 2 + 1));
|
||||||
|
out.push('"');
|
||||||
|
backslashes = 0;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.extend(std::iter::repeat('\\').take(backslashes));
|
||||||
|
backslashes = 0;
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.extend(std::iter::repeat('\\').take(backslashes));
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// For displaying env names/values in the "set name=value" bits.
|
||||||
|
// Single-quote for PowerShell-like readability; fine for display purposes.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn ps_quote(s: &OsStr) -> String {
|
||||||
|
let s = s.to_string_lossy();
|
||||||
|
if s.chars().any(|c| c.is_whitespace() || r#"'&|<>()^"%!;"#.contains(c)) {
|
||||||
|
format!("'{}'", s.replace('\'', "''"))
|
||||||
|
} else {
|
||||||
|
s.into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
-5
@@ -1,22 +1,31 @@
|
|||||||
use clap::{
|
use clap::{
|
||||||
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
|
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use crate::command::preview_command;
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
|
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
|
||||||
use gman::config::Config;
|
use gman::config::{Config, RunConfig};
|
||||||
use gman::providers::SupportedProvider;
|
|
||||||
use gman::providers::local::LocalProvider;
|
use gman::providers::local::LocalProvider;
|
||||||
|
use gman::providers::{SecretProvider, SupportedProvider};
|
||||||
use heck::ToSnakeCase;
|
use heck::ToSnakeCase;
|
||||||
|
use log::debug;
|
||||||
use std::io::{self, IsTerminal, Read, Write};
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
use std::panic;
|
use std::panic;
|
||||||
use std::panic::PanicHookInfo;
|
use std::panic::PanicHookInfo;
|
||||||
|
use std::process::Command;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
mod command;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{key}";
|
||||||
|
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{value}";
|
||||||
|
|
||||||
#[derive(Debug, Clone, ValueEnum)]
|
#[derive(Debug, Clone, ValueEnum)]
|
||||||
enum OutputFormat {
|
enum OutputFormat {
|
||||||
Text,
|
Text,
|
||||||
@@ -60,11 +69,19 @@ struct Cli {
|
|||||||
#[arg(long, value_enum)]
|
#[arg(long, value_enum)]
|
||||||
provider: Option<ProviderKind>,
|
provider: Option<ProviderKind>,
|
||||||
|
|
||||||
|
/// Specify a run profile to use when wrapping a command
|
||||||
|
#[arg(long)]
|
||||||
|
profile: Option<String>,
|
||||||
|
|
||||||
|
/// Output the command that will be run instead of executing it
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Commands,
|
command: Commands,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Clone, Debug)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Add a secret to the configured secret provider
|
/// Add a secret to the configured secret provider
|
||||||
Add {
|
Add {
|
||||||
@@ -91,12 +108,17 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// List all secrets stored in the configured secret provider (if supported by the provider)
|
/// List all secrets stored in the configured secret provider (if supported by the provider)
|
||||||
/// If a provider does not support listing secrets, this command will return an error.
|
/// If a provider does not support listing secrets, this command will return an error.
|
||||||
List {},
|
List {},
|
||||||
|
|
||||||
/// Sync secrets with remote storage (if supported by the provider)
|
/// Sync secrets with remote storage (if supported by the provider)
|
||||||
Sync {},
|
Sync {},
|
||||||
|
|
||||||
|
/// Wrap the provided command and supply it with secrets as environment variables or as
|
||||||
|
/// configured in a corresponding run profile
|
||||||
|
#[command(external_subcommand)]
|
||||||
|
External(Vec<OsString>),
|
||||||
|
|
||||||
/// Generate shell completion scripts
|
/// Generate shell completion scripts
|
||||||
Completions {
|
Completions {
|
||||||
/// The shell to generate the script for
|
/// The shell to generate the script for
|
||||||
@@ -200,6 +222,9 @@ fn main() -> Result<()> {
|
|||||||
Some(_) => (),
|
Some(_) => (),
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
Commands::External(tokens) => {
|
||||||
|
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
|
||||||
|
}
|
||||||
Commands::Completions { shell } => {
|
Commands::Completions { shell } => {
|
||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
let bin_name = cmd.get_name().to_string();
|
let bin_name = cmd.get_name().to_string();
|
||||||
@@ -225,6 +250,155 @@ fn load_config(cli: &Cli) -> Result<Config> {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn wrap_and_run_command(
|
||||||
|
secrets_provider: Box<dyn SecretProvider>,
|
||||||
|
config: &Config,
|
||||||
|
tokens: Vec<OsString>,
|
||||||
|
profile_name: Option<String>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (prog, args) = tokens
|
||||||
|
.split_first()
|
||||||
|
.with_context(|| "need a command to run")?;
|
||||||
|
let run_config_profile_name = if let Some(ref profile_name) = profile_name {
|
||||||
|
profile_name.as_str()
|
||||||
|
} else {
|
||||||
|
prog.to_str()
|
||||||
|
.ok_or_else(|| anyhow!("failed to convert program name to string"))?
|
||||||
|
};
|
||||||
|
let run_config_opt = config.run_configs.as_ref().and_then(|configs| {
|
||||||
|
configs.iter().filter(|c| c.name.is_some()).find(|c| {
|
||||||
|
c.name.as_ref().expect("failed to unwrap run config name") == run_config_profile_name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(run_cfg) = run_config_opt {
|
||||||
|
let secrets_result =
|
||||||
|
run_cfg
|
||||||
|
.secrets
|
||||||
|
.as_ref()
|
||||||
|
.expect("no secrets configured for run profile")
|
||||||
|
.iter()
|
||||||
|
.map(|key| {
|
||||||
|
let secret_name = key.to_snake_case();
|
||||||
|
debug!(
|
||||||
|
"Retrieving secret '{secret_name}' for run profile '{}'",
|
||||||
|
run_config_profile_name
|
||||||
|
);
|
||||||
|
secrets_provider
|
||||||
|
.get_secret(&config, key.to_snake_case().as_str())
|
||||||
|
.ok()
|
||||||
|
.map_or_else(
|
||||||
|
|| {
|
||||||
|
debug!("Failed to fetch secret '{secret_name}' from secret provider");
|
||||||
|
(
|
||||||
|
key.to_uppercase(),
|
||||||
|
Err(anyhow!(
|
||||||
|
"Failed to fetch secret '{secret_name}' from secret provider"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|value| if dry_run {
|
||||||
|
(key.to_uppercase(), Ok("*****".into()))
|
||||||
|
} else {
|
||||||
|
(key.to_uppercase(), Ok(value))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let err = secrets_result
|
||||||
|
.clone()
|
||||||
|
.filter(|(_, r)| r.is_err())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !err.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Failed to fetch {} secrets from secret provider. {}",
|
||||||
|
err.len(),
|
||||||
|
err.iter()
|
||||||
|
.map(|(k, _)| format!("\n'{}'", k))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let secrets = secrets_result
|
||||||
|
.map(|(k, r)| (k, r.unwrap()))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let mut cmd_def = Command::new(prog);
|
||||||
|
|
||||||
|
if run_cfg.flag.is_some() {
|
||||||
|
let args = parse_args(args, run_cfg, secrets.clone(), dry_run)?;
|
||||||
|
run_cmd(&mut cmd_def.args(&args), dry_run)?;
|
||||||
|
} else {
|
||||||
|
run_cmd(cmd_def.args(args).envs(secrets), dry_run)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("No run profile found for '{run_config_profile_name}'");
|
||||||
|
return Err(anyhow!(
|
||||||
|
"No run profile found for '{run_config_profile_name}'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
|
||||||
|
if dry_run {
|
||||||
|
eprintln!("Command to be executed: {}", preview_command(cmd));
|
||||||
|
} else {
|
||||||
|
cmd.status()
|
||||||
|
.with_context(|| format!("failed to execute command '{:?}'", cmd))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args(
|
||||||
|
args: &[OsString],
|
||||||
|
run_config: &RunConfig,
|
||||||
|
secrets: HashMap<String, String>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<Vec<OsString>> {
|
||||||
|
let args = args.to_vec();
|
||||||
|
let flag = run_config
|
||||||
|
.flag
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("flag must be set if arg_format is set"))?;
|
||||||
|
let flag_position = run_config
|
||||||
|
.flag_position
|
||||||
|
.ok_or_else(|| anyhow!("flag_position must be set if flag is set"))?;
|
||||||
|
let arg_format = run_config
|
||||||
|
.arg_format
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("arg_format must be set if flag is set"))?;
|
||||||
|
let mut args = args.to_vec();
|
||||||
|
|
||||||
|
if flag_position > args.len() {
|
||||||
|
secrets.iter().for_each(|(k, v)| {
|
||||||
|
let v = if dry_run { "*****" } else { v };
|
||||||
|
args.push(OsString::from(flag));
|
||||||
|
args.push(OsString::from(
|
||||||
|
arg_format
|
||||||
|
.replace(ARG_FORMAT_PLACEHOLDER_KEY, k)
|
||||||
|
.replace(ARG_FORMAT_PLACEHOLDER_VALUE, v),
|
||||||
|
));
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
secrets.iter().for_each(|(k, v)| {
|
||||||
|
let v = if dry_run { "*****" } else { v };
|
||||||
|
args.insert(
|
||||||
|
flag_position,
|
||||||
|
OsString::from(
|
||||||
|
arg_format
|
||||||
|
.replace(ARG_FORMAT_PLACEHOLDER_KEY, k)
|
||||||
|
.replace(ARG_FORMAT_PLACEHOLDER_VALUE, v),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
args.insert(flag_position, OsString::from(flag));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_all_stdin() -> Result<String> {
|
fn read_all_stdin() -> Result<String> {
|
||||||
if io::stdin().is_terminal() {
|
if io::stdin().is_terminal() {
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
|
|||||||
+51
-4
@@ -3,8 +3,52 @@ use log::debug;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_with::DisplayFromStr;
|
use serde_with::DisplayFromStr;
|
||||||
use serde_with::serde_as;
|
use serde_with::serde_as;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use validator::Validate;
|
use validator::{Validate, ValidationError};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
|
||||||
|
#[validate(schema(function = "flags_or_none", skip_on_field_errors = false))]
|
||||||
|
pub struct RunConfig {
|
||||||
|
#[validate(required)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[validate(required)]
|
||||||
|
pub secrets: Option<Vec<String>>,
|
||||||
|
pub flag: Option<String>,
|
||||||
|
#[validate(range(min = 1))]
|
||||||
|
pub flag_position: Option<usize>,
|
||||||
|
pub arg_format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flags_or_none(run_config: &RunConfig) -> Result<(), ValidationError> {
|
||||||
|
match (
|
||||||
|
&run_config.flag,
|
||||||
|
&run_config.flag_position,
|
||||||
|
&run_config.arg_format,
|
||||||
|
) {
|
||||||
|
(Some(_), Some(_), Some(format)) => {
|
||||||
|
let has_key = format.contains("{key}");
|
||||||
|
let has_value = format.contains("{value}");
|
||||||
|
if has_key && has_value {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let mut err = ValidationError::new("missing_placeholders");
|
||||||
|
err.message = Some(Cow::Borrowed("must contain both '{key}' and '{value}' (with the '{' and '}' characters) in the arg_format"));
|
||||||
|
err.add_param(Cow::Borrowed("has_key"), &has_key);
|
||||||
|
err.add_param(Cow::Borrowed("has_value"), &has_value);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(None, None, None) => Ok(()),
|
||||||
|
_ => {
|
||||||
|
let mut err = ValidationError::new("both_or_none");
|
||||||
|
err.message = Some(Cow::Borrowed(
|
||||||
|
"When defining a flag to pass secrets into the command with, all of 'flag', 'flag_position', and 'arg_format' must be defined in the run configuration",
|
||||||
|
));
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
|
||||||
@@ -13,12 +57,14 @@ pub struct Config {
|
|||||||
pub provider: SupportedProvider,
|
pub provider: SupportedProvider,
|
||||||
pub password_file: Option<PathBuf>,
|
pub password_file: Option<PathBuf>,
|
||||||
pub git_branch: Option<String>,
|
pub git_branch: Option<String>,
|
||||||
/// The git remote URL to push changes to (e.g. git@github.com:user/repo.git)
|
/// The git remote URL to push changes to (e.g. git@github.com:user/repo.git)
|
||||||
pub git_remote_url: Option<String>,
|
pub git_remote_url: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
#[validate(email)]
|
#[validate(email)]
|
||||||
pub git_user_email: Option<String>,
|
pub git_user_email: Option<String>,
|
||||||
pub git_executable: Option<PathBuf>,
|
pub git_executable: Option<PathBuf>,
|
||||||
|
#[validate(nested)]
|
||||||
|
pub run_configs: Option<Vec<RunConfig>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
@@ -30,7 +76,8 @@ impl Default for Config {
|
|||||||
git_remote_url: None,
|
git_remote_url: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
git_executable: None,
|
git_executable: None,
|
||||||
|
run_configs: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user