aws-inventory-manager 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aws-inventory-manager might be problematic. Click here for more details.

Files changed (65) hide show
  1. aws_inventory_manager-0.2.0.dist-info/METADATA +508 -0
  2. aws_inventory_manager-0.2.0.dist-info/RECORD +65 -0
  3. aws_inventory_manager-0.2.0.dist-info/WHEEL +5 -0
  4. aws_inventory_manager-0.2.0.dist-info/entry_points.txt +2 -0
  5. aws_inventory_manager-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. aws_inventory_manager-0.2.0.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +5 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +1450 -0
  15. src/cost/__init__.py +5 -0
  16. src/cost/analyzer.py +226 -0
  17. src/cost/explorer.py +209 -0
  18. src/cost/reporter.py +237 -0
  19. src/delta/__init__.py +5 -0
  20. src/delta/calculator.py +180 -0
  21. src/delta/reporter.py +225 -0
  22. src/models/__init__.py +17 -0
  23. src/models/cost_report.py +87 -0
  24. src/models/delta_report.py +111 -0
  25. src/models/inventory.py +124 -0
  26. src/models/resource.py +99 -0
  27. src/models/snapshot.py +108 -0
  28. src/snapshot/__init__.py +6 -0
  29. src/snapshot/capturer.py +347 -0
  30. src/snapshot/filter.py +245 -0
  31. src/snapshot/inventory_storage.py +264 -0
  32. src/snapshot/resource_collectors/__init__.py +5 -0
  33. src/snapshot/resource_collectors/apigateway.py +140 -0
  34. src/snapshot/resource_collectors/backup.py +136 -0
  35. src/snapshot/resource_collectors/base.py +81 -0
  36. src/snapshot/resource_collectors/cloudformation.py +55 -0
  37. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  38. src/snapshot/resource_collectors/codebuild.py +69 -0
  39. src/snapshot/resource_collectors/codepipeline.py +82 -0
  40. src/snapshot/resource_collectors/dynamodb.py +65 -0
  41. src/snapshot/resource_collectors/ec2.py +240 -0
  42. src/snapshot/resource_collectors/ecs.py +215 -0
  43. src/snapshot/resource_collectors/eks.py +200 -0
  44. src/snapshot/resource_collectors/elb.py +126 -0
  45. src/snapshot/resource_collectors/eventbridge.py +156 -0
  46. src/snapshot/resource_collectors/iam.py +188 -0
  47. src/snapshot/resource_collectors/kms.py +111 -0
  48. src/snapshot/resource_collectors/lambda_func.py +112 -0
  49. src/snapshot/resource_collectors/rds.py +109 -0
  50. src/snapshot/resource_collectors/route53.py +86 -0
  51. src/snapshot/resource_collectors/s3.py +105 -0
  52. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  53. src/snapshot/resource_collectors/sns.py +68 -0
  54. src/snapshot/resource_collectors/sqs.py +72 -0
  55. src/snapshot/resource_collectors/ssm.py +160 -0
  56. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  57. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  58. src/snapshot/resource_collectors/waf.py +159 -0
  59. src/snapshot/storage.py +259 -0
  60. src/utils/__init__.py +12 -0
  61. src/utils/export.py +87 -0
  62. src/utils/hash.py +60 -0
  63. src/utils/logging.py +63 -0
  64. src/utils/paths.py +51 -0
  65. src/utils/progress.py +41 -0
