use std::collections::BTreeSet;
use crate::config::ResolvedSecret;
use crate::deploy_tracker::{DeployIndex, DeployStatus};
#[derive(Debug, Clone)]
pub struct TargetOrphan {
pub tracker_key: String,
pub key: String,
pub service: String,
pub app: Option<String>,
pub env: String,
pub last_deployed_at: String,
}
impl TargetOrphan {
pub fn target_display(&self) -> String {
crate::config::format_target_label(&self.service, self.app.as_deref())
}
}
pub fn detect(
index: &DeployIndex,
resolved: &[ResolvedSecret],
env_filter: Option<&str>,
) -> Vec<TargetOrphan> {
let mut expected: BTreeSet<String> = BTreeSet::new();
for secret in resolved {
for target in &secret.targets {
let tk = DeployIndex::tracker_key(
&secret.key,
&target.service,
target.app.as_deref(),
&target.environment,
);
expected.insert(tk);
}
}
let mut orphans = Vec::new();
for (tracker_key, record) in &index.records {
if expected.contains(tracker_key) {
continue;
}
if record.value_hash == DeployIndex::TOMBSTONE_HASH
&& record.last_deploy_status == DeployStatus::Success
{
continue;
}
let Some(parts) = DeployIndex::parse_tracker_key(tracker_key) else {
continue;
};
if let Some(filter) = env_filter {
if parts.env != filter {
continue;
}
}
orphans.push(TargetOrphan {
tracker_key: tracker_key.clone(),
key: parts.key,
service: parts.service,
app: parts.app,
env: parts.env,
last_deployed_at: record.last_deployed_at.clone(),
});
}
orphans
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ResolvedTarget;
use crate::deploy_tracker::DeployIndex;
use std::path::Path;
fn make_resolved(key: &str, service: &str, app: Option<&str>, env: &str) -> ResolvedSecret {
ResolvedSecret {
key: key.to_string(),
group: "Test".to_string(),
description: None,
targets: vec![ResolvedTarget {
service: service.to_string(),
app: app.map(std::string::ToString::to_string),
environment: env.to_string(),
}],
validate: None,
required: crate::config::Required::All,
allow_empty: false,
}
}
#[test]
fn no_orphans_when_index_matches_config() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_success(
"API_KEY:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
"hash1".to_string(),
);
let resolved = vec![make_resolved("API_KEY", "fly", Some("web"), "dev")];
let orphans = detect(&index, &resolved, None);
assert!(orphans.is_empty());
}
#[test]
fn secret_removed_from_config_detected() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_success(
"OLD_KEY:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
"hash1".to_string(),
);
let resolved = vec![make_resolved("API_KEY", "fly", Some("web"), "dev")];
let orphans = detect(&index, &resolved, None);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].key, "OLD_KEY");
assert_eq!(orphans[0].service, "fly");
assert_eq!(orphans[0].app.as_deref(), Some("web"));
assert_eq!(orphans[0].env, "dev");
}
#[test]
fn target_removed_from_secret_detected() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_success(
"API_KEY:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
"hash1".to_string(),
);
index.record_success(
"API_KEY:cloudflare:dev".to_string(),
"cloudflare:dev".to_string(),
"hash1".to_string(),
);
let resolved = vec![make_resolved("API_KEY", "fly", Some("web"), "dev")];
let orphans = detect(&index, &resolved, None);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].service, "cloudflare");
}
#[test]
fn successful_tombstone_excluded() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_success(
"OLD_KEY:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
DeployIndex::TOMBSTONE_HASH.to_string(),
);
let resolved: Vec<ResolvedSecret> = vec![];
let orphans = detect(&index, &resolved, None);
assert!(orphans.is_empty());
}
#[test]
fn failed_tombstone_included() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_failure(
"OLD_KEY:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
DeployIndex::TOMBSTONE_HASH.to_string(),
"timeout".to_string(),
);
let resolved: Vec<ResolvedSecret> = vec![];
let orphans = detect(&index, &resolved, None);
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].key, "OLD_KEY");
}
#[test]
fn env_filter_applied() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_success(
"OLD_KEY:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
"hash1".to_string(),
);
index.record_success(
"OLD_KEY:fly:web:prod".to_string(),
"fly:web:prod".to_string(),
"hash1".to_string(),
);
let resolved: Vec<ResolvedSecret> = vec![];
let orphans = detect(&index, &resolved, Some("prod"));
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].env, "prod");
}
#[test]
fn empty_deploy_index_no_orphans() {
let index = DeployIndex::new(Path::new("/tmp/test.json"));
let resolved = vec![make_resolved("API_KEY", "fly", Some("web"), "dev")];
let orphans = detect(&index, &resolved, None);
assert!(orphans.is_empty());
}
#[test]
fn empty_config_all_records_are_orphans() {
let mut index = DeployIndex::new(Path::new("/tmp/test.json"));
index.record_success(
"KEY_A:fly:web:dev".to_string(),
"fly:web:dev".to_string(),
"hash1".to_string(),
);
index.record_success(
"KEY_B:cloudflare:prod".to_string(),
"cloudflare:prod".to_string(),
"hash2".to_string(),
);
let resolved: Vec<ResolvedSecret> = vec![];
let orphans = detect(&index, &resolved, None);
assert_eq!(orphans.len(), 2);
}
}