iam-policy-validator 1.7.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 iam-policy-validator might be problematic. Click here for more details.
- iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
- iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
- iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +7 -0
- iam_validator/checks/__init__.py +43 -0
- iam_validator/checks/action_condition_enforcement.py +884 -0
- iam_validator/checks/action_resource_matching.py +441 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +92 -0
- iam_validator/checks/condition_type_mismatch.py +259 -0
- iam_validator/checks/full_wildcard.py +71 -0
- iam_validator/checks/mfa_condition_check.py +112 -0
- iam_validator/checks/policy_size.py +147 -0
- iam_validator/checks/policy_type_validation.py +305 -0
- iam_validator/checks/principal_validation.py +776 -0
- iam_validator/checks/resource_validation.py +138 -0
- iam_validator/checks/sensitive_action.py +254 -0
- iam_validator/checks/service_wildcard.py +107 -0
- iam_validator/checks/set_operator_validation.py +157 -0
- iam_validator/checks/sid_uniqueness.py +170 -0
- iam_validator/checks/utils/__init__.py +1 -0
- iam_validator/checks/utils/policy_level_checks.py +143 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/checks/wildcard_action.py +67 -0
- iam_validator/checks/wildcard_resource.py +135 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +531 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +600 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +940 -0
- iam_validator/core/check_registry.py +607 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +104 -0
- iam_validator/core/config/condition_requirements.py +155 -0
- iam_validator/core/config/config_loader.py +472 -0
- iam_validator/core/config/defaults.py +523 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +95 -0
- iam_validator/core/config/wildcards.py +124 -0
- iam_validator/core/constants.py +74 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +59 -0
- iam_validator/core/formatters/csv.py +170 -0
- iam_validator/core/formatters/enhanced.py +440 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +63 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/models.py +327 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +424 -0
- iam_validator/core/report.py +872 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +815 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +187 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +425 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +31 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +206 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Cache management command for IAM Policy Validator."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from iam_validator.commands.base import Command
|
|
11
|
+
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
12
|
+
from iam_validator.core.config.config_loader import ConfigLoader
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheCommand(Command):
|
|
19
|
+
"""Manage AWS service definition cache."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self) -> str:
|
|
23
|
+
return "cache"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def help(self) -> str:
|
|
27
|
+
return "Manage AWS service definition cache"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def epilog(self) -> str:
|
|
31
|
+
return """
|
|
32
|
+
Examples:
|
|
33
|
+
# Show cache information
|
|
34
|
+
iam-validator cache info
|
|
35
|
+
|
|
36
|
+
# List all cached services
|
|
37
|
+
iam-validator cache list
|
|
38
|
+
|
|
39
|
+
# Clear all cached AWS service definitions
|
|
40
|
+
iam-validator cache clear
|
|
41
|
+
|
|
42
|
+
# Refresh cache (clear and pre-fetch common services)
|
|
43
|
+
iam-validator cache refresh
|
|
44
|
+
|
|
45
|
+
# Pre-fetch common AWS services
|
|
46
|
+
iam-validator cache prefetch
|
|
47
|
+
|
|
48
|
+
# Show cache location
|
|
49
|
+
iam-validator cache location
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
53
|
+
"""Add cache command arguments."""
|
|
54
|
+
subparsers = parser.add_subparsers(dest="cache_action", help="Cache action to perform")
|
|
55
|
+
|
|
56
|
+
# Info subcommand
|
|
57
|
+
info_parser = subparsers.add_parser("info", help="Show cache information and statistics")
|
|
58
|
+
info_parser.add_argument(
|
|
59
|
+
"--config",
|
|
60
|
+
type=str,
|
|
61
|
+
help="Path to configuration file",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# List subcommand
|
|
65
|
+
list_parser = subparsers.add_parser("list", help="List all cached AWS services")
|
|
66
|
+
list_parser.add_argument(
|
|
67
|
+
"--config",
|
|
68
|
+
type=str,
|
|
69
|
+
help="Path to configuration file",
|
|
70
|
+
)
|
|
71
|
+
list_parser.add_argument(
|
|
72
|
+
"--format",
|
|
73
|
+
choices=["table", "columns", "simple"],
|
|
74
|
+
default="table",
|
|
75
|
+
help="Output format (default: table)",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Clear subcommand
|
|
79
|
+
clear_parser = subparsers.add_parser(
|
|
80
|
+
"clear", help="Clear all cached AWS service definitions"
|
|
81
|
+
)
|
|
82
|
+
clear_parser.add_argument(
|
|
83
|
+
"--config",
|
|
84
|
+
type=str,
|
|
85
|
+
help="Path to configuration file",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Refresh subcommand
|
|
89
|
+
refresh_parser = subparsers.add_parser(
|
|
90
|
+
"refresh", help="Clear cache and pre-fetch common AWS services"
|
|
91
|
+
)
|
|
92
|
+
refresh_parser.add_argument(
|
|
93
|
+
"--config",
|
|
94
|
+
type=str,
|
|
95
|
+
help="Path to configuration file",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Prefetch subcommand
|
|
99
|
+
prefetch_parser = subparsers.add_parser(
|
|
100
|
+
"prefetch", help="Pre-fetch common AWS services (without clearing)"
|
|
101
|
+
)
|
|
102
|
+
prefetch_parser.add_argument(
|
|
103
|
+
"--config",
|
|
104
|
+
type=str,
|
|
105
|
+
help="Path to configuration file",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Location subcommand
|
|
109
|
+
location_parser = subparsers.add_parser("location", help="Show cache directory location")
|
|
110
|
+
location_parser.add_argument(
|
|
111
|
+
"--config",
|
|
112
|
+
type=str,
|
|
113
|
+
help="Path to configuration file",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def execute(self, args: argparse.Namespace) -> int:
|
|
117
|
+
"""Execute cache command."""
|
|
118
|
+
if not hasattr(args, "cache_action") or not args.cache_action:
|
|
119
|
+
console.print("[red]Error:[/red] No cache action specified")
|
|
120
|
+
console.print("Use 'iam-validator cache --help' for available actions")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
# Load config to get cache settings
|
|
124
|
+
config_path = getattr(args, "config", None)
|
|
125
|
+
config = ConfigLoader.load_config(explicit_path=config_path, allow_missing=True)
|
|
126
|
+
|
|
127
|
+
cache_enabled = config.get_setting("cache_enabled", True)
|
|
128
|
+
cache_ttl_hours = config.get_setting("cache_ttl_hours", 168)
|
|
129
|
+
cache_directory = config.get_setting("cache_directory", None)
|
|
130
|
+
cache_ttl_seconds = cache_ttl_hours * 3600
|
|
131
|
+
|
|
132
|
+
# Get cache directory (even if caching is disabled, for info purposes)
|
|
133
|
+
cache_dir = AWSServiceFetcher._get_cache_directory(cache_directory)
|
|
134
|
+
|
|
135
|
+
action = args.cache_action
|
|
136
|
+
|
|
137
|
+
if action == "info":
|
|
138
|
+
return await self._show_info(cache_dir, cache_enabled, cache_ttl_hours)
|
|
139
|
+
elif action == "list":
|
|
140
|
+
output_format = getattr(args, "format", "table")
|
|
141
|
+
return self._list_cached_services(cache_dir, output_format)
|
|
142
|
+
elif action == "clear":
|
|
143
|
+
return await self._clear_cache(cache_dir, cache_enabled)
|
|
144
|
+
elif action == "refresh":
|
|
145
|
+
return await self._refresh_cache(cache_enabled, cache_ttl_seconds, cache_directory)
|
|
146
|
+
elif action == "prefetch":
|
|
147
|
+
return await self._prefetch_services(cache_enabled, cache_ttl_seconds, cache_directory)
|
|
148
|
+
elif action == "location":
|
|
149
|
+
return self._show_location(cache_dir)
|
|
150
|
+
else:
|
|
151
|
+
console.print(f"[red]Error:[/red] Unknown cache action: {action}")
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
async def _show_info(self, cache_dir: Path, cache_enabled: bool, cache_ttl_hours: int) -> int:
|
|
155
|
+
"""Show cache information and statistics."""
|
|
156
|
+
table = Table(title="Cache Information")
|
|
157
|
+
table.add_column("Setting", style="cyan", no_wrap=True)
|
|
158
|
+
table.add_column("Value", style="white")
|
|
159
|
+
|
|
160
|
+
# Cache status
|
|
161
|
+
table.add_row(
|
|
162
|
+
"Status", "[green]Enabled[/green]" if cache_enabled else "[red]Disabled[/red]"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Cache location
|
|
166
|
+
table.add_row("Location", str(cache_dir))
|
|
167
|
+
|
|
168
|
+
# Cache exists?
|
|
169
|
+
exists = cache_dir.exists()
|
|
170
|
+
table.add_row("Exists", "[green]Yes[/green]" if exists else "[yellow]No[/yellow]")
|
|
171
|
+
|
|
172
|
+
# Cache TTL
|
|
173
|
+
ttl_days = cache_ttl_hours / 24
|
|
174
|
+
table.add_row("TTL", f"{cache_ttl_hours} hours ({ttl_days:.1f} days)")
|
|
175
|
+
|
|
176
|
+
if exists:
|
|
177
|
+
# Count cached files
|
|
178
|
+
cache_files = list(cache_dir.glob("*.json"))
|
|
179
|
+
table.add_row("Cached Services", str(len(cache_files)))
|
|
180
|
+
|
|
181
|
+
# Calculate cache size
|
|
182
|
+
total_size = sum(f.stat().st_size for f in cache_files)
|
|
183
|
+
size_mb = total_size / (1024 * 1024)
|
|
184
|
+
table.add_row("Cache Size", f"{size_mb:.2f} MB")
|
|
185
|
+
|
|
186
|
+
# Show some cached services
|
|
187
|
+
if cache_files:
|
|
188
|
+
service_names = []
|
|
189
|
+
for f in cache_files[:5]:
|
|
190
|
+
name = f.stem.split("_")[0] if "_" in f.stem else f.stem
|
|
191
|
+
service_names.append(name)
|
|
192
|
+
sample = ", ".join(service_names)
|
|
193
|
+
if len(cache_files) > 5:
|
|
194
|
+
sample += f", ... ({len(cache_files) - 5} more)"
|
|
195
|
+
table.add_row("Sample Services", sample)
|
|
196
|
+
|
|
197
|
+
console.print(table)
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
def _list_cached_services(self, cache_dir: Path, output_format: str) -> int:
|
|
201
|
+
"""List all cached AWS services."""
|
|
202
|
+
if not cache_dir.exists():
|
|
203
|
+
console.print("[yellow]Cache directory does not exist[/yellow]")
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
cache_files = list(cache_dir.glob("*.json"))
|
|
207
|
+
|
|
208
|
+
if not cache_files:
|
|
209
|
+
console.print("[yellow]No services cached yet[/yellow]")
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
# Extract service names from filenames
|
|
213
|
+
services = []
|
|
214
|
+
for f in cache_files:
|
|
215
|
+
# Handle both formats: "service_hash.json" and "services_list.json"
|
|
216
|
+
if f.stem == "services_list":
|
|
217
|
+
continue # Skip the services list file
|
|
218
|
+
|
|
219
|
+
# Extract service name (before underscore or full name)
|
|
220
|
+
name = f.stem.split("_")[0] if "_" in f.stem else f.stem
|
|
221
|
+
|
|
222
|
+
# Get file stats
|
|
223
|
+
size = f.stat().st_size
|
|
224
|
+
mtime = f.stat().st_mtime
|
|
225
|
+
|
|
226
|
+
services.append({"name": name, "size": size, "file": f.name, "mtime": mtime})
|
|
227
|
+
|
|
228
|
+
# Sort by service name
|
|
229
|
+
services.sort(key=lambda x: x["name"])
|
|
230
|
+
|
|
231
|
+
if output_format == "table":
|
|
232
|
+
self._print_services_table(services)
|
|
233
|
+
elif output_format == "columns":
|
|
234
|
+
self._print_services_columns(services)
|
|
235
|
+
else: # simple
|
|
236
|
+
self._print_services_simple(services)
|
|
237
|
+
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
def _print_services_table(self, services: list[dict]) -> None:
|
|
241
|
+
"""Print services in a nice table format."""
|
|
242
|
+
from datetime import datetime
|
|
243
|
+
|
|
244
|
+
table = Table(title=f"Cached AWS Services ({len(services)} total)")
|
|
245
|
+
table.add_column("Service", style="cyan", no_wrap=True)
|
|
246
|
+
table.add_column("Cache File", style="white")
|
|
247
|
+
table.add_column("Size", style="yellow", justify="right")
|
|
248
|
+
table.add_column("Cached", style="green")
|
|
249
|
+
|
|
250
|
+
for svc in services:
|
|
251
|
+
size_kb = svc["size"] / 1024
|
|
252
|
+
cached_time = datetime.fromtimestamp(svc["mtime"]).strftime("%Y-%m-%d %H:%M")
|
|
253
|
+
|
|
254
|
+
table.add_row(svc["name"], svc["file"], f"{size_kb:.1f} KB", cached_time)
|
|
255
|
+
|
|
256
|
+
console.print(table)
|
|
257
|
+
|
|
258
|
+
def _print_services_columns(self, services: list[dict]) -> None:
|
|
259
|
+
"""Print services in columns format (like ls)."""
|
|
260
|
+
from rich.columns import Columns
|
|
261
|
+
|
|
262
|
+
console.print(f"[cyan]Cached AWS Services ({len(services)} total):[/cyan]\n")
|
|
263
|
+
|
|
264
|
+
service_names = [f"[green]{svc['name']}[/green]" for svc in services]
|
|
265
|
+
console.print(Columns(service_names, equal=True, expand=False))
|
|
266
|
+
|
|
267
|
+
def _print_services_simple(self, services: list[dict]) -> None:
|
|
268
|
+
"""Print services in simple list format."""
|
|
269
|
+
console.print(f"[cyan]Cached AWS Services ({len(services)} total):[/cyan]\n")
|
|
270
|
+
|
|
271
|
+
for svc in services:
|
|
272
|
+
console.print(svc["name"])
|
|
273
|
+
|
|
274
|
+
async def _clear_cache(self, cache_dir: Path, cache_enabled: bool) -> int:
|
|
275
|
+
"""Clear all cached AWS service definitions."""
|
|
276
|
+
if not cache_enabled:
|
|
277
|
+
console.print("[yellow]Warning:[/yellow] Cache is disabled in config")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
if not cache_dir.exists():
|
|
281
|
+
console.print("[yellow]Cache directory does not exist, nothing to clear[/yellow]")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
# Count files before deletion
|
|
285
|
+
cache_files = list(cache_dir.glob("*.json"))
|
|
286
|
+
file_count = len(cache_files)
|
|
287
|
+
|
|
288
|
+
if file_count == 0:
|
|
289
|
+
console.print("[yellow]Cache is already empty[/yellow]")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
# Delete cache files
|
|
293
|
+
deleted = 0
|
|
294
|
+
failed = 0
|
|
295
|
+
for cache_file in cache_files:
|
|
296
|
+
try:
|
|
297
|
+
cache_file.unlink()
|
|
298
|
+
deleted += 1
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error(f"Failed to delete {cache_file}: {e}")
|
|
301
|
+
failed += 1
|
|
302
|
+
|
|
303
|
+
if failed == 0:
|
|
304
|
+
console.print(f"[green]✓[/green] Cleared {deleted} cached service definitions")
|
|
305
|
+
else:
|
|
306
|
+
console.print(
|
|
307
|
+
f"[yellow]![/yellow] Cleared {deleted} files, failed to delete {failed} files"
|
|
308
|
+
)
|
|
309
|
+
return 1
|
|
310
|
+
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
async def _refresh_cache(
|
|
314
|
+
self, cache_enabled: bool, cache_ttl_seconds: int, cache_directory: str | None
|
|
315
|
+
) -> int:
|
|
316
|
+
"""Clear cache and pre-fetch common services."""
|
|
317
|
+
if not cache_enabled:
|
|
318
|
+
console.print("[red]Error:[/red] Cache is disabled in config")
|
|
319
|
+
console.print("Enable cache by setting 'cache_enabled: true' in your config")
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
console.print("[cyan]Refreshing cache...[/cyan]")
|
|
323
|
+
|
|
324
|
+
# Create fetcher and clear cache
|
|
325
|
+
async with AWSServiceFetcher(
|
|
326
|
+
enable_cache=cache_enabled,
|
|
327
|
+
cache_ttl=cache_ttl_seconds,
|
|
328
|
+
cache_dir=cache_directory,
|
|
329
|
+
prefetch_common=False, # Don't prefetch yet, we'll do it after clearing
|
|
330
|
+
) as fetcher:
|
|
331
|
+
# Clear existing cache
|
|
332
|
+
console.print("Clearing old cache...")
|
|
333
|
+
await fetcher.clear_caches()
|
|
334
|
+
|
|
335
|
+
# Prefetch common services
|
|
336
|
+
console.print("Fetching fresh AWS service definitions...")
|
|
337
|
+
services = await fetcher.fetch_services()
|
|
338
|
+
console.print(f"[green]✓[/green] Fetched list of {len(services)} AWS services")
|
|
339
|
+
|
|
340
|
+
# Prefetch common services
|
|
341
|
+
console.print("Pre-fetching common services...")
|
|
342
|
+
prefetched = 0
|
|
343
|
+
for service_name in fetcher.COMMON_SERVICES:
|
|
344
|
+
try:
|
|
345
|
+
await fetcher.fetch_service_by_name(service_name)
|
|
346
|
+
prefetched += 1
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.warning(f"Failed to prefetch {service_name}: {e}")
|
|
349
|
+
|
|
350
|
+
console.print(f"[green]✓[/green] Pre-fetched {prefetched} common services")
|
|
351
|
+
|
|
352
|
+
console.print("[green]✓[/green] Cache refreshed successfully")
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
async def _prefetch_services(
|
|
356
|
+
self, cache_enabled: bool, cache_ttl_seconds: int, cache_directory: str | None
|
|
357
|
+
) -> int:
|
|
358
|
+
"""Pre-fetch common AWS services without clearing cache."""
|
|
359
|
+
if not cache_enabled:
|
|
360
|
+
console.print("[red]Error:[/red] Cache is disabled in config")
|
|
361
|
+
console.print("Enable cache by setting 'cache_enabled: true' in your config")
|
|
362
|
+
return 1
|
|
363
|
+
|
|
364
|
+
console.print("[cyan]Pre-fetching common AWS services...[/cyan]")
|
|
365
|
+
|
|
366
|
+
async with AWSServiceFetcher(
|
|
367
|
+
enable_cache=cache_enabled,
|
|
368
|
+
cache_ttl=cache_ttl_seconds,
|
|
369
|
+
cache_dir=cache_directory,
|
|
370
|
+
prefetch_common=True, # Enable prefetching
|
|
371
|
+
) as fetcher:
|
|
372
|
+
# Prefetching happens in __aenter__, just wait for it
|
|
373
|
+
prefetched = len(fetcher._prefetched_services)
|
|
374
|
+
total = len(fetcher.COMMON_SERVICES)
|
|
375
|
+
|
|
376
|
+
console.print(
|
|
377
|
+
f"[green]✓[/green] Pre-fetched {prefetched}/{total} common services successfully"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
def _show_location(self, cache_dir: Path) -> int:
|
|
383
|
+
"""Show cache directory location."""
|
|
384
|
+
console.print(f"[cyan]Cache directory:[/cyan] {cache_dir}")
|
|
385
|
+
|
|
386
|
+
if cache_dir.exists():
|
|
387
|
+
console.print("[green]✓[/green] Directory exists")
|
|
388
|
+
else:
|
|
389
|
+
console.print("[yellow]![/yellow] Directory does not exist yet")
|
|
390
|
+
console.print("It will be created automatically when caching is used")
|
|
391
|
+
|
|
392
|
+
return 0
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Download AWS service definitions command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import BarColumn, Progress, TaskID, TextColumn, TimeRemainingColumn
|
|
13
|
+
|
|
14
|
+
from iam_validator.commands.base import Command
|
|
15
|
+
from iam_validator.core.config import AWS_SERVICE_REFERENCE_BASE_URL
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
BASE_URL = AWS_SERVICE_REFERENCE_BASE_URL
|
|
21
|
+
DEFAULT_OUTPUT_DIR = Path("aws_services")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DownloadServicesCommand(Command):
|
|
25
|
+
"""Download all AWS service definition JSON files."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return "sync-services"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def help(self) -> str:
|
|
33
|
+
return "Sync/download all AWS service definitions for offline use"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def epilog(self) -> str:
|
|
37
|
+
return """
|
|
38
|
+
Examples:
|
|
39
|
+
# Sync all AWS service definitions to default directory (aws_services/)
|
|
40
|
+
iam-validator sync-services
|
|
41
|
+
|
|
42
|
+
# Sync to a custom directory
|
|
43
|
+
iam-validator sync-services --output-dir /path/to/backup
|
|
44
|
+
|
|
45
|
+
# Limit concurrent downloads
|
|
46
|
+
iam-validator sync-services --max-concurrent 5
|
|
47
|
+
|
|
48
|
+
# Enable verbose output
|
|
49
|
+
iam-validator sync-services --log-level debug
|
|
50
|
+
|
|
51
|
+
Directory structure:
|
|
52
|
+
aws_services/
|
|
53
|
+
_manifest.json # Metadata about the download
|
|
54
|
+
_services.json # List of all services
|
|
55
|
+
s3.json # Individual service definitions
|
|
56
|
+
ec2.json
|
|
57
|
+
iam.json
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
This command is useful for:
|
|
61
|
+
- Creating offline backups of AWS service definitions
|
|
62
|
+
- Avoiding API rate limiting during development
|
|
63
|
+
- Ensuring consistent service definitions across environments
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
67
|
+
"""Add sync-services command arguments."""
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--output-dir",
|
|
70
|
+
type=Path,
|
|
71
|
+
default=DEFAULT_OUTPUT_DIR,
|
|
72
|
+
help=f"Output directory for downloaded files (default: {DEFAULT_OUTPUT_DIR})",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--max-concurrent",
|
|
77
|
+
type=int,
|
|
78
|
+
default=10,
|
|
79
|
+
help="Maximum number of concurrent downloads (default: 10)",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def execute(self, args: argparse.Namespace) -> int:
|
|
83
|
+
"""Execute the sync-services command."""
|
|
84
|
+
output_dir = args.output_dir
|
|
85
|
+
max_concurrent = args.max_concurrent
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
await self._download_all_services(output_dir, max_concurrent)
|
|
89
|
+
return 0
|
|
90
|
+
except Exception as e:
|
|
91
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
92
|
+
logger.error(f"Download failed: {e}", exc_info=True)
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
async def _download_services_list(self, client: httpx.AsyncClient) -> list[dict]:
|
|
96
|
+
"""Download the list of all AWS services.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
client: HTTP client for making requests
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
List of service info dictionaries
|
|
103
|
+
"""
|
|
104
|
+
console.print(f"[cyan]Fetching services list from {BASE_URL}...[/cyan]")
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
response = await client.get(BASE_URL, timeout=30.0)
|
|
108
|
+
response.raise_for_status()
|
|
109
|
+
services = response.json()
|
|
110
|
+
|
|
111
|
+
console.print(f"[green]✓[/green] Found {len(services)} AWS services")
|
|
112
|
+
return services
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Failed to fetch services list: {e}")
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
async def _download_service_detail(
|
|
118
|
+
self,
|
|
119
|
+
client: httpx.AsyncClient,
|
|
120
|
+
service_name: str,
|
|
121
|
+
service_url: str,
|
|
122
|
+
semaphore: asyncio.Semaphore,
|
|
123
|
+
progress: Progress,
|
|
124
|
+
task_id: TaskID,
|
|
125
|
+
) -> tuple[str, dict | None]:
|
|
126
|
+
"""Download detailed JSON for a single service.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
client: HTTP client for making requests
|
|
130
|
+
service_name: Name of the service
|
|
131
|
+
service_url: URL to fetch service details
|
|
132
|
+
semaphore: Semaphore to limit concurrent requests
|
|
133
|
+
progress: Progress bar instance
|
|
134
|
+
task_id: Progress task ID
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Tuple of (service_name, service_data) or (service_name, None) if failed
|
|
138
|
+
"""
|
|
139
|
+
async with semaphore:
|
|
140
|
+
try:
|
|
141
|
+
logger.debug(f"Downloading {service_name}...")
|
|
142
|
+
response = await client.get(service_url, timeout=30.0)
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
data = response.json()
|
|
145
|
+
logger.debug(f"✓ Downloaded {service_name}")
|
|
146
|
+
progress.update(task_id, advance=1)
|
|
147
|
+
return service_name, data
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(f"✗ Failed to download {service_name}: {e}")
|
|
150
|
+
progress.update(task_id, advance=1)
|
|
151
|
+
return service_name, None
|
|
152
|
+
|
|
153
|
+
async def _download_all_services(self, output_dir: Path, max_concurrent: int = 10) -> None:
|
|
154
|
+
"""Download all AWS service definitions.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
output_dir: Directory to save the downloaded files
|
|
158
|
+
max_concurrent: Maximum number of concurrent downloads
|
|
159
|
+
"""
|
|
160
|
+
# Create output directory
|
|
161
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
console.print(f"[cyan]Output directory:[/cyan] {output_dir.absolute()}\n")
|
|
163
|
+
|
|
164
|
+
# Create HTTP client with connection pooling
|
|
165
|
+
async with httpx.AsyncClient(
|
|
166
|
+
limits=httpx.Limits(max_connections=max_concurrent, max_keepalive_connections=5),
|
|
167
|
+
timeout=httpx.Timeout(30.0),
|
|
168
|
+
) as client:
|
|
169
|
+
# Download services list
|
|
170
|
+
services = await self._download_services_list(client)
|
|
171
|
+
|
|
172
|
+
# Save services list (underscore prefix for easy discovery at top of directory)
|
|
173
|
+
services_file = output_dir / "_services.json"
|
|
174
|
+
with open(services_file, "w") as f:
|
|
175
|
+
json.dump(services, f, indent=2)
|
|
176
|
+
console.print(f"[green]✓[/green] Saved services list to {services_file}\n")
|
|
177
|
+
|
|
178
|
+
# Download all service details with rate limiting and progress bar
|
|
179
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
180
|
+
tasks = []
|
|
181
|
+
|
|
182
|
+
# Set up progress bar
|
|
183
|
+
with Progress(
|
|
184
|
+
TextColumn("[progress.description]{task.description}"),
|
|
185
|
+
BarColumn(),
|
|
186
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
187
|
+
TextColumn("({task.completed}/{task.total})"),
|
|
188
|
+
TimeRemainingColumn(),
|
|
189
|
+
console=console,
|
|
190
|
+
) as progress:
|
|
191
|
+
task_id = progress.add_task(
|
|
192
|
+
"[cyan]Downloading service definitions...", total=len(services)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
for item in services:
|
|
196
|
+
service_name = item.get("service")
|
|
197
|
+
service_url = item.get("url")
|
|
198
|
+
|
|
199
|
+
if service_name and service_url:
|
|
200
|
+
task = self._download_service_detail(
|
|
201
|
+
client, service_name, service_url, semaphore, progress, task_id
|
|
202
|
+
)
|
|
203
|
+
tasks.append(task)
|
|
204
|
+
|
|
205
|
+
# Download all services concurrently
|
|
206
|
+
results = await asyncio.gather(*tasks)
|
|
207
|
+
|
|
208
|
+
# Save individual service files
|
|
209
|
+
successful = 0
|
|
210
|
+
failed = 0
|
|
211
|
+
|
|
212
|
+
console.print("\n[cyan]Saving service definitions...[/cyan]")
|
|
213
|
+
|
|
214
|
+
for service_name, data in results:
|
|
215
|
+
if data is not None:
|
|
216
|
+
# Normalize filename (lowercase, safe characters)
|
|
217
|
+
filename = f"{service_name.lower().replace(' ', '_')}.json"
|
|
218
|
+
service_file = output_dir / filename
|
|
219
|
+
|
|
220
|
+
with open(service_file, "w") as f:
|
|
221
|
+
json.dump(data, f, indent=2)
|
|
222
|
+
|
|
223
|
+
successful += 1
|
|
224
|
+
else:
|
|
225
|
+
failed += 1
|
|
226
|
+
|
|
227
|
+
# Create manifest with metadata
|
|
228
|
+
manifest = {
|
|
229
|
+
"download_date": datetime.now(timezone.utc).isoformat(),
|
|
230
|
+
"total_services": len(services),
|
|
231
|
+
"successful_downloads": successful,
|
|
232
|
+
"failed_downloads": failed,
|
|
233
|
+
"base_url": BASE_URL,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
manifest_file = output_dir / "_manifest.json"
|
|
237
|
+
with open(manifest_file, "w") as f:
|
|
238
|
+
json.dump(manifest, f, indent=2)
|
|
239
|
+
|
|
240
|
+
# Print summary
|
|
241
|
+
console.print(f"\n{'=' * 60}")
|
|
242
|
+
console.print("[bold cyan]Download Summary:[/bold cyan]")
|
|
243
|
+
console.print(f" Total services: {len(services)}")
|
|
244
|
+
console.print(f" [green]Successful:[/green] {successful}")
|
|
245
|
+
if failed > 0:
|
|
246
|
+
console.print(f" [red]Failed:[/red] {failed}")
|
|
247
|
+
console.print(f" Output directory: {output_dir.absolute()}")
|
|
248
|
+
console.print(f" Manifest: {manifest_file}")
|
|
249
|
+
console.print(f"{'=' * 60}")
|
|
250
|
+
|
|
251
|
+
if failed > 0:
|
|
252
|
+
console.print(
|
|
253
|
+
"\n[yellow]Warning:[/yellow] Some services failed to download. "
|
|
254
|
+
"Check the logs for details."
|
|
255
|
+
)
|