aws-inventory-manager 0.13.2__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.13.2.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
- aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
- aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.13.2.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 +12 -0
- src/cli/config.py +130 -0
- src/cli/main.py +3626 -0
- src/config_service/__init__.py +21 -0
- src/config_service/collector.py +346 -0
- src/config_service/detector.py +256 -0
- src/config_service/resource_type_mapping.py +328 -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 +206 -0
- src/delta/differ.py +185 -0
- src/delta/formatters.py +272 -0
- src/delta/models.py +154 -0
- src/delta/reporter.py +234 -0
- src/models/__init__.py +21 -0
- src/models/config_diff.py +135 -0
- src/models/cost_report.py +87 -0
- src/models/deletion_operation.py +104 -0
- src/models/deletion_record.py +97 -0
- src/models/delta_report.py +122 -0
- src/models/efs_resource.py +80 -0
- src/models/elasticache_resource.py +90 -0
- src/models/group.py +318 -0
- src/models/inventory.py +133 -0
- src/models/protection_rule.py +123 -0
- src/models/report.py +288 -0
- src/models/resource.py +111 -0
- src/models/security_finding.py +102 -0
- src/models/snapshot.py +122 -0
- src/restore/__init__.py +20 -0
- src/restore/audit.py +175 -0
- src/restore/cleaner.py +461 -0
- src/restore/config.py +209 -0
- src/restore/deleter.py +976 -0
- src/restore/dependency.py +254 -0
- src/restore/safety.py +115 -0
- src/security/__init__.py +0 -0
- src/security/checks/__init__.py +0 -0
- src/security/checks/base.py +56 -0
- src/security/checks/ec2_checks.py +88 -0
- src/security/checks/elasticache_checks.py +149 -0
- src/security/checks/iam_checks.py +102 -0
- src/security/checks/rds_checks.py +140 -0
- src/security/checks/s3_checks.py +95 -0
- src/security/checks/secrets_checks.py +96 -0
- src/security/checks/sg_checks.py +142 -0
- src/security/cis_mapper.py +97 -0
- src/security/models.py +53 -0
- src/security/reporter.py +174 -0
- src/security/scanner.py +87 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +451 -0
- src/snapshot/filter.py +259 -0
- src/snapshot/inventory_storage.py +236 -0
- src/snapshot/report_formatter.py +250 -0
- src/snapshot/reporter.py +189 -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/efs_collector.py +102 -0
- src/snapshot/resource_collectors/eks.py +200 -0
- src/snapshot/resource_collectors/elasticache_collector.py +79 -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 +139 -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 +82 -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 +351 -0
- src/storage/__init__.py +21 -0
- src/storage/audit_store.py +419 -0
- src/storage/database.py +294 -0
- src/storage/group_store.py +749 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +413 -0
- src/storage/schema.py +288 -0
- src/storage/snapshot_store.py +346 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +305 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/pagination.py +41 -0
- src/utils/paths.py +51 -0
- src/utils/progress.py +41 -0
- src/utils/unsupported_resources.py +306 -0
- src/web/__init__.py +5 -0
- src/web/app.py +97 -0
- src/web/dependencies.py +69 -0
- src/web/routes/__init__.py +1 -0
- src/web/routes/api/__init__.py +18 -0
- src/web/routes/api/charts.py +156 -0
- src/web/routes/api/cleanup.py +186 -0
- src/web/routes/api/filters.py +253 -0
- src/web/routes/api/groups.py +305 -0
- src/web/routes/api/inventories.py +80 -0
- src/web/routes/api/queries.py +202 -0
- src/web/routes/api/resources.py +379 -0
- src/web/routes/api/snapshots.py +314 -0
- src/web/routes/api/views.py +260 -0
- src/web/routes/pages.py +198 -0
- src/web/services/__init__.py +1 -0
- src/web/templates/base.html +949 -0
- src/web/templates/components/navbar.html +31 -0
- src/web/templates/components/sidebar.html +104 -0
- src/web/templates/pages/audit_logs.html +86 -0
- src/web/templates/pages/cleanup.html +279 -0
- src/web/templates/pages/dashboard.html +227 -0
- src/web/templates/pages/diff.html +175 -0
- src/web/templates/pages/error.html +30 -0
- src/web/templates/pages/groups.html +721 -0
- src/web/templates/pages/queries.html +246 -0
- src/web/templates/pages/resources.html +2251 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
src/utils/hash.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Configuration hashing utility for change detection."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, Set
|
|
6
|
+
|
|
7
|
+
# Attributes to exclude from hashing (volatile data)
|
|
8
|
+
EXCLUDE_ATTRIBUTES: Set[str] = {
|
|
9
|
+
"ResponseMetadata",
|
|
10
|
+
"LastModifiedDate",
|
|
11
|
+
"CreatedDate",
|
|
12
|
+
"CreateDate",
|
|
13
|
+
"State",
|
|
14
|
+
"Status",
|
|
15
|
+
"RequestId",
|
|
16
|
+
"VersionId",
|
|
17
|
+
"LastUpdateTime",
|
|
18
|
+
"LastUpdatedTime",
|
|
19
|
+
"ModifiedTime",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_config_hash(resource_data: Dict[str, Any]) -> str:
|
|
24
|
+
"""Compute stable SHA256 hash of resource configuration.
|
|
25
|
+
|
|
26
|
+
This hash is used for change detection. Volatile attributes
|
|
27
|
+
(timestamps, states, etc.) are excluded to prevent false positives.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
resource_data: Resource configuration dictionary
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
64-character SHA256 hex string
|
|
34
|
+
"""
|
|
35
|
+
# Deep copy and remove excluded attributes
|
|
36
|
+
clean_data = _remove_volatile_attributes(resource_data, EXCLUDE_ATTRIBUTES)
|
|
37
|
+
|
|
38
|
+
# Normalize: sort keys for deterministic JSON
|
|
39
|
+
normalized = json.dumps(clean_data, sort_keys=True, default=str)
|
|
40
|
+
|
|
41
|
+
# Hash
|
|
42
|
+
return hashlib.sha256(normalized.encode()).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _remove_volatile_attributes(data: Any, exclude_set: Set[str]) -> Any:
|
|
46
|
+
"""Recursively remove excluded attributes from nested dict/list.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
data: Data structure to clean (dict, list, or primitive)
|
|
50
|
+
exclude_set: Set of attribute names to exclude
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Cleaned data structure
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(data, dict):
|
|
56
|
+
return {k: _remove_volatile_attributes(v, exclude_set) for k, v in data.items() if k not in exclude_set}
|
|
57
|
+
elif isinstance(data, list):
|
|
58
|
+
return [_remove_volatile_attributes(item, exclude_set) for item in data]
|
|
59
|
+
else:
|
|
60
|
+
return data
|
src/utils/logging.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Logging configuration for AWS Baseline Snapshot tool."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logging(level: str = "INFO", log_file: Optional[str] = None, verbose: bool = False) -> None:
|
|
9
|
+
"""Configure logging for the application.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
level: Log level (DEBUG, INFO, WARN, ERROR)
|
|
13
|
+
log_file: Optional log file path
|
|
14
|
+
verbose: If True, show detailed logs; if False, suppress all but critical
|
|
15
|
+
"""
|
|
16
|
+
# Convert string level to logging constant
|
|
17
|
+
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
|
18
|
+
|
|
19
|
+
# In non-verbose mode, suppress all logs except CRITICAL
|
|
20
|
+
# User will only see styled Rich console output
|
|
21
|
+
if not verbose:
|
|
22
|
+
numeric_level = logging.CRITICAL
|
|
23
|
+
|
|
24
|
+
# Create formatter
|
|
25
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
26
|
+
|
|
27
|
+
# Configure root logger
|
|
28
|
+
root_logger = logging.getLogger()
|
|
29
|
+
root_logger.setLevel(numeric_level)
|
|
30
|
+
|
|
31
|
+
# Remove existing handlers
|
|
32
|
+
for handler in root_logger.handlers[:]:
|
|
33
|
+
root_logger.removeHandler(handler)
|
|
34
|
+
|
|
35
|
+
# Console handler (only add if verbose or log_file specified)
|
|
36
|
+
if verbose or log_file:
|
|
37
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
38
|
+
console_handler.setLevel(numeric_level)
|
|
39
|
+
console_handler.setFormatter(formatter)
|
|
40
|
+
root_logger.addHandler(console_handler)
|
|
41
|
+
|
|
42
|
+
# File handler (if specified)
|
|
43
|
+
if log_file:
|
|
44
|
+
file_handler = logging.FileHandler(log_file)
|
|
45
|
+
file_handler.setLevel(logging.DEBUG) # Always log everything to file
|
|
46
|
+
file_handler.setFormatter(formatter)
|
|
47
|
+
root_logger.addHandler(file_handler)
|
|
48
|
+
|
|
49
|
+
# Suppress noisy third-party loggers
|
|
50
|
+
logging.getLogger("boto3").setLevel(logging.CRITICAL)
|
|
51
|
+
logging.getLogger("botocore").setLevel(logging.CRITICAL)
|
|
52
|
+
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
|
|
53
|
+
logging.getLogger("s3transfer").setLevel(logging.CRITICAL)
|
|
54
|
+
|
|
55
|
+
# Suppress internal module logs unless verbose
|
|
56
|
+
if not verbose:
|
|
57
|
+
logging.getLogger("src").setLevel(logging.CRITICAL)
|
|
58
|
+
logging.getLogger("src.snapshot").setLevel(logging.CRITICAL)
|
|
59
|
+
logging.getLogger("src.snapshot.resource_collectors").setLevel(logging.CRITICAL)
|
|
60
|
+
logging.getLogger("src.snapshot.capturer").setLevel(logging.CRITICAL)
|
|
61
|
+
logging.getLogger("src.snapshot.storage").setLevel(logging.CRITICAL)
|
|
62
|
+
logging.getLogger("src.aws").setLevel(logging.CRITICAL)
|
|
63
|
+
logging.getLogger("src.aws.credentials").setLevel(logging.CRITICAL)
|
src/utils/pagination.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Terminal pagination utilities for large resource lists.
|
|
3
|
+
|
|
4
|
+
This module provides pagination functionality for displaying large datasets
|
|
5
|
+
in the terminal with user-friendly navigation controls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Generator, List, TypeVar
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def paginate_resources(items: List[T], page_size: int = 100) -> Generator[List[T], None, None]:
|
|
16
|
+
"""
|
|
17
|
+
Paginate a list of items into pages of specified size.
|
|
18
|
+
|
|
19
|
+
This is a memory-efficient generator that yields pages of items
|
|
20
|
+
without loading everything into memory at once.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
items: List of items to paginate
|
|
24
|
+
page_size: Number of items per page (default: 100)
|
|
25
|
+
|
|
26
|
+
Yields:
|
|
27
|
+
Lists of items, each containing up to page_size items
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
>>> resources = list(range(250))
|
|
31
|
+
>>> for page in paginate_resources(resources, page_size=100):
|
|
32
|
+
... print(f"Page has {len(page)} items")
|
|
33
|
+
Page has 100 items
|
|
34
|
+
Page has 100 items
|
|
35
|
+
Page has 50 items
|
|
36
|
+
"""
|
|
37
|
+
if not items:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
for i in range(0, len(items), page_size):
|
|
41
|
+
yield items[i : i + page_size]
|
src/utils/paths.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Path resolution utilities for snapshot storage."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_snapshot_storage_path(custom_path: Optional[Union[str, Path]] = None) -> Path:
|
|
9
|
+
"""Resolve snapshot storage path with precedence: parameter > env var > default.
|
|
10
|
+
|
|
11
|
+
Precedence order:
|
|
12
|
+
1. custom_path parameter (if provided)
|
|
13
|
+
2. AWS_INVENTORY_STORAGE_PATH environment variable (if set)
|
|
14
|
+
3. ~/.snapshots (default)
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
custom_path: Optional custom path override
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Resolved Path object for snapshot storage
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
# Use default
|
|
24
|
+
>>> get_snapshot_storage_path()
|
|
25
|
+
Path.home() / '.snapshots'
|
|
26
|
+
|
|
27
|
+
# Use environment variable
|
|
28
|
+
>>> os.environ['AWS_INVENTORY_STORAGE_PATH'] = '/data/snapshots'
|
|
29
|
+
>>> get_snapshot_storage_path()
|
|
30
|
+
Path('/data/snapshots')
|
|
31
|
+
|
|
32
|
+
# Use parameter (highest priority)
|
|
33
|
+
>>> get_snapshot_storage_path('/custom/path')
|
|
34
|
+
Path('/custom/path')
|
|
35
|
+
"""
|
|
36
|
+
# Priority 1: Custom path parameter (but not empty string)
|
|
37
|
+
if custom_path:
|
|
38
|
+
# Handle both str and Path types
|
|
39
|
+
if isinstance(custom_path, str):
|
|
40
|
+
if custom_path.strip():
|
|
41
|
+
return Path(custom_path).expanduser().resolve()
|
|
42
|
+
else: # Path object
|
|
43
|
+
return custom_path.expanduser().resolve()
|
|
44
|
+
|
|
45
|
+
# Priority 2: Environment variable
|
|
46
|
+
env_path = os.getenv("AWS_INVENTORY_STORAGE_PATH")
|
|
47
|
+
if env_path:
|
|
48
|
+
return Path(env_path).expanduser().resolve()
|
|
49
|
+
|
|
50
|
+
# Priority 3: Default to ~/.snapshots
|
|
51
|
+
return Path.home() / ".snapshots"
|
src/utils/progress.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Progress indicator utilities using Rich library."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
from rich.progress import (
|
|
6
|
+
BarColumn,
|
|
7
|
+
Progress,
|
|
8
|
+
SpinnerColumn,
|
|
9
|
+
TaskProgressColumn,
|
|
10
|
+
TextColumn,
|
|
11
|
+
TimeRemainingColumn,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@contextmanager
|
|
16
|
+
def create_progress():
|
|
17
|
+
"""Create a Rich progress context for tracking operations.
|
|
18
|
+
|
|
19
|
+
Yields:
|
|
20
|
+
Progress instance configured for multi-task tracking
|
|
21
|
+
"""
|
|
22
|
+
with Progress(
|
|
23
|
+
SpinnerColumn(),
|
|
24
|
+
TextColumn("[progress.description]{task.description}"),
|
|
25
|
+
BarColumn(),
|
|
26
|
+
TaskProgressColumn(),
|
|
27
|
+
TimeRemainingColumn(),
|
|
28
|
+
) as progress:
|
|
29
|
+
yield progress
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_spinner_progress():
|
|
33
|
+
"""Create a simple spinner progress for indeterminate operations.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Progress instance with spinner
|
|
37
|
+
"""
|
|
38
|
+
return Progress(
|
|
39
|
+
SpinnerColumn(),
|
|
40
|
+
TextColumn("[progress.description]{task.description}"),
|
|
41
|
+
)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Utility for detecting unsupported AWS resources.
|
|
2
|
+
|
|
3
|
+
This module helps identify AWS resources that exist in an account but are not
|
|
4
|
+
covered by any of our collectors. This is important for:
|
|
5
|
+
- Ensuring complete inventory coverage
|
|
6
|
+
- Identifying when new AWS services need collectors
|
|
7
|
+
- Alerting users about resources that won't be included in snapshots
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
import boto3
|
|
15
|
+
from botocore.exceptions import ClientError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Mapping of AWS resource type prefixes to our collector service names
|
|
21
|
+
# This maps from AWS Resource Groups Tagging API format to our collectors
|
|
22
|
+
SUPPORTED_RESOURCE_TYPE_PREFIXES: Dict[str, str] = {
|
|
23
|
+
# IAM
|
|
24
|
+
"iam:": "iam",
|
|
25
|
+
# Compute
|
|
26
|
+
"ec2:": "ec2",
|
|
27
|
+
"lambda:": "lambda",
|
|
28
|
+
"ecs:": "ecs",
|
|
29
|
+
"eks:": "eks",
|
|
30
|
+
# Storage
|
|
31
|
+
"s3:": "s3",
|
|
32
|
+
"dynamodb:": "dynamodb",
|
|
33
|
+
"elasticache:": "elasticache",
|
|
34
|
+
"rds:": "rds",
|
|
35
|
+
"elasticfilesystem:": "efs",
|
|
36
|
+
"backup:": "backup",
|
|
37
|
+
# Networking
|
|
38
|
+
"elasticloadbalancing:": "elb",
|
|
39
|
+
"route53:": "route53",
|
|
40
|
+
# Messaging
|
|
41
|
+
"sns:": "sns",
|
|
42
|
+
"sqs:": "sqs",
|
|
43
|
+
# Security
|
|
44
|
+
"secretsmanager:": "secretsmanager",
|
|
45
|
+
"kms:": "kms",
|
|
46
|
+
"wafv2:": "waf",
|
|
47
|
+
# Monitoring & Logging
|
|
48
|
+
"cloudwatch:": "cloudwatch",
|
|
49
|
+
"logs:": "cloudwatch", # CloudWatch Logs is part of CloudWatch collector
|
|
50
|
+
# Integration & Orchestration
|
|
51
|
+
"states:": "stepfunctions",
|
|
52
|
+
"events:": "eventbridge",
|
|
53
|
+
"apigateway:": "apigateway",
|
|
54
|
+
"codepipeline:": "codepipeline",
|
|
55
|
+
"codebuild:": "codebuild",
|
|
56
|
+
# Management
|
|
57
|
+
"cloudformation:": "cloudformation",
|
|
58
|
+
"ssm:": "ssm",
|
|
59
|
+
# VPC
|
|
60
|
+
"ec2:vpc-endpoint": "vpcendpoints",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class UnsupportedResource:
|
|
66
|
+
"""Represents an AWS resource that is not covered by any collector."""
|
|
67
|
+
|
|
68
|
+
resource_arn: str
|
|
69
|
+
resource_type: str
|
|
70
|
+
tags: Dict[str, str]
|
|
71
|
+
region: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class UnsupportedResourceReport:
|
|
76
|
+
"""Report of unsupported resources found in an AWS account."""
|
|
77
|
+
|
|
78
|
+
unsupported_resources: List[UnsupportedResource]
|
|
79
|
+
unsupported_types: Set[str]
|
|
80
|
+
supported_types: Set[str]
|
|
81
|
+
total_resources_scanned: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_all_tagged_resources(
|
|
85
|
+
session: Optional[boto3.Session] = None,
|
|
86
|
+
regions: Optional[List[str]] = None,
|
|
87
|
+
) -> List[Tuple[str, str, Dict[str, str], str]]:
|
|
88
|
+
"""Get all tagged resources across all services using Resource Groups Tagging API.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
session: Optional boto3 session (uses default if not provided)
|
|
92
|
+
regions: Optional list of regions to scan (uses all regions if not provided)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of tuples (arn, resource_type, tags, region)
|
|
96
|
+
"""
|
|
97
|
+
if session is None:
|
|
98
|
+
session = boto3.Session()
|
|
99
|
+
|
|
100
|
+
if regions is None:
|
|
101
|
+
# Get all available regions
|
|
102
|
+
ec2 = session.client("ec2", region_name="us-east-1")
|
|
103
|
+
try:
|
|
104
|
+
response = ec2.describe_regions(AllRegions=False)
|
|
105
|
+
regions = [r["RegionName"] for r in response["Regions"]]
|
|
106
|
+
except ClientError as e:
|
|
107
|
+
logger.warning(f"Could not get regions, using default list: {e}")
|
|
108
|
+
regions = ["us-east-1", "us-west-2", "eu-west-1"]
|
|
109
|
+
|
|
110
|
+
all_resources: List[Tuple[str, str, Dict[str, str], str]] = []
|
|
111
|
+
|
|
112
|
+
for region in regions:
|
|
113
|
+
try:
|
|
114
|
+
tagging = session.client("resourcegroupstaggingapi", region_name=region)
|
|
115
|
+
paginator = tagging.get_paginator("get_resources")
|
|
116
|
+
|
|
117
|
+
for page in paginator.paginate():
|
|
118
|
+
for resource in page.get("ResourceTagMappingList", []):
|
|
119
|
+
arn = resource["ResourceARN"]
|
|
120
|
+
tags = {t["Key"]: t["Value"] for t in resource.get("Tags", [])}
|
|
121
|
+
|
|
122
|
+
# Extract resource type from ARN
|
|
123
|
+
# ARN format: arn:partition:service:region:account:resource-type/resource-id
|
|
124
|
+
parts = arn.split(":")
|
|
125
|
+
if len(parts) >= 6:
|
|
126
|
+
service = parts[2]
|
|
127
|
+
resource_part = parts[5] if len(parts) > 5 else ""
|
|
128
|
+
|
|
129
|
+
# Handle resource types like "bucket/name" or "function:name"
|
|
130
|
+
if "/" in resource_part:
|
|
131
|
+
resource_type = f"{service}:{resource_part.split('/')[0]}"
|
|
132
|
+
elif ":" in resource_part:
|
|
133
|
+
resource_type = f"{service}:{resource_part.split(':')[0]}"
|
|
134
|
+
else:
|
|
135
|
+
resource_type = f"{service}:{resource_part}"
|
|
136
|
+
|
|
137
|
+
all_resources.append((arn, resource_type, tags, region))
|
|
138
|
+
|
|
139
|
+
except ClientError as e:
|
|
140
|
+
logger.debug(f"Could not scan region {region}: {e}")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.warning(f"Error scanning region {region}: {e}")
|
|
143
|
+
|
|
144
|
+
return all_resources
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_resource_type_supported(resource_type: str) -> bool:
|
|
148
|
+
"""Check if a resource type is supported by any collector.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
resource_type: The AWS resource type (e.g., "s3:bucket", "lambda:function")
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if the resource type is supported
|
|
155
|
+
"""
|
|
156
|
+
resource_type_lower = resource_type.lower()
|
|
157
|
+
|
|
158
|
+
for prefix in SUPPORTED_RESOURCE_TYPE_PREFIXES:
|
|
159
|
+
if resource_type_lower.startswith(prefix):
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def detect_unsupported_resources(
|
|
166
|
+
session: Optional[boto3.Session] = None,
|
|
167
|
+
regions: Optional[List[str]] = None,
|
|
168
|
+
include_untagged_warning: bool = True,
|
|
169
|
+
) -> UnsupportedResourceReport:
|
|
170
|
+
"""Detect AWS resources that are not supported by any collector.
|
|
171
|
+
|
|
172
|
+
This function uses the Resource Groups Tagging API to find all tagged resources
|
|
173
|
+
and compares them against our supported resource types.
|
|
174
|
+
|
|
175
|
+
Note: This only detects TAGGED resources. Resources without tags will not be
|
|
176
|
+
detected by this method. Use AWS Config for more comprehensive detection.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
session: Optional boto3 session
|
|
180
|
+
regions: Optional list of regions to scan
|
|
181
|
+
include_untagged_warning: Whether to log a warning about untagged resources
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
UnsupportedResourceReport with details about unsupported resources
|
|
185
|
+
"""
|
|
186
|
+
if include_untagged_warning:
|
|
187
|
+
logger.info(
|
|
188
|
+
"Note: This detection only covers TAGGED resources. "
|
|
189
|
+
"Untagged resources will not be detected. "
|
|
190
|
+
"Consider enabling AWS Config for comprehensive resource tracking."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Get all tagged resources
|
|
194
|
+
all_resources = get_all_tagged_resources(session, regions)
|
|
195
|
+
|
|
196
|
+
unsupported_resources: List[UnsupportedResource] = []
|
|
197
|
+
unsupported_types: Set[str] = set()
|
|
198
|
+
supported_types: Set[str] = set()
|
|
199
|
+
|
|
200
|
+
for arn, resource_type, tags, region in all_resources:
|
|
201
|
+
if is_resource_type_supported(resource_type):
|
|
202
|
+
supported_types.add(resource_type)
|
|
203
|
+
else:
|
|
204
|
+
unsupported_types.add(resource_type)
|
|
205
|
+
unsupported_resources.append(
|
|
206
|
+
UnsupportedResource(
|
|
207
|
+
resource_arn=arn,
|
|
208
|
+
resource_type=resource_type,
|
|
209
|
+
tags=tags,
|
|
210
|
+
region=region,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return UnsupportedResourceReport(
|
|
215
|
+
unsupported_resources=unsupported_resources,
|
|
216
|
+
unsupported_types=unsupported_types,
|
|
217
|
+
supported_types=supported_types,
|
|
218
|
+
total_resources_scanned=len(all_resources),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_unsupported_resource_summary(report: UnsupportedResourceReport) -> str:
|
|
223
|
+
"""Generate a human-readable summary of unsupported resources.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
report: The unsupported resource report
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Formatted string summary
|
|
230
|
+
"""
|
|
231
|
+
lines = [
|
|
232
|
+
f"Unsupported Resource Detection Report",
|
|
233
|
+
"=" * 40,
|
|
234
|
+
f"Total resources scanned: {report.total_resources_scanned}",
|
|
235
|
+
f"Supported resource types found: {len(report.supported_types)}",
|
|
236
|
+
f"Unsupported resource types found: {len(report.unsupported_types)}",
|
|
237
|
+
f"Total unsupported resources: {len(report.unsupported_resources)}",
|
|
238
|
+
"",
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
if report.unsupported_types:
|
|
242
|
+
lines.append("Unsupported Resource Types:")
|
|
243
|
+
lines.append("-" * 30)
|
|
244
|
+
for resource_type in sorted(report.unsupported_types):
|
|
245
|
+
count = sum(
|
|
246
|
+
1
|
|
247
|
+
for r in report.unsupported_resources
|
|
248
|
+
if r.resource_type == resource_type
|
|
249
|
+
)
|
|
250
|
+
lines.append(f" {resource_type}: {count} resource(s)")
|
|
251
|
+
|
|
252
|
+
lines.append("")
|
|
253
|
+
lines.append("Consider adding collectors for these services to ensure")
|
|
254
|
+
lines.append("complete inventory coverage and safe cleanup operations.")
|
|
255
|
+
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def check_for_unsupported_resources_quick(
|
|
260
|
+
session: Optional[boto3.Session] = None,
|
|
261
|
+
region: str = "us-east-1",
|
|
262
|
+
limit: int = 100,
|
|
263
|
+
) -> Tuple[bool, Set[str]]:
|
|
264
|
+
"""Quick check for unsupported resources in a single region.
|
|
265
|
+
|
|
266
|
+
This is a faster alternative to full detection, useful for CLI warnings.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
session: Optional boto3 session
|
|
270
|
+
region: Region to check
|
|
271
|
+
limit: Maximum number of resources to check
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Tuple of (has_unsupported, unsupported_types)
|
|
275
|
+
"""
|
|
276
|
+
if session is None:
|
|
277
|
+
session = boto3.Session()
|
|
278
|
+
|
|
279
|
+
unsupported_types: Set[str] = set()
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
tagging = session.client("resourcegroupstaggingapi", region_name=region)
|
|
283
|
+
|
|
284
|
+
response = tagging.get_resources(ResourcesPerPage=limit)
|
|
285
|
+
|
|
286
|
+
for resource in response.get("ResourceTagMappingList", []):
|
|
287
|
+
arn = resource["ResourceARN"]
|
|
288
|
+
parts = arn.split(":")
|
|
289
|
+
if len(parts) >= 6:
|
|
290
|
+
service = parts[2]
|
|
291
|
+
resource_part = parts[5] if len(parts) > 5 else ""
|
|
292
|
+
|
|
293
|
+
if "/" in resource_part:
|
|
294
|
+
resource_type = f"{service}:{resource_part.split('/')[0]}"
|
|
295
|
+
elif ":" in resource_part:
|
|
296
|
+
resource_type = f"{service}:{resource_part.split(':')[0]}"
|
|
297
|
+
else:
|
|
298
|
+
resource_type = f"{service}:{resource_part}"
|
|
299
|
+
|
|
300
|
+
if not is_resource_type_supported(resource_type):
|
|
301
|
+
unsupported_types.add(resource_type)
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.debug(f"Quick unsupported resource check failed: {e}")
|
|
305
|
+
|
|
306
|
+
return bool(unsupported_types), unsupported_types
|
src/web/__init__.py
ADDED
src/web/app.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""FastAPI application factory for AWS Inventory Browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
from fastapi.templating import Jinja2Templates
|
|
12
|
+
|
|
13
|
+
from .dependencies import init_database
|
|
14
|
+
|
|
15
|
+
# Get the directory where this module is located
|
|
16
|
+
MODULE_DIR = Path(__file__).parent
|
|
17
|
+
TEMPLATES_DIR = MODULE_DIR / "templates"
|
|
18
|
+
STATIC_DIR = MODULE_DIR / "static"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_templates() -> Jinja2Templates:
|
|
22
|
+
"""Get configured Jinja2Templates instance."""
|
|
23
|
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
24
|
+
|
|
25
|
+
# Add custom filters
|
|
26
|
+
def format_number(value: int) -> str:
|
|
27
|
+
"""Format number with commas."""
|
|
28
|
+
return f"{value:,}"
|
|
29
|
+
|
|
30
|
+
def truncate_arn(arn: str, max_length: int = 50) -> str:
|
|
31
|
+
"""Truncate ARN for display."""
|
|
32
|
+
if len(arn) <= max_length:
|
|
33
|
+
return arn
|
|
34
|
+
return arn[:max_length - 3] + "..."
|
|
35
|
+
|
|
36
|
+
def service_from_type(resource_type: str) -> str:
|
|
37
|
+
"""Extract service name from resource type."""
|
|
38
|
+
if ":" in resource_type:
|
|
39
|
+
return resource_type.split(":")[0]
|
|
40
|
+
return resource_type
|
|
41
|
+
|
|
42
|
+
templates.env.filters["format_number"] = format_number
|
|
43
|
+
templates.env.filters["truncate_arn"] = truncate_arn
|
|
44
|
+
templates.env.filters["service"] = service_from_type
|
|
45
|
+
|
|
46
|
+
return templates
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@asynccontextmanager
|
|
50
|
+
async def lifespan(app: FastAPI):
|
|
51
|
+
"""Manage application lifecycle."""
|
|
52
|
+
# Startup: Initialize database
|
|
53
|
+
storage_path = getattr(app.state, "storage_path", None)
|
|
54
|
+
init_database(storage_path)
|
|
55
|
+
yield
|
|
56
|
+
# Shutdown: Nothing to clean up
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_app(storage_path: Optional[str] = None) -> FastAPI:
|
|
60
|
+
"""Create and configure the FastAPI application.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
storage_path: Optional path to storage directory.
|
|
64
|
+
If not provided, uses default from Config.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Configured FastAPI application instance.
|
|
68
|
+
"""
|
|
69
|
+
app = FastAPI(
|
|
70
|
+
title="AWS Inventory Browser",
|
|
71
|
+
description="Browse and analyze AWS resource inventory",
|
|
72
|
+
version="1.0.0",
|
|
73
|
+
lifespan=lifespan,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Store config for lifespan access
|
|
77
|
+
app.state.storage_path = storage_path
|
|
78
|
+
|
|
79
|
+
# Mount static files if directory exists
|
|
80
|
+
if STATIC_DIR.exists():
|
|
81
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
82
|
+
|
|
83
|
+
# Get templates
|
|
84
|
+
templates = get_templates()
|
|
85
|
+
|
|
86
|
+
# Include API routes
|
|
87
|
+
from .routes.api import router as api_router
|
|
88
|
+
app.include_router(api_router, prefix="/api")
|
|
89
|
+
|
|
90
|
+
# Include page routes
|
|
91
|
+
from .routes import pages
|
|
92
|
+
app.include_router(pages.router)
|
|
93
|
+
|
|
94
|
+
# Add templates to app state for access in routes
|
|
95
|
+
app.state.templates = templates
|
|
96
|
+
|
|
97
|
+
return app
|