#![allow(clippy::module_name_repetitions)]
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use secretenv_core::{BackendUri, Config};
use tokio::fs;
use crate::backends_init::build_registry;
use crate::doctor::run_doctor;
#[derive(Debug, Clone)]
pub struct SetupOpts {
pub registry_uri: String,
pub region: Option<String>,
pub profile: Option<String>,
pub account: Option<String>,
pub vault_address: Option<String>,
pub vault_namespace: Option<String>,
pub gcp_project: Option<String>,
pub gcp_impersonate_service_account: Option<String>,
pub azure_vault_url: Option<String>,
pub azure_tenant: Option<String>,
pub azure_subscription: Option<String>,
pub force: bool,
pub skip_doctor: bool,
pub target: Option<PathBuf>,
}
pub async fn run_setup(opts: &SetupOpts) -> Result<()> {
let uri = BackendUri::parse(&opts.registry_uri)
.with_context(|| format!("parsing registry URI '{}'", opts.registry_uri))?;
if uri.is_alias() {
bail!(
"registry URI must be a direct backend URI, not secretenv://<alias> — \
pass something like 'aws-ssm-prod:///registries/shared' or 'local:///path/to/r.toml'"
);
}
let backend_type = backend_type_from_scheme(&uri.scheme)?;
if backend_type == "aws-ssm" && opts.region.is_none() {
bail!(
"aws-ssm backends require --region (e.g. `secretenv setup {} --region us-east-1`)",
opts.registry_uri
);
}
if backend_type == "aws-secrets" && opts.region.is_none() {
bail!(
"aws-secrets backends require --region \
(e.g. `secretenv setup {} --region us-east-1`)",
opts.registry_uri
);
}
if backend_type == "vault" && opts.vault_address.is_none() {
bail!(
"vault backends require --vault-address \
(e.g. `secretenv setup {} --vault-address https://vault.company.com`)",
opts.registry_uri
);
}
if backend_type == "gcp" && opts.gcp_project.is_none() {
bail!(
"gcp backends require --gcp-project \
(e.g. `secretenv setup {} --gcp-project my-project-prod`)",
opts.registry_uri
);
}
if backend_type == "azure" && opts.azure_vault_url.is_none() {
bail!(
"azure backends require --azure-vault-url \
(e.g. `secretenv setup {} --azure-vault-url https://my-kv.vault.azure.net/`)",
opts.registry_uri
);
}
let target = resolve_target(opts.target.as_deref())?;
if target.exists() && !opts.force {
bail!(
"config already exists at '{}' — use --force to overwrite, \
or edit the file manually",
target.display()
);
}
let content = build_config_toml(&uri, backend_type, opts);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating parent directory '{}'", parent.display()))?;
}
fs::write(&target, &content)
.await
.with_context(|| format!("writing config.toml to '{}'", target.display()))?;
println!("wrote config to '{}'", target.display());
if !opts.skip_doctor {
println!();
let config = Config::load_from(&target)
.with_context(|| format!("reloading just-written config at '{}'", target.display()))?;
let backends = build_registry(&config)?;
if let Err(err) = run_doctor(&config, &backends, false).await {
eprintln!(
"\nNote: {err:#}. Fix the underlying issue and re-run `secretenv doctor` \
to verify."
);
}
}
Ok(())
}
fn backend_type_from_scheme(scheme: &str) -> Result<&'static str> {
if scheme == "local" {
Ok("local")
} else if scheme == "aws-ssm" || scheme.starts_with("aws-ssm-") {
Ok("aws-ssm")
} else if scheme == "aws-secrets" || scheme.starts_with("aws-secrets-") {
Ok("aws-secrets")
} else if scheme == "1password" || scheme.starts_with("1password-") {
Ok("1password")
} else if scheme == "vault" || scheme.starts_with("vault-") {
Ok("vault")
} else if scheme == "gcp" || scheme.starts_with("gcp-") {
Ok("gcp")
} else if scheme == "azure" || scheme.starts_with("azure-") {
Ok("azure")
} else {
bail!(
"unknown backend scheme '{scheme}' — supported: local, aws-ssm(-*), \
aws-secrets(-*), 1password(-*), vault(-*), gcp(-*), azure(-*). \
Did you mean one of these?"
)
}
}
fn resolve_target(override_path: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = override_path {
return Ok(p.to_path_buf());
}
let base = directories::BaseDirs::new().ok_or_else(|| {
anyhow::anyhow!("could not determine a home directory for XDG config lookup")
})?;
Ok(base.config_dir().join("secretenv").join("config.toml"))
}
#[allow(clippy::unwrap_used)]
fn build_config_toml(uri: &BackendUri, backend_type: &str, opts: &SetupOpts) -> String {
let mut out = String::new();
writeln!(out, "# secretenv config — generated by `secretenv setup`").unwrap();
writeln!(out, "# Edit freely; re-run `secretenv doctor` after changes.\n").unwrap();
writeln!(out, "[registries.default]").unwrap();
writeln!(out, "sources = [{}]\n", toml_string(&uri.raw)).unwrap();
writeln!(out, "[backends.{}]", toml_key(&uri.scheme)).unwrap();
writeln!(out, "type = {}", toml_string(backend_type)).unwrap();
match backend_type {
"aws-ssm" | "aws-secrets" => {
if let Some(r) = &opts.region {
writeln!(out, "aws_region = {}", toml_string(r)).unwrap();
}
if let Some(p) = &opts.profile {
writeln!(out, "aws_profile = {}", toml_string(p)).unwrap();
}
}
"1password" => {
if let Some(a) = &opts.account {
writeln!(out, "op_account = {}", toml_string(a)).unwrap();
}
}
"vault" => {
if let Some(addr) = &opts.vault_address {
writeln!(out, "vault_address = {}", toml_string(addr)).unwrap();
}
if let Some(ns) = &opts.vault_namespace {
writeln!(out, "vault_namespace = {}", toml_string(ns)).unwrap();
}
}
"gcp" => {
if let Some(p) = &opts.gcp_project {
writeln!(out, "gcp_project = {}", toml_string(p)).unwrap();
}
if let Some(sa) = &opts.gcp_impersonate_service_account {
writeln!(out, "gcp_impersonate_service_account = {}", toml_string(sa)).unwrap();
}
}
"azure" => {
if let Some(u) = &opts.azure_vault_url {
writeln!(out, "azure_vault_url = {}", toml_string(u)).unwrap();
}
if let Some(t) = &opts.azure_tenant {
writeln!(out, "azure_tenant = {}", toml_string(t)).unwrap();
}
if let Some(s) = &opts.azure_subscription {
writeln!(out, "azure_subscription = {}", toml_string(s)).unwrap();
}
}
_ => {}
}
out
}
fn toml_string(s: &str) -> String {
toml::Value::String(s.to_owned()).to_string()
}
fn toml_key(s: &str) -> String {
if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
s.to_owned()
} else {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn opts(uri: &str) -> SetupOpts {
SetupOpts {
registry_uri: uri.to_owned(),
region: None,
profile: None,
account: None,
vault_address: None,
vault_namespace: None,
gcp_project: None,
gcp_impersonate_service_account: None,
azure_vault_url: None,
azure_tenant: None,
azure_subscription: None,
force: false,
skip_doctor: true,
target: None,
}
}
#[test]
fn scheme_local_maps_to_local() {
assert_eq!(backend_type_from_scheme("local").unwrap(), "local");
}
#[test]
fn scheme_aws_ssm_bare_maps_to_aws_ssm() {
assert_eq!(backend_type_from_scheme("aws-ssm").unwrap(), "aws-ssm");
}
#[test]
fn scheme_aws_ssm_suffixed_maps_to_aws_ssm() {
assert_eq!(backend_type_from_scheme("aws-ssm-prod").unwrap(), "aws-ssm");
assert_eq!(backend_type_from_scheme("aws-ssm-dev-staging").unwrap(), "aws-ssm");
}
#[test]
fn scheme_1password_bare_and_suffixed() {
assert_eq!(backend_type_from_scheme("1password").unwrap(), "1password");
assert_eq!(backend_type_from_scheme("1password-personal").unwrap(), "1password");
assert_eq!(backend_type_from_scheme("1password-team").unwrap(), "1password");
}
#[test]
fn scheme_vault_bare_and_suffixed() {
assert_eq!(backend_type_from_scheme("vault").unwrap(), "vault");
assert_eq!(backend_type_from_scheme("vault-eng").unwrap(), "vault");
assert_eq!(backend_type_from_scheme("vault-payments").unwrap(), "vault");
}
#[test]
fn scheme_gcp_bare_and_suffixed() {
assert_eq!(backend_type_from_scheme("gcp").unwrap(), "gcp");
assert_eq!(backend_type_from_scheme("gcp-prod").unwrap(), "gcp");
}
#[test]
fn scheme_azure_bare_and_suffixed() {
assert_eq!(backend_type_from_scheme("azure").unwrap(), "azure");
assert_eq!(backend_type_from_scheme("azure-prod").unwrap(), "azure");
}
#[test]
fn scheme_unknown_errors() {
let err = backend_type_from_scheme("totally-made-up").unwrap_err();
assert!(format!("{err:#}").contains("unknown backend scheme"));
}
#[test]
fn toml_includes_registry_and_backend_for_local() {
let uri = BackendUri::parse("local:///tmp/registry.toml").unwrap();
let content = build_config_toml(&uri, "local", &opts("local:///tmp/registry.toml"));
assert!(content.contains("[registries.default]"));
assert!(content.contains("sources = [\"local:///tmp/registry.toml\"]"));
assert!(content.contains("[backends.local]"));
assert!(content.contains("type = \"local\""));
assert!(!content.contains("aws_region"));
assert!(!content.contains("op_account"));
}
#[test]
fn toml_includes_aws_region_when_provided() {
let uri = BackendUri::parse("aws-ssm-prod:///prod/registry").unwrap();
let mut o = opts("aws-ssm-prod:///prod/registry");
o.region = Some("us-east-1".into());
o.profile = Some("prod".into());
let content = build_config_toml(&uri, "aws-ssm", &o);
assert!(content.contains("[backends.aws-ssm-prod]"));
assert!(content.contains("type = \"aws-ssm\""));
assert!(content.contains("aws_region = \"us-east-1\""));
assert!(content.contains("aws_profile = \"prod\""));
}
#[test]
fn toml_includes_op_account_when_provided() {
let uri = BackendUri::parse("1password-team://Shared/Reg/body").unwrap();
let mut o = opts("1password-team://Shared/Reg/body");
o.account = Some("myteam.1password.com".into());
let content = build_config_toml(&uri, "1password", &o);
assert!(content.contains("[backends.1password-team]"));
assert!(content.contains("type = \"1password\""));
assert!(content.contains("op_account = \"myteam.1password.com\""));
}
#[test]
fn toml_roundtrips_through_config_loader() {
let uri = BackendUri::parse("aws-ssm-prod:///prod/r").unwrap();
let mut o = opts("aws-ssm-prod:///prod/r");
o.region = Some("us-east-1".into());
let content = build_config_toml(&uri, "aws-ssm", &o);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &content).unwrap();
let config = Config::load_from(tmp.path()).unwrap();
assert_eq!(config.registries["default"].sources.len(), 1);
let backend = &config.backends["aws-ssm-prod"];
assert_eq!(backend.backend_type, "aws-ssm");
assert_eq!(backend.raw_fields["aws_region"].as_str(), Some("us-east-1"));
}
#[test]
fn toml_key_keeps_bare_when_safe() {
assert_eq!(toml_key("aws-ssm-prod"), "aws-ssm-prod");
assert_eq!(toml_key("1password-team"), "1password-team");
assert_eq!(toml_key("local"), "local");
}
#[test]
fn toml_key_quotes_when_needed() {
assert_eq!(toml_key("has spaces"), "\"has spaces\"");
assert_eq!(toml_key("has.dot"), "\"has.dot\"");
}
}