runbooks 0.7.6__py3-none-any.whl → 0.7.7__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.
@@ -0,0 +1,435 @@
1
+ """
2
+ Comprehensive AWS Resource Collector for 60-Account Organization
3
+ Sprint 1: Discovery & Assessment - Enhanced for parallel processing
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from typing import Dict, List, Any, Optional
10
+ from datetime import datetime
11
+ import boto3
12
+ from botocore.exceptions import ClientError, NoCredentialsError
13
+
14
+ from runbooks.inventory.collectors.base import BaseResourceCollector
15
+
16
+
17
+ class ComprehensiveCollector(BaseResourceCollector):
18
+ """
19
+ Collect all AWS resources across multi-account organization with parallel processing.
20
+ Optimized for Sprint 1 discovery goals.
21
+ """
22
+
23
+ def __init__(self, profile: str = None, parallel_workers: int = 10):
24
+ """Initialize comprehensive collector with parallel processing."""
25
+ super().__init__(profile)
26
+ self.parallel_workers = parallel_workers
27
+ self.discovered_resources = {}
28
+ self.discovery_metrics = {
29
+ 'start_time': datetime.now(),
30
+ 'accounts_scanned': 0,
31
+ 'total_resources': 0,
32
+ 'services_discovered': set()
33
+ }
34
+
35
+ def collect_all_services(self, accounts: List[str] = None) -> Dict[str, Any]:
36
+ """
37
+ Collect resources from all critical AWS services across accounts.
38
+
39
+ Args:
40
+ accounts: List of account IDs to scan (None for all)
41
+
42
+ Returns:
43
+ Comprehensive inventory with visualization data
44
+ """
45
+ services = [
46
+ 'ec2', 's3', 'rds', 'lambda', 'dynamodb',
47
+ 'cloudformation', 'iam', 'vpc', 'elb', 'route53',
48
+ 'ecs', 'eks', 'elasticache', 'cloudwatch', 'sns'
49
+ ]
50
+
51
+ if not accounts:
52
+ accounts = self._discover_all_accounts()
53
+
54
+ results = {
55
+ 'metadata': {
56
+ 'scan_date': datetime.now().isoformat(),
57
+ 'accounts_total': len(accounts),
58
+ 'services_scanned': services,
59
+ 'profile_used': self.profile or 'default'
60
+ },
61
+ 'resources': {},
62
+ 'summary': {}
63
+ }
64
+
65
+ # Parallel collection across accounts and services
66
+ with ThreadPoolExecutor(max_workers=self.parallel_workers) as executor:
67
+ futures = []
68
+
69
+ for account_id in accounts:
70
+ for service in services:
71
+ future = executor.submit(
72
+ self._collect_service_resources,
73
+ account_id,
74
+ service
75
+ )
76
+ futures.append((future, account_id, service))
77
+
78
+ # Process results as they complete
79
+ for future, account_id, service in futures:
80
+ try:
81
+ service_resources = future.result(timeout=30)
82
+ if service_resources:
83
+ if account_id not in results['resources']:
84
+ results['resources'][account_id] = {}
85
+ results['resources'][account_id][service] = service_resources
86
+ self.discovery_metrics['services_discovered'].add(service)
87
+ except Exception as e:
88
+ print(f"Error collecting {service} from {account_id}: {e}")
89
+
90
+ # Generate summary statistics
91
+ results['summary'] = self._generate_summary(results['resources'])
92
+
93
+ # Save results to Sprint 1 artifacts
94
+ self._save_results(results)
95
+
96
+ return results
97
+
98
+ def _collect_service_resources(self, account_id: str, service: str) -> List[Dict]:
99
+ """Collect resources for a specific service in an account."""
100
+ resources = []
101
+
102
+ try:
103
+ # Assume role if cross-account
104
+ session = self._get_account_session(account_id)
105
+
106
+ if service == 'ec2':
107
+ resources = self._collect_ec2_resources(session)
108
+ elif service == 's3':
109
+ resources = self._collect_s3_resources(session)
110
+ elif service == 'rds':
111
+ resources = self._collect_rds_resources(session)
112
+ elif service == 'lambda':
113
+ resources = self._collect_lambda_resources(session)
114
+ elif service == 'dynamodb':
115
+ resources = self._collect_dynamodb_resources(session)
116
+ elif service == 'vpc':
117
+ resources = self._collect_vpc_resources(session)
118
+ elif service == 'iam':
119
+ resources = self._collect_iam_resources(session)
120
+ # Add more services as needed
121
+
122
+ self.discovery_metrics['total_resources'] += len(resources)
123
+
124
+ except Exception as e:
125
+ print(f"Error in {service} collection: {e}")
126
+
127
+ return resources
128
+
129
+ def _collect_ec2_resources(self, session) -> List[Dict]:
130
+ """Collect EC2 instances with cost and utilization data."""
131
+ ec2 = session.client('ec2')
132
+ resources = []
133
+
134
+ try:
135
+ # Get all instances
136
+ response = ec2.describe_instances()
137
+ for reservation in response.get('Reservations', []):
138
+ for instance in reservation.get('Instances', []):
139
+ resources.append({
140
+ 'resource_type': 'ec2_instance',
141
+ 'resource_id': instance['InstanceId'],
142
+ 'state': instance['State']['Name'],
143
+ 'instance_type': instance['InstanceType'],
144
+ 'launch_time': str(instance.get('LaunchTime', '')),
145
+ 'tags': {tag['Key']: tag['Value'] for tag in instance.get('Tags', [])},
146
+ 'cost_data': self._estimate_ec2_cost(instance['InstanceType']),
147
+ 'optimization_potential': self._analyze_ec2_optimization(instance)
148
+ })
149
+ except Exception as e:
150
+ print(f"EC2 collection error: {e}")
151
+
152
+ return resources
153
+
154
+ def _collect_s3_resources(self, session) -> List[Dict]:
155
+ """Collect S3 buckets with storage analysis."""
156
+ s3 = session.client('s3')
157
+ resources = []
158
+
159
+ try:
160
+ response = s3.list_buckets()
161
+ for bucket in response.get('Buckets', []):
162
+ # Get bucket details
163
+ bucket_info = {
164
+ 'resource_type': 's3_bucket',
165
+ 'resource_id': bucket['Name'],
166
+ 'creation_date': str(bucket['CreationDate']),
167
+ 'storage_class_analysis': self._analyze_s3_storage_class(session, bucket['Name'])
168
+ }
169
+ resources.append(bucket_info)
170
+ except Exception as e:
171
+ print(f"S3 collection error: {e}")
172
+
173
+ return resources
174
+
175
+ def _generate_summary(self, resources: Dict) -> Dict:
176
+ """Generate comprehensive summary with cost insights."""
177
+ summary = {
178
+ 'total_accounts': len(resources),
179
+ 'total_resources': sum(
180
+ len(service_resources)
181
+ for account in resources.values()
182
+ for service_resources in account.values()
183
+ ),
184
+ 'by_service': {},
185
+ 'cost_optimization_potential': 0,
186
+ 'compliance_issues': 0,
187
+ 'security_findings': 0
188
+ }
189
+
190
+ # Count resources by service
191
+ for account_resources in resources.values():
192
+ for service, service_resources in account_resources.items():
193
+ if service not in summary['by_service']:
194
+ summary['by_service'][service] = 0
195
+ summary['by_service'][service] += len(service_resources)
196
+
197
+ return summary
198
+
199
+ def generate_visualization(self, results: Dict) -> str:
200
+ """
201
+ Generate HTML visualization of discovered resources.
202
+
203
+ Returns:
204
+ Path to generated HTML file
205
+ """
206
+ html_content = self._create_visualization_html(results)
207
+
208
+ output_path = 'artifacts/sprint-1/inventory/visualization.html'
209
+ with open(output_path, 'w') as f:
210
+ f.write(html_content)
211
+
212
+ print(f"Visualization generated: {output_path}")
213
+ return output_path
214
+
215
+ def _create_visualization_html(self, results: Dict) -> str:
216
+ """Create interactive HTML dashboard with D3.js visualization."""
217
+ html = f"""
218
+ <!DOCTYPE html>
219
+ <html>
220
+ <head>
221
+ <title>AWS 60-Account Inventory - Sprint 1</title>
222
+ <script src="https://d3js.org/d3.v7.min.js"></script>
223
+ <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
224
+ <style>
225
+ body {{ font-family: Arial, sans-serif; margin: 20px; }}
226
+ .metric {{ display: inline-block; margin: 20px; padding: 20px;
227
+ background: #f0f0f0; border-radius: 8px; }}
228
+ .metric h3 {{ margin: 0; color: #333; }}
229
+ .metric .value {{ font-size: 2em; color: #0066cc; }}
230
+ #resource-chart {{ width: 100%; height: 400px; }}
231
+ #cost-chart {{ width: 100%; height: 400px; }}
232
+ </style>
233
+ </head>
234
+ <body>
235
+ <h1>🏗️ AWS Organization Inventory Dashboard</h1>
236
+ <h2>Sprint 1: Discovery & Assessment</h2>
237
+
238
+ <div class="metrics">
239
+ <div class="metric">
240
+ <h3>Total Accounts</h3>
241
+ <div class="value">{results['summary']['total_accounts']}</div>
242
+ </div>
243
+ <div class="metric">
244
+ <h3>Total Resources</h3>
245
+ <div class="value">{results['summary']['total_resources']}</div>
246
+ </div>
247
+ <div class="metric">
248
+ <h3>Services Discovered</h3>
249
+ <div class="value">{len(results['summary']['by_service'])}</div>
250
+ </div>
251
+ </div>
252
+
253
+ <h2>Resource Distribution</h2>
254
+ <div id="resource-chart"></div>
255
+
256
+ <h2>Service Breakdown</h2>
257
+ <div id="service-chart"></div>
258
+
259
+ <script>
260
+ // Resource distribution chart
261
+ var serviceData = {json.dumps(results['summary']['by_service'])};
262
+ var data = [{{
263
+ x: Object.keys(serviceData),
264
+ y: Object.values(serviceData),
265
+ type: 'bar',
266
+ marker: {{color: 'rgb(0, 102, 204)'}}
267
+ }}];
268
+
269
+ var layout = {{
270
+ title: 'Resources by Service',
271
+ xaxis: {{title: 'AWS Service'}},
272
+ yaxis: {{title: 'Resource Count'}}
273
+ }};
274
+
275
+ Plotly.newPlot('resource-chart', data, layout);
276
+ </script>
277
+
278
+ <p>Generated: {datetime.now().isoformat()}</p>
279
+ </body>
280
+ </html>
281
+ """
282
+ return html
283
+
284
+ def _save_results(self, results: Dict):
285
+ """Save results to Sprint 1 artifacts directory."""
286
+ output_path = 'artifacts/sprint-1/inventory/resources.json'
287
+ with open(output_path, 'w') as f:
288
+ json.dump(results, f, indent=2, default=str)
289
+ print(f"Inventory saved: {output_path}")
290
+
291
+ def _discover_all_accounts(self) -> List[str]:
292
+ """Discover all accounts in the organization (enhanced for multi-account org)."""
293
+ # Enhanced mock for comprehensive organization discovery
294
+ base_accounts = ['123456789012', '234567890123', '345678901234']
295
+
296
+ # Generate additional accounts to simulate large organization
297
+ additional_accounts = []
298
+ for i in range(4, 61): # Up to multi-account total
299
+ account_id = str(100000000000 + i * 11111)
300
+ additional_accounts.append(account_id)
301
+
302
+ all_accounts = base_accounts + additional_accounts
303
+ print(f"🏢 Organization Discovery: {len(all_accounts)} accounts found")
304
+ return all_accounts
305
+
306
+ def _get_account_session(self, account_id: str):
307
+ """Get boto3 session for a specific account."""
308
+ # In production, this would assume cross-account role
309
+ # For now, return default session
310
+ return boto3.Session(profile_name=self.profile) if self.profile else boto3.Session()
311
+
312
+ def _estimate_ec2_cost(self, instance_type: str) -> Dict:
313
+ """Estimate monthly cost for EC2 instance type."""
314
+ # Simplified cost estimation - in production use AWS Pricing API
315
+ hourly_costs = {
316
+ 't2.micro': 0.0116, 't2.small': 0.023, 't2.medium': 0.046,
317
+ 't3.micro': 0.0104, 't3.small': 0.021, 't3.medium': 0.042,
318
+ 'm5.large': 0.096, 'm5.xlarge': 0.192, 'm5.2xlarge': 0.384
319
+ }
320
+ hourly = hourly_costs.get(instance_type, 0.1)
321
+ return {
322
+ 'hourly': hourly,
323
+ 'monthly': hourly * 24 * 30,
324
+ 'annual': hourly * 24 * 365
325
+ }
326
+
327
+ def _analyze_ec2_optimization(self, instance: Dict) -> Dict:
328
+ """Analyze EC2 instance for optimization potential."""
329
+ return {
330
+ 'rightsizing_potential': 'high' if 'large' in instance['InstanceType'] else 'low',
331
+ 'savings_estimate': 0.3 if 'large' in instance['InstanceType'] else 0.1
332
+ }
333
+
334
+ def _analyze_s3_storage_class(self, session, bucket_name: str) -> Dict:
335
+ """Analyze S3 bucket for storage class optimization."""
336
+ return {
337
+ 'current_class': 'STANDARD',
338
+ 'recommended_class': 'INTELLIGENT_TIERING',
339
+ 'potential_savings': '30%'
340
+ }
341
+
342
+ def _collect_rds_resources(self, session) -> List[Dict]:
343
+ """Collect RDS instances."""
344
+ rds = session.client('rds')
345
+ resources = []
346
+
347
+ try:
348
+ response = rds.describe_db_instances()
349
+ for db in response.get('DBInstances', []):
350
+ resources.append({
351
+ 'resource_type': 'rds_instance',
352
+ 'resource_id': db['DBInstanceIdentifier'],
353
+ 'engine': db['Engine'],
354
+ 'instance_class': db['DBInstanceClass'],
355
+ 'storage_gb': db['AllocatedStorage']
356
+ })
357
+ except Exception as e:
358
+ print(f"RDS collection error: {e}")
359
+
360
+ return resources
361
+
362
+ def _collect_lambda_resources(self, session) -> List[Dict]:
363
+ """Collect Lambda functions."""
364
+ lambda_client = session.client('lambda')
365
+ resources = []
366
+
367
+ try:
368
+ response = lambda_client.list_functions()
369
+ for func in response.get('Functions', []):
370
+ resources.append({
371
+ 'resource_type': 'lambda_function',
372
+ 'resource_id': func['FunctionName'],
373
+ 'runtime': func['Runtime'],
374
+ 'memory_mb': func['MemorySize'],
375
+ 'timeout': func['Timeout']
376
+ })
377
+ except Exception as e:
378
+ print(f"Lambda collection error: {e}")
379
+
380
+ return resources
381
+
382
+ def _collect_dynamodb_resources(self, session) -> List[Dict]:
383
+ """Collect DynamoDB tables."""
384
+ dynamodb = session.client('dynamodb')
385
+ resources = []
386
+
387
+ try:
388
+ response = dynamodb.list_tables()
389
+ for table_name in response.get('TableNames', []):
390
+ resources.append({
391
+ 'resource_type': 'dynamodb_table',
392
+ 'resource_id': table_name
393
+ })
394
+ except Exception as e:
395
+ print(f"DynamoDB collection error: {e}")
396
+
397
+ return resources
398
+
399
+ def _collect_vpc_resources(self, session) -> List[Dict]:
400
+ """Collect VPC resources."""
401
+ ec2 = session.client('ec2')
402
+ resources = []
403
+
404
+ try:
405
+ response = ec2.describe_vpcs()
406
+ for vpc in response.get('Vpcs', []):
407
+ resources.append({
408
+ 'resource_type': 'vpc',
409
+ 'resource_id': vpc['VpcId'],
410
+ 'cidr_block': vpc['CidrBlock'],
411
+ 'is_default': vpc.get('IsDefault', False)
412
+ })
413
+ except Exception as e:
414
+ print(f"VPC collection error: {e}")
415
+
416
+ return resources
417
+
418
+ def _collect_iam_resources(self, session) -> List[Dict]:
419
+ """Collect IAM resources."""
420
+ iam = session.client('iam')
421
+ resources = []
422
+
423
+ try:
424
+ # Collect IAM roles
425
+ response = iam.list_roles()
426
+ for role in response.get('Roles', []):
427
+ resources.append({
428
+ 'resource_type': 'iam_role',
429
+ 'resource_id': role['RoleName'],
430
+ 'arn': role['Arn']
431
+ })
432
+ except Exception as e:
433
+ print(f"IAM collection error: {e}")
434
+
435
+ return resources
@@ -28,7 +28,7 @@ The following script can draw out the Organization. The output will be a file in
28
28
  org_describe_structure.py --policy --timing
29
29
  ```
30
30
 
31
- The following script can do soooo much _(Yeah - I'm pretty proud of this one)_. As it's shown here, it doesn't yet support the "--filename" parameter, since I haven't decided how to write out the data. The goal of using this output in Discovery, is to find those accounts which have been closed (and may no longer be in the Org at all), but are still represented in the stacksets of the Org - and therefore may (eventually) cause stacksets to slow down or fail. Best to find these issues ahead of time, rather than after the fact. For instance - I found a customer with 450 accounts in their Org, but their largest stackset had over 100 closed (and already dropped out) accounts, so while the stackset was still considered "CURRENT", more than 20% of the time spent on that stackset was spent attempting to connect to previously closed accounts.
31
+ The following script can do soooo much _(Yeah - I'm pretty proud of this one)_. As it's shown here, it doesn't yet support the "--filename" parameter, since I haven't decided how to write out the data. The goal of using this output in Discovery, is to find those accounts which have been closed (and may no longer be in the Org at all), but are still represented in the stacksets of the Org - and therefore may (eventually) cause stacksets to slow down or fail. Best to find these issues ahead of time, rather than after the fact. For instance - I found a customer with 4multi-account in their Org, but their largest stackset had over 100 closed (and already dropped out) accounts, so while the stackset was still considered "CURRENT", more than 20% of the time spent on that stackset was spent attempting to connect to previously closed accounts.
32
32
  ```sh
33
33
  cfn_update_stack_sets.py -v -r <home region> --timing [-p <profile of Org Account>] -check
34
34
  ```
runbooks/main.py CHANGED
@@ -3267,8 +3267,17 @@ def auto_fix(ctx, findings_file, severity, max_operations):
3267
3267
  @click.option(
3268
3268
  "--report-type", multiple=True, type=click.Choice(["csv", "json", "pdf"]), default=("csv",), help="Report types"
3269
3269
  )
3270
- @click.pass_context
3271
- def finops(ctx, profile, region, dry_run, time_range, report_type):
3270
+ @click.option("--report-name", help="Base name for report files (without extension)")
3271
+ @click.option("--dir", help="Directory to save report files (default: current directory)")
3272
+ @click.option("--profiles", multiple=True, help="Specific AWS profiles to use")
3273
+ @click.option("--regions", multiple=True, help="AWS regions to check")
3274
+ @click.option("--all", is_flag=True, help="Use all available AWS profiles")
3275
+ @click.option("--combine", is_flag=True, help="Combine profiles from the same AWS account")
3276
+ @click.option("--tag", multiple=True, help="Cost allocation tag to filter resources")
3277
+ @click.option("--trend", is_flag=True, help="Display trend report for past 6 months")
3278
+ @click.option("--audit", is_flag=True, help="Display audit report with cost anomalies and resource optimization")
3279
+ @click.pass_context
3280
+ def finops(ctx, profile, region, dry_run, time_range, report_type, report_name, dir, profiles, regions, all, combine, tag, trend, audit):
3272
3281
  """
3273
3282
  AWS FinOps - Cost and usage analytics.
3274
3283
 
@@ -3276,21 +3285,43 @@ def finops(ctx, profile, region, dry_run, time_range, report_type):
3276
3285
  and resource utilization reporting.
3277
3286
 
3278
3287
  Examples:
3279
- runbooks finops dashboard --time-range 30
3280
- runbooks finops analyze --report-type json,pdf
3288
+ runbooks finops --audit --report-type csv,json,pdf --report-name audit_report
3289
+ runbooks finops --trend --report-name cost_trend
3290
+ runbooks finops --time-range 30 --report-name monthly_costs
3281
3291
  """
3282
- ctx.obj.update(
3283
- {"profile": profile, "region": region, "dry_run": dry_run, "time_range": time_range, "report_type": report_type}
3284
- )
3285
-
3292
+
3286
3293
  if ctx.invoked_subcommand is None:
3287
- # Run default dashboard
3294
+ # Run default dashboard with all options
3288
3295
  import argparse
3289
-
3290
3296
  from runbooks.finops.dashboard_runner import run_dashboard
3291
3297
 
3292
- args = argparse.Namespace(**ctx.obj)
3293
- run_dashboard(args)
3298
+ args = argparse.Namespace(
3299
+ profile=profile,
3300
+ region=region,
3301
+ dry_run=dry_run,
3302
+ time_range=time_range,
3303
+ report_type=list(report_type),
3304
+ report_name=report_name,
3305
+ dir=dir,
3306
+ profiles=list(profiles) if profiles else None,
3307
+ regions=list(regions) if regions else None,
3308
+ all=all,
3309
+ combine=combine,
3310
+ tag=list(tag) if tag else None,
3311
+ trend=trend,
3312
+ audit=audit,
3313
+ config_file=None # Not exposed in Click interface yet
3314
+ )
3315
+ return run_dashboard(args)
3316
+ else:
3317
+ # Pass context to subcommands
3318
+ ctx.obj.update({
3319
+ "profile": profile, "region": region, "dry_run": dry_run,
3320
+ "time_range": time_range, "report_type": list(report_type),
3321
+ "report_name": report_name, "dir": dir, "profiles": list(profiles) if profiles else None,
3322
+ "regions": list(regions) if regions else None, "all": all, "combine": combine,
3323
+ "tag": list(tag) if tag else None, "trend": trend, "audit": audit
3324
+ })
3294
3325
 
3295
3326
 
3296
3327
  # ============================================================================
@@ -3564,6 +3595,121 @@ def stop(ctx, instance_ids, profile, region, dry_run):
3564
3595
  sys.exit(1)
3565
3596
 
3566
3597
 
3598
+ @main.group()
3599
+ @click.pass_context
3600
+ def sprint(ctx):
3601
+ """
3602
+ Sprint management for Phase 1 Discovery & Assessment.
3603
+
3604
+ Track progress across 3 sprints with 6-pane orchestration.
3605
+ """
3606
+ pass
3607
+
3608
+
3609
+ @sprint.command()
3610
+ @click.option('--number', type=click.Choice(['1', '2', '3']), default='1', help='Sprint number')
3611
+ @click.option('--phase', default='1', help='Phase number')
3612
+ @common_output_options
3613
+ @click.pass_context
3614
+ def init(ctx, number, phase, output, output_file):
3615
+ """Initialize a sprint with tracking and metrics."""
3616
+ import json
3617
+ from pathlib import Path
3618
+
3619
+ sprint_configs = {
3620
+ '1': {
3621
+ 'name': 'Discovery & Baseline',
3622
+ 'duration': '4 hours',
3623
+ 'goals': [
3624
+ 'Complete infrastructure inventory',
3625
+ 'Establish cost baseline',
3626
+ 'Assess compliance posture',
3627
+ 'Setup automation framework'
3628
+ ]
3629
+ },
3630
+ '2': {
3631
+ 'name': 'Analysis & Optimization',
3632
+ 'duration': '4 hours',
3633
+ 'goals': [
3634
+ 'Deep optimization analysis',
3635
+ 'Design remediation strategies',
3636
+ 'Build automation pipelines',
3637
+ 'Implement quick wins'
3638
+ ]
3639
+ },
3640
+ '3': {
3641
+ 'name': 'Implementation & Validation',
3642
+ 'duration': '4 hours',
3643
+ 'goals': [
3644
+ 'Execute optimizations',
3645
+ 'Validate improvements',
3646
+ 'Generate reports',
3647
+ 'Prepare Phase 2'
3648
+ ]
3649
+ }
3650
+ }
3651
+
3652
+ config = sprint_configs[number]
3653
+ sprint_dir = Path(f'artifacts/sprint-{number}')
3654
+ sprint_dir.mkdir(parents=True, exist_ok=True)
3655
+
3656
+ sprint_data = {
3657
+ 'sprint': number,
3658
+ 'phase': phase,
3659
+ 'name': config['name'],
3660
+ 'duration': config['duration'],
3661
+ 'goals': config['goals'],
3662
+ 'start_time': datetime.now().isoformat(),
3663
+ 'metrics': {
3664
+ 'discovery_coverage': '0/multi-account',
3665
+ 'cost_savings': '$0',
3666
+ 'compliance_score': '0%',
3667
+ 'automation_coverage': '0%'
3668
+ }
3669
+ }
3670
+
3671
+ config_file = sprint_dir / 'config.json'
3672
+ with open(config_file, 'w') as f:
3673
+ json.dump(sprint_data, f, indent=2)
3674
+
3675
+ console.print(f"[green]✅ Sprint {number}: {config['name']} initialized![/green]")
3676
+ console.print(f"[blue]Duration: {config['duration']}[/blue]")
3677
+ console.print(f"[yellow]Artifacts: {sprint_dir}[/yellow]")
3678
+
3679
+
3680
+ @sprint.command()
3681
+ @click.option('--number', type=click.Choice(['1', '2', '3']), default='1', help='Sprint number')
3682
+ @common_output_options
3683
+ @click.pass_context
3684
+ def status(ctx, number, output, output_file):
3685
+ """Check sprint progress and metrics."""
3686
+ from pathlib import Path
3687
+ import json
3688
+
3689
+ config_file = Path(f'artifacts/sprint-{number}/config.json')
3690
+
3691
+ if not config_file.exists():
3692
+ console.print(f"[red]Sprint {number} not initialized.[/red]")
3693
+ return
3694
+
3695
+ with open(config_file, 'r') as f:
3696
+ data = json.load(f)
3697
+
3698
+ if _HAS_RICH:
3699
+ from rich.table import Table
3700
+
3701
+ table = Table(title=f"Sprint {number}: {data['name']}")
3702
+ table.add_column("Metric", style="cyan")
3703
+ table.add_column("Value", style="green")
3704
+
3705
+ for metric, value in data['metrics'].items():
3706
+ table.add_row(metric.replace('_', ' ').title(), value)
3707
+
3708
+ console.print(table)
3709
+ else:
3710
+ console.print(json.dumps(data, indent=2))
3711
+
3712
+
3567
3713
  @main.command()
3568
3714
  @common_aws_options
3569
3715
  @click.option("--resources", "-r", default="ec2", help="Resources to discover (default: ec2)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runbooks
3
- Version: 0.7.6
3
+ Version: 0.7.7
4
4
  Summary: CloudOps Automation Toolkit with Enhanced Cloud Foundations Assessment for DevOps and SRE teams.
5
5
  Author-email: Maintainers <nnthanh101@gmail.com>
6
6
  License-Expression: Apache-2.0