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.
- aws_inventory_manager-0.2.0.dist-info/METADATA +508 -0
- aws_inventory_manager-0.2.0.dist-info/RECORD +65 -0
- aws_inventory_manager-0.2.0.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.2.0.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.2.0.dist-info/licenses/LICENSE +21 -0
- aws_inventory_manager-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/aws/__init__.py +11 -0
- src/aws/client.py +128 -0
- src/aws/credentials.py +191 -0
- src/aws/rate_limiter.py +177 -0
- src/cli/__init__.py +5 -0
- src/cli/config.py +130 -0
- src/cli/main.py +1450 -0
- src/cost/__init__.py +5 -0
- src/cost/analyzer.py +226 -0
- src/cost/explorer.py +209 -0
- src/cost/reporter.py +237 -0
- src/delta/__init__.py +5 -0
- src/delta/calculator.py +180 -0
- src/delta/reporter.py +225 -0
- src/models/__init__.py +17 -0
- src/models/cost_report.py +87 -0
- src/models/delta_report.py +111 -0
- src/models/inventory.py +124 -0
- src/models/resource.py +99 -0
- src/models/snapshot.py +108 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +347 -0
- src/snapshot/filter.py +245 -0
- src/snapshot/inventory_storage.py +264 -0
- src/snapshot/resource_collectors/__init__.py +5 -0
- src/snapshot/resource_collectors/apigateway.py +140 -0
- src/snapshot/resource_collectors/backup.py +136 -0
- src/snapshot/resource_collectors/base.py +81 -0
- src/snapshot/resource_collectors/cloudformation.py +55 -0
- src/snapshot/resource_collectors/cloudwatch.py +109 -0
- src/snapshot/resource_collectors/codebuild.py +69 -0
- src/snapshot/resource_collectors/codepipeline.py +82 -0
- src/snapshot/resource_collectors/dynamodb.py +65 -0
- src/snapshot/resource_collectors/ec2.py +240 -0
- src/snapshot/resource_collectors/ecs.py +215 -0
- src/snapshot/resource_collectors/eks.py +200 -0
- src/snapshot/resource_collectors/elb.py +126 -0
- src/snapshot/resource_collectors/eventbridge.py +156 -0
- src/snapshot/resource_collectors/iam.py +188 -0
- src/snapshot/resource_collectors/kms.py +111 -0
- src/snapshot/resource_collectors/lambda_func.py +112 -0
- src/snapshot/resource_collectors/rds.py +109 -0
- src/snapshot/resource_collectors/route53.py +86 -0
- src/snapshot/resource_collectors/s3.py +105 -0
- src/snapshot/resource_collectors/secretsmanager.py +70 -0
- src/snapshot/resource_collectors/sns.py +68 -0
- src/snapshot/resource_collectors/sqs.py +72 -0
- src/snapshot/resource_collectors/ssm.py +160 -0
- src/snapshot/resource_collectors/stepfunctions.py +74 -0
- src/snapshot/resource_collectors/vpcendpoints.py +79 -0
- src/snapshot/resource_collectors/waf.py +159 -0
- src/snapshot/storage.py +259 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +87 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/paths.py +51 -0
- 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
|
src/snapshot/storage.py
ADDED
|
@@ -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)
|