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/__init__.py +29 -0
- ic/cli.py +769 -0
- ic/commands/__init__.py +10 -0
- ic/commands/config.py +699 -0
- ic/compat/__init__.py +241 -0
- ic/compat/cli.py +289 -0
- ic/compat/common.py +243 -0
- ic/config/__init__.py +10 -0
- ic/config/cleanup.py +382 -0
- ic/config/docs_organizer.py +587 -0
- ic/config/external.py +456 -0
- ic/config/manager.py +898 -0
- ic/config/migration.py +628 -0
- ic/config/schema.py +595 -0
- ic/config/secrets.py +437 -0
- ic/config/security.py +462 -0
- ic/core/__init__.py +16 -0
- ic/core/logging.py +311 -0
- ic/core/mcp_manager.py +856 -0
- ic/core/session.py +392 -0
- ic/core/silence_logging.py +67 -0
- ic_code-1.0.0.dist-info/METADATA +354 -0
- ic_code-1.0.0.dist-info/RECORD +27 -0
- ic_code-1.0.0.dist-info/WHEEL +5 -0
- ic_code-1.0.0.dist-info/entry_points.txt +2 -0
- ic_code-1.0.0.dist-info/licenses/LICENSE +21 -0
- ic_code-1.0.0.dist-info/top_level.txt +1 -0
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")
|