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/config/manager.py ADDED
@@ -0,0 +1,898 @@
1
+ """
2
+ Configuration management module for IC.
3
+
4
+ This module provides configuration loading, validation, and management functionality.
5
+ """
6
+
7
+ import os
8
+ import time
9
+ import yaml
10
+ import json
11
+ import shutil
12
+ from datetime import datetime
13
+ from typing import Dict, Any, Optional, List, Union
14
+ from pathlib import Path
15
+ import logging
16
+
17
+ from .security import SecurityManager
18
+ from .secrets import SecretsManager
19
+ from .external import ExternalConfigLoader
20
+ from .migration import MigrationManager
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ConfigManager:
26
+ """
27
+ Manages configuration loading and validation for IC.
28
+ """
29
+
30
+
31
+ # 성능 최적화: 설정 캐시
32
+ _config_cache = None
33
+ _cache_timestamp = None
34
+ _cache_ttl = 300 # 5분 캐시
35
+
36
+ def _is_cache_valid(self):
37
+ """캐시 유효성 검사"""
38
+ if self._config_cache is None or self._cache_timestamp is None:
39
+ return False
40
+ return (time.time() - self._cache_timestamp) < self._cache_ttl
41
+
42
+ def _update_cache(self, config):
43
+ """캐시 업데이트"""
44
+ self._config_cache = config
45
+ self._cache_timestamp = time.time()
46
+
47
+ def __init__(self, security_manager: Optional[SecurityManager] = None):
48
+ """Initialize ConfigManager with optional SecurityManager integration."""
49
+ self.config_data: Dict[str, Any] = {}
50
+ self.secrets_data: Dict[str, Any] = {}
51
+ self.external_configs: Dict[str, Any] = {}
52
+ self.config_sources: List[str] = []
53
+ self.security_manager = security_manager
54
+ self.secrets_manager = SecretsManager(self)
55
+ self.external_loader = ExternalConfigLoader(self)
56
+ self.migration_manager = MigrationManager(self)
57
+ self._backup_dir = Path.home() / ".ic" / "backups"
58
+
59
+ def load_config(self, config_paths: Optional[List[Union[str, Path]]] = None) -> Dict[str, Any]:
60
+ """
61
+ Load configuration from multiple sources with proper precedence.
62
+
63
+ Args:
64
+ config_paths: Optional list of config file paths to load
65
+
66
+ Returns:
67
+ Merged configuration dictionary
68
+ """
69
+ if config_paths is None:
70
+ config_paths = self._get_default_config_paths()
71
+
72
+ # Start with default configuration
73
+ config = self._get_default_config()
74
+ self.config_sources = ["default"]
75
+
76
+ # Load configuration files in order of precedence
77
+ for config_path in config_paths:
78
+ if isinstance(config_path, str):
79
+ config_path = Path(config_path)
80
+
81
+ if config_path.exists():
82
+ try:
83
+ file_config = self._load_config_file(config_path)
84
+ config = self._merge_configs(config, file_config)
85
+ self.config_sources.append(str(config_path))
86
+ logger.debug(f"Loaded configuration from {config_path}")
87
+ except Exception as e:
88
+ logger.warning(f"Failed to load config from {config_path}: {e}")
89
+
90
+ # Override with environment variables
91
+ env_config = self._load_env_config()
92
+ if env_config:
93
+ config = self._merge_configs(config, env_config)
94
+ self.config_sources.append("environment")
95
+
96
+ # Validate security if SecurityManager is available (log to file only)
97
+ if self.security_manager:
98
+ security_warnings = self.security_manager.validate_config_security(config)
99
+ if security_warnings:
100
+ for warning in security_warnings:
101
+ logger.debug(f"Security warning: {warning}") # Changed to debug level
102
+
103
+ self.config_data = config
104
+ return config
105
+
106
+ def _get_default_config_paths(self) -> List[Path]:
107
+ """
108
+ Get default configuration file paths in order of precedence.
109
+
110
+ Returns:
111
+ List of configuration file paths
112
+ """
113
+ paths = []
114
+
115
+ # System configuration
116
+ system_config = Path("/etc/ic/config.yaml")
117
+ if system_config.exists():
118
+ paths.append(system_config)
119
+
120
+ # User configuration
121
+ user_config = Path.home() / ".ic" / "config.yaml"
122
+ if user_config.exists():
123
+ paths.append(user_config)
124
+
125
+ # Project configuration
126
+ project_configs = [
127
+ Path("ic.yaml"),
128
+ Path(".ic/config.yaml"),
129
+ Path("config/config.yaml"),
130
+ ]
131
+ for config_path in project_configs:
132
+ if config_path.exists():
133
+ paths.append(config_path)
134
+ break
135
+
136
+ return paths
137
+
138
+ def _load_config_file(self, config_path: Path) -> Dict[str, Any]:
139
+ """
140
+ Load configuration from a file.
141
+
142
+ Args:
143
+ config_path: Path to configuration file
144
+
145
+ Returns:
146
+ Configuration dictionary
147
+ """
148
+ with open(config_path, 'r', encoding='utf-8') as f:
149
+ if config_path.suffix.lower() in ['.yaml', '.yml']:
150
+ return yaml.safe_load(f) or {}
151
+ elif config_path.suffix.lower() == '.json':
152
+ return json.load(f) or {}
153
+ else:
154
+ raise ValueError(f"Unsupported config file format: {config_path.suffix}")
155
+
156
+ def _load_env_config(self) -> Dict[str, Any]:
157
+ """
158
+ Load configuration from environment variables.
159
+
160
+ Returns:
161
+ Configuration dictionary from environment variables
162
+ """
163
+ env_config = {}
164
+
165
+ # Map environment variables to config structure
166
+ env_mappings = {
167
+ # Logging
168
+ 'IC_LOG_LEVEL': ['logging', 'console_level'],
169
+ 'IC_LOG_FILE_LEVEL': ['logging', 'file_level'],
170
+ 'IC_LOG_FILE_PATH': ['logging', 'file_path'],
171
+ 'IC_LOG_MAX_FILES': ['logging', 'max_files'],
172
+
173
+ # AWS
174
+ 'AWS_PROFILE': ['aws', 'default_profile'],
175
+ 'AWS_REGION': ['aws', 'default_region'],
176
+ 'AWS_ACCOUNTS': ['aws', 'accounts'], # Comma-separated
177
+ 'AWS_CROSS_ACCOUNT_ROLE': ['aws', 'cross_account_role'],
178
+ 'AWS_SESSION_DURATION': ['aws', 'session_duration'],
179
+ 'AWS_MAX_WORKERS': ['aws', 'max_workers'],
180
+
181
+ # Azure
182
+ 'AZURE_SUBSCRIPTION_ID': ['azure', 'subscription_id'],
183
+ 'AZURE_SUBSCRIPTIONS': ['azure', 'subscriptions'], # Comma-separated
184
+ 'AZURE_TENANT_ID': ['azure', 'tenant_id'],
185
+ 'AZURE_CLIENT_ID': ['azure', 'client_id'],
186
+ 'AZURE_CLIENT_SECRET': ['azure', 'client_secret'],
187
+ 'AZURE_LOCATIONS': ['azure', 'locations'], # Comma-separated
188
+ 'AZURE_MAX_WORKERS': ['azure', 'max_workers'],
189
+
190
+ # GCP
191
+ 'GCP_PROJECT_ID': ['gcp', 'project_id'],
192
+ 'GCP_PROJECTS': ['gcp', 'projects'], # Comma-separated
193
+ 'GCP_REGIONS': ['gcp', 'regions'], # Comma-separated
194
+ 'GCP_ZONES': ['gcp', 'zones'], # Comma-separated
195
+ 'GCP_SERVICE_ACCOUNT_KEY_PATH': ['gcp', 'service_account_key_path'],
196
+ 'GOOGLE_APPLICATION_CREDENTIALS': ['gcp', 'service_account_key_path'],
197
+ 'GCP_MAX_WORKERS': ['gcp', 'max_workers'],
198
+
199
+ # OCI
200
+ 'OCI_CONFIG_PATH': ['oci', 'config_path'],
201
+ 'OCI_MAX_WORKERS': ['oci', 'max_workers'],
202
+
203
+ # CloudFlare
204
+ 'CLOUDFLARE_EMAIL': ['cloudflare', 'email'],
205
+ 'CLOUDFLARE_API_TOKEN': ['cloudflare', 'api_token'],
206
+ 'CLOUDFLARE_ACCOUNTS': ['cloudflare', 'accounts'], # Comma-separated
207
+ 'CLOUDFLARE_ZONES': ['cloudflare', 'zones'], # Comma-separated
208
+
209
+ # SSH
210
+ 'SSH_CONFIG_FILE': ['ssh', 'config_file'],
211
+ 'SSH_KEY_DIR': ['ssh', 'key_dir'],
212
+ 'SSH_MAX_WORKERS': ['ssh', 'max_workers'],
213
+
214
+ # Slack
215
+ 'SLACK_WEBHOOK_URL': ['slack', 'webhook_url'],
216
+ 'SLACK_ENABLED': ['slack', 'enabled'],
217
+
218
+ # MCP
219
+ 'MCP_GITHUB_TOKEN': ['mcp', 'servers', 'github', 'personal_access_token'],
220
+ }
221
+
222
+ for env_var, config_path in env_mappings.items():
223
+ value = os.getenv(env_var)
224
+ if value:
225
+ # Handle comma-separated values
226
+ if env_var.endswith('S') and ',' in value: # Plural env vars with commas
227
+ value = [item.strip() for item in value.split(',') if item.strip()]
228
+
229
+ # Handle boolean values
230
+ if env_var.endswith('_ENABLED'):
231
+ value = value.lower() in ('true', '1', 'yes', 'on')
232
+
233
+ # Handle integer values
234
+ if any(field in env_var for field in ['MAX_WORKERS', 'DURATION', 'MAX_FILES']):
235
+ try:
236
+ value = int(value)
237
+ except ValueError:
238
+ logger.warning(f"Invalid integer value for {env_var}: {value}")
239
+ continue
240
+
241
+ self._set_nested_value(env_config, config_path, value)
242
+
243
+ return env_config
244
+
245
+ def _set_nested_value(self, config: Dict[str, Any], path: List[str], value: Any) -> None:
246
+ """
247
+ Set a nested value in configuration dictionary.
248
+
249
+ Args:
250
+ config: Configuration dictionary
251
+ path: List of keys representing the path
252
+ value: Value to set
253
+ """
254
+ current = config
255
+ for key in path[:-1]:
256
+ if key not in current:
257
+ current[key] = {}
258
+ current = current[key]
259
+ current[path[-1]] = value
260
+
261
+ def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
262
+ """
263
+ Merge two configuration dictionaries.
264
+
265
+ Args:
266
+ base: Base configuration
267
+ override: Override configuration
268
+
269
+ Returns:
270
+ Merged configuration
271
+ """
272
+ result = base.copy()
273
+
274
+ for key, value in override.items():
275
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
276
+ result[key] = self._merge_configs(result[key], value)
277
+ else:
278
+ result[key] = value
279
+
280
+ return result
281
+
282
+ def _get_default_config(self) -> Dict[str, Any]:
283
+ """
284
+ Get default configuration.
285
+
286
+ Returns:
287
+ Default configuration dictionary
288
+ """
289
+ return {
290
+ "version": "1.0",
291
+ "logging": {
292
+ "console_level": "ERROR",
293
+ "file_level": "INFO",
294
+ "file_path": "logs/ic_{date}.log",
295
+ "max_files": 30,
296
+ "format": "%(asctime)s [%(levelname)s] - %(message)s",
297
+ "mask_sensitive": True,
298
+ },
299
+ "aws": {
300
+ "accounts": [],
301
+ "regions": ["ap-northeast-2"],
302
+ "cross_account_role": "OrganizationAccountAccessRole",
303
+ "session_duration": 3600,
304
+ "max_workers": 10,
305
+ "tags": {
306
+ "required": ["User", "Team", "Environment"],
307
+ "optional": ["Service", "Application"],
308
+ "rules": {
309
+ "User": "^.+$",
310
+ "Team": "^\\d+$",
311
+ "Environment": "^(PROD|STG|DEV|TEST|QA)$",
312
+ },
313
+ },
314
+ },
315
+ "azure": {
316
+ "subscriptions": [],
317
+ "locations": ["Korea Central"],
318
+ "max_workers": 10,
319
+ },
320
+ "gcp": {
321
+ "mcp": {
322
+ "enabled": True,
323
+ "endpoint": "http://localhost:8080/gcp",
324
+ "auth_method": "service_account",
325
+ "prefer_mcp": True,
326
+ },
327
+ "projects": [],
328
+ "regions": ["asia-northeast3"],
329
+ "zones": ["asia-northeast3-a"],
330
+ "max_workers": 10,
331
+ },
332
+ "oci": {
333
+ "config_path": "~/.oci/config",
334
+ "max_workers": 10,
335
+ },
336
+ "cloudflare": {
337
+ "accounts": [],
338
+ "zones": [],
339
+ },
340
+ "ssh": {
341
+ "config_file": "~/.ssh/config",
342
+ "key_dir": "~/aws-key",
343
+ "max_workers": 70,
344
+ "timeouts": {
345
+ "port_scan": 0.5,
346
+ "ssh_connect": 5,
347
+ },
348
+ },
349
+ "mcp": {
350
+ "servers": {
351
+ "github": {
352
+ "enabled": True,
353
+ "auto_approve": [],
354
+ },
355
+ "terraform": {
356
+ "enabled": True,
357
+ "auto_approve": [],
358
+ },
359
+ "aws_docs": {
360
+ "enabled": True,
361
+ "auto_approve": ["read_documentation", "search_documentation"],
362
+ },
363
+ "azure": {
364
+ "enabled": True,
365
+ "auto_approve": ["documentation"],
366
+ },
367
+ },
368
+ },
369
+ "slack": {
370
+ "enabled": False,
371
+ },
372
+ "security": {
373
+ "sensitive_keys": [
374
+ "password", "passwd", "pwd",
375
+ "token", "access_token", "refresh_token", "auth_token",
376
+ "key", "api_key", "access_key", "secret_key", "private_key",
377
+ "secret", "client_secret", "webhook_secret",
378
+ "webhook_url", "webhook",
379
+ "credential", "credentials",
380
+ "cert", "certificate",
381
+ "session", "session_token",
382
+ ],
383
+ "mask_pattern": "***MASKED***",
384
+ "warn_on_sensitive_in_config": True,
385
+ "git_hooks_enabled": True,
386
+ },
387
+ }
388
+
389
+ def get_config(self) -> Dict[str, Any]:
390
+ """
391
+ Get current configuration.
392
+
393
+ Returns:
394
+ Current configuration dictionary
395
+ """
396
+ return self.config_data
397
+
398
+ def get_config_sources(self) -> List[str]:
399
+ """
400
+ Get list of configuration sources that were loaded.
401
+
402
+ Returns:
403
+ List of configuration source names
404
+ """
405
+ return self.config_sources.copy()
406
+
407
+ def save_config(self, config_path: Union[str, Path], config_data: Optional[Dict[str, Any]] = None) -> None:
408
+ """
409
+ Save configuration to file.
410
+
411
+ Args:
412
+ config_path: Path to save configuration
413
+ config_data: Configuration data to save (uses current config if None)
414
+ """
415
+ if config_data is None:
416
+ config_data = self.config_data
417
+
418
+ config_path = Path(config_path)
419
+ config_path.parent.mkdir(parents=True, exist_ok=True)
420
+
421
+ with open(config_path, 'w', encoding='utf-8') as f:
422
+ if config_path.suffix.lower() in ['.yaml', '.yml']:
423
+ yaml.dump(config_data, f, default_flow_style=False, indent=2)
424
+ elif config_path.suffix.lower() == '.json':
425
+ json.dump(config_data, f, indent=2)
426
+ else:
427
+ raise ValueError(f"Unsupported config file format: {config_path.suffix}")
428
+
429
+ logger.info(f"Configuration saved to {config_path}")
430
+
431
+ def backup_config(self, config_path: Union[str, Path]) -> Optional[Path]:
432
+ """
433
+ Create a backup of existing configuration file.
434
+
435
+ Args:
436
+ config_path: Path to configuration file to backup
437
+
438
+ Returns:
439
+ Path to backup file if successful, None otherwise
440
+ """
441
+ config_path = Path(config_path)
442
+ if not config_path.exists():
443
+ return None
444
+
445
+ # Create backup directory
446
+ self._backup_dir.mkdir(parents=True, exist_ok=True)
447
+
448
+ # Generate backup filename with timestamp
449
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
450
+ backup_name = f"{config_path.stem}_{timestamp}{config_path.suffix}"
451
+ backup_path = self._backup_dir / backup_name
452
+
453
+ try:
454
+ shutil.copy2(config_path, backup_path)
455
+ logger.info(f"Configuration backed up to {backup_path}")
456
+ return backup_path
457
+ except Exception as e:
458
+ logger.error(f"Failed to backup configuration: {e}")
459
+ return None
460
+
461
+ def safe_update_config(self, config_path: Union[str, Path],
462
+ config_data: Dict[str, Any]) -> bool:
463
+ """
464
+ Safely update configuration with backup and validation.
465
+
466
+ Args:
467
+ config_path: Path to configuration file
468
+ config_data: New configuration data
469
+
470
+ Returns:
471
+ True if update was successful
472
+ """
473
+ config_path = Path(config_path)
474
+
475
+ # Create backup if file exists
476
+ backup_path = None
477
+ if config_path.exists():
478
+ backup_path = self.backup_config(config_path)
479
+ if backup_path is None:
480
+ logger.error("Failed to create backup, aborting update")
481
+ return False
482
+
483
+ # Validate security if SecurityManager is available
484
+ if self.security_manager:
485
+ security_warnings = self.security_manager.validate_config_security(config_data)
486
+ if security_warnings:
487
+ for warning in security_warnings:
488
+ logger.warning(f"Security warning in new config: {warning}")
489
+
490
+ # If there are critical security issues, abort
491
+ critical_warnings = [w for w in security_warnings if "secret" in w.lower()]
492
+ if critical_warnings:
493
+ logger.error("Critical security issues found, aborting update")
494
+ return False
495
+
496
+ try:
497
+ # Save new configuration
498
+ self.save_config(config_path, config_data)
499
+
500
+ # Verify the saved configuration can be loaded
501
+ test_config = self._load_config_file(config_path)
502
+ if not test_config:
503
+ raise ValueError("Saved configuration is empty or invalid")
504
+
505
+ logger.info(f"Configuration successfully updated at {config_path}")
506
+ return True
507
+
508
+ except Exception as e:
509
+ logger.error(f"Failed to update configuration: {e}")
510
+
511
+ # Restore from backup if available
512
+ if backup_path and backup_path.exists():
513
+ try:
514
+ shutil.copy2(backup_path, config_path)
515
+ logger.info(f"Configuration restored from backup")
516
+ except Exception as restore_error:
517
+ logger.error(f"Failed to restore from backup: {restore_error}")
518
+
519
+ return False
520
+
521
+ def validate_config(self, config_data: Optional[Dict[str, Any]] = None) -> List[str]:
522
+ """
523
+ Validate configuration data.
524
+
525
+ Args:
526
+ config_data: Configuration data to validate (uses current config if None)
527
+
528
+ Returns:
529
+ List of validation errors
530
+ """
531
+ if config_data is None:
532
+ config_data = self.config_data
533
+
534
+ errors = []
535
+
536
+ # Basic structure validation
537
+ if not isinstance(config_data, dict):
538
+ errors.append("Configuration must be a dictionary")
539
+ return errors
540
+
541
+ # Version validation
542
+ if 'version' not in config_data:
543
+ errors.append("Configuration missing required 'version' field")
544
+
545
+ # Validate required sections
546
+ required_sections = ['logging', 'aws', 'azure', 'gcp', 'security']
547
+ for section in required_sections:
548
+ if section not in config_data:
549
+ errors.append(f"Configuration missing required section: {section}")
550
+
551
+ # Validate logging configuration
552
+ if 'logging' in config_data:
553
+ logging_config = config_data['logging']
554
+ if not isinstance(logging_config, dict):
555
+ errors.append("Logging configuration must be a dictionary")
556
+ else:
557
+ required_log_fields = ['console_level', 'file_level', 'file_path']
558
+ for field in required_log_fields:
559
+ if field not in logging_config:
560
+ errors.append(f"Logging configuration missing required field: {field}")
561
+
562
+ # Security validation if SecurityManager is available
563
+ if self.security_manager:
564
+ security_warnings = self.security_manager.validate_config_security(config_data)
565
+ errors.extend(security_warnings)
566
+
567
+ return errors
568
+
569
+ def get_config_value(self, key_path: str, default: Any = None) -> Any:
570
+ """
571
+ Get a configuration value using dot notation.
572
+
573
+ Args:
574
+ key_path: Dot-separated path to configuration value (e.g., 'aws.regions')
575
+ default: Default value if key is not found
576
+
577
+ Returns:
578
+ Configuration value or default
579
+ """
580
+ keys = key_path.split('.')
581
+ current = self.config_data
582
+
583
+ try:
584
+ for key in keys:
585
+ current = current[key]
586
+ return current
587
+ except (KeyError, TypeError):
588
+ return default
589
+
590
+ def set_config_value(self, key_path: str, value: Any) -> None:
591
+ """
592
+ Set a configuration value using dot notation.
593
+
594
+ Args:
595
+ key_path: Dot-separated path to configuration value
596
+ value: Value to set
597
+ """
598
+ keys = key_path.split('.')
599
+ current = self.config_data
600
+
601
+ # Navigate to parent of target key
602
+ for key in keys[:-1]:
603
+ if key not in current:
604
+ current[key] = {}
605
+ current = current[key]
606
+
607
+ # Set the value
608
+ current[keys[-1]] = value
609
+
610
+ def cleanup_old_backups(self, max_backups: int = 10) -> None:
611
+ """
612
+ Clean up old backup files, keeping only the most recent ones.
613
+
614
+ Args:
615
+ max_backups: Maximum number of backup files to keep
616
+ """
617
+ if not self._backup_dir.exists():
618
+ return
619
+
620
+ try:
621
+ backup_files = list(self._backup_dir.glob("*.yaml")) + list(self._backup_dir.glob("*.yml"))
622
+ backup_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
623
+
624
+ # Remove old backups
625
+ for backup_file in backup_files[max_backups:]:
626
+ backup_file.unlink()
627
+ logger.debug(f"Removed old backup: {backup_file}")
628
+
629
+ except Exception as e:
630
+ logger.warning(f"Failed to cleanup old backups: {e}")
631
+
632
+ def load_all_configs(self) -> Dict[str, Any]:
633
+ """
634
+ Load all configurations including secrets and external configs.
635
+
636
+ Returns:
637
+ Merged configuration dictionary
638
+ """
639
+ # Load base configuration
640
+ config = self.load_config()
641
+
642
+ # Load secrets configuration
643
+ secrets = self.load_secrets_config()
644
+ if secrets:
645
+ config = self._merge_configs(config, secrets)
646
+ self.config_sources.append("secrets")
647
+
648
+ # Load external configurations
649
+ external = self.load_external_configs()
650
+ if external:
651
+ self.external_configs = external
652
+ self.config_sources.append("external")
653
+
654
+ self.config_data = config
655
+ return config
656
+
657
+ def load_secrets_config(self) -> Dict[str, Any]:
658
+ """
659
+ Load secrets configuration using SecretsManager.
660
+
661
+ Returns:
662
+ Secrets configuration dictionary
663
+ """
664
+ secrets_config = self.secrets_manager.load_secrets()
665
+ self.secrets_data = secrets_config
666
+ return secrets_config
667
+
668
+ def _load_secrets_from_env(self) -> Dict[str, Any]:
669
+ """
670
+ Load sensitive configuration from environment variables.
671
+
672
+ Returns:
673
+ Environment-based secrets configuration
674
+ """
675
+ env_secrets = {}
676
+
677
+ # AWS secrets
678
+ aws_accounts = os.getenv('AWS_ACCOUNTS')
679
+ if aws_accounts:
680
+ self._set_nested_value(env_secrets, ['aws', 'accounts'],
681
+ [acc.strip() for acc in aws_accounts.split(',') if acc.strip()])
682
+
683
+ # CloudFlare secrets
684
+ cf_email = os.getenv('CLOUDFLARE_EMAIL')
685
+ cf_token = os.getenv('CLOUDFLARE_API_TOKEN')
686
+ cf_accounts = os.getenv('CLOUDFLARE_ACCOUNTS')
687
+ cf_zones = os.getenv('CLOUDFLARE_ZONES')
688
+
689
+ if any([cf_email, cf_token, cf_accounts, cf_zones]):
690
+ cf_config = {}
691
+ if cf_email:
692
+ cf_config['email'] = cf_email
693
+ if cf_token:
694
+ cf_config['api_token'] = cf_token
695
+ if cf_accounts:
696
+ cf_config['accounts'] = [acc.strip() for acc in cf_accounts.split(',') if acc.strip()]
697
+ if cf_zones:
698
+ cf_config['zones'] = [zone.strip() for zone in cf_zones.split(',') if zone.strip()]
699
+ env_secrets['cloudflare'] = cf_config
700
+
701
+ # GCP secrets
702
+ gcp_key_path = os.getenv('GCP_SERVICE_ACCOUNT_KEY_PATH') or os.getenv('GOOGLE_APPLICATION_CREDENTIALS')
703
+ gcp_projects = os.getenv('GCP_PROJECTS')
704
+
705
+ if gcp_key_path or gcp_projects:
706
+ gcp_config = {}
707
+ if gcp_key_path:
708
+ gcp_config['service_account_key_path'] = gcp_key_path
709
+ if gcp_projects:
710
+ gcp_config['projects'] = [proj.strip() for proj in gcp_projects.split(',') if proj.strip()]
711
+ env_secrets['gcp'] = gcp_config
712
+
713
+ # Azure secrets
714
+ azure_tenant = os.getenv('AZURE_TENANT_ID')
715
+ azure_client_id = os.getenv('AZURE_CLIENT_ID')
716
+ azure_client_secret = os.getenv('AZURE_CLIENT_SECRET')
717
+ azure_subscriptions = os.getenv('AZURE_SUBSCRIPTIONS')
718
+
719
+ if any([azure_tenant, azure_client_id, azure_client_secret, azure_subscriptions]):
720
+ azure_config = {}
721
+ if azure_tenant:
722
+ azure_config['tenant_id'] = azure_tenant
723
+ if azure_client_id:
724
+ azure_config['client_id'] = azure_client_id
725
+ if azure_client_secret:
726
+ azure_config['client_secret'] = azure_client_secret
727
+ if azure_subscriptions:
728
+ azure_config['subscriptions'] = [sub.strip() for sub in azure_subscriptions.split(',') if sub.strip()]
729
+ env_secrets['azure'] = azure_config
730
+
731
+ # Slack secrets
732
+ slack_webhook = os.getenv('SLACK_WEBHOOK_URL')
733
+ if slack_webhook:
734
+ env_secrets['slack'] = {'webhook_url': slack_webhook}
735
+
736
+ return env_secrets
737
+
738
+ def load_external_configs(self) -> Dict[str, Any]:
739
+ """
740
+ Load external configuration files using ExternalConfigLoader.
741
+
742
+ Returns:
743
+ External configurations dictionary
744
+ """
745
+ external_configs = self.external_loader.load_all_external_configs()
746
+ self.external_configs = external_configs
747
+ return external_configs
748
+
749
+ def _load_aws_config(self) -> Dict[str, Any]:
750
+ """Load AWS configuration from ~/.aws/config and ~/.aws/credentials"""
751
+ aws_config = {}
752
+
753
+ # Load AWS config file
754
+ aws_config_path = Path.home() / ".aws" / "config"
755
+ if aws_config_path.exists():
756
+ try:
757
+ import configparser
758
+ config = configparser.ConfigParser()
759
+ config.read(aws_config_path)
760
+
761
+ profiles = {}
762
+ for section_name in config.sections():
763
+ if section_name.startswith('profile '):
764
+ profile_name = section_name.split('profile ')[1]
765
+ profiles[profile_name] = dict(config[section_name])
766
+ elif section_name == 'default':
767
+ profiles['default'] = dict(config[section_name])
768
+
769
+ if profiles:
770
+ aws_config['profiles'] = profiles
771
+
772
+ except Exception as e:
773
+ logger.warning(f"Failed to load AWS config: {e}")
774
+
775
+ # Load AWS credentials file
776
+ aws_creds_path = Path.home() / ".aws" / "credentials"
777
+ if aws_creds_path.exists():
778
+ try:
779
+ import configparser
780
+ config = configparser.ConfigParser()
781
+ config.read(aws_creds_path)
782
+
783
+ credentials = {}
784
+ for section_name in config.sections():
785
+ credentials[section_name] = dict(config[section_name])
786
+
787
+ if credentials:
788
+ aws_config['credentials'] = credentials
789
+
790
+ except Exception as e:
791
+ logger.warning(f"Failed to load AWS credentials: {e}")
792
+
793
+ return aws_config
794
+
795
+ def _load_oci_config(self) -> Dict[str, Any]:
796
+ """Load OCI configuration from ~/.oci/config"""
797
+ oci_config = {}
798
+
799
+ oci_config_path = Path.home() / ".oci" / "config"
800
+ if oci_config_path.exists():
801
+ try:
802
+ import configparser
803
+ config = configparser.ConfigParser()
804
+ config.read(oci_config_path)
805
+
806
+ profiles = {}
807
+ for section_name in config.sections():
808
+ profiles[section_name] = dict(config[section_name])
809
+
810
+ if profiles:
811
+ oci_config['profiles'] = profiles
812
+
813
+ except Exception as e:
814
+ logger.warning(f"Failed to load OCI config: {e}")
815
+
816
+ return oci_config
817
+
818
+ def _load_ssh_config(self) -> Dict[str, Any]:
819
+ """Load SSH configuration from ~/.ssh/config"""
820
+ ssh_config = {}
821
+
822
+ ssh_config_path = Path.home() / ".ssh" / "config"
823
+ if ssh_config_path.exists():
824
+ try:
825
+ hosts = {}
826
+ current_host = None
827
+
828
+ with open(ssh_config_path, 'r') as f:
829
+ for line in f:
830
+ line = line.strip()
831
+ if not line or line.startswith('#'):
832
+ continue
833
+
834
+ if line.lower().startswith('host '):
835
+ current_host = line.split(' ', 1)[1]
836
+ hosts[current_host] = {}
837
+ elif current_host and ' ' in line:
838
+ key, value = line.split(' ', 1)
839
+ hosts[current_host][key.lower()] = value
840
+
841
+ if hosts:
842
+ ssh_config['hosts'] = hosts
843
+
844
+ except Exception as e:
845
+ logger.warning(f"Failed to load SSH config: {e}")
846
+
847
+ return ssh_config
848
+
849
+ def _load_cloudflare_config(self) -> Dict[str, Any]:
850
+ """Load CloudFlare configuration if exists"""
851
+ cf_config = {}
852
+
853
+ # Check for CloudFlare config in various locations
854
+ possible_paths = [
855
+ Path.home() / ".cloudflare" / "config",
856
+ Path.home() / ".cloudflare" / "config.yaml",
857
+ Path("config") / "cloudflare.yaml"
858
+ ]
859
+
860
+ for cf_path in possible_paths:
861
+ if cf_path.exists():
862
+ try:
863
+ if cf_path.suffix.lower() in ['.yaml', '.yml']:
864
+ cf_config = self._load_config_file(cf_path)
865
+ else:
866
+ # Try to parse as simple key=value format
867
+ with open(cf_path, 'r') as f:
868
+ for line in f:
869
+ line = line.strip()
870
+ if '=' in line and not line.startswith('#'):
871
+ key, value = line.split('=', 1)
872
+ cf_config[key.strip()] = value.strip()
873
+ break
874
+ except Exception as e:
875
+ logger.warning(f"Failed to load CloudFlare config from {cf_path}: {e}")
876
+
877
+ return cf_config
878
+
879
+ def migrate_from_env(self, env_file_path: str = ".env", force: bool = False) -> bool:
880
+ """
881
+ Migrate configuration from .env file to YAML format using MigrationManager.
882
+
883
+ Args:
884
+ env_file_path: Path to the .env file
885
+ force: Force migration even if YAML files already exist
886
+
887
+ Returns:
888
+ True if migration was successful
889
+ """
890
+ return self.migration_manager.migrate_env_to_yaml(env_file_path, force)
891
+
892
+ def invalidate_cache(self):
893
+ """캐시를 무효화합니다."""
894
+ self._config_cache = None
895
+ self._env_cache = None
896
+ self._cache_timestamp = 0
897
+ self._file_timestamps.clear()
898
+ logger.debug("Configuration cache invalidated")