1use crate::error::{AvError, Result};
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TaggedResource {
21 pub arn: String,
23 pub service: String,
25 pub resource_type: String,
27 pub name: String,
29}
30
31impl TaggedResource {
32 pub fn from_arn(arn: &str) -> Option<Self> {
48 let parts: Vec<&str> = arn.splitn(6, ':').collect();
50 if parts.len() < 6 || parts[0] != "arn" {
51 return None;
52 }
53 let service = parts[2].to_string();
54 let resource_part = parts[5];
55
56 let slash_pos = resource_part.find('/');
65 let colon_pos = resource_part.find(':');
66 let separator = match (slash_pos, colon_pos) {
67 (Some(s), Some(c)) if s < c => Some(('/', s)),
68 (Some(_), Some(c)) => Some((':', c)),
69 (Some(s), None) => Some(('/', s)),
70 (None, Some(c)) => Some((':', c)),
71 (None, None) => None,
72 };
73 let (resource_type, name) = if let Some((_, idx)) = separator {
74 let (t, rest) = resource_part.split_at(idx);
75 (t.to_string(), rest[1..].to_string())
77 } else {
78 let default_type = match service.as_str() {
80 "s3" => "bucket",
81 "sqs" => "queue",
82 "sns" => "topic",
83 _ => "resource",
84 };
85 (default_type.to_string(), resource_part.to_string())
86 };
87
88 Some(TaggedResource {
89 arn: arn.to_string(),
90 service,
91 resource_type,
92 name,
93 })
94 }
95}
96
97pub fn discover(session_id: &str) -> Result<Vec<TaggedResource>> {
108 crate::validate::session_id(session_id)?;
111
112 let regions: Vec<Option<String>> = match std::env::var("AUDEX_CLEANUP_REGIONS") {
113 Ok(csv) if !csv.trim().is_empty() => csv
114 .split(',')
115 .map(|r| r.trim().to_string())
116 .filter(|r| !r.is_empty())
117 .map(Some)
118 .collect(),
119 _ => vec![None],
120 };
121
122 let mut seen = std::collections::HashSet::new();
123 let mut all_resources = Vec::new();
124 for region in ®ions {
125 let found = discover_in_region(session_id, region.as_deref())?;
126 for r in found {
127 if seen.insert(r.arn.clone()) {
128 all_resources.push(r);
129 }
130 }
131 }
132 Ok(all_resources)
133}
134
135fn discover_in_region(session_id: &str, region: Option<&str>) -> Result<Vec<TaggedResource>> {
137 let filter = format!("Key=tryaudex-session,Values={session_id}");
138
139 #[derive(Deserialize)]
140 struct Response {
141 #[serde(rename = "ResourceTagMappingList", default)]
142 mappings: Vec<Mapping>,
143 #[serde(rename = "PaginationToken", default)]
144 pagination_token: Option<String>,
145 }
146 #[derive(Deserialize)]
147 struct Mapping {
148 #[serde(rename = "ResourceARN")]
149 arn: String,
150 }
151
152 let mut all_resources = Vec::new();
153 let mut pagination_token: Option<String> = None;
154
155 loop {
156 let mut cmd = Command::new("aws");
157 if let Some(r) = region {
158 cmd.args(["--region", r]);
159 }
160 cmd.args([
161 "resourcegroupstaggingapi",
162 "get-resources",
163 "--tag-filters",
164 &filter,
165 "--output",
166 "json",
167 ]);
168 if let Some(ref token) = pagination_token {
169 cmd.args(["--pagination-token", token]);
170 }
171
172 let output = cmd.output().map_err(AvError::Io)?;
173
174 if !output.status.success() {
175 return Err(AvError::InvalidPolicy(format!(
176 "aws resourcegroupstaggingapi failed: {}",
177 String::from_utf8_lossy(&output.stderr).trim()
178 )));
179 }
180
181 let parsed: Response = serde_json::from_slice(&output.stdout).map_err(|e| {
182 AvError::InvalidPolicy(format!("Failed to parse tagging API response: {e}"))
183 })?;
184
185 all_resources.extend(
186 parsed
187 .mappings
188 .into_iter()
189 .filter_map(|m| TaggedResource::from_arn(&m.arn)),
190 );
191
192 match parsed.pagination_token {
193 Some(ref t) if !t.is_empty() => pagination_token = Some(t.clone()),
194 _ => break,
195 }
196 }
197
198 Ok(all_resources)
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum DeleteOutcome {
204 Deleted,
205 DryRun,
206 Unsupported,
207 Failed(String),
208}
209
210fn region_from_arn(arn: &str) -> Option<&str> {
212 let parts: Vec<&str> = arn.split(':').collect();
213 if parts.len() >= 4 && !parts[3].is_empty() {
214 Some(parts[3])
215 } else {
216 None
217 }
218}
219
220pub fn delete_command(r: &TaggedResource) -> Option<Vec<String>> {
224 let s = |s: &str| s.to_string();
225 let mut cmd = match (r.service.as_str(), r.resource_type.as_str()) {
226 ("s3", "bucket") => Some(vec![
227 s("aws"),
228 s("s3"),
229 s("rb"),
230 format!("s3://{}", r.name),
231 ]),
234 ("dynamodb", "table") => Some(vec![
235 s("aws"),
236 s("dynamodb"),
237 s("delete-table"),
238 s("--table-name"),
239 r.name.clone(),
240 ]),
241 ("sqs", "queue") => Some(vec![
242 s("aws"),
243 s("sqs"),
244 s("delete-queue"),
245 s("--queue-url"),
246 sqs_url_from_arn(&r.arn)?,
249 ]),
250 ("sns", "topic") => Some(vec![
251 s("aws"),
252 s("sns"),
253 s("delete-topic"),
254 s("--topic-arn"),
255 r.arn.clone(),
256 ]),
257 ("lambda", "function") => Some(vec![
258 s("aws"),
259 s("lambda"),
260 s("delete-function"),
261 s("--function-name"),
262 r.name.clone(),
263 ]),
264 ("rds", "db") => {
265 let snap_id = format!("audex-cleanup-{}", r.name);
268 let snap_id: String = snap_id
269 .chars()
270 .map(|c| {
271 if c.is_ascii_alphanumeric() || c == '-' {
272 c
273 } else {
274 '-'
275 }
276 })
277 .take(255)
278 .collect();
279 Some(vec![
280 s("aws"),
281 s("rds"),
282 s("delete-db-instance"),
283 s("--db-instance-identifier"),
284 r.name.clone(),
285 s("--final-db-snapshot-identifier"),
286 snap_id,
287 ])
288 }
289 ("iam", "role") => Some(vec![
290 s("aws"),
291 s("iam"),
292 s("delete-role"),
293 s("--role-name"),
294 r.name.clone(),
295 ]),
296 ("iam", "user") => Some(vec![
297 s("aws"),
298 s("iam"),
299 s("delete-user"),
300 s("--user-name"),
301 r.name.clone(),
302 ]),
303 ("iam", "policy") => Some(vec![
304 s("aws"),
305 s("iam"),
306 s("delete-policy"),
307 s("--policy-arn"),
308 r.arn.clone(),
309 ]),
310 ("secretsmanager", "secret") => Some(vec![
311 s("aws"),
312 s("secretsmanager"),
313 s("delete-secret"),
314 s("--secret-id"),
315 r.arn.clone(),
316 ]),
319 ("ssm", "parameter") => Some(vec![
320 s("aws"),
321 s("ssm"),
322 s("delete-parameter"),
323 s("--name"),
324 r.name.clone(),
325 ]),
326 ("logs", "log-group") => Some(vec![
327 s("aws"),
328 s("logs"),
329 s("delete-log-group"),
330 s("--log-group-name"),
331 r.name.clone(),
332 ]),
333 ("cloudformation", "stack") => Some(vec![
334 s("aws"),
335 s("cloudformation"),
336 s("delete-stack"),
337 s("--stack-name"),
338 r.name.clone(),
339 ]),
340 ("ecr", "repository") => Some(vec![
341 s("aws"),
342 s("ecr"),
343 s("delete-repository"),
344 s("--repository-name"),
345 r.name.clone(),
346 s("--force"),
347 ]),
348 ("kms", "key") => Some(vec![
349 s("aws"),
350 s("kms"),
351 s("schedule-key-deletion"),
352 s("--key-id"),
353 r.name.clone(),
354 s("--pending-window-in-days"),
355 s("7"),
356 ]),
357 ("ec2", "instance") => Some(vec![
359 s("aws"),
360 s("ec2"),
361 s("terminate-instances"),
362 s("--instance-ids"),
363 r.name.clone(),
364 ]),
365 ("ec2", "volume") => Some(vec![
366 s("aws"),
367 s("ec2"),
368 s("delete-volume"),
369 s("--volume-id"),
370 r.name.clone(),
371 ]),
372 ("ec2", "security-group") => Some(vec![
373 s("aws"),
374 s("ec2"),
375 s("delete-security-group"),
376 s("--group-id"),
377 r.name.clone(),
378 ]),
379 ("ec2", "vpc") => Some(vec![
380 s("aws"),
381 s("ec2"),
382 s("delete-vpc"),
383 s("--vpc-id"),
384 r.name.clone(),
385 ]),
386 ("ec2", "subnet") => Some(vec![
387 s("aws"),
388 s("ec2"),
389 s("delete-subnet"),
390 s("--subnet-id"),
391 r.name.clone(),
392 ]),
393 ("ec2", "internet-gateway") => Some(vec![
394 s("aws"),
395 s("ec2"),
396 s("delete-internet-gateway"),
397 s("--internet-gateway-id"),
398 r.name.clone(),
399 ]),
400 ("ec2", "natgateway") => Some(vec![
401 s("aws"),
402 s("ec2"),
403 s("delete-nat-gateway"),
404 s("--nat-gateway-id"),
405 r.name.clone(),
406 ]),
407 ("ec2", "route-table") => Some(vec![
408 s("aws"),
409 s("ec2"),
410 s("delete-route-table"),
411 s("--route-table-id"),
412 r.name.clone(),
413 ]),
414 ("ec2", "key-pair") => Some(vec![
415 s("aws"),
416 s("ec2"),
417 s("delete-key-pair"),
418 s("--key-pair-id"),
419 r.name.clone(),
420 ]),
421 ("ec2", "snapshot") => Some(vec![
422 s("aws"),
423 s("ec2"),
424 s("delete-snapshot"),
425 s("--snapshot-id"),
426 r.name.clone(),
427 ]),
428 ("ec2", "image") => Some(vec![
429 s("aws"),
430 s("ec2"),
431 s("deregister-image"),
432 s("--image-id"),
433 r.name.clone(),
434 ]),
435 _ => None,
436 }?;
437
438 if r.service != "iam" {
441 if let Some(region) = region_from_arn(&r.arn) {
442 cmd.push("--region".to_string());
443 cmd.push(region.to_string());
444 }
445 }
446
447 Some(cmd)
448}
449
450fn sqs_url_from_arn(arn: &str) -> Option<String> {
461 let parts: Vec<&str> = arn.split(':').collect();
463 if parts.len() < 6 || parts[2] != "sqs" {
464 return None;
465 }
466 let region = parts[3];
467 let account = parts[4];
468 let name = parts[5];
469 let partition = if parts.len() > 1 { parts[1] } else { "aws" };
471 let suffix = match partition {
472 "aws-cn" => "amazonaws.com.cn",
473 "aws-us-gov" => "amazonaws.com",
474 "aws" => "amazonaws.com",
475 _ => "amazonaws.com",
476 };
477 Some(format!("https://sqs.{region}.{suffix}/{account}/{name}"))
478}
479
480pub fn delete(r: &TaggedResource, dry_run: bool) -> DeleteOutcome {
488 let argv = match delete_command(r) {
489 Some(a) => a,
490 None => return DeleteOutcome::Unsupported,
491 };
492 if dry_run {
493 return DeleteOutcome::DryRun;
494 }
495 if r.service == "iam" && r.resource_type == "role" {
496 if let Err(msg) = detach_iam_role_policies(&r.name) {
497 tracing::warn!(role = %r.name, error = %msg, "role policy pre-detach failed");
500 }
501 }
502 if r.service == "cloudformation" && r.resource_type == "stack" {
506 if let Ok(output) = Command::new("aws")
507 .args(["cloudformation", "describe-stacks", "--stack-name", &r.name])
508 .output()
509 {
510 #[derive(serde::Deserialize)]
513 struct DescribeStacksResponse {
514 #[serde(rename = "Stacks")]
515 stacks: Vec<StackEntry>,
516 }
517 #[derive(serde::Deserialize)]
518 struct StackEntry {
519 #[serde(rename = "EnableTerminationProtection", default)]
520 enable_termination_protection: bool,
521 }
522 let protected = serde_json::from_slice::<DescribeStacksResponse>(&output.stdout)
523 .ok()
524 .and_then(|r| r.stacks.into_iter().next())
525 .map(|s| s.enable_termination_protection)
526 .unwrap_or(false);
527 if protected {
528 return DeleteOutcome::Failed(format!(
529 "Stack '{}' has termination protection enabled. \
530 Disable it first with: aws cloudformation update-termination-protection \
531 --no-enable-termination-protection --stack-name {}",
532 r.name, r.name
533 ));
534 }
535 }
536 }
537 if r.service == "ec2" && r.resource_type == "instance" {
542 if let Ok(output) = Command::new("aws")
543 .args([
544 "ec2",
545 "describe-instance-attribute",
546 "--instance-id",
547 &r.name,
548 "--attribute",
549 "disableApiTermination",
550 ])
551 .output()
552 {
553 #[derive(serde::Deserialize)]
554 struct DisableApiTermination {
555 #[serde(rename = "Value", default)]
556 value: bool,
557 }
558 #[derive(serde::Deserialize)]
559 struct DescribeInstanceAttributeResponse {
560 #[serde(rename = "DisableApiTermination")]
561 disable_api_termination: DisableApiTermination,
562 }
563 let protected =
564 serde_json::from_slice::<DescribeInstanceAttributeResponse>(&output.stdout)
565 .ok()
566 .map(|r| r.disable_api_termination.value)
567 .unwrap_or(false);
568 if protected {
569 tracing::warn!(
570 instance_id = %r.name,
571 "EC2 instance has termination protection enabled; skipping termination. \
572 Disable it first with: aws ec2 modify-instance-attribute \
573 --instance-id {} --no-disable-api-termination",
574 r.name
575 );
576 return DeleteOutcome::Failed(format!(
577 "Instance '{}' has termination protection enabled. \
578 Disable it first with: aws ec2 modify-instance-attribute \
579 --instance-id {} --no-disable-api-termination",
580 r.name, r.name
581 ));
582 }
583 }
584 }
585 let output = match Command::new(&argv[0]).args(&argv[1..]).output() {
586 Ok(o) => o,
587 Err(e) => return DeleteOutcome::Failed(e.to_string()),
588 };
589 if output.status.success() {
590 DeleteOutcome::Deleted
591 } else {
592 DeleteOutcome::Failed(String::from_utf8_lossy(&output.stderr).trim().to_string())
593 }
594}
595
596fn detach_iam_role_policies(role_name: &str) -> std::result::Result<(), String> {
600 {
602 #[derive(Deserialize)]
603 struct Attached {
604 #[serde(rename = "AttachedPolicies", default)]
605 policies: Vec<AttachedPolicy>,
606 #[serde(rename = "Marker")]
607 marker: Option<String>,
608 }
609 #[derive(Deserialize)]
610 struct AttachedPolicy {
611 #[serde(rename = "PolicyArn")]
612 arn: String,
613 }
614 let mut marker: Option<String> = None;
615 loop {
616 let mut args = vec![
617 "iam",
618 "list-attached-role-policies",
619 "--role-name",
620 role_name,
621 "--output",
622 "json",
623 ];
624 let marker_val;
625 if let Some(ref m) = marker {
626 marker_val = m.clone();
627 args.push("--marker");
628 args.push(&marker_val);
629 }
630 let output = Command::new("aws")
631 .args(&args)
632 .output()
633 .map_err(|e| e.to_string())?;
634 if !output.status.success() {
635 break;
636 }
637 let parsed: Attached = match serde_json::from_slice(&output.stdout) {
638 Ok(p) => p,
639 Err(_) => break,
640 };
641 for p in &parsed.policies {
642 let _ = Command::new("aws")
643 .args([
644 "iam",
645 "detach-role-policy",
646 "--role-name",
647 role_name,
648 "--policy-arn",
649 &p.arn,
650 ])
651 .output();
652 }
653 match parsed.marker {
654 Some(m) if !m.is_empty() => marker = Some(m),
655 _ => break,
656 }
657 }
658 }
659
660 {
662 #[derive(Deserialize)]
663 struct Inline {
664 #[serde(rename = "PolicyNames", default)]
665 names: Vec<String>,
666 #[serde(rename = "Marker")]
667 marker: Option<String>,
668 }
669 let mut marker: Option<String> = None;
670 loop {
671 let mut args = vec![
672 "iam",
673 "list-role-policies",
674 "--role-name",
675 role_name,
676 "--output",
677 "json",
678 ];
679 let marker_val;
680 if let Some(ref m) = marker {
681 marker_val = m.clone();
682 args.push("--marker");
683 args.push(&marker_val);
684 }
685 let output = Command::new("aws")
686 .args(&args)
687 .output()
688 .map_err(|e| e.to_string())?;
689 if !output.status.success() {
690 break;
691 }
692 let parsed: Inline = match serde_json::from_slice(&output.stdout) {
693 Ok(p) => p,
694 Err(_) => break,
695 };
696 for name in &parsed.names {
697 let _ = Command::new("aws")
698 .args([
699 "iam",
700 "delete-role-policy",
701 "--role-name",
702 role_name,
703 "--policy-name",
704 name,
705 ])
706 .output();
707 }
708 match parsed.marker {
709 Some(m) if !m.is_empty() => marker = Some(m),
710 _ => break,
711 }
712 }
713 }
714
715 Ok(())
716}
717
718pub fn delete_tier(r: &TaggedResource) -> u8 {
727 match (r.service.as_str(), r.resource_type.as_str()) {
728 ("iam", "policy") => 2,
729 ("iam", "role") => 1,
730 _ => 0,
731 }
732}
733
734pub fn sort_for_deletion(resources: &mut [TaggedResource]) {
737 resources.sort_by_key(delete_tier);
738}
739
740#[derive(Debug, Clone, Copy, PartialEq)]
749pub struct DailyCostHint {
750 pub usd_per_day: f64,
752 pub usage_dependent: bool,
754}
755
756pub fn estimate_daily_cost(r: &TaggedResource) -> Option<DailyCostHint> {
760 let hint = match (r.service.as_str(), r.resource_type.as_str()) {
761 ("rds", "db") => DailyCostHint {
764 usd_per_day: 0.41,
765 usage_dependent: true,
766 },
767 ("ec2", "instance") => DailyCostHint {
769 usd_per_day: 0.25,
770 usage_dependent: true,
771 },
772 ("kms", "key") => DailyCostHint {
774 usd_per_day: 0.033,
775 usage_dependent: false,
776 },
777 ("secretsmanager", "secret") => DailyCostHint {
779 usd_per_day: 0.013,
780 usage_dependent: false,
781 },
782 ("s3", "bucket") => DailyCostHint {
785 usd_per_day: 0.0,
786 usage_dependent: true,
787 },
788 ("dynamodb", "table") => DailyCostHint {
791 usd_per_day: 0.0,
792 usage_dependent: true,
793 },
794 ("logs", "log-group") => DailyCostHint {
796 usd_per_day: 0.0,
797 usage_dependent: true,
798 },
799 ("ecr", "repository") => DailyCostHint {
801 usd_per_day: 0.0,
802 usage_dependent: true,
803 },
804 ("iam", _)
807 | ("lambda", _)
808 | ("sqs", _)
809 | ("sns", _)
810 | ("cloudformation", _)
811 | ("ssm", _) => return None,
812 _ => return None,
813 };
814 Some(hint)
815}
816
817pub fn estimate_daily_cost_total(resources: &[TaggedResource]) -> (f64, bool) {
820 let mut total = 0.0;
821 let mut any_usage = false;
822 for r in resources {
823 if let Some(h) = estimate_daily_cost(r) {
824 total += h.usd_per_day;
825 any_usage |= h.usage_dependent;
826 }
827 }
828 (total, any_usage)
829}
830
831#[derive(Debug, Clone)]
835pub struct OrphanedSession {
836 pub session_id: String,
837 pub status: String,
838 pub ended_at: chrono::DateTime<chrono::Utc>,
839 pub resources: Vec<TaggedResource>,
840 pub daily_cost: f64,
841 pub usage_dependent: bool,
842}
843
844pub fn find_orphans(sessions: &[crate::session::Session]) -> Vec<OrphanedSession> {
853 use crate::session::SessionStatus;
854 let mut out = Vec::new();
855 for s in sessions {
856 if s.status == SessionStatus::Active {
857 continue;
858 }
859 let Ok(resources) = discover(&s.id) else {
860 continue;
861 };
862 if resources.is_empty() {
863 continue;
864 }
865 let (daily_cost, usage_dependent) = estimate_daily_cost_total(&resources);
866 out.push(OrphanedSession {
867 session_id: s.id.clone(),
868 status: s.status.to_string(),
869 ended_at: s.expires_at,
870 resources,
871 daily_cost,
872 usage_dependent,
873 });
874 }
875 out.sort_by(|a, b| {
877 b.daily_cost
878 .partial_cmp(&a.daily_cost)
879 .unwrap_or(std::cmp::Ordering::Equal)
880 });
881 out
882}
883
884#[derive(Debug, Clone, Default)]
886pub struct CleanupReport {
887 pub deleted: Vec<TaggedResource>,
888 pub failed: Vec<(TaggedResource, String)>,
889 pub unsupported: Vec<TaggedResource>,
890 pub dry_run: bool,
891}
892
893pub fn cleanup_session(session_id: &str, dry_run: bool) -> Result<CleanupReport> {
897 let session_store = crate::session::SessionStore::new()?;
899 if let Ok(session) = session_store.load(session_id) {
900 match session.provider {
901 crate::session::CloudProvider::Gcp => {
902 return Err(AvError::InvalidPolicy(
903 "Cleanup is not yet supported for GCP sessions. \
904 Use `gcloud` CLI to manage GCP resources directly."
905 .to_string(),
906 ));
907 }
908 crate::session::CloudProvider::Azure => {
909 return Err(AvError::InvalidPolicy(
910 "Cleanup is not yet supported for Azure sessions. \
911 Use `az` CLI to manage Azure resources directly."
912 .to_string(),
913 ));
914 }
915 crate::session::CloudProvider::Aws => {} }
917 }
918
919 let mut resources = discover(session_id)?;
920 sort_for_deletion(&mut resources);
921 let mut report = CleanupReport {
922 dry_run,
923 ..Default::default()
924 };
925 for r in resources {
926 match delete(&r, dry_run) {
927 DeleteOutcome::Deleted => report.deleted.push(r),
928 DeleteOutcome::DryRun => report.deleted.push(r),
929 DeleteOutcome::Unsupported => report.unsupported.push(r),
930 DeleteOutcome::Failed(err) => report.failed.push((r, err)),
931 }
932 }
933 Ok(report)
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
948pub struct CleanupState {
949 pub session_id: String,
950 pub started_at: chrono::DateTime<chrono::Utc>,
951 pub deleted_arns: Vec<String>,
953 pub failed: Vec<(TaggedResource, String)>,
955}
956
957impl CleanupState {
958 fn new(session_id: &str) -> Self {
959 Self {
960 session_id: session_id.to_string(),
961 started_at: chrono::Utc::now(),
962 deleted_arns: Vec::new(),
963 failed: Vec::new(),
964 }
965 }
966
967 pub fn is_deleted(&self, arn: &str) -> bool {
968 self.deleted_arns.iter().any(|a| a == arn)
969 }
970
971 pub fn record_deleted(&mut self, arn: &str) {
972 if !self.is_deleted(arn) {
973 self.deleted_arns.push(arn.to_string());
974 }
975 self.failed.retain(|(r, _)| r.arn != arn);
977 }
978
979 pub fn record_failed(&mut self, resource: &TaggedResource, reason: &str) {
980 self.failed.retain(|(r, _)| r.arn != resource.arn);
981 self.failed.push((resource.clone(), reason.to_string()));
982 }
983}
984
985pub struct CleanupStateStore {
987 dir: PathBuf,
988}
989
990impl CleanupStateStore {
991 pub fn new() -> Result<Self> {
992 let base = dirs::data_local_dir().ok_or_else(|| {
993 AvError::InvalidPolicy(
994 "Could not determine local data directory. Set XDG_DATA_HOME or HOME.".to_string(),
995 )
996 })?;
997 let dir = base.join("audex").join("cleanup_state");
998 std::fs::create_dir_all(&dir)?;
999 Ok(Self { dir })
1000 }
1001
1002 pub fn with_dir(dir: impl AsRef<Path>) -> Result<Self> {
1003 let dir = dir.as_ref().to_path_buf();
1004 std::fs::create_dir_all(&dir)?;
1005 Ok(Self { dir })
1006 }
1007
1008 fn path_for(&self, session_id: &str) -> Result<PathBuf> {
1009 crate::validate::session_id(session_id)?;
1010 Ok(self.dir.join(format!("{session_id}.json")))
1011 }
1012
1013 pub fn load_or_new(&self, session_id: &str) -> Result<CleanupState> {
1016 let path = self.path_for(session_id)?;
1017 if !path.exists() {
1018 return Ok(CleanupState::new(session_id));
1019 }
1020 let json = std::fs::read_to_string(&path)?;
1021 serde_json::from_str(&json).map_err(|e| {
1022 AvError::InvalidPolicy(format!("corrupt cleanup state file {path:?}: {e}"))
1023 })
1024 }
1025
1026 pub fn save(&self, state: &CleanupState) -> Result<()> {
1027 let path = self.path_for(&state.session_id)?;
1028 let json = serde_json::to_string_pretty(state)?;
1029 std::fs::write(&path, json)?;
1030 Ok(())
1031 }
1032
1033 pub fn remove(&self, session_id: &str) {
1034 if let Ok(path) = self.path_for(session_id) {
1035 let _ = std::fs::remove_file(path);
1036 }
1037 }
1038
1039 pub fn exists(&self, session_id: &str) -> bool {
1040 self.path_for(session_id)
1041 .map(|p| p.exists())
1042 .unwrap_or(false)
1043 }
1044
1045 pub fn list_pending(&self) -> Vec<CleanupState> {
1052 let mut out = Vec::new();
1053 let entries = match std::fs::read_dir(&self.dir) {
1054 Ok(e) => e,
1055 Err(_) => return out,
1056 };
1057 for entry in entries.flatten() {
1058 let path = entry.path();
1059 if path.extension().and_then(|s| s.to_str()) != Some("json") {
1060 continue;
1061 }
1062 let Ok(json) = std::fs::read_to_string(&path) else {
1063 continue;
1064 };
1065 let Ok(state) = serde_json::from_str::<CleanupState>(&json) else {
1066 continue;
1067 };
1068 if !state.failed.is_empty() {
1069 out.push(state);
1070 }
1071 }
1072 out.sort_by_key(|s| s.started_at);
1074 out
1075 }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn parses_s3_bucket_arn() {
1084 let r = TaggedResource::from_arn("arn:aws:s3:::my-bucket").unwrap();
1085 assert_eq!(r.service, "s3");
1086 assert_eq!(r.resource_type, "bucket");
1087 assert_eq!(r.name, "my-bucket");
1088 }
1089
1090 #[test]
1091 fn parses_dynamodb_table_arn() {
1092 let r = TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:123:table/Users").unwrap();
1093 assert_eq!(r.service, "dynamodb");
1094 assert_eq!(r.resource_type, "table");
1095 assert_eq!(r.name, "Users");
1096 }
1097
1098 #[test]
1099 fn parses_lambda_function_arn() {
1100 let r = TaggedResource::from_arn("arn:aws:lambda:us-east-1:123:function:my-fn").unwrap();
1101 assert_eq!(r.service, "lambda");
1102 assert_eq!(r.resource_type, "function");
1103 assert_eq!(r.name, "my-fn");
1104 }
1105
1106 #[test]
1107 fn parses_iam_role_arn() {
1108 let r = TaggedResource::from_arn("arn:aws:iam::123:role/MyRole").unwrap();
1109 assert_eq!(r.service, "iam");
1110 assert_eq!(r.resource_type, "role");
1111 assert_eq!(r.name, "MyRole");
1112 }
1113
1114 #[test]
1115 fn parses_sqs_queue_arn() {
1116 let r = TaggedResource::from_arn("arn:aws:sqs:us-east-1:123:my-queue").unwrap();
1117 assert_eq!(r.service, "sqs");
1118 assert_eq!(r.resource_type, "queue");
1119 assert_eq!(r.name, "my-queue");
1120 }
1121
1122 #[test]
1123 fn rejects_bad_arn() {
1124 assert!(TaggedResource::from_arn("not-an-arn").is_none());
1125 assert!(TaggedResource::from_arn("arn:aws:s3").is_none());
1126 }
1127
1128 #[test]
1129 fn sqs_url_rebuilt_from_arn() {
1130 let url = sqs_url_from_arn("arn:aws:sqs:us-east-1:123456789012:foo").unwrap();
1131 assert_eq!(url, "https://sqs.us-east-1.amazonaws.com/123456789012/foo");
1132 }
1133
1134 #[test]
1135 fn sqs_url_govcloud() {
1136 let url = sqs_url_from_arn("arn:aws-us-gov:sqs:us-gov-west-1:123456789012:bar").unwrap();
1137 assert_eq!(
1138 url,
1139 "https://sqs.us-gov-west-1.amazonaws.com/123456789012/bar"
1140 );
1141 }
1142
1143 #[test]
1144 fn sqs_url_china() {
1145 let url = sqs_url_from_arn("arn:aws-cn:sqs:cn-north-1:123456789012:baz").unwrap();
1146 assert_eq!(
1147 url,
1148 "https://sqs.cn-north-1.amazonaws.com.cn/123456789012/baz"
1149 );
1150 }
1151
1152 #[test]
1153 fn delete_command_for_s3_bucket() {
1154 let r = TaggedResource::from_arn("arn:aws:s3:::mybk").unwrap();
1155 let cmd = delete_command(&r).unwrap();
1156 assert_eq!(cmd[0..3], vec!["aws", "s3", "rb"]);
1157 assert!(cmd.contains(&"s3://mybk".to_string()));
1158 assert!(!cmd.contains(&"--force".to_string()));
1160 }
1161
1162 #[test]
1163 fn delete_command_for_dynamodb() {
1164 let r = TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:1:table/T").unwrap();
1165 let cmd = delete_command(&r).unwrap();
1166 assert_eq!(cmd[0..3], vec!["aws", "dynamodb", "delete-table"]);
1167 assert!(cmd.contains(&"T".to_string()));
1168 }
1169
1170 #[test]
1171 fn delete_command_for_sqs_uses_url() {
1172 let r = TaggedResource::from_arn("arn:aws:sqs:us-east-1:1:q1").unwrap();
1173 let cmd = delete_command(&r).unwrap();
1174 assert!(cmd.iter().any(|s| s.starts_with("https://sqs.")));
1175 }
1176
1177 #[test]
1178 fn delete_command_for_kms_schedules_deletion() {
1179 let r = TaggedResource::from_arn("arn:aws:kms:us-east-1:1:key/uuid").unwrap();
1180 let cmd = delete_command(&r).unwrap();
1181 assert!(cmd.contains(&"schedule-key-deletion".to_string()));
1182 assert!(cmd.contains(&"7".to_string())); }
1184
1185 #[test]
1186 fn delete_command_for_unsupported_returns_none() {
1187 let r = TaggedResource {
1188 arn: "arn:aws:exotic:us-east-1:1:thing/x".to_string(),
1189 service: "exotic".to_string(),
1190 resource_type: "thing".to_string(),
1191 name: "x".to_string(),
1192 };
1193 assert!(delete_command(&r).is_none());
1194 }
1195
1196 #[test]
1197 fn delete_dry_run_returns_dryrun_outcome() {
1198 let r = TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:1:table/T").unwrap();
1199 assert_eq!(delete(&r, true), DeleteOutcome::DryRun);
1200 }
1201
1202 #[test]
1203 fn cleanup_state_records_deleted_arns() {
1204 let mut state = CleanupState::new("sess-1");
1205 assert!(!state.is_deleted("arn:aws:s3:::bk"));
1206 state.record_deleted("arn:aws:s3:::bk");
1207 assert!(state.is_deleted("arn:aws:s3:::bk"));
1208 }
1209
1210 #[test]
1211 fn cleanup_state_record_deleted_is_idempotent() {
1212 let mut state = CleanupState::new("s");
1213 state.record_deleted("arn:x");
1214 state.record_deleted("arn:x");
1215 assert_eq!(state.deleted_arns.len(), 1);
1216 }
1217
1218 #[test]
1219 fn cleanup_state_record_deleted_clears_prior_failure() {
1220 let mut state = CleanupState::new("s");
1221 let r = TaggedResource::from_arn("arn:aws:s3:::bk").unwrap();
1222 state.record_failed(&r, "temporary throttle");
1223 assert_eq!(state.failed.len(), 1);
1224 state.record_deleted(&r.arn);
1225 assert!(state.failed.is_empty());
1226 assert!(state.is_deleted(&r.arn));
1227 }
1228
1229 #[test]
1230 fn cleanup_state_record_failed_updates_reason() {
1231 let mut state = CleanupState::new("s");
1232 let r = TaggedResource::from_arn("arn:aws:s3:::bk").unwrap();
1233 state.record_failed(&r, "first error");
1234 state.record_failed(&r, "second error");
1235 assert_eq!(state.failed.len(), 1);
1236 assert_eq!(state.failed[0].1, "second error");
1237 }
1238
1239 #[test]
1240 fn cleanup_state_roundtrips_through_store() {
1241 let tmp = tempfile::tempdir().unwrap();
1242 let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1243 let mut state = store.load_or_new("sess-xyz").unwrap();
1244 assert!(state.deleted_arns.is_empty());
1245 state.record_deleted("arn:aws:s3:::bk1");
1246 store.save(&state).unwrap();
1247
1248 let reloaded = store.load_or_new("sess-xyz").unwrap();
1249 assert_eq!(reloaded.deleted_arns, vec!["arn:aws:s3:::bk1".to_string()]);
1250 assert!(store.exists("sess-xyz"));
1251
1252 store.remove("sess-xyz");
1253 assert!(!store.exists("sess-xyz"));
1254 }
1255
1256 #[test]
1257 fn cleanup_state_store_returns_fresh_state_for_unknown_session() {
1258 let tmp = tempfile::tempdir().unwrap();
1259 let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1260 let state = store.load_or_new("brand-new").unwrap();
1261 assert_eq!(state.session_id, "brand-new");
1262 assert!(state.deleted_arns.is_empty());
1263 assert!(state.failed.is_empty());
1264 }
1265
1266 #[test]
1267 fn delete_tier_puts_iam_policies_last() {
1268 let role = TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap();
1269 let policy = TaggedResource::from_arn("arn:aws:iam::1:policy/P").unwrap();
1270 let leaf = TaggedResource::from_arn("arn:aws:s3:::bk").unwrap();
1271 assert!(delete_tier(&leaf) < delete_tier(&role));
1272 assert!(delete_tier(&role) < delete_tier(&policy));
1273 }
1274
1275 #[test]
1276 fn sort_for_deletion_orders_leaves_roles_policies() {
1277 let mut items = vec![
1278 TaggedResource::from_arn("arn:aws:iam::1:policy/P").unwrap(),
1279 TaggedResource::from_arn("arn:aws:s3:::bk").unwrap(),
1280 TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap(),
1281 TaggedResource::from_arn("arn:aws:dynamodb:us-east-1:1:table/T").unwrap(),
1282 ];
1283 sort_for_deletion(&mut items);
1284 assert_eq!(items[0].service, "s3");
1286 assert_eq!(items[1].service, "dynamodb");
1287 assert_eq!(
1288 (items[2].service.as_str(), items[2].resource_type.as_str()),
1289 ("iam", "role")
1290 );
1291 assert_eq!(
1292 (items[3].service.as_str(), items[3].resource_type.as_str()),
1293 ("iam", "policy")
1294 );
1295 }
1296
1297 #[test]
1298 fn sort_for_deletion_is_stable_within_tier() {
1299 let mut items = vec![
1301 TaggedResource::from_arn("arn:aws:s3:::first").unwrap(),
1302 TaggedResource::from_arn("arn:aws:s3:::second").unwrap(),
1303 ];
1304 sort_for_deletion(&mut items);
1305 assert_eq!(items[0].name, "first");
1306 assert_eq!(items[1].name, "second");
1307 }
1308
1309 #[test]
1310 fn cost_hint_rds_is_nonzero_and_usage_dependent() {
1311 let db = TaggedResource::from_arn("arn:aws:rds:us-east-1:1:db:prod").unwrap();
1312 let h = estimate_daily_cost(&db).expect("rds has a hint");
1313 assert!(h.usd_per_day > 0.0);
1314 assert!(h.usage_dependent);
1315 }
1316
1317 #[test]
1318 fn cost_hint_iam_and_lambda_are_free() {
1319 let role = TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap();
1320 let func = TaggedResource::from_arn("arn:aws:lambda:us-east-1:1:function:f").unwrap();
1321 assert!(estimate_daily_cost(&role).is_none());
1322 assert!(estimate_daily_cost(&func).is_none());
1323 }
1324
1325 #[test]
1326 fn cost_hint_kms_is_flat_and_not_usage_dependent() {
1327 let key = TaggedResource::from_arn("arn:aws:kms:us-east-1:1:key/uuid").unwrap();
1328 let h = estimate_daily_cost(&key).expect("kms has a hint");
1329 assert!(h.usd_per_day > 0.0);
1330 assert!(!h.usage_dependent);
1331 }
1332
1333 #[test]
1334 fn cost_hint_s3_is_usage_dependent_floor_zero() {
1335 let bucket = TaggedResource::from_arn("arn:aws:s3:::mybk").unwrap();
1336 let h = estimate_daily_cost(&bucket).expect("s3 has a hint");
1337 assert_eq!(h.usd_per_day, 0.0);
1338 assert!(h.usage_dependent);
1339 }
1340
1341 #[test]
1342 fn list_pending_returns_only_states_with_failures() {
1343 let tmp = tempfile::tempdir().unwrap();
1344 let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1345 let mut a = store.load_or_new("sess-a").unwrap();
1347 let res = TaggedResource::from_arn("arn:aws:s3:::stuck").unwrap();
1348 a.record_failed(&res, "throttled");
1349 store.save(&a).unwrap();
1350 let mut b = store.load_or_new("sess-b").unwrap();
1352 b.record_deleted("arn:aws:s3:::ok");
1353 store.save(&b).unwrap();
1354 let pending = store.list_pending();
1357 assert_eq!(pending.len(), 1);
1358 assert_eq!(pending[0].session_id, "sess-a");
1359 assert_eq!(pending[0].failed.len(), 1);
1360 }
1361
1362 #[test]
1363 fn list_pending_is_empty_when_no_state_files() {
1364 let tmp = tempfile::tempdir().unwrap();
1365 let store = CleanupStateStore::with_dir(tmp.path()).unwrap();
1366 assert!(store.list_pending().is_empty());
1367 }
1368
1369 #[test]
1370 fn total_cost_sums_hints_and_flags_usage() {
1371 let items = vec![
1372 TaggedResource::from_arn("arn:aws:kms:us-east-1:1:key/a").unwrap(),
1373 TaggedResource::from_arn("arn:aws:secretsmanager:us-east-1:1:secret:s-AbCd").unwrap(),
1374 TaggedResource::from_arn("arn:aws:iam::1:role/R").unwrap(),
1375 TaggedResource::from_arn("arn:aws:s3:::bk").unwrap(),
1376 ];
1377 let (total, any_usage) = estimate_daily_cost_total(&items);
1378 assert!((total - 0.046).abs() < 0.0005);
1380 assert!(any_usage);
1382 }
1383}