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/migration.py ADDED
@@ -0,0 +1,628 @@
1
+ """
2
+ Migration manager module for IC.
3
+
4
+ This module provides migration functionality from .env files to YAML configuration.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Dict, Any, List, Optional, Tuple
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class MigrationManager:
18
+ """
19
+ Manages migration from .env files to YAML configuration system.
20
+ """
21
+
22
+ def __init__(self, config_manager=None):
23
+ """
24
+ Initialize MigrationManager.
25
+
26
+ Args:
27
+ config_manager: Reference to ConfigManager instance
28
+ """
29
+ self.config_manager = config_manager
30
+ self.backup_dir = Path("backup")
31
+ self.migration_history: List[Dict[str, Any]] = []
32
+
33
+ def migrate_env_to_yaml(self, env_file_path: str = ".env",
34
+ force: bool = False) -> bool:
35
+ """
36
+ Migrate configuration from .env file to YAML format.
37
+
38
+ Args:
39
+ env_file_path: Path to the .env file
40
+ force: Force migration even if YAML files already exist
41
+
42
+ Returns:
43
+ True if migration was successful
44
+ """
45
+ env_path = Path(env_file_path)
46
+ if not env_path.exists():
47
+ logger.warning(f"No .env file found at {env_path}")
48
+ return False
49
+
50
+ # Check if YAML files already exist
51
+ config_dir = Path("config")
52
+ default_yaml = config_dir / "default.yaml"
53
+ secrets_yaml = config_dir / "secrets.yaml"
54
+
55
+ if not force and (default_yaml.exists() or secrets_yaml.exists()):
56
+ logger.warning("YAML configuration files already exist. Use force=True to overwrite.")
57
+ return False
58
+
59
+ try:
60
+ # Parse .env file
61
+ env_vars = self._parse_env_file(env_path)
62
+
63
+ # Separate sensitive and non-sensitive data
64
+ default_config, secrets_config = self._categorize_env_vars(env_vars)
65
+
66
+ # Create config directory
67
+ config_dir.mkdir(exist_ok=True)
68
+
69
+ # Backup existing files if they exist
70
+ if default_yaml.exists():
71
+ self._backup_file(default_yaml)
72
+ if secrets_yaml.exists():
73
+ self._backup_file(secrets_yaml)
74
+
75
+ # Save configurations
76
+ success = True
77
+
78
+ # Save default configuration
79
+ if default_config:
80
+ success &= self._save_yaml_config(default_yaml, default_config)
81
+
82
+ # Save secrets configuration
83
+ if secrets_config:
84
+ success &= self._save_yaml_config(secrets_yaml, secrets_config)
85
+
86
+ # Set restrictive permissions on secrets file
87
+ try:
88
+ secrets_yaml.chmod(0o600)
89
+ except Exception as e:
90
+ logger.warning(f"Failed to set restrictive permissions on secrets file: {e}")
91
+
92
+ if success:
93
+ # Backup original .env file
94
+ self._backup_file(env_path)
95
+
96
+ # Record migration
97
+ self._record_migration(env_path, default_yaml, secrets_yaml)
98
+
99
+ logger.info("Successfully migrated .env file to YAML configuration")
100
+ return True
101
+ else:
102
+ logger.error("Failed to save YAML configuration files")
103
+ return False
104
+
105
+ except Exception as e:
106
+ logger.error(f"Failed to migrate .env file: {e}")
107
+ return False
108
+
109
+ def _parse_env_file(self, env_path: Path) -> Dict[str, str]:
110
+ """
111
+ Parse .env file and extract key-value pairs.
112
+
113
+ Args:
114
+ env_path: Path to .env file
115
+
116
+ Returns:
117
+ Dictionary of environment variables
118
+ """
119
+ env_vars = {}
120
+
121
+ with open(env_path, 'r', encoding='utf-8') as f:
122
+ for line_num, line in enumerate(f, 1):
123
+ line = line.strip()
124
+
125
+ # Skip empty lines and comments
126
+ if not line or line.startswith('#'):
127
+ continue
128
+
129
+ # Handle lines with equals sign
130
+ if '=' in line:
131
+ key, value = line.split('=', 1)
132
+ key = key.strip()
133
+ value = value.strip()
134
+
135
+ # Remove quotes if present
136
+ if value.startswith('"') and value.endswith('"'):
137
+ value = value[1:-1]
138
+ elif value.startswith("'") and value.endswith("'"):
139
+ value = value[1:-1]
140
+
141
+ env_vars[key] = value
142
+ else:
143
+ logger.warning(f"Skipping invalid line {line_num} in {env_path}: {line}")
144
+
145
+ return env_vars
146
+
147
+ def _categorize_env_vars(self, env_vars: Dict[str, str]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
148
+ """
149
+ Categorize environment variables into default config and secrets.
150
+
151
+ Args:
152
+ env_vars: Dictionary of environment variables
153
+
154
+ Returns:
155
+ Tuple of (default_config, secrets_config)
156
+ """
157
+ # Start with base default configuration
158
+ default_config = self._get_base_default_config()
159
+ secrets_config = {"version": "2.0"}
160
+
161
+ # Define sensitive keys
162
+ sensitive_keys = {
163
+ 'SLACK_WEBHOOK_URL', 'CLOUDFLARE_EMAIL', 'CLOUDFLARE_API_TOKEN',
164
+ 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
165
+ 'GCP_SERVICE_ACCOUNT_KEY_PATH', 'GOOGLE_APPLICATION_CREDENTIALS',
166
+ 'AWS_ACCOUNTS' # Account IDs are considered sensitive
167
+ }
168
+
169
+ # Categorize each environment variable
170
+ for key, value in env_vars.items():
171
+ if key in sensitive_keys or self._is_sensitive_key(key):
172
+ self._add_to_secrets_config(secrets_config, key, value)
173
+ else:
174
+ self._add_to_default_config(default_config, key, value)
175
+
176
+ return default_config, secrets_config
177
+
178
+ def _is_sensitive_key(self, key: str) -> bool:
179
+ """
180
+ Check if a key is potentially sensitive.
181
+
182
+ Args:
183
+ key: Environment variable key
184
+
185
+ Returns:
186
+ True if key is potentially sensitive
187
+ """
188
+ sensitive_patterns = [
189
+ 'token', 'key', 'secret', 'password', 'passwd', 'pwd',
190
+ 'credential', 'webhook', 'api_key', 'access_key', 'private_key'
191
+ ]
192
+
193
+ key_lower = key.lower()
194
+ return any(pattern in key_lower for pattern in sensitive_patterns)
195
+
196
+ def _add_to_secrets_config(self, secrets_config: Dict[str, Any], key: str, value: str):
197
+ """Add environment variable to secrets configuration."""
198
+ if key == 'AWS_ACCOUNTS':
199
+ self._set_nested_value(secrets_config, ['aws', 'accounts'],
200
+ [acc.strip() for acc in value.split(',') if acc.strip()])
201
+ elif key == 'CLOUDFLARE_EMAIL':
202
+ self._set_nested_value(secrets_config, ['cloudflare', 'email'], value)
203
+ elif key == 'CLOUDFLARE_API_TOKEN':
204
+ self._set_nested_value(secrets_config, ['cloudflare', 'api_token'], value)
205
+ elif key == 'CLOUDFLARE_ACCOUNTS':
206
+ self._set_nested_value(secrets_config, ['cloudflare', 'accounts'],
207
+ [acc.strip() for acc in value.split(',') if acc.strip()])
208
+ elif key == 'CLOUDFLARE_ZONES':
209
+ self._set_nested_value(secrets_config, ['cloudflare', 'zones'],
210
+ [zone.strip() for zone in value.split(',') if zone.strip()])
211
+ elif key == 'SLACK_WEBHOOK_URL':
212
+ self._set_nested_value(secrets_config, ['slack', 'webhook_url'], value)
213
+ elif key == 'GCP_SERVICE_ACCOUNT_KEY_PATH' or key == 'GOOGLE_APPLICATION_CREDENTIALS':
214
+ self._set_nested_value(secrets_config, ['gcp', 'service_account_key_path'], value)
215
+ elif key == 'GCP_PROJECTS':
216
+ self._set_nested_value(secrets_config, ['gcp', 'projects'],
217
+ [proj.strip() for proj in value.split(',') if proj.strip()])
218
+ elif key == 'AZURE_TENANT_ID':
219
+ self._set_nested_value(secrets_config, ['azure', 'tenant_id'], value)
220
+ elif key == 'AZURE_CLIENT_ID':
221
+ self._set_nested_value(secrets_config, ['azure', 'client_id'], value)
222
+ elif key == 'AZURE_CLIENT_SECRET':
223
+ self._set_nested_value(secrets_config, ['azure', 'client_secret'], value)
224
+ elif key == 'AZURE_SUBSCRIPTIONS':
225
+ self._set_nested_value(secrets_config, ['azure', 'subscriptions'],
226
+ [sub.strip() for sub in value.split(',') if sub.strip()])
227
+ else:
228
+ # Generic sensitive key handling
229
+ logger.warning(f"Unknown sensitive key '{key}', storing in secrets under 'other'")
230
+ self._set_nested_value(secrets_config, ['other', key.lower()], value)
231
+
232
+ def _add_to_default_config(self, default_config: Dict[str, Any], key: str, value: str):
233
+ """Add environment variable to default configuration."""
234
+ if key == 'REGIONS':
235
+ self._set_nested_value(default_config, ['aws', 'regions'],
236
+ [reg.strip() for reg in value.split(',') if reg.strip()])
237
+ elif key == 'REQUIRED_TAGS':
238
+ self._set_nested_value(default_config, ['aws', 'tags', 'required'],
239
+ [tag.strip() for tag in value.split(',') if tag.strip()])
240
+ elif key == 'OPTIONAL_TAGS':
241
+ self._set_nested_value(default_config, ['aws', 'tags', 'optional'],
242
+ [tag.strip() for tag in value.split(',') if tag.strip()])
243
+ elif key.startswith('RULE_'):
244
+ rule_name = key[5:].lower() # Remove 'RULE_' prefix
245
+ self._set_nested_value(default_config, ['aws', 'tags', 'rules', rule_name.title()], value)
246
+ elif key == 'SSH_MAX_WORKER':
247
+ self._set_nested_value(default_config, ['ssh', 'max_workers'], int(value))
248
+ elif key == 'SSH_SKIP_PREFIXES':
249
+ self._set_nested_value(default_config, ['ssh', 'skip_prefixes'],
250
+ [prefix.strip() for prefix in value.split(',') if prefix.strip()])
251
+ elif key == 'PORT_OPEN_TIMEOUT':
252
+ self._set_nested_value(default_config, ['ssh', 'timeouts', 'port_scan'], float(value))
253
+ elif key == 'SSH_TIMEOUT':
254
+ self._set_nested_value(default_config, ['ssh', 'timeouts', 'ssh_connect'], int(value))
255
+ elif key == 'SSH_KEY_DIR':
256
+ self._set_nested_value(default_config, ['ssh', 'key_dir'], value)
257
+ elif key == 'SSH_CONFIG_FILE':
258
+ self._set_nested_value(default_config, ['ssh', 'config_file'], value)
259
+ elif key == 'LOG_LEVEL':
260
+ self._set_nested_value(default_config, ['logging', 'console_level'], value.upper())
261
+ elif key == 'OCI_CONFIG_PATH':
262
+ self._set_nested_value(default_config, ['oci', 'config_path'], value)
263
+ elif key == 'GCP_REGIONS':
264
+ self._set_nested_value(default_config, ['gcp', 'regions'],
265
+ [reg.strip() for reg in value.split(',') if reg.strip()])
266
+ elif key == 'GCP_ZONES':
267
+ self._set_nested_value(default_config, ['gcp', 'zones'],
268
+ [zone.strip() for zone in value.split(',') if zone.strip()])
269
+ elif key == 'GCP_MAX_WORKERS':
270
+ self._set_nested_value(default_config, ['gcp', 'max_workers'], int(value))
271
+ elif key == 'AZURE_LOCATIONS':
272
+ self._set_nested_value(default_config, ['azure', 'locations'],
273
+ [loc.strip() for loc in value.split(',') if loc.strip()])
274
+ elif key == 'AZURE_MAX_WORKERS':
275
+ self._set_nested_value(default_config, ['azure', 'max_workers'], int(value))
276
+ else:
277
+ # Generic non-sensitive key handling
278
+ logger.info(f"Unknown non-sensitive key '{key}', storing in default config under 'other'")
279
+ self._set_nested_value(default_config, ['other', key.lower()], value)
280
+
281
+ def _set_nested_value(self, config: Dict[str, Any], path: List[str], value: Any):
282
+ """Set a nested value in configuration dictionary."""
283
+ current = config
284
+ for key in path[:-1]:
285
+ if key not in current:
286
+ current[key] = {}
287
+ current = current[key]
288
+ current[path[-1]] = value
289
+
290
+ def _get_base_default_config(self) -> Dict[str, Any]:
291
+ """Get base default configuration structure."""
292
+ return {
293
+ "version": "2.0",
294
+ "logging": {
295
+ "console_level": "ERROR",
296
+ "file_level": "INFO",
297
+ "file_path": "~/.ic/logs/ic_{date}.log",
298
+ "max_files": 30,
299
+ "format": "%(asctime)s [%(levelname)s] - %(message)s",
300
+ "mask_sensitive": True,
301
+ },
302
+ "aws": {
303
+ "config_path": "~/.aws/config",
304
+ "credentials_path": "~/.aws/credentials",
305
+ "accounts": [],
306
+ "regions": ["ap-northeast-2"],
307
+ "cross_account_role": "OrganizationAccountAccessRole",
308
+ "session_duration": 3600,
309
+ "max_workers": 10,
310
+ "tags": {
311
+ "required": ["User", "Team", "Environment"],
312
+ "optional": ["Service", "Application"],
313
+ "rules": {
314
+ "User": "^.+$",
315
+ "Team": "^\\d+$",
316
+ "Environment": "^(PROD|STG|DEV|TEST|QA)$",
317
+ },
318
+ },
319
+ },
320
+ "azure": {
321
+ "subscriptions": [],
322
+ "locations": ["Korea Central"],
323
+ "max_workers": 10,
324
+ },
325
+ "gcp": {
326
+ "mcp": {
327
+ "enabled": True,
328
+ "endpoint": "http://localhost:8080/gcp",
329
+ "auth_method": "service_account",
330
+ "prefer_mcp": True,
331
+ },
332
+ "projects": [],
333
+ "regions": ["asia-northeast3"],
334
+ "zones": ["asia-northeast3-a"],
335
+ "max_workers": 10,
336
+ },
337
+ "oci": {
338
+ "config_path": "~/.oci/config",
339
+ "max_workers": 10,
340
+ },
341
+ "cloudflare": {
342
+ "config_path": "~/.cloudflare/config",
343
+ "accounts": [],
344
+ "zones": [],
345
+ },
346
+ "ssh": {
347
+ "config_file": "~/.ssh/config",
348
+ "key_dir": "~/aws-key",
349
+ "max_workers": 70,
350
+ "skip_prefixes": ["git", "akrr-portx", "akrr-taas-gw", "agw01", "semaphore"],
351
+ "timeouts": {
352
+ "port_scan": 0.5,
353
+ "ssh_connect": 5,
354
+ },
355
+ },
356
+ "mcp": {
357
+ "servers": {
358
+ "github": {
359
+ "enabled": True,
360
+ "auto_approve": [],
361
+ },
362
+ "terraform": {
363
+ "enabled": True,
364
+ "auto_approve": [],
365
+ },
366
+ "aws_docs": {
367
+ "enabled": True,
368
+ "auto_approve": ["read_documentation", "search_documentation"],
369
+ },
370
+ "azure": {
371
+ "enabled": True,
372
+ "auto_approve": ["documentation"],
373
+ },
374
+ },
375
+ },
376
+ "slack": {
377
+ "enabled": False,
378
+ },
379
+ "security": {
380
+ "sensitive_keys": [
381
+ "password", "passwd", "pwd",
382
+ "token", "access_token", "refresh_token", "auth_token",
383
+ "key", "api_key", "access_key", "secret_key", "private_key",
384
+ "secret", "client_secret", "webhook_secret",
385
+ "webhook_url", "webhook",
386
+ "credential", "credentials",
387
+ "cert", "certificate",
388
+ "session", "session_token",
389
+ ],
390
+ "mask_pattern": "***MASKED***",
391
+ "warn_on_sensitive_in_config": True,
392
+ "git_hooks_enabled": True,
393
+ },
394
+ }
395
+
396
+ def _save_yaml_config(self, file_path: Path, config: Dict[str, Any]) -> bool:
397
+ """
398
+ Save configuration to YAML file.
399
+
400
+ Args:
401
+ file_path: Path to save the file
402
+ config: Configuration dictionary
403
+
404
+ Returns:
405
+ True if successful
406
+ """
407
+ try:
408
+ import yaml
409
+
410
+ with open(file_path, 'w', encoding='utf-8') as f:
411
+ yaml.dump(config, f, default_flow_style=False, indent=2,
412
+ allow_unicode=True, sort_keys=False)
413
+
414
+ logger.info(f"Saved configuration to {file_path}")
415
+ return True
416
+
417
+ except Exception as e:
418
+ logger.error(f"Failed to save configuration to {file_path}: {e}")
419
+ return False
420
+
421
+ def _backup_file(self, file_path: Path) -> Optional[Path]:
422
+ """
423
+ Create a backup of a file.
424
+
425
+ Args:
426
+ file_path: Path to file to backup
427
+
428
+ Returns:
429
+ Path to backup file if successful
430
+ """
431
+ if not file_path.exists():
432
+ return None
433
+
434
+ # Create backup directory
435
+ self.backup_dir.mkdir(exist_ok=True)
436
+
437
+ # Generate backup filename with timestamp
438
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
439
+ backup_name = f"{file_path.stem}_{timestamp}{file_path.suffix}"
440
+ backup_path = self.backup_dir / backup_name
441
+
442
+ try:
443
+ shutil.copy2(file_path, backup_path)
444
+ logger.info(f"Backed up {file_path} to {backup_path}")
445
+ return backup_path
446
+ except Exception as e:
447
+ logger.error(f"Failed to backup {file_path}: {e}")
448
+ return None
449
+
450
+ def _record_migration(self, env_path: Path, default_yaml: Path, secrets_yaml: Path):
451
+ """Record migration details for history."""
452
+ migration_record = {
453
+ "timestamp": datetime.now().isoformat(),
454
+ "source_file": str(env_path),
455
+ "target_files": {
456
+ "default_config": str(default_yaml),
457
+ "secrets_config": str(secrets_yaml) if secrets_yaml.exists() else None
458
+ },
459
+ "backup_location": str(self.backup_dir)
460
+ }
461
+
462
+ self.migration_history.append(migration_record)
463
+
464
+ def create_migration_history_document(self) -> bool:
465
+ """
466
+ Create a migration history document.
467
+
468
+ Returns:
469
+ True if document was created successfully
470
+ """
471
+ try:
472
+ history_content = self._generate_migration_history_content()
473
+
474
+ history_path = self.backup_dir / "migration_history.md"
475
+ with open(history_path, 'w', encoding='utf-8') as f:
476
+ f.write(history_content)
477
+
478
+ logger.info(f"Created migration history document at {history_path}")
479
+ return True
480
+
481
+ except Exception as e:
482
+ logger.error(f"Failed to create migration history document: {e}")
483
+ return False
484
+
485
+ def _generate_migration_history_content(self) -> str:
486
+ """Generate migration history document content."""
487
+ content = """# IC Configuration Migration History
488
+
489
+ This document records the migration from .env files to YAML configuration system.
490
+
491
+ ## Migration Overview
492
+
493
+ The IC configuration system has been migrated from environment variable-based (.env)
494
+ configuration to a structured YAML-based system with the following benefits:
495
+
496
+ - **Security**: Sensitive data is separated into `config/secrets.yaml`
497
+ - **Structure**: Configuration is organized by service and purpose
498
+ - **Validation**: Built-in validation and security checks
499
+ - **External References**: Direct integration with cloud provider config files
500
+ - **Fixed Logging**: Logs are written to a consistent location
501
+
502
+ ## File Structure Changes
503
+
504
+ ### Before Migration
505
+ ```
506
+ .env # All configuration in one file
507
+ logs/ # Logs created in current directory
508
+ ```
509
+
510
+ ### After Migration
511
+ ```
512
+ config/
513
+ ├── default.yaml # Non-sensitive configuration
514
+ └── secrets.yaml # Sensitive configuration (600 permissions)
515
+
516
+ ~/.ic/
517
+ └── logs/ # Fixed log location
518
+
519
+ backup/
520
+ ├── .env_YYYYMMDD_HHMMSS # Backed up original .env
521
+ └── migration_history.md # This document
522
+ ```
523
+
524
+ ## Migration Records
525
+
526
+ """
527
+
528
+ if self.migration_history:
529
+ for i, record in enumerate(self.migration_history, 1):
530
+ content += f"### Migration {i}\n\n"
531
+ content += f"- **Date**: {record['timestamp']}\n"
532
+ content += f"- **Source**: {record['source_file']}\n"
533
+ content += f"- **Default Config**: {record['target_files']['default_config']}\n"
534
+
535
+ if record['target_files']['secrets_config']:
536
+ content += f"- **Secrets Config**: {record['target_files']['secrets_config']}\n"
537
+
538
+ content += f"- **Backup Location**: {record['backup_location']}\n\n"
539
+ else:
540
+ content += "No migration records found.\n\n"
541
+
542
+ content += """## Configuration Categories
543
+
544
+ ### Default Configuration (config/default.yaml)
545
+ - Logging settings
546
+ - Service endpoints and regions
547
+ - Worker thread counts
548
+ - Timeout values
549
+ - Tag validation rules
550
+ - External config file paths
551
+
552
+ ### Secrets Configuration (config/secrets.yaml)
553
+ - API tokens and keys
554
+ - Account IDs and credentials
555
+ - Webhook URLs
556
+ - Service account paths
557
+ - Subscription IDs
558
+
559
+ ## Security Notes
560
+
561
+ 1. **File Permissions**: `config/secrets.yaml` should have 600 permissions (owner read/write only)
562
+ 2. **Version Control**: Add `config/secrets.yaml` to `.gitignore`
563
+ 3. **Environment Fallback**: If secrets.yaml is missing, system falls back to environment variables
564
+ 4. **Sensitive Data Masking**: All logs automatically mask sensitive information
565
+
566
+ ## Rollback Instructions
567
+
568
+ If you need to rollback to the .env system:
569
+
570
+ 1. Copy the backed up .env file from the backup directory
571
+ 2. Remove or rename the config/ directory
572
+ 3. Restart the application
573
+
574
+ ## Next Steps
575
+
576
+ 1. Review the generated configuration files
577
+ 2. Update any service-specific settings as needed
578
+ 3. Ensure secrets.yaml has proper file permissions
579
+ 4. Add secrets.yaml to .gitignore if using version control
580
+ 5. Test all services to ensure proper configuration loading
581
+
582
+ """
583
+
584
+ return content
585
+
586
+ def validate_migration(self) -> Dict[str, List[str]]:
587
+ """
588
+ Validate the migration results.
589
+
590
+ Returns:
591
+ Dictionary of validation results
592
+ """
593
+ issues = {
594
+ "errors": [],
595
+ "warnings": [],
596
+ "info": []
597
+ }
598
+
599
+ # Check if config files exist
600
+ config_dir = Path("config")
601
+ default_yaml = config_dir / "default.yaml"
602
+ secrets_yaml = config_dir / "secrets.yaml"
603
+
604
+ if not default_yaml.exists():
605
+ issues["errors"].append("default.yaml not found in config directory")
606
+
607
+ if not secrets_yaml.exists():
608
+ issues["warnings"].append("secrets.yaml not found - will use environment variables")
609
+ else:
610
+ # Check file permissions
611
+ try:
612
+ file_mode = secrets_yaml.stat().st_mode & 0o777
613
+ if file_mode != 0o600:
614
+ issues["warnings"].append(f"secrets.yaml has insecure permissions: {oct(file_mode)}")
615
+ except Exception as e:
616
+ issues["warnings"].append(f"Could not check secrets.yaml permissions: {e}")
617
+
618
+ # Check if backup was created
619
+ if not self.backup_dir.exists():
620
+ issues["warnings"].append("No backup directory found")
621
+ else:
622
+ backup_files = list(self.backup_dir.glob("*.env*"))
623
+ if not backup_files:
624
+ issues["warnings"].append("No .env backup files found")
625
+ else:
626
+ issues["info"].append(f"Found {len(backup_files)} backup files")
627
+
628
+ return issues