Updated the README with a more clear example of what the purpose of gman is, and also support fully isolated, multiple local based configurations with isolated git repositories so users can have separate repos for different environments
This commit is contained in:
+4
-4
@@ -17,7 +17,7 @@ const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
|
||||
pub fn wrap_and_run_command(
|
||||
secrets_provider: Box<dyn SecretProvider>,
|
||||
config: &Config,
|
||||
provider_config: &ProviderConfig,
|
||||
provider_config: &ProviderConfig,
|
||||
tokens: Vec<OsString>,
|
||||
profile_name: Option<String>,
|
||||
dry_run: bool,
|
||||
@@ -270,7 +270,7 @@ mod tests {
|
||||
fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete_secret(&self, _key: &str) -> Result<()> {
|
||||
fn delete_secret(&self, _config: &ProviderConfig, _key: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn sync(&self, _config: &mut ProviderConfig) -> Result<()> {
|
||||
@@ -345,7 +345,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_wrap_and_run_command_no_profile() {
|
||||
let cfg = Config::default();
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hi")];
|
||||
let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err();
|
||||
@@ -367,7 +367,7 @@ mod tests {
|
||||
run_configs: Some(vec![run_cfg]),
|
||||
..Config::default()
|
||||
};
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
|
||||
|
||||
// Capture stderr for dry_run preview
|
||||
|
||||
+11
-9
@@ -41,11 +41,11 @@ enum OutputFormat {
|
||||
)]
|
||||
struct Cli {
|
||||
/// Specify the output format
|
||||
#[arg(short, long, value_enum)]
|
||||
#[arg(short, long, global = true, value_enum, env = "GMAN_OUTPUT")]
|
||||
output: Option<OutputFormat>,
|
||||
|
||||
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
|
||||
#[arg(long, value_enum)]
|
||||
#[arg(long, value_enum, global = true, env = "GMAN_PROVIDER")]
|
||||
provider: Option<String>,
|
||||
|
||||
/// Specify a run profile to use when wrapping a command
|
||||
@@ -53,7 +53,7 @@ struct Cli {
|
||||
profile: Option<String>,
|
||||
|
||||
/// Output the command that will be run instead of executing it
|
||||
#[arg(long)]
|
||||
#[arg(long, global = true)]
|
||||
dry_run: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
@@ -162,14 +162,16 @@ fn main() -> Result<()> {
|
||||
}
|
||||
Commands::Delete { name } => {
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider.delete_secret(&snake_case_name).map(|_| {
|
||||
if cli.output.is_none() {
|
||||
println!("✓ Secret '{snake_case_name}' deleted from the vault.")
|
||||
}
|
||||
})?;
|
||||
secrets_provider
|
||||
.delete_secret(&provider_config, &snake_case_name)
|
||||
.map(|_| {
|
||||
if cli.output.is_none() {
|
||||
println!("✓ Secret '{snake_case_name}' deleted from the vault.")
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Commands::List {} => {
|
||||
let secrets = secrets_provider.list_secrets()?;
|
||||
let secrets = secrets_provider.list_secrets(&provider_config)?;
|
||||
if secrets.is_empty() {
|
||||
match cli.output {
|
||||
Some(OutputFormat::Json) => {
|
||||
|
||||
+2
-2
@@ -218,8 +218,8 @@ impl Config {
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use gman::config::Config;
|
||||
/// let provider_config = Config::default().extract_provider_config(None);
|
||||
/// println!("using provider config: {}", provider_config.unwrap().name);
|
||||
/// let provider_config = Config::default().extract_provider_config(None).unwrap();
|
||||
/// println!("using provider config: {:?}", provider_config.name);
|
||||
/// ```
|
||||
pub fn extract_provider_config(&self, provider_name: Option<String>) -> Result<ProviderConfig> {
|
||||
let name = provider_name
|
||||
|
||||
@@ -4,7 +4,7 @@ use dialoguer::Confirm;
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use indoc::formatdoc;
|
||||
use log::debug;
|
||||
use std::env;
|
||||
use std::{env, fs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use validator::Validate;
|
||||
@@ -25,12 +25,28 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
|
||||
opts.validate()
|
||||
.with_context(|| "invalid git sync options")?;
|
||||
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
|
||||
let repo_dir = confy::get_configuration_file_path("gman", "vault")
|
||||
let config_dir = confy::get_configuration_file_path("gman", "vault")
|
||||
.with_context(|| "get config dir")?
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| anyhow!("Failed to determine repo dir"))?;
|
||||
std::fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
|
||||
.ok_or_else(|| anyhow!("Failed to determine config dir"))?;
|
||||
|
||||
let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
|
||||
let repo_name = repo_name_from_url(remote_url);
|
||||
let repo_dir = config_dir.join(format!(".{}", repo_name));
|
||||
fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
|
||||
|
||||
// Move the default vault into the repo dir on first sync so only vault.yml is tracked.
|
||||
let default_vault = confy::get_configuration_file_path("gman", "vault")
|
||||
.with_context(|| "get default vault path")?;
|
||||
let repo_vault = repo_dir.join("vault.yml");
|
||||
if default_vault.exists() && !repo_vault.exists() {
|
||||
fs::rename(&default_vault, &repo_vault)
|
||||
.with_context(|| format!("move {} -> {}", default_vault.display(), repo_vault.display()))?;
|
||||
} else if !repo_vault.exists() {
|
||||
// Ensure an empty vault exists to allow initial commits
|
||||
fs::write(&repo_vault, "{}\n").with_context(|| format!("create {}", repo_vault.display()))?;
|
||||
}
|
||||
|
||||
let git = resolve_git(opts.git_executable.as_ref())?;
|
||||
ensure_git_available(&git)?;
|
||||
@@ -42,7 +58,6 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
|
||||
.trim()
|
||||
.to_string();
|
||||
let branch = opts.branch.as_ref().expect("no target branch defined");
|
||||
let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
|
||||
|
||||
debug!(
|
||||
"{}",
|
||||
@@ -74,7 +89,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
|
||||
|
||||
// Stage and commit any subsequent local changes after aligning with remote
|
||||
// so we don't merge uncommitted local state.
|
||||
stage_all(&git, &repo_dir)?;
|
||||
stage_vault_only(&git, &repo_dir)?;
|
||||
|
||||
commit_now(&git, &repo_dir, &commit_message)?;
|
||||
|
||||
@@ -228,8 +243,8 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_all(git: &Path, repo: &Path) -> Result<()> {
|
||||
run_git(git, repo, &["add", "-A"])?;
|
||||
fn stage_vault_only(git: &Path, repo: &Path) -> Result<()> {
|
||||
run_git(git, repo, &["add", "vault.yml"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -321,6 +336,16 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn repo_name_from_url(url: &str) -> String {
|
||||
let mut s = url;
|
||||
if let Some(idx) = s.rfind('/') {
|
||||
s = &s[idx + 1..];
|
||||
} else if let Some(idx) = s.rfind(':') {
|
||||
s = &s[idx + 1..];
|
||||
}
|
||||
s.trim_end_matches(".git").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -370,4 +395,12 @@ mod tests {
|
||||
env::remove_var("GIT_EXECUTABLE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repo_name_from_url() {
|
||||
assert_eq!(repo_name_from_url("git@github.com:user/vault.git"), "vault");
|
||||
assert_eq!(repo_name_from_url("https://github.com/user/test-vault.git"), "test-vault");
|
||||
assert_eq!(repo_name_from_url("ssh://git@example.com/x/y/z.git"), "z");
|
||||
assert_eq!(repo_name_from_url("git@example.com:ns/repo"), "repo");
|
||||
}
|
||||
}
|
||||
|
||||
+66
-12
@@ -2,10 +2,11 @@ use anyhow::{anyhow, bail, Context};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::providers::git_sync::{sync_and_push, SyncOpts};
|
||||
use crate::providers::git_sync::{repo_name_from_url, sync_and_push, SyncOpts};
|
||||
use crate::providers::SecretProvider;
|
||||
use crate::{
|
||||
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
|
||||
@@ -69,7 +70,8 @@ impl SecretProvider for LocalProvider {
|
||||
}
|
||||
|
||||
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> {
|
||||
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
let vault_path = active_vault_path(config)?;
|
||||
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
|
||||
let envelope = vault
|
||||
.get(key)
|
||||
.with_context(|| format!("key '{key}' not found in the vault"))?;
|
||||
@@ -82,7 +84,8 @@ impl SecretProvider for LocalProvider {
|
||||
}
|
||||
|
||||
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
let vault_path = active_vault_path(config)?;
|
||||
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
|
||||
if vault.contains_key(key) {
|
||||
error!(
|
||||
"Key '{key}' already exists in the vault. Use a different key or delete the existing one first."
|
||||
@@ -96,11 +99,12 @@ impl SecretProvider for LocalProvider {
|
||||
|
||||
vault.insert(key.to_string(), envelope);
|
||||
|
||||
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
|
||||
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
|
||||
}
|
||||
|
||||
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
let vault_path = active_vault_path(config)?;
|
||||
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
|
||||
|
||||
let password = get_password(config)?;
|
||||
let envelope = encrypt_string(&password, value)?;
|
||||
@@ -113,27 +117,29 @@ impl SecretProvider for LocalProvider {
|
||||
.with_context(|| format!("key '{key}' not found in the vault"))?;
|
||||
*vault_entry = envelope;
|
||||
|
||||
return confy::store("gman", "vault", vault)
|
||||
return store_vault(&vault_path, &vault)
|
||||
.with_context(|| "failed to save secret to the vault");
|
||||
}
|
||||
|
||||
vault.insert(key.to_string(), envelope);
|
||||
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
|
||||
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
|
||||
}
|
||||
|
||||
fn delete_secret(&self, key: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()> {
|
||||
let vault_path = active_vault_path(config)?;
|
||||
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
|
||||
if !vault.contains_key(key) {
|
||||
error!("Key '{key}' does not exist in the vault.");
|
||||
bail!("key '{key}' does not exist");
|
||||
}
|
||||
|
||||
vault.remove(key);
|
||||
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
|
||||
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
|
||||
}
|
||||
|
||||
fn list_secrets(&self) -> Result<Vec<String>> {
|
||||
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
fn list_secrets(&self, config: &ProviderConfig) -> Result<Vec<String>> {
|
||||
let vault_path = active_vault_path(config)?;
|
||||
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
|
||||
let keys: Vec<String> = vault.keys().cloned().collect();
|
||||
|
||||
Ok(keys)
|
||||
@@ -190,6 +196,54 @@ impl SecretProvider for LocalProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_vault_path() -> Result<PathBuf> {
|
||||
confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir")
|
||||
}
|
||||
|
||||
fn base_config_dir() -> Result<PathBuf> {
|
||||
default_vault_path()?
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| anyhow!("Failed to determine config dir"))
|
||||
}
|
||||
|
||||
fn repo_dir_for_config(config: &ProviderConfig) -> Result<Option<PathBuf>> {
|
||||
if let Some(remote) = &config.git_remote_url {
|
||||
let name = repo_name_from_url(remote);
|
||||
let dir = base_config_dir()?.join(format!(".{}", name));
|
||||
Ok(Some(dir))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn active_vault_path(config: &ProviderConfig) -> Result<PathBuf> {
|
||||
if let Some(dir) = repo_dir_for_config(config)?
|
||||
&& dir.exists()
|
||||
{
|
||||
return Ok(dir.join("vault.yml"));
|
||||
}
|
||||
|
||||
default_vault_path()
|
||||
}
|
||||
|
||||
fn load_vault(path: &Path) -> Result<HashMap<String, String>> {
|
||||
if !path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let s = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
let map: HashMap<String, String> = serde_yaml::from_str(&s).unwrap_or_default();
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn store_vault(path: &Path, map: &HashMap<String, String>) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?;
|
||||
fs::write(path, s).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
|
||||
@@ -34,8 +34,8 @@ pub trait SecretProvider {
|
||||
self.name()
|
||||
))
|
||||
}
|
||||
fn delete_secret(&self, key: &str) -> Result<()>;
|
||||
fn list_secrets(&self) -> Result<Vec<String>> {
|
||||
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()>;
|
||||
fn list_secrets(&self, _config: &ProviderConfig) -> Result<Vec<String>> {
|
||||
Err(anyhow!(
|
||||
"list secrets is not supported for the provider {}",
|
||||
self.name()
|
||||
|
||||
Reference in New Issue
Block a user