Added support for run profiles

This commit is contained in:
2025-09-09 16:23:26 -06:00
parent a0b710b96b
commit 5623eb4128
3 changed files with 341 additions and 9 deletions
+111
View File
@@ -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 doesnt 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
View File
@@ -1,22 +1,31 @@
use clap::{
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 crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
use gman::config::Config;
use gman::providers::SupportedProvider;
use gman::config::{Config, RunConfig};
use gman::providers::local::LocalProvider;
use gman::providers::{SecretProvider, SupportedProvider};
use heck::ToSnakeCase;
use log::debug;
use std::io::{self, IsTerminal, Read, Write};
use std::panic;
use std::panic::PanicHookInfo;
use std::process::Command;
use validator::Validate;
mod command;
mod utils;
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{key}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{value}";
#[derive(Debug, Clone, ValueEnum)]
enum OutputFormat {
Text,
@@ -60,11 +69,19 @@ struct Cli {
#[arg(long, value_enum)]
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: Commands,
}
#[derive(Subcommand, Debug)]
#[derive(Subcommand, Clone, Debug)]
enum Commands {
/// Add a secret to the configured secret provider
Add {
@@ -91,12 +108,17 @@ enum Commands {
},
/// 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 {},
/// Sync secrets with remote storage (if supported by the provider)
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
Completions {
/// The shell to generate the script for
@@ -200,6 +222,9 @@ fn main() -> Result<()> {
Some(_) => (),
})?;
}
Commands::External(tokens) => {
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
@@ -225,6 +250,155 @@ fn load_config(cli: &Cli) -> Result<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> {
if io::stdin().is_terminal() {
#[cfg(not(windows))]
+51 -4
View File
@@ -3,8 +3,52 @@ use log::debug;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
use std::borrow::Cow;
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]
#[derive(Debug, Clone, Validate, Serialize, Deserialize)]
@@ -13,12 +57,14 @@ pub struct Config {
pub provider: SupportedProvider,
pub password_file: Option<PathBuf>,
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_user_name: Option<String>,
#[validate(email)]
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 {
@@ -30,7 +76,8 @@ impl Default for Config {
git_remote_url: None,
git_user_name: None,
git_user_email: None,
git_executable: None,
git_executable: None,
run_configs: None,
}
}
}