@@ -0,0 +1,74 @@
1
+ """Step Functions resource collector."""
2
+
3
+ from typing import List
4
+
5
+ from ...models.resource import Resource
6
+ from ...utils.hash import compute_config_hash
7
+ from .base import BaseResourceCollector
8
+
9
+
10
+ class StepFunctionsCollector(BaseResourceCollector):
11
+ """Collector for AWS Step Functions resources."""
12
+
13
+ @property
14
+ def service_name(self) -> str:
15
+ return "stepfunctions"
16
+
17
+ def collect(self) -> List[Resource]:
18
+ """Collect Step Functions state machines.
19
+
20
+ Returns:
21
+ List of Step Functions state machine resources
22
+ """
23
+ resources = []
24
+ client = self._create_client("stepfunctions")
25
+
26
+ try:
27
+ paginator = client.get_paginator("list_state_machines")
28
+ for page in paginator.paginate():
29
+ for state_machine in page.get("stateMachines", []):
30
+ sm_arn = state_machine["stateMachineArn"]
31
+ sm_name = state_machine["name"]
32
+
33
+ try:
34
+ # Get detailed state machine info
35
+ sm_details = client.describe_state_machine(stateMachineArn=sm_arn)
36
+
37
+ # Get tags
38
+ tags = {}
39
+ try:
40
+ tag_response = client.list_tags_for_resource(resourceArn=sm_arn)
41
+ for tag in tag_response.get("tags", []):
42
+ tags[tag["key"]] = tag["value"]
43
+ except Exception as e:
44
+ self.logger.debug(f"Could not get tags for state machine {sm_name}: {e}")
45
+
46
+ # Extract creation date
47
+ created_at = sm_details.get("creationDate")
48
+
49
+ # Remove the definition for config hash (can be large)
50
+ # but keep key metadata
51
+ config = {k: v for k, v in sm_details.items() if k != "definition"}
52
+
53
+ # Create resource
54
+ resource = Resource(
55
+ arn=sm_arn,
56
+ resource_type="AWS::StepFunctions::StateMachine",
57
+ name=sm_name,
58
+ region=self.region,
59
+ tags=tags,
60
+ config_hash=compute_config_hash(config),
61
+ created_at=created_at,
62
+ raw_config=config,
63
+ )
64
+ resources.append(resource)
65
+
66
+ except Exception as e:
67
+ self.logger.debug(f"Error processing state machine {sm_name}: {e}")
68
+ continue
69
+
70
+ except Exception as e:
71
+ self.logger.error(f"Error collecting Step Functions state machines in {self.region}: {e}")
72
+
73
+ self.logger.debug(f"Collected {len(resources)} Step Functions state machines in {self.region}")
74
+ return resources
@@ -0,0 +1,79 @@
1
+ """VPC Endpoints resource collector."""
2
+
3
+ from typing import List
4
+
5
+ from ...models.resource import Resource
6
+ from ...utils.hash import compute_config_hash
7
+ from .base import BaseResourceCollector
8
+
9
+
10
+ class VPCEndpointsCollector(BaseResourceCollector):
11
+ """Collector for VPC Endpoints (Interface and Gateway endpoints)."""
12
+
13
+ @property
14
+ def service_name(self) -> str:
15
+ return "vpc-endpoints"
16
+
17
+ def collect(self) -> List[Resource]:
18
+ """Collect VPC Endpoints.
19
+
20
+ Collects both Interface and Gateway VPC endpoints.
21
+
22
+ Returns:
23
+ List of VPC Endpoint resources
24
+ """
25
+ resources = []
26
+ client = self._create_client("ec2")
27
+
28
+ try:
29
+ paginator = client.get_paginator("describe_vpc_endpoints")
30
+ for page in paginator.paginate():
31
+ for endpoint in page.get("VpcEndpoints", []):
32
+ endpoint_id = endpoint["VpcEndpointId"]
33
+ service_name = endpoint.get("ServiceName", "unknown")
34
+ endpoint_type = endpoint.get("VpcEndpointType", "Unknown")
35
+
36
+ # Extract tags
37
+ tags = {}
38
+ for tag in endpoint.get("Tags", []):
39
+ tags[tag["Key"]] = tag["Value"]
40
+
41
+ # Get account ID for ARN
42
+ sts_client = self.session.client("sts")
43
+ account_id = sts_client.get_caller_identity()["Account"]
44
+
45
+ # Build ARN
46
+ arn = f"arn:aws:ec2:{self.region}:{account_id}:vpc-endpoint/{endpoint_id}"
47
+
48
+ # Extract creation date
49
+ created_at = endpoint.get("CreationTimestamp")
50
+
51
+ # Determine resource type based on endpoint type
52
+ if endpoint_type == "Interface":
53
+ resource_type = "AWS::EC2::VPCEndpoint::Interface"
54
+ elif endpoint_type == "Gateway":
55
+ resource_type = "AWS::EC2::VPCEndpoint::Gateway"
56
+ else:
57
+ resource_type = f"AWS::EC2::VPCEndpoint::{endpoint_type}"
58
+
59
+ # Use service name as the friendly name
60
+ name = f"{endpoint_id} ({service_name.split('.')[-1]})"
61
+
62
+ # Create resource
63
+ resource = Resource(
64
+ arn=arn,
65
+ resource_type=resource_type,
66
+ name=name,
67
+ region=self.region,
68
+ tags=tags,
69
+ config_hash=compute_config_hash(endpoint),
70
+ created_at=created_at,
71
+ raw_config=endpoint,
72
+ )
73
+ resources.append(resource)
74
+
75
+ except Exception as e:
76
+ self.logger.error(f"Error collecting VPC endpoints in {self.region}: {e}")
77
+
78
+ self.logger.debug(f"Collected {len(resources)} VPC endpoints in {self.region}")
79
+ return resources
@@ -0,0 +1,159 @@
1
+ """WAF resource collector."""
2
+
3
+ from typing import List
4
+
5
+ from ...models.resource import Resource
6
+ from ...utils.hash import compute_config_hash
7
+ from .base import BaseResourceCollector
8
+
9
+
10
+ class WAFCollector(BaseResourceCollector):
11
+ """Collector for AWS WAF (Web Application Firewall) resources."""
12
+
13
+ @property
14
+ def service_name(self) -> str:
15
+ return "waf"
16
+
17
+ def collect(self) -> List[Resource]:
18
+ """Collect WAF resources.
19
+
20
+ Collects WAFv2 Web ACLs (regional and global/CloudFront).
21
+
22
+ Returns:
23
+ List of WAF Web ACL resources
24
+ """
25
+ resources = []
26
+
27
+ # Collect regional Web ACLs
28
+ resources.extend(self._collect_regional_web_acls())
29
+
30
+ # Collect CloudFront Web ACLs (global, only from us-east-1)
31
+ if self.region == "us-east-1":
32
+ resources.extend(self._collect_cloudfront_web_acls())
33
+
34
+ self.logger.debug(f"Collected {len(resources)} WAF Web ACLs in {self.region}")
35
+ return resources
36
+
37
+ def _collect_regional_web_acls(self) -> List[Resource]:
38
+ """Collect regional WAFv2 Web ACLs.
39
+
40
+ Returns:
41
+ List of regional Web ACL resources
42
+ """
43
+ resources = []
44
+
45
+ try:
46
+ client = self._create_client("wafv2")
47
+
48
+ paginator = client.get_paginator("list_web_acls")
49
+ for page in paginator.paginate(Scope="REGIONAL"):
50
+ for web_acl_summary in page.get("WebACLs", []):
51
+ web_acl_name = web_acl_summary["Name"]
52
+ web_acl_id = web_acl_summary["Id"]
53
+ web_acl_arn = web_acl_summary["ARN"]
54
+
55
+ try:
56
+ # Get detailed Web ACL info
57
+ web_acl_response = client.get_web_acl(Name=web_acl_name, Scope="REGIONAL", Id=web_acl_id)
58
+ web_acl = web_acl_response.get("WebACL", {})
59
+
60
+ # Get tags
61
+ tags = {}
62
+ try:
63
+ tag_response = client.list_tags_for_resource(ResourceARN=web_acl_arn)
64
+ for tag_info in tag_response.get("TagInfoForResource", {}).get("TagList", []):
65
+ tags[tag_info["Key"]] = tag_info["Value"]
66
+ except Exception as e:
67
+ self.logger.debug(f"Could not get tags for Web ACL {web_acl_name}: {e}")
68
+
69
+ # WAFv2 doesn't provide creation timestamp
70
+ created_at = None
71
+
72
+ # Remove large rule definitions for config hash
73
+ config = {k: v for k, v in web_acl.items() if k not in ["Rules"]}
74
+ config["RuleCount"] = len(web_acl.get("Rules", []))
75
+
76
+ # Create resource
77
+ resource = Resource(
78
+ arn=web_acl_arn,
79
+ resource_type="AWS::WAFv2::WebACL::Regional",
80
+ name=web_acl_name,
81
+ region=self.region,
82
+ tags=tags,
83
+ config_hash=compute_config_hash(config),
84
+ created_at=created_at,
85
+ raw_config=config,
86
+ )
87
+ resources.append(resource)
88
+
89
+ except Exception as e:
90
+ self.logger.debug(f"Error processing Web ACL {web_acl_name}: {e}")
91
+ continue
92
+
93
+ except Exception as e:
94
+ self.logger.error(f"Error collecting regional WAF Web ACLs in {self.region}: {e}")
95
+
96
+ return resources
97
+
98
+ def _collect_cloudfront_web_acls(self) -> List[Resource]:
99
+ """Collect CloudFront (global) WAFv2 Web ACLs.
100
+
101
+ Only collects when in us-east-1 since CloudFront resources are global.
102
+
103
+ Returns:
104
+ List of CloudFront Web ACL resources
105
+ """
106
+ resources = []
107
+
108
+ try:
109
+ client = self._create_client("wafv2")
110
+
111
+ paginator = client.get_paginator("list_web_acls")
112
+ for page in paginator.paginate(Scope="CLOUDFRONT"):
113
+ for web_acl_summary in page.get("WebACLs", []):
114
+ web_acl_name = web_acl_summary["Name"]
115
+ web_acl_id = web_acl_summary["Id"]
116
+ web_acl_arn = web_acl_summary["ARN"]
117
+
118
+ try:
119
+ # Get detailed Web ACL info
120
+ web_acl_response = client.get_web_acl(Name=web_acl_name, Scope="CLOUDFRONT", Id=web_acl_id)
121
+ web_acl = web_acl_response.get("WebACL", {})
122
+
123
+ # Get tags
124
+ tags = {}
125
+ try:
126
+ tag_response = client.list_tags_for_resource(ResourceARN=web_acl_arn)
127
+ for tag_info in tag_response.get("TagInfoForResource", {}).get("TagList", []):
128
+ tags[tag_info["Key"]] = tag_info["Value"]
129
+ except Exception as e:
130
+ self.logger.debug(f"Could not get tags for CloudFront Web ACL {web_acl_name}: {e}")
131
+
132
+ # WAFv2 doesn't provide creation timestamp
133
+ created_at = None
134
+
135
+ # Remove large rule definitions for config hash
136
+ config = {k: v for k, v in web_acl.items() if k not in ["Rules"]}
137
+ config["RuleCount"] = len(web_acl.get("Rules", []))
138
+
139
+ # Create resource
140
+ resource = Resource(
141
+ arn=web_acl_arn,
142
+ resource_type="AWS::WAFv2::WebACL::CloudFront",
143
+ name=web_acl_name,
144
+ region="global", # CloudFront is global
145
+ tags=tags,
146
+ config_hash=compute_config_hash(config),
147
+ created_at=created_at,
148
+ raw_config=config,
149
+ )
150
+ resources.append(resource)
151
+
152
+ except Exception as e:
153
+ self.logger.debug(f"Error processing CloudFront Web ACL {web_acl_name}: {e}")
154
+ continue
155
+
156
+ except Exception as e:
157
+ self.logger.error(f"Error collecting CloudFront WAF Web ACLs: {e}")
158
+
159
+ return resources
@@ -0,0 +1,259 @@
1
+ """Snapshot storage manager for saving and loading snapshots."""
2
+
3
+ import gzip
4
+ import logging
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ import yaml
10
+
11
+ from ..models.snapshot import Snapshot
12
+ from ..utils.paths import get_snapshot_storage_path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SnapshotStorage:
18
+ """Manages snapshot persistence to local filesystem."""
19
+
20
+ def __init__(self, storage_dir: Optional[Union[str, Path]] = None):
21
+ """Initialize snapshot storage.
22
+
23
+ Args:
24
+ storage_dir: Directory to store snapshots (default: ~/.snapshots via get_snapshot_storage_path())
25
+ """
26
+ self.storage_dir = get_snapshot_storage_path(storage_dir)
27
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
28
+ self.active_file = self.storage_dir / ".active"
29
+ self.index_file = self.storage_dir / ".index.yaml"
30
+
31
+ def save_snapshot(self, snapshot: Snapshot, compress: bool = False) -> Path:
32
+ """Save snapshot to YAML file, optionally compressed.
33
+
34
+ Args:
35
+ snapshot: Snapshot instance to save
36
+ compress: Whether to compress with gzip (default: False)
37
+
38
+ Returns:
39
+ Path to saved snapshot file
40
+ """
41
+ filename = f"{snapshot.name}.yaml"
42
+ if compress:
43
+ filename += ".gz"
44
+
45
+ filepath = self.storage_dir / filename
46
+
47
+ # Convert snapshot to dict
48
+ snapshot_dict = snapshot.to_dict()
49
+
50
+ # Serialize to YAML
51
+ yaml_str = yaml.dump(
52
+ snapshot_dict,
53
+ default_flow_style=False, # Block style (more readable)
54
+ sort_keys=False, # Preserve insertion order
55
+ allow_unicode=True,
56
+ )
57
+
58
+ # Save (compressed or uncompressed)
59
+ if compress:
60
+ with gzip.open(filepath, "wt", encoding="utf-8") as f:
61
+ f.write(yaml_str)
62
+ logger.debug(f"Saved compressed snapshot to {filepath}")
63
+ else:
64
+ with open(filepath, "w", encoding="utf-8") as f:
65
+ f.write(yaml_str)
66
+ logger.debug(f"Saved snapshot to {filepath}")
67
+
68
+ # Update index
69
+ self._update_index(snapshot)
70
+
71
+ # Set as active if requested
72
+ if snapshot.is_active:
73
+ self.set_active_snapshot(snapshot.name)
74
+
75
+ return filepath
76
+
77
+ def load_snapshot(self, snapshot_name: str) -> Snapshot:
78
+ """Load snapshot from YAML file (auto-detects compression).
79
+
80
+ Args:
81
+ snapshot_name: Name of snapshot to load
82
+
83
+ Returns:
84
+ Snapshot instance
85
+
86
+ Raises:
87
+ FileNotFoundError: If snapshot file doesn't exist
88
+ """
89
+ # Try compressed first
90
+ filepath_gz = self.storage_dir / f"{snapshot_name}.yaml.gz"
91
+ if filepath_gz.exists():
92
+ with gzip.open(filepath_gz, "rt", encoding="utf-8") as f:
93
+ snapshot_dict = yaml.safe_load(f)
94
+ logger.debug(f"Loaded compressed snapshot from {filepath_gz}")
95
+ return Snapshot.from_dict(snapshot_dict)
96
+
97
+ # Try uncompressed
98
+ filepath = self.storage_dir / f"{snapshot_name}.yaml"
99
+ if filepath.exists():
100
+ with open(filepath, "r", encoding="utf-8") as f:
101
+ snapshot_dict = yaml.safe_load(f)
102
+ logger.debug(f"Loaded snapshot from {filepath}")
103
+ return Snapshot.from_dict(snapshot_dict)
104
+
105
+ raise FileNotFoundError(f"Snapshot '{snapshot_name}' not found")
106
+
107
+ def list_snapshots(self) -> List[Dict[str, Any]]:
108
+ """List all available snapshots with metadata.
109
+
110
+ Returns:
111
+ List of snapshot metadata dictionaries
112
+ """
113
+ snapshots = []
114
+ active_name = self.get_active_snapshot_name()
115
+
116
+ # Find all snapshot files
117
+ for filepath in self.storage_dir.glob("*.yaml*"):
118
+ if filepath.name.startswith("."):
119
+ continue # Skip hidden files
120
+
121
+ name = filepath.stem
122
+ if name.endswith(".yaml"): # Handle .yaml.gz case
123
+ name = name[:-5]
124
+
125
+ # Get file stats
126
+ stats = filepath.stat()
127
+ size_mb = stats.st_size / (1024 * 1024)
128
+
129
+ snapshots.append(
130
+ {
131
+ "name": name,
132
+ "filepath": str(filepath),
133
+ "size_mb": round(size_mb, 2),
134
+ "modified": datetime.fromtimestamp(stats.st_mtime),
135
+ "is_active": (name == active_name),
136
+ }
137
+ )
138
+
139
+ # Sort by modified date (newest first)
140
+ snapshots.sort(key=lambda x: x["modified"], reverse=True) # type: ignore
141
+
142
+ logger.debug(f"Found {len(snapshots)} snapshots")
143
+ return snapshots
144
+
145
+ def delete_snapshot(self, snapshot_name: str) -> bool:
146
+ """Delete a snapshot file.
147
+
148
+ Args:
149
+ snapshot_name: Name of snapshot to delete
150
+
151
+ Returns:
152
+ True if deleted successfully
153
+
154
+ Raises:
155
+ ValueError: If trying to delete active snapshot
156
+ FileNotFoundError: If snapshot doesn't exist
157
+ """
158
+ # Check if it's the active snapshot
159
+ if snapshot_name == self.get_active_snapshot_name():
160
+ raise ValueError(
161
+ f"Cannot delete active snapshot '{snapshot_name}'. " "Set another snapshot as active first."
162
+ )
163
+
164
+ # Try to delete both compressed and uncompressed versions
165
+ deleted = False
166
+
167
+ filepath_gz = self.storage_dir / f"{snapshot_name}.yaml.gz"
168
+ if filepath_gz.exists():
169
+ filepath_gz.unlink()
170
+ deleted = True
171
+ logger.debug(f"Deleted {filepath_gz}")
172
+
173
+ filepath = self.storage_dir / f"{snapshot_name}.yaml"
174
+ if filepath.exists():
175
+ filepath.unlink()
176
+ deleted = True
177
+ logger.debug(f"Deleted {filepath}")
178
+
179
+ if not deleted:
180
+ raise FileNotFoundError(f"Snapshot '{snapshot_name}' not found")
181
+
182
+ # Update index
183
+ self._remove_from_index(snapshot_name)
184
+
185
+ return True
186
+
187
+ def get_active_snapshot_name(self) -> Optional[str]:
188
+ """Get the name of the currently active snapshot.
189
+
190
+ Returns:
191
+ Active snapshot name, or None if no active snapshot
192
+ """
193
+ if self.active_file.exists():
194
+ return self.active_file.read_text().strip()
195
+ return None
196
+
197
+ def set_active_snapshot(self, snapshot_name: str) -> None:
198
+ """Set a snapshot as the active baseline.
199
+
200
+ Args:
201
+ snapshot_name: Name of snapshot to set as active
202
+
203
+ Raises:
204
+ FileNotFoundError: If snapshot doesn't exist
205
+ """
206
+ # Verify snapshot exists
207
+ try:
208
+ self.load_snapshot(snapshot_name)
209
+ except FileNotFoundError:
210
+ raise FileNotFoundError(f"Cannot set active: snapshot '{snapshot_name}' not found")
211
+
212
+ # Update active file
213
+ self.active_file.write_text(snapshot_name)
214
+ logger.debug(f"Set active snapshot to: {snapshot_name}")
215
+
216
+ def _update_index(self, snapshot: Snapshot) -> None:
217
+ """Update the snapshot index file.
218
+
219
+ Args:
220
+ snapshot: Snapshot to add/update in index
221
+ """
222
+ # Load existing index
223
+ index = self._load_index()
224
+
225
+ # Update entry
226
+ index[snapshot.name] = {
227
+ "name": snapshot.name,
228
+ "created_at": snapshot.created_at.isoformat(),
229
+ "account_id": snapshot.account_id,
230
+ "regions": snapshot.regions,
231
+ "resource_count": snapshot.resource_count,
232
+ "service_counts": snapshot.service_counts,
233
+ }
234
+
235
+ # Save index
236
+ self._save_index(index)
237
+
238
+ def _remove_from_index(self, snapshot_name: str) -> None:
239
+ """Remove snapshot from index.
240
+
241
+ Args:
242
+ snapshot_name: Name of snapshot to remove
243
+ """
244
+ index = self._load_index()
245
+ if snapshot_name in index:
246
+ del index[snapshot_name]
247
+ self._save_index(index)
248
+
249
+ def _load_index(self) -> Dict[str, Any]:
250
+ """Load snapshot index from file."""
251
+ if self.index_file.exists():
252
+ with open(self.index_file, "r") as f:
253
+ return yaml.safe_load(f) or {}
254
+ return {}
255
+
256
+ def _save_index(self, index: Dict[str, Any]) -> None:
257
+ """Save snapshot index to file."""
258
+ with open(self.index_file, "w") as f:
259
+ yaml.dump(index, f, default_flow_style=False)
src/utils/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Utility modules for AWS Baseline Snapshot tool."""
2
+
3
+ from .export import export_to_csv, export_to_json
4
+ from .hash import compute_config_hash
5
+ from .logging import setup_logging
6
+
7
+ __all__ = [
8
+ "setup_logging",
9
+ "compute_config_hash",
10
+ "export_to_json",
11
+ "export_to_csv",
12
+ ]
src/utils/export.py ADDED
@@ -0,0 +1,87 @@
1
+ """Export utilities for JSON and CSV formats."""
2
+
3
+ import csv
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def export_to_json(data: Any, filepath: str) -> Path:
13
+ """Export data to JSON file.
14
+
15
+ Args:
16
+ data: Data to export (must be JSON-serializable)
17
+ filepath: Destination file path
18
+
19
+ Returns:
20
+ Path to exported file
21
+ """
22
+ path = Path(filepath)
23
+
24
+ with open(path, "w", encoding="utf-8") as f:
25
+ json.dump(data, f, indent=2, default=str)
26
+
27
+ logger.info(f"Exported data to JSON: {path}")
28
+ return path
29
+
30
+
31
+ def export_to_csv(data: List[Dict[str, Any]], filepath: str) -> Path:
32
+ """Export list of dictionaries to CSV file.
33
+
34
+ Args:
35
+ data: List of dictionaries to export
36
+ filepath: Destination file path
37
+
38
+ Returns:
39
+ Path to exported file
40
+
41
+ Raises:
42
+ ValueError: If data is empty or not a list of dicts
43
+ """
44
+ if not data:
45
+ raise ValueError("Cannot export empty data to CSV")
46
+
47
+ if not isinstance(data, list) or not isinstance(data[0], dict):
48
+ raise ValueError("Data must be a list of dictionaries for CSV export")
49
+
50
+ path = Path(filepath)
51
+
52
+ # Get fieldnames from first item
53
+ fieldnames = list(data[0].keys())
54
+
55
+ with open(path, "w", newline="", encoding="utf-8") as f:
56
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
57
+ writer.writeheader()
58
+ writer.writerows(data)
59
+
60
+ logger.info(f"Exported {len(data)} rows to CSV: {path}")
61
+ return path
62
+
63
+
64
+ def flatten_dict(d: Dict[str, Any], parent_key: str = "", sep: str = "_") -> Dict[str, Any]:
65
+ """Flatten a nested dictionary for CSV export.
66
+
67
+ Args:
68
+ d: Dictionary to flatten
69
+ parent_key: Parent key for nested items
70
+ sep: Separator for concatenating keys
71
+
72
+ Returns:
73
+ Flattened dictionary
74
+ """
75
+ from typing import Any, List, Tuple
76
+
77
+ items: List[Tuple[str, Any]] = []
78
+ for k, v in d.items():
79
+ new_key = f"{parent_key}{sep}{k}" if parent_key else k
80
+ if isinstance(v, dict):
81
+ items.extend(flatten_dict(v, new_key, sep=sep).items())
82
+ elif isinstance(v, list):
83
+ # Convert lists to comma-separated strings
84
+ items.append((new_key, ", ".join(str(x) for x in v)))
85
+ else:
86
+ items.append((new_key, v))
87
+ return dict(items)