ic-code 1.0.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.
ic/core/session.py ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Enhanced AWS Session Manager for IC CLI
4
+
5
+ This module provides intelligent AWS session management with:
6
+ - Profile type detection (assume_role vs direct credentials)
7
+ - Session caching for improved performance
8
+ - Account alias resolution with fallback to account ID
9
+ - Backward compatibility with existing configurations
10
+ """
11
+
12
+ import os
13
+ import re
14
+ import time
15
+ import configparser
16
+ from datetime import datetime, timedelta
17
+ from typing import Dict, Optional, Tuple, Any
18
+ from dataclasses import dataclass
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+
21
+ import boto3
22
+ from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
23
+
24
+ from ic.core.logging import get_logger
25
+
26
+ logger = get_logger()
27
+
28
+
29
+ @dataclass
30
+ class ProfileInfo:
31
+ """Information about an AWS profile"""
32
+ name: str
33
+ type: str # 'assume_role' or 'direct'
34
+ account_id: str
35
+ role_arn: Optional[str] = None
36
+ source_profile: Optional[str] = None
37
+
38
+
39
+ @dataclass
40
+ class SessionInfo:
41
+ """Information about an AWS session"""
42
+ session: boto3.Session
43
+ account_id: str
44
+ account_alias: str
45
+ region: str
46
+ created_at: datetime
47
+ expires_at: Optional[datetime] = None
48
+
49
+
50
+ class AWSSessionManager:
51
+ """Enhanced AWS session manager with intelligent profile detection and caching"""
52
+
53
+ def __init__(self, config=None):
54
+ """
55
+ Initialize the AWS session manager
56
+
57
+ Args:
58
+ config: Configuration object with AWS settings
59
+ """
60
+ self.config = config
61
+ self.profile_cache: Dict[str, ProfileInfo] = {}
62
+ self.session_cache: Dict[str, SessionInfo] = {}
63
+ self.account_alias_cache: Dict[str, str] = {}
64
+ self._profiles_loaded = False
65
+
66
+ # Configuration defaults
67
+ self.session_duration = getattr(config, 'session_duration', 3600) if config else 3600
68
+ self.max_workers = getattr(config, 'max_workers', 10) if config else 10
69
+
70
+ def get_profiles(self) -> Dict[str, ProfileInfo]:
71
+ """
72
+ Get AWS profiles with account ID mapping and type detection
73
+
74
+ Returns:
75
+ Dict mapping account_id to ProfileInfo
76
+ """
77
+ if self._profiles_loaded and self.profile_cache:
78
+ return self.profile_cache
79
+
80
+ logger.log_info_file_only("Loading AWS profiles from ~/.aws/config")
81
+
82
+ config = configparser.ConfigParser()
83
+ aws_config_path = os.path.expanduser('~/.aws/config')
84
+
85
+ if not os.path.exists(aws_config_path):
86
+ logger.log_error(f"AWS config file not found: {aws_config_path}")
87
+ return {}
88
+
89
+ try:
90
+ config.read(aws_config_path)
91
+ except Exception as e:
92
+ logger.log_error(f"Failed to read AWS config: {e}")
93
+ return {}
94
+
95
+ profiles = {}
96
+
97
+ # Process profile sections
98
+ for section in config.sections():
99
+ if section.startswith('profile '):
100
+ profile_name = section.split('profile ')[1]
101
+ self._process_profile_section(config[section], profile_name, profiles)
102
+
103
+ # Process default profile if exists
104
+ if 'default' in config.sections():
105
+ self._process_profile_section(config['default'], 'default', profiles)
106
+
107
+ self.profile_cache = profiles
108
+ self._profiles_loaded = True
109
+
110
+ logger.log_info_file_only(f"Loaded {len(profiles)} AWS profiles")
111
+ return profiles
112
+
113
+ def _process_profile_section(self, section, profile_name: str, profiles: Dict[str, ProfileInfo]):
114
+ """Process a single profile section from AWS config"""
115
+ try:
116
+ role_arn = section.get('role_arn')
117
+
118
+ if role_arn:
119
+ # Assume role profile
120
+ account_id = self._extract_account_id_from_arn(role_arn)
121
+ if account_id:
122
+ source_profile = section.get('source_profile')
123
+ profiles[account_id] = ProfileInfo(
124
+ name=profile_name,
125
+ type='assume_role',
126
+ account_id=account_id,
127
+ role_arn=role_arn,
128
+ source_profile=source_profile
129
+ )
130
+ logger.log_info_file_only(f"Found assume_role profile: {profile_name} -> {account_id}")
131
+ else:
132
+ # Direct credentials profile
133
+ account_id = self._get_account_id_from_session(profile_name)
134
+ if account_id:
135
+ profiles[account_id] = ProfileInfo(
136
+ name=profile_name,
137
+ type='direct',
138
+ account_id=account_id
139
+ )
140
+ logger.log_info_file_only(f"Found direct profile: {profile_name} -> {account_id}")
141
+
142
+ except Exception as e:
143
+ logger.log_error(f"Error processing profile {profile_name}: {e}")
144
+
145
+ def _extract_account_id_from_arn(self, role_arn: str) -> Optional[str]:
146
+ """Extract account ID from role ARN"""
147
+ match = re.search(r'arn:aws:iam::(\d+):role', role_arn)
148
+ return match.group(1) if match else None
149
+
150
+ def _get_account_id_from_session(self, profile_name: str) -> Optional[str]:
151
+ """Get account ID from a session using STS get_caller_identity"""
152
+ try:
153
+ session = boto3.Session(profile_name=profile_name)
154
+ sts = session.client('sts')
155
+ identity = sts.get_caller_identity()
156
+ return identity['Account']
157
+ except Exception as e:
158
+ logger.log_info_file_only(f"Could not get account ID for profile {profile_name}: {e}")
159
+ return None
160
+
161
+ def create_session(self, account_id: str, region: str) -> Optional[boto3.Session]:
162
+ """
163
+ Create an AWS session for the specified account and region
164
+
165
+ Args:
166
+ account_id: AWS account ID
167
+ region: AWS region name
168
+
169
+ Returns:
170
+ boto3.Session or None if creation fails
171
+ """
172
+ cache_key = f"{account_id}:{region}"
173
+
174
+ # Check cache first
175
+ if cache_key in self.session_cache:
176
+ session_info = self.session_cache[cache_key]
177
+ if self._is_session_valid(session_info):
178
+ logger.log_info_file_only(f"Using cached session for {account_id} in {region}")
179
+ return session_info.session
180
+ else:
181
+ # Remove expired session
182
+ del self.session_cache[cache_key]
183
+
184
+ # Get profile information
185
+ profiles = self.get_profiles()
186
+ profile_info = profiles.get(account_id)
187
+
188
+ if not profile_info:
189
+ logger.log_error(f"No profile found for account {account_id}")
190
+ return None
191
+
192
+ logger.log_info_file_only(f"Creating {profile_info.type} session for account {account_id} in {region}")
193
+
194
+ try:
195
+ if profile_info.type == 'assume_role':
196
+ session = self._create_assume_role_session(profile_info, region)
197
+ else:
198
+ session = self._create_direct_session(profile_info, region)
199
+
200
+ if session:
201
+ # Get account alias for caching
202
+ account_alias = self.get_account_alias(session)
203
+
204
+ # Cache the session
205
+ session_info = SessionInfo(
206
+ session=session,
207
+ account_id=account_id,
208
+ account_alias=account_alias,
209
+ region=region,
210
+ created_at=datetime.now(),
211
+ expires_at=datetime.now() + timedelta(seconds=self.session_duration) if profile_info.type == 'assume_role' else None
212
+ )
213
+ self.session_cache[cache_key] = session_info
214
+
215
+ logger.log_info_file_only(f"Successfully created session for {account_id} ({account_alias}) in {region}")
216
+ return session
217
+
218
+ except Exception as e:
219
+ logger.log_error(f"Failed to create session for account {account_id}: {e}")
220
+
221
+ return None
222
+
223
+ def _create_assume_role_session(self, profile_info: ProfileInfo, region: str) -> Optional[boto3.Session]:
224
+ """Create session using assume role"""
225
+ if not profile_info.source_profile or not profile_info.role_arn:
226
+ logger.log_error(f"Missing source_profile or role_arn for assume_role profile {profile_info.name}")
227
+ return None
228
+
229
+ try:
230
+ # Create source session
231
+ source_session = boto3.Session(
232
+ profile_name=profile_info.source_profile,
233
+ region_name=region
234
+ )
235
+
236
+ # Assume role
237
+ sts_client = source_session.client('sts')
238
+ response = sts_client.assume_role(
239
+ RoleArn=profile_info.role_arn,
240
+ RoleSessionName=f"ic-session-{int(time.time())}",
241
+ DurationSeconds=self.session_duration
242
+ )
243
+
244
+ credentials = response['Credentials']
245
+ return boto3.Session(
246
+ aws_access_key_id=credentials['AccessKeyId'],
247
+ aws_secret_access_key=credentials['SecretAccessKey'],
248
+ aws_session_token=credentials['SessionToken'],
249
+ region_name=region
250
+ )
251
+
252
+ except Exception as e:
253
+ logger.log_error(f"Failed to assume role {profile_info.role_arn}: {e}")
254
+ return None
255
+
256
+ def _create_direct_session(self, profile_info: ProfileInfo, region: str) -> Optional[boto3.Session]:
257
+ """Create session using direct credentials"""
258
+ try:
259
+ return boto3.Session(
260
+ profile_name=profile_info.name,
261
+ region_name=region
262
+ )
263
+ except Exception as e:
264
+ logger.log_error(f"Failed to create direct session for profile {profile_info.name}: {e}")
265
+ return None
266
+
267
+ def _is_session_valid(self, session_info: SessionInfo) -> bool:
268
+ """Check if a cached session is still valid"""
269
+ if session_info.expires_at:
270
+ # Check if assume role session has expired
271
+ return datetime.now() < session_info.expires_at
272
+ else:
273
+ # Direct sessions don't expire, but check if they're too old
274
+ age = datetime.now() - session_info.created_at
275
+ return age < timedelta(hours=1) # Refresh after 1 hour
276
+
277
+ def get_account_alias(self, session: boto3.Session) -> str:
278
+ """
279
+ Get account alias with fallback to account ID
280
+
281
+ Args:
282
+ session: boto3.Session
283
+
284
+ Returns:
285
+ Account alias or account ID if alias not available
286
+ """
287
+ try:
288
+ # Try to get from cache first
289
+ sts = session.client('sts')
290
+ identity = sts.get_caller_identity()
291
+ account_id = identity['Account']
292
+
293
+ if account_id in self.account_alias_cache:
294
+ return self.account_alias_cache[account_id]
295
+
296
+ # Try to get account alias
297
+ iam = session.client('iam')
298
+ aliases = iam.list_account_aliases()
299
+
300
+ if aliases['AccountAliases']:
301
+ alias = aliases['AccountAliases'][0]
302
+ self.account_alias_cache[account_id] = alias
303
+ return alias
304
+ else:
305
+ # No alias, use account ID
306
+ self.account_alias_cache[account_id] = account_id
307
+ return account_id
308
+
309
+ except Exception as e:
310
+ logger.log_info_file_only(f"Failed to get account alias: {e}")
311
+ return "unknown"
312
+
313
+ def create_sessions_parallel(self, account_regions: list) -> Dict[str, boto3.Session]:
314
+ """
315
+ Create multiple sessions in parallel
316
+
317
+ Args:
318
+ account_regions: List of (account_id, region) tuples
319
+
320
+ Returns:
321
+ Dict mapping "account_id:region" to session
322
+ """
323
+ sessions = {}
324
+
325
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
326
+ future_to_key = {}
327
+
328
+ for account_id, region in account_regions:
329
+ cache_key = f"{account_id}:{region}"
330
+ future = executor.submit(self.create_session, account_id, region)
331
+ future_to_key[future] = cache_key
332
+
333
+ for future in as_completed(future_to_key):
334
+ cache_key = future_to_key[future]
335
+ try:
336
+ session = future.result()
337
+ if session:
338
+ sessions[cache_key] = session
339
+ except Exception as e:
340
+ logger.log_error(f"Failed to create session for {cache_key}: {e}")
341
+
342
+ return sessions
343
+
344
+ def clear_cache(self):
345
+ """Clear all cached sessions and profiles"""
346
+ self.session_cache.clear()
347
+ self.profile_cache.clear()
348
+ self.account_alias_cache.clear()
349
+ self._profiles_loaded = False
350
+ logger.log_info_file_only("Cleared AWS session cache")
351
+
352
+ def get_session_info(self, account_id: str, region: str) -> Optional[SessionInfo]:
353
+ """Get cached session information"""
354
+ cache_key = f"{account_id}:{region}"
355
+ return self.session_cache.get(cache_key)
356
+
357
+ def list_cached_sessions(self) -> Dict[str, SessionInfo]:
358
+ """List all cached sessions"""
359
+ return self.session_cache.copy()
360
+
361
+
362
+ # Backward compatibility functions
363
+ def get_profiles() -> Dict[str, str]:
364
+ """
365
+ Backward compatibility function for existing code
366
+
367
+ Returns:
368
+ Dict mapping account_id to profile_name
369
+ """
370
+ manager = AWSSessionManager()
371
+ profiles = manager.get_profiles()
372
+ return {account_id: profile.name for account_id, profile in profiles.items()}
373
+
374
+
375
+ def create_session(profile_name: str, region_name: str) -> Optional[boto3.Session]:
376
+ """
377
+ Backward compatibility function for existing code
378
+
379
+ Args:
380
+ profile_name: AWS profile name
381
+ region_name: AWS region name
382
+
383
+ Returns:
384
+ boto3.Session or None
385
+ """
386
+ try:
387
+ session = boto3.Session(profile_name=profile_name, region_name=region_name)
388
+ logger.log_info_file_only(f"Created session for profile '{profile_name}' in region '{region_name}'")
389
+ return session
390
+ except Exception as e:
391
+ logger.log_error(f"Failed to create session for profile '{profile_name}' in region '{region_name}': {e}")
392
+ return None
@@ -0,0 +1,67 @@
1
+ """
2
+ Logging silencer module to suppress console output except for ERROR messages.
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ class SilentFilter(logging.Filter):
11
+ """Filter to suppress all log messages except ERROR and CRITICAL."""
12
+
13
+ def filter(self, record: logging.LogRecord) -> bool:
14
+ return record.levelno >= logging.ERROR
15
+
16
+
17
+ class SilentHandler(logging.Handler):
18
+ """Handler that does nothing - completely silent."""
19
+
20
+ def emit(self, record: logging.LogRecord) -> None:
21
+ pass
22
+
23
+
24
+ def silence_all_logging():
25
+ """Silence all logging output to console except ERROR and CRITICAL."""
26
+ # Get root logger
27
+ root_logger = logging.getLogger()
28
+
29
+ # Remove all existing handlers
30
+ for handler in root_logger.handlers[:]:
31
+ root_logger.removeHandler(handler)
32
+
33
+ # Add silent handler for everything below ERROR
34
+ silent_handler = SilentHandler()
35
+ silent_handler.setLevel(logging.DEBUG)
36
+ root_logger.addHandler(silent_handler)
37
+
38
+ # Add console handler only for ERROR and above
39
+ console_handler = logging.StreamHandler(sys.stderr)
40
+ console_handler.setLevel(logging.ERROR)
41
+ console_handler.addFilter(SilentFilter())
42
+ root_logger.addHandler(console_handler)
43
+
44
+ # Set root logger level to DEBUG to catch everything
45
+ root_logger.setLevel(logging.DEBUG)
46
+
47
+ # Also silence specific loggers that might be problematic
48
+ problematic_loggers = [
49
+ 'ic.config.manager',
50
+ 'ic.config.secrets',
51
+ 'ic.config.external',
52
+ 'ic.config.security',
53
+ 'ic.core.logging',
54
+ 'rich'
55
+ ]
56
+
57
+ for logger_name in problematic_loggers:
58
+ logger = logging.getLogger(logger_name)
59
+ logger.handlers.clear()
60
+ logger.addHandler(SilentHandler())
61
+ logger.setLevel(logging.CRITICAL)
62
+ logger.propagate = False
63
+
64
+
65
+ def restore_error_only_logging():
66
+ """Restore logging to show only ERROR and CRITICAL messages."""
67
+ silence_all_logging()