use anyhow::Result;
use colored::Colorize;
use tsafe_core::pushconfig::{self, PushSource};
#[cfg(feature = "akv-pull")]
use crate::cmd_kv_push::cmd_kv_push;
use crate::cli::PushOnError;
fn source_display_label(source: &PushSource, index: usize, total: usize) -> String {
match source.name() {
Some(n) => format!("[{n}]"),
None => format!("[{}/{}]", index + 1, total),
}
}
fn unsupported_source_name(source: &PushSource) -> Option<&'static str> {
match source {
PushSource::Kv { .. } => {
if cfg!(feature = "akv-pull") {
None
} else {
Some("Azure Key Vault")
}
}
PushSource::Aws { .. } => Some("AWS Secrets Manager (aws-push not yet compiled)"),
PushSource::Ssm { .. } => Some("AWS SSM Parameter Store (ssm-push not yet compiled)"),
PushSource::Gcp { .. } => Some("GCP Secret Manager (gcp-push not yet compiled)"),
}
}
fn unsupported_source_error(source: &PushSource) -> anyhow::Error {
let source_name = unsupported_source_name(source).unwrap_or("unknown source");
anyhow::anyhow!(
"{source_name} is not available in this tsafe build\n\
\n This build supports Azure Key Vault push via `tsafe kv-push`.\n\
\n Fix: remove this source from the push config or install a build with the required push feature.\n\
\n Check compiled capabilities: tsafe build-info"
)
}
fn print_dry_run(cfg_path: &std::path::Path, sources: &[&PushSource]) {
println!("Dry run — no live API calls will be made, no secrets will be written.");
println!("Config: {}", cfg_path.display());
println!();
if sources.is_empty() {
println!(" (no sources match the current filter)");
return;
}
println!("Sources that would be invoked (in manifest order):");
for (i, source) in sources.iter().enumerate() {
let label = match source.name() {
Some(n) => format!("name={n}"),
None => format!("index={}", i + 1),
};
let delete_note = if source.delete_missing() {
" [delete-missing]"
} else {
""
};
match source {
PushSource::Kv {
vault_url, prefix, ..
} => {
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] akv: {vault_url}{prefix_note}{delete_note}");
}
PushSource::Aws { region, prefix, .. } => {
let region_str = region.as_deref().unwrap_or("(from env)");
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] aws: region={region_str}{prefix_note}{delete_note}");
}
PushSource::Ssm { region, path, .. } => {
let region_str = region.as_deref().unwrap_or("(from env)");
let path_str = path.as_deref().unwrap_or("/");
println!(" {i}. [{label}] ssm: region={region_str} path={path_str}{delete_note}");
}
PushSource::Gcp {
project, prefix, ..
} => {
let project_str = project.as_deref().unwrap_or("(from env)");
let prefix_note = prefix
.as_deref()
.map(|p| format!(" prefix={p}"))
.unwrap_or_default();
println!(" {i}. [{label}] gcp: project={project_str}{prefix_note}{delete_note}");
}
}
}
println!();
println!(
"Note: pre-flight diff requires a live run. Run without --dry-run to see what would change."
);
}
#[tracing::instrument(skip_all, fields(source_count, dry_run))]
pub(crate) fn cmd_push(
profile: &str,
config_path: Option<&std::path::Path>,
source_filter: &[String],
dry_run: bool,
yes: bool,
global_delete_missing: bool,
on_error: PushOnError,
) -> Result<()> {
let cfg_path = match config_path {
Some(p) => p.to_path_buf(),
None => {
let cwd = std::env::current_dir()?;
pushconfig::find_config(&cwd).ok_or_else(|| {
anyhow::anyhow!(
"no .tsafe.yml / .tsafe.json found (searched upward from {})\n\
\n Fix: create a .tsafe.yml with a `pushes:` section in your project root.\n\
\n Example:\n pushes:\n - source: akv\n vault_url: https://myvault.vault.azure.net",
cwd.display()
)
})?
}
};
let cfg = pushconfig::load(&cfg_path).map_err(|e| {
let msg = e.to_string();
if msg.contains("missing field `pushes`") || msg.contains("pushes") {
anyhow::anyhow!(
"no `pushes:` key found in {} — add a `pushes:` section.\n\
\n Example:\n pushes:\n - source: akv\n vault_url: https://myvault.vault.azure.net\n\
\n Original error: {msg}",
cfg_path.display()
)
} else {
anyhow::anyhow!("{msg}")
}
})?;
let filtered_sources: Vec<&PushSource> = if source_filter.is_empty() {
cfg.pushes.iter().collect()
} else {
cfg.pushes
.iter()
.filter(|s| {
s.name()
.map(|n| source_filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect()
};
tracing::Span::current().record("source_count", filtered_sources.len());
tracing::Span::current().record("dry_run", dry_run);
if dry_run {
print_dry_run(&cfg_path, &filtered_sources);
return Ok(());
}
println!("Using config: {}", cfg_path.display());
if !source_filter.is_empty() {
println!("Source filter active: {}", source_filter.join(", "));
}
let total_sources = filtered_sources.len();
let mut successes = 0usize;
let mut failures = 0usize;
for (i, source) in filtered_sources.iter().enumerate() {
let source_label = source_display_label(source, i, total_sources);
if unsupported_source_name(source).is_some() {
let err = unsupported_source_error(source);
failures += 1;
match on_error {
PushOnError::FailAll => return Err(err),
PushOnError::SkipFailed => {
eprintln!("{} Source {source_label} skipped: {err}", "!".yellow());
continue;
}
}
}
let effective_delete_missing = global_delete_missing || source.delete_missing();
let run_result: Result<()> = match source {
#[cfg(feature = "akv-pull")]
PushSource::Kv {
vault_url, prefix, ..
} => {
println!("\n{source_label} Azure Key Vault: {vault_url}");
std::env::set_var("TSAFE_AKV_URL", vault_url);
cmd_kv_push(
profile,
prefix.as_deref(),
None, false, yes,
effective_delete_missing,
)
}
#[allow(unreachable_patterns)]
_ => Err(unsupported_source_error(source)),
};
if let Err(err) = run_result {
failures += 1;
match on_error {
PushOnError::FailAll => return Err(err),
PushOnError::SkipFailed => {
eprintln!("{} Source {source_label} failed: {err}", "!".yellow());
}
}
} else {
successes += 1;
println!("{} Source {source_label} succeeded", "✓".green());
}
}
println!(
"\n{} Push complete ({} sources)",
"✓".green(),
total_sources
);
println!(
"{} source summary: {} succeeded, {} failed",
"•".cyan(),
successes,
failures
);
if failures > 0 {
eprintln!(
"{} {} source(s) failed (mode: {:?})",
"!".yellow(),
failures,
on_error
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_source_returns_none_for_akv_when_feature_is_on() {
let source = PushSource::Kv {
name: None,
vault_url: "https://example.vault.azure.net".into(),
prefix: None,
delete_missing: false,
};
let result = unsupported_source_name(&source);
if cfg!(feature = "akv-pull") {
assert_eq!(result, None);
} else {
assert!(result.is_some());
}
}
#[test]
fn unsupported_source_returns_some_for_aws_ssm_gcp() {
let cases = [
PushSource::Aws {
name: None,
region: Some("us-east-1".into()),
prefix: None,
delete_missing: false,
},
PushSource::Ssm {
name: None,
region: Some("us-east-1".into()),
path: Some("/".into()),
delete_missing: false,
},
PushSource::Gcp {
name: None,
project: Some("demo".into()),
prefix: None,
delete_missing: false,
},
];
for source in &cases {
assert!(
unsupported_source_name(source).is_some(),
"expected unsupported for {:?}",
source.provider_type()
);
}
}
#[test]
fn source_filter_selects_named_sources_only() {
let sources = [
PushSource::Kv {
name: Some("prod-akv".into()),
vault_url: "https://prod.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Kv {
name: Some("staging-akv".into()),
vault_url: "https://staging.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Kv {
name: None,
vault_url: "https://anon.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
];
let filter = ["prod-akv".to_string()];
let filtered: Vec<&PushSource> = sources
.iter()
.filter(|s| {
s.name()
.map(|n| filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name(), Some("prod-akv"));
}
#[test]
fn sequential_execution_order_is_preserved() {
let sources = [
PushSource::Kv {
name: Some("first".into()),
vault_url: "https://first.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Kv {
name: Some("second".into()),
vault_url: "https://second.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Kv {
name: Some("third".into()),
vault_url: "https://third.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
];
let filtered: Vec<&PushSource> = sources.iter().collect();
assert_eq!(filtered.len(), 3);
let names: Vec<Option<&str>> = filtered.iter().map(|s| s.name()).collect();
assert_eq!(names, vec![Some("first"), Some("second"), Some("third")]);
let labels: Vec<String> = filtered
.iter()
.enumerate()
.map(|(i, s)| source_display_label(s, i, filtered.len()))
.collect();
assert_eq!(labels, vec!["[first]", "[second]", "[third]"]);
}
#[test]
fn empty_source_filter_includes_all_sources() {
let sources = [
PushSource::Kv {
name: Some("a".into()),
vault_url: "https://a.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Kv {
name: None,
vault_url: "https://b.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
];
let filter: Vec<String> = Vec::new();
let filtered: Vec<&PushSource> = if filter.is_empty() {
sources.iter().collect()
} else {
sources
.iter()
.filter(|s| {
s.name()
.map(|n| filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect()
};
assert_eq!(filtered.len(), 2);
}
}