#![warn(clippy::pedantic)]
mod backends;
mod generation;
mod inventory;
use crate::backends::SecretBackend;
use clap::{Parser, Subcommand};
use cofre_types::{BackendKind, RotationPolicy, SecretMaterializationPlan, SecretRef};
#[derive(Parser, Debug)]
#[command(version, about = "Typed secret materialization — never see your secrets again.", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Plan {
#[arg(long)]
manifest: String,
},
Apply {
#[arg(long)]
manifest: String,
#[arg(long)]
rotate: Option<String>,
#[arg(long)]
mock: bool,
},
Verify {
#[arg(long)]
manifest: String,
#[arg(long)]
mock: bool,
},
Inventory {
#[arg(long)]
manifest: String,
},
#[command(name = "__sops-editor-hook", hide = true)]
SopsEditorHook { path: String },
}
fn main() {
let cli = Cli::parse();
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let exit_code = rt.block_on(async {
match cli.command {
Command::Plan { manifest } => cmd_plan(&manifest).await,
Command::Apply {
manifest,
rotate,
mock,
} => cmd_apply(&manifest, rotate.as_deref(), mock).await,
Command::Verify { manifest, mock } => cmd_verify(&manifest, mock).await,
Command::Inventory { manifest } => cmd_inventory(&manifest).await,
Command::SopsEditorHook { path } => cmd_sops_editor_hook(&path),
}
});
std::process::exit(exit_code);
}
fn load_plan(path: &str) -> Option<SecretMaterializationPlan> {
match std::fs::read_to_string(path) {
Ok(body) => match SecretMaterializationPlan::from_yaml(&body) {
Ok(p) => Some(p),
Err(e) => {
eprintln!("error: parse plan: {e}");
None
}
},
Err(e) => {
eprintln!("error: read {path}: {e}");
None
}
}
}
async fn cmd_plan(manifest: &str) -> i32 {
let Some(plan) = load_plan(manifest) else {
return 2;
};
println!(
"plan: {} ({} secret(s))",
plan.metadata.name,
plan.secrets.len()
);
if let Some(src) = &plan.metadata.source {
println!("source: {src}");
}
for s in &plan.secrets {
let policy = match &s.generation {
None => "external".to_string(),
Some(g) => format!("{g:?}"),
};
println!(
" - {} [backend: {}] [gen: {}] [rotation: {:?}]",
s.name,
s.backend.stable_id(),
policy,
s.rotation
);
}
eprintln!("note: plan-time inspection — no backends contacted, no secrets generated.");
0
}
async fn cmd_apply(manifest: &str, rotate: Option<&str>, mock: bool) -> i32 {
let Some(plan) = load_plan(manifest) else {
return 2;
};
if let Some(name) = rotate {
match plan.secrets.iter().find(|s| s.name == name) {
Some(s) if s.rotation == RotationPolicy::Never => {
eprintln!(
"error: secret {name:?} has RotationPolicy::Never — cofre refuses to rotate it"
);
return 4;
}
Some(_) => {}
None => {
eprintln!("error: --rotate target {name:?} not found in plan");
return 4;
}
}
}
let mut akeyless: Option<backends::AkeylessBackend> = None;
let mock_backend = if mock {
Some(backends::MockBackend::new())
} else {
None
};
let mut sops_batches: std::collections::HashMap<String, Vec<backends::SopsHookEntry>> =
std::collections::HashMap::new();
let argv0 = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("cofre"));
let mut wrote = 0;
let mut skipped = 0;
let mut errored = 0;
for s in &plan.secrets {
let force = rotate == Some(s.name.as_str());
let Some(policy) = s.generation.clone() else {
skipped += 1;
continue;
};
match &s.backend {
BackendKind::Akeyless { .. } => {
if mock {
let mb = mock_backend.as_ref().unwrap();
if let Err(e) = apply_one_via_backend(mb, s, &policy, force).await {
eprintln!("error: {} via mock: {e}", s.name);
errored += 1;
continue;
}
} else {
if akeyless.is_none() {
match backends::AkeylessBackend::from_env().await {
Ok(b) => akeyless = Some(b),
Err(e) => {
eprintln!("error: akeyless auth: {e}");
return 5;
}
}
}
let ab = akeyless.as_ref().unwrap();
if let Err(e) = apply_one_via_backend(ab, s, &policy, force).await {
eprintln!("error: {} via akeyless: {e}", s.name);
errored += 1;
continue;
}
}
wrote += 1;
}
BackendKind::Sops { file, yaml_path } => {
sops_batches
.entry(file.clone())
.or_default()
.push(backends::SopsHookEntry {
yaml_path: yaml_path.clone(),
policy,
force,
});
}
BackendKind::Mock { .. } => {
if !mock {
eprintln!(
"error: secret {} targets a Mock backend but --mock not given",
s.name
);
return 4;
}
let mb = mock_backend.as_ref().unwrap();
if let Err(e) = apply_one_via_backend(mb, s, &policy, force).await {
eprintln!("error: {} via mock: {e}", s.name);
errored += 1;
continue;
}
wrote += 1;
}
}
}
for (file, entries) in &sops_batches {
let backend = match backends::SopsBackend::new(file.clone(), argv0.clone()) {
Ok(b) => b,
Err(e) => {
eprintln!("error: sops backend init for {file}: {e}");
errored += entries.len();
continue;
}
};
match backend.apply_batch(entries).await {
Ok(true) => wrote += entries.len(),
Ok(false) => skipped += entries.len(),
Err(e) => {
eprintln!("error: sops apply for {file}: {e}");
errored += entries.len();
}
}
}
println!(
"wrote: {} | skipped: {} | errored: {} | (rotate: {:?})",
wrote, skipped, errored, rotate
);
if errored > 0 { 1 } else { 0 }
}
async fn apply_one_via_backend(
backend: &dyn backends::SecretBackend,
secret: &SecretRef,
policy: &cofre_types::SecretGenPolicy,
force: bool,
) -> Result<bool, String> {
let exists = backend
.exists(secret)
.await
.map_err(|e| format!("exists: {e}"))?;
if exists && !force {
return Ok(false);
}
let value = generation::generate(policy).map_err(|e| format!("generate: {e}"))?;
backend
.write(secret, value)
.await
.map_err(|e| format!("write: {e}"))?;
Ok(true)
}
async fn cmd_verify(manifest: &str, mock: bool) -> i32 {
let Some(plan) = load_plan(manifest) else {
return 2;
};
let akeyless = if mock {
None
} else {
match backends::AkeylessBackend::from_env().await {
Ok(b) => Some(b),
Err(e) => {
eprintln!("error: akeyless auth: {e}");
return 5;
}
}
};
let mock_backend = if mock {
Some(backends::MockBackend::new())
} else {
None
};
let mut missing = 0;
let mut present = 0;
for s in &plan.secrets {
let exists_result = match &s.backend {
BackendKind::Akeyless { .. } => {
if mock {
Ok(false)
} else {
akeyless.as_ref().unwrap().exists(s).await
}
}
BackendKind::Mock { .. } => mock_backend.as_ref().unwrap().exists(s).await,
BackendKind::Sops { .. } => {
eprintln!(
" ? {} (sops backend — `verify` doesn't decrypt; run `apply` for idempotent reconciliation)",
s.name
);
continue;
}
};
match exists_result {
Ok(true) => {
println!(" ✓ {} (present)", s.name);
present += 1;
}
Ok(false) => {
println!(" × {} (missing)", s.name);
missing += 1;
}
Err(e) => {
println!(" ! {} (error: {})", s.name, e);
missing += 1;
}
}
}
println!("present: {} | missing: {}", present, missing);
if missing > 0 { 1 } else { 0 }
}
async fn cmd_inventory(manifest: &str) -> i32 {
let Some(plan) = load_plan(manifest) else {
return 2;
};
eprintln!(
"note: inventory emits structural-only entries; value hashing lands when rotation tracking ships."
);
let entries: Vec<inventory::InventoryEntry> = plan
.secrets
.iter()
.map(|s| inventory::InventoryEntry {
name: s.name.clone(),
backend: s.backend.stable_id(),
salt_hex: String::new(),
value_hash_hex: String::new(),
last_applied_utc: String::new(),
rotation: s.rotation,
})
.collect();
let inv = inventory::Inventory {
plan: plan.metadata.name.clone(),
entries,
};
match serde_yaml::to_string(&inv) {
Ok(s) => {
println!("{s}");
0
}
Err(e) => {
eprintln!("error: inventory serialize: {e}");
6
}
}
}
fn cmd_sops_editor_hook(path: &str) -> i32 {
let p = std::path::Path::new(path);
match backends::run_editor_hook(p) {
Ok(()) => 0,
Err(e) => {
eprintln!("__sops-editor-hook: {e}");
7
}
}
}