vagrant-ssh-config-manager 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2150 @@
1
+ require 'fileutils'
2
+ require 'pathname'
3
+ require 'digest'
4
+ require_relative 'file_locker'
5
+
6
+ module VagrantPlugins
7
+ module SshConfigManager
8
+ class SshConfigManager
9
+ attr_reader :ssh_config_file, :project_name
10
+
11
+ def initialize(machine, config = nil)
12
+ @machine = machine
13
+ @config = config || machine.config.sshconfigmanager
14
+ @logger = Log4r::Logger.new("vagrant::plugins::ssh_config_manager::ssh_config_manager")
15
+
16
+ # Determine SSH config file path
17
+ @ssh_config_file = determine_ssh_config_file
18
+ @project_name = generate_project_name
19
+ @include_file_path = generate_include_file_path
20
+ end
21
+
22
+ # Add SSH configuration entry for a machine
23
+ def add_ssh_entry(ssh_config_data)
24
+ return false unless ssh_config_data && ssh_config_data['Host']
25
+
26
+ begin
27
+ ensure_ssh_config_structure
28
+ write_include_file(ssh_config_data)
29
+ add_include_directive
30
+
31
+ @logger.info("Added SSH entry for host: #{ssh_config_data['Host']}")
32
+ true
33
+ rescue => e
34
+ @logger.error("Failed to add SSH entry: #{e.message}")
35
+ false
36
+ end
37
+ end
38
+
39
+ # Remove SSH configuration entry for a machine
40
+ def remove_ssh_entry(host_name)
41
+ return false unless host_name
42
+
43
+ begin
44
+ removed = remove_from_include_file(host_name)
45
+ cleanup_empty_include_file
46
+ cleanup_include_directive_if_needed
47
+
48
+ @logger.info("Removed SSH entry for host: #{host_name}") if removed
49
+ removed
50
+ rescue => e
51
+ @logger.error("Failed to remove SSH entry: #{e.message}")
52
+ false
53
+ end
54
+ end
55
+
56
+ # Update SSH configuration entry for a machine
57
+ def update_ssh_entry(ssh_config_data)
58
+ return false unless ssh_config_data && ssh_config_data['Host']
59
+
60
+ begin
61
+ host_name = ssh_config_data['Host']
62
+ remove_ssh_entry(host_name)
63
+ add_ssh_entry(ssh_config_data)
64
+
65
+ @logger.info("Updated SSH entry for host: #{host_name}")
66
+ true
67
+ rescue => e
68
+ @logger.error("Failed to update SSH entry: #{e.message}")
69
+ false
70
+ end
71
+ end
72
+
73
+ # Check if SSH entry exists for a host
74
+ def ssh_entry_exists?(host_name)
75
+ return false unless File.exist?(@include_file_path)
76
+
77
+ content = File.read(@include_file_path)
78
+ content.include?("Host #{host_name}")
79
+ rescue
80
+ false
81
+ end
82
+
83
+ # Get all SSH entries managed by this project
84
+ def get_project_ssh_entries
85
+ return [] unless File.exist?(@include_file_path)
86
+
87
+ entries = []
88
+ current_entry = nil
89
+
90
+ File.readlines(@include_file_path).each do |line|
91
+ line = line.strip
92
+ next if line.empty? || line.start_with?('#')
93
+
94
+ if line.start_with?('Host ')
95
+ entries << current_entry if current_entry
96
+ current_entry = { 'Host' => line.sub(/^Host\s+/, '') }
97
+ elsif current_entry && line.include?(' ')
98
+ key, value = line.split(' ', 2)
99
+ current_entry[key.strip] = value.strip
100
+ end
101
+ end
102
+
103
+ entries << current_entry if current_entry
104
+ entries
105
+ rescue
106
+ []
107
+ end
108
+
109
+ # Advanced include file management methods
110
+
111
+ # Create or update include file with multiple SSH entries
112
+ def manage_include_file(ssh_entries)
113
+ return false if ssh_entries.nil? || ssh_entries.empty?
114
+
115
+ begin
116
+ ensure_ssh_config_structure
117
+ write_multiple_entries_to_include_file(ssh_entries)
118
+ add_include_directive
119
+
120
+ @logger.info("Managed include file with #{ssh_entries.length} SSH entries")
121
+ true
122
+ rescue => e
123
+ @logger.error("Failed to manage include file: #{e.message}")
124
+ false
125
+ end
126
+ end
127
+
128
+ # Get include file status and information
129
+ def include_file_info
130
+ info = {
131
+ path: @include_file_path,
132
+ exists: File.exist?(@include_file_path),
133
+ size: 0,
134
+ entries_count: 0,
135
+ last_modified: nil
136
+ }
137
+
138
+ if info[:exists]
139
+ stat = File.stat(@include_file_path)
140
+ info[:size] = stat.size
141
+ info[:last_modified] = stat.mtime
142
+ info[:entries_count] = count_entries_in_include_file
143
+ end
144
+
145
+ info
146
+ end
147
+
148
+ # Backup include file before operations
149
+ def backup_include_file
150
+ return nil unless File.exist?(@include_file_path)
151
+
152
+ backup_path = "#{@include_file_path}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
153
+ FileUtils.cp(@include_file_path, backup_path)
154
+
155
+ @logger.debug("Created backup of include file: #{backup_path}")
156
+ backup_path
157
+ rescue => e
158
+ @logger.warn("Failed to create backup: #{e.message}")
159
+ nil
160
+ end
161
+
162
+ # Restore include file from backup
163
+ def restore_include_file(backup_path)
164
+ return false unless File.exist?(backup_path)
165
+
166
+ begin
167
+ FileUtils.cp(backup_path, @include_file_path)
168
+ @logger.info("Restored include file from backup: #{backup_path}")
169
+ true
170
+ rescue => e
171
+ @logger.error("Failed to restore from backup: #{e.message}")
172
+ false
173
+ end
174
+ end
175
+
176
+ # Validate include file format and structure
177
+ def validate_include_file
178
+ return { valid: true, errors: [] } unless File.exist?(@include_file_path)
179
+
180
+ errors = []
181
+ line_number = 0
182
+ current_host = nil
183
+
184
+ begin
185
+ File.readlines(@include_file_path).each do |line|
186
+ line_number += 1
187
+ line_stripped = line.strip
188
+
189
+ next if line_stripped.empty? || line_stripped.start_with?('#')
190
+
191
+ if line_stripped.start_with?('Host ')
192
+ host_name = line_stripped.sub(/^Host\s+/, '')
193
+ if host_name.empty?
194
+ errors << "Line #{line_number}: Empty host name"
195
+ else
196
+ current_host = host_name
197
+ end
198
+ elsif current_host
199
+ unless line_stripped.include?(' ')
200
+ errors << "Line #{line_number}: Invalid SSH option format"
201
+ end
202
+ else
203
+ errors << "Line #{line_number}: SSH option without host declaration"
204
+ end
205
+ end
206
+ rescue => e
207
+ errors << "Failed to read include file: #{e.message}"
208
+ end
209
+
210
+ {
211
+ valid: errors.empty?,
212
+ errors: errors,
213
+ path: @include_file_path
214
+ }
215
+ end
216
+
217
+ # Clean up orphaned include files (for debugging/maintenance)
218
+ def cleanup_orphaned_include_files
219
+ config_d_dir = File.dirname(@include_file_path)
220
+ return 0 unless File.exist?(config_d_dir)
221
+
222
+ cleaned_count = 0
223
+
224
+ Dir.glob(File.join(config_d_dir, 'vagrant-*')).each do |file_path|
225
+ next unless File.file?(file_path)
226
+
227
+ # Check if file is empty or only contains comments
228
+ content = File.read(file_path).strip
229
+ if content.empty? || content.lines.all? { |line| line.strip.empty? || line.strip.start_with?('#') }
230
+ File.delete(file_path)
231
+ cleaned_count += 1
232
+ @logger.debug("Cleaned up orphaned include file: #{file_path}")
233
+ end
234
+ end
235
+
236
+ cleaned_count
237
+ rescue => e
238
+ @logger.warn("Failed to cleanup orphaned files: #{e.message}")
239
+ 0
240
+ end
241
+
242
+ # Advanced main SSH config file management
243
+
244
+ # Get information about main SSH config file
245
+ def main_config_info
246
+ info = {
247
+ path: @ssh_config_file,
248
+ exists: File.exist?(@ssh_config_file),
249
+ size: 0,
250
+ writable: false,
251
+ include_directive_exists: false,
252
+ last_modified: nil
253
+ }
254
+
255
+ if info[:exists]
256
+ stat = File.stat(@ssh_config_file)
257
+ info[:size] = stat.size
258
+ info[:last_modified] = stat.mtime
259
+ info[:writable] = File.writable?(@ssh_config_file)
260
+ info[:include_directive_exists] = include_directive_exists?
261
+ end
262
+
263
+ info
264
+ end
265
+
266
+ # Safely add include directive with conflict detection
267
+ def add_include_directive_safe
268
+ return true if include_directive_exists?
269
+
270
+ begin
271
+ # Check if main config file is writable
272
+ unless File.writable?(@ssh_config_file) || !File.exist?(@ssh_config_file)
273
+ @logger.warn("SSH config file is not writable: #{@ssh_config_file}")
274
+ return false
275
+ end
276
+
277
+ backup_main_config
278
+ add_include_directive_with_validation
279
+ true
280
+ rescue => e
281
+ @logger.error("Failed to add include directive safely: #{e.message}")
282
+ false
283
+ end
284
+ end
285
+
286
+ # Remove include directive and clean up
287
+ def remove_include_directive_safe
288
+ return true unless include_directive_exists?
289
+
290
+ begin
291
+ backup_main_config
292
+ remove_include_directive_with_validation
293
+ true
294
+ rescue => e
295
+ @logger.error("Failed to remove include directive safely: #{e.message}")
296
+ false
297
+ end
298
+ end
299
+
300
+ # Validate main SSH config file structure
301
+ def validate_main_config
302
+ return { valid: true, errors: [], warnings: [] } unless File.exist?(@ssh_config_file)
303
+
304
+ errors = []
305
+ warnings = []
306
+ line_number = 0
307
+
308
+ begin
309
+ File.readlines(@ssh_config_file).each do |line|
310
+ line_number += 1
311
+ line_stripped = line.strip
312
+
313
+ next if line_stripped.empty? || line_stripped.start_with?('#')
314
+
315
+ # Check for include directive syntax
316
+ if line_stripped.start_with?('Include ')
317
+ include_path = line_stripped.sub(/^Include\s+/, '')
318
+ unless File.exist?(File.expand_path(include_path))
319
+ warnings << "Line #{line_number}: Include file does not exist: #{include_path}"
320
+ end
321
+ end
322
+
323
+ # Check for potential conflicts with our include
324
+ if line_stripped.start_with?('Host ') && line_stripped.include?(@project_name)
325
+ warnings << "Line #{line_number}: Potential host name conflict detected"
326
+ end
327
+ end
328
+ rescue => e
329
+ errors << "Failed to read main config file: #{e.message}"
330
+ end
331
+
332
+ {
333
+ valid: errors.empty?,
334
+ errors: errors,
335
+ warnings: warnings,
336
+ path: @ssh_config_file
337
+ }
338
+ end
339
+
340
+ # Get all include directives in main config
341
+ def get_include_directives
342
+ return [] unless File.exist?(@ssh_config_file)
343
+
344
+ includes = []
345
+ line_number = 0
346
+
347
+ File.readlines(@ssh_config_file).each do |line|
348
+ line_number += 1
349
+ line_stripped = line.strip
350
+
351
+ if line_stripped.start_with?('Include ')
352
+ include_path = line_stripped.sub(/^Include\s+/, '')
353
+ includes << {
354
+ line_number: line_number,
355
+ path: include_path,
356
+ absolute_path: File.expand_path(include_path),
357
+ exists: File.exist?(File.expand_path(include_path)),
358
+ is_ours: include_path == @include_file_path
359
+ }
360
+ end
361
+ end
362
+
363
+ includes
364
+ rescue => e
365
+ @logger.warn("Failed to get include directives: #{e.message}")
366
+ []
367
+ end
368
+
369
+ # Check for conflicts with existing SSH config
370
+ def check_host_conflicts(host_name)
371
+ conflicts = []
372
+ return conflicts unless File.exist?(@ssh_config_file)
373
+
374
+ # Check in main config file
375
+ conflicts.concat(find_host_in_file(@ssh_config_file, host_name, 'main config'))
376
+
377
+ # Check in other include files
378
+ get_include_directives.each do |include_info|
379
+ next if include_info[:is_ours] || !include_info[:exists]
380
+
381
+ conflicts.concat(find_host_in_file(
382
+ include_info[:absolute_path],
383
+ host_name,
384
+ "include file: #{include_info[:path]}"
385
+ ))
386
+ end
387
+
388
+ conflicts
389
+ end
390
+
391
+ # Project-based naming and isolation methods
392
+
393
+ # Generate unique project identifier
394
+ def generate_project_identifier
395
+ # Use multiple factors to ensure uniqueness
396
+ project_path = @machine.env.root_path
397
+ project_name = File.basename(project_path)
398
+
399
+ # Create a hash of the full path for uniqueness
400
+ path_hash = Digest::SHA256.hexdigest(project_path.to_s)[0..7]
401
+
402
+ # Combine sanitized name with hash
403
+ base_name = sanitize_name(project_name)
404
+ "#{base_name}-#{path_hash}"
405
+ end
406
+
407
+ # Generate host name with project isolation
408
+ def generate_isolated_host_name(machine_name)
409
+ project_id = generate_project_identifier
410
+ machine_name_clean = sanitize_name(machine_name.to_s)
411
+
412
+ # Format: project-hash-machine
413
+ host_name = "#{project_id}-#{machine_name_clean}"
414
+
415
+ # Ensure host name is not too long (SSH has practical limits)
416
+ truncate_host_name(host_name)
417
+ end
418
+
419
+ # Get all hosts managed by this project
420
+ def get_project_hosts
421
+ hosts = []
422
+ project_id = generate_project_identifier
423
+
424
+ # Search in our include file
425
+ if File.exist?(@include_file_path)
426
+ hosts.concat(extract_hosts_from_file(@include_file_path, project_id))
427
+ end
428
+
429
+ hosts
430
+ end
431
+
432
+ # Check if a host belongs to this project
433
+ def project_owns_host?(host_name)
434
+ project_id = generate_project_identifier
435
+ host_name.start_with?(project_id)
436
+ end
437
+
438
+ # Clean up all hosts for this project
439
+ def cleanup_project_hosts
440
+ cleaned_count = 0
441
+
442
+ get_project_hosts.each do |host_name|
443
+ if remove_ssh_entry(host_name)
444
+ cleaned_count += 1
445
+ end
446
+ end
447
+
448
+ @logger.info("Cleaned up #{cleaned_count} hosts for project: #{@project_name}")
449
+ cleaned_count
450
+ end
451
+
452
+ # Get project statistics
453
+ def get_project_stats
454
+ {
455
+ project_name: @project_name,
456
+ project_id: generate_project_identifier,
457
+ project_path: @machine.env.root_path.to_s,
458
+ include_file: @include_file_path,
459
+ hosts_count: get_project_hosts.count,
460
+ include_file_exists: File.exist?(@include_file_path),
461
+ include_file_size: File.exist?(@include_file_path) ? File.size(@include_file_path) : 0
462
+ }
463
+ end
464
+
465
+ # Migrate old naming scheme to new project-based scheme
466
+ def migrate_to_project_naming(old_host_names)
467
+ return false if old_host_names.nil? || old_host_names.empty?
468
+
469
+ migrated_count = 0
470
+
471
+ old_host_names.each do |old_host_name|
472
+ # Extract machine name from old host name
473
+ machine_name = extract_machine_name_from_host(old_host_name)
474
+ next unless machine_name
475
+
476
+ # Generate new host name
477
+ new_host_name = generate_isolated_host_name(machine_name)
478
+
479
+ # Skip if names are the same
480
+ next if old_host_name == new_host_name
481
+
482
+ # Get SSH config for the old host
483
+ ssh_config = find_ssh_config_for_host(old_host_name)
484
+ next unless ssh_config
485
+
486
+ # Update host name in config
487
+ ssh_config['Host'] = new_host_name
488
+
489
+ # Remove old entry and add new one
490
+ if remove_ssh_entry(old_host_name) && add_ssh_entry(ssh_config)
491
+ migrated_count += 1
492
+ @logger.info("Migrated host: #{old_host_name} -> #{new_host_name}")
493
+ end
494
+ end
495
+
496
+ @logger.info("Migrated #{migrated_count} hosts to project-based naming")
497
+ migrated_count > 0
498
+ end
499
+
500
+ # List all Vagrant projects detected in SSH config
501
+ def list_vagrant_projects
502
+ projects = {}
503
+ config_d_dir = File.dirname(@include_file_path)
504
+
505
+ return projects unless File.exist?(config_d_dir)
506
+
507
+ Dir.glob(File.join(config_d_dir, 'vagrant-*')).each do |file_path|
508
+ next unless File.file?(file_path)
509
+
510
+ # Extract project info from filename
511
+ filename = File.basename(file_path)
512
+ if filename.match(/^vagrant-(.+)$/)
513
+ project_info = parse_project_from_filename($1)
514
+ next unless project_info
515
+
516
+ hosts = extract_hosts_from_file(file_path)
517
+
518
+ projects[project_info[:id]] = {
519
+ name: project_info[:name],
520
+ id: project_info[:id],
521
+ include_file: file_path,
522
+ hosts: hosts,
523
+ hosts_count: hosts.count,
524
+ last_modified: File.mtime(file_path)
525
+ }
526
+ end
527
+ end
528
+
529
+ projects
530
+ end
531
+
532
+ private
533
+
534
+ def truncate_host_name(host_name, max_length = 64)
535
+ return host_name if host_name.length <= max_length
536
+
537
+ # Keep the project hash part and truncate the machine name part
538
+ parts = host_name.split('-')
539
+ if parts.length >= 3
540
+ # Keep project name and hash, truncate machine name
541
+ project_part = parts[0..-2].join('-')
542
+ machine_part = parts[-1]
543
+
544
+ available_length = max_length - project_part.length - 1
545
+ if available_length > 0
546
+ truncated_machine = machine_part[0...available_length]
547
+ return "#{project_part}-#{truncated_machine}"
548
+ end
549
+ end
550
+
551
+ # Fallback: simple truncation
552
+ host_name[0...max_length]
553
+ end
554
+
555
+ def extract_hosts_from_file(file_path, project_filter = nil)
556
+ hosts = []
557
+ return hosts unless File.exist?(file_path)
558
+
559
+ File.readlines(file_path).each do |line|
560
+ line_stripped = line.strip
561
+ if line_stripped.start_with?('Host ')
562
+ host_name = line_stripped.sub(/^Host\s+/, '')
563
+ # Apply project filter if specified
564
+ if project_filter.nil? || host_name.start_with?(project_filter)
565
+ hosts << host_name
566
+ end
567
+ end
568
+ end
569
+
570
+ hosts
571
+ rescue => e
572
+ @logger.warn("Failed to extract hosts from #{file_path}: #{e.message}")
573
+ []
574
+ end
575
+
576
+ def extract_machine_name_from_host(host_name)
577
+ # Try to extract machine name from various naming patterns
578
+
579
+ # New project-based pattern: project-hash-machine
580
+ if host_name.match(/^.+-[a-f0-9]{8}-(.+)$/)
581
+ return $1
582
+ end
583
+
584
+ # Old pattern: project-machine
585
+ parts = host_name.split('-')
586
+ return parts.last if parts.length >= 2
587
+
588
+ # Single name
589
+ host_name
590
+ end
591
+
592
+ def find_ssh_config_for_host(host_name)
593
+ entries = get_project_ssh_entries
594
+ entries.find { |entry| entry['Host'] == host_name }
595
+ end
596
+
597
+ def parse_project_from_filename(filename_part)
598
+ # Parse project info from include filename
599
+ # Format: project-name-hash or just project-name
600
+
601
+ if filename_part.match(/^(.+)-([a-f0-9]{8})$/)
602
+ {
603
+ name: $1,
604
+ id: filename_part,
605
+ hash: $2
606
+ }
607
+ else
608
+ {
609
+ name: filename_part,
610
+ id: filename_part,
611
+ hash: nil
612
+ }
613
+ end
614
+ end
615
+
616
+ # Enhanced sanitization for project-based naming
617
+ def sanitize_name(name)
618
+ return 'unknown' if name.nil? || name.to_s.strip.empty?
619
+
620
+ # Remove or replace problematic characters
621
+ sanitized = name.to_s
622
+ .gsub(/[^a-zA-Z0-9\-_.]/, '-') # Replace invalid chars
623
+ .gsub(/\.+/, '.') # Collapse multiple dots
624
+ .gsub(/-+/, '-') # Collapse multiple dashes
625
+ .gsub(/^[-._]+|[-._]+$/, '') # Remove leading/trailing special chars
626
+ .downcase
627
+
628
+ # Ensure name is not empty after sanitization
629
+ sanitized.empty? ? 'unknown' : sanitized
630
+ end
631
+
632
+ def determine_ssh_config_file
633
+ # Use custom path if specified, otherwise default to ~/.ssh/config
634
+ if @config && @config.ssh_conf_file
635
+ File.expand_path(@config.ssh_conf_file)
636
+ else
637
+ File.expand_path('~/.ssh/config')
638
+ end
639
+ end
640
+
641
+ def generate_project_name
642
+ return @project_name if @project_name
643
+
644
+ # Use the new project identifier method for consistency
645
+ @project_name = generate_project_identifier
646
+ end
647
+
648
+ def generate_include_file_path
649
+ # Create include file path: ~/.ssh/config.d/vagrant-{project-name}
650
+ ssh_dir = File.dirname(@ssh_config_file)
651
+ config_d_dir = File.join(ssh_dir, 'config.d')
652
+ File.join(config_d_dir, "vagrant-#{@project_name}")
653
+ end
654
+
655
+ def ensure_ssh_config_structure
656
+ # Create SSH directory if it doesn't exist
657
+ ssh_dir = File.dirname(@ssh_config_file)
658
+ FileUtils.mkdir_p(ssh_dir, mode: 0700) unless File.exist?(ssh_dir)
659
+
660
+ # Create config.d directory if it doesn't exist
661
+ config_d_dir = File.dirname(@include_file_path)
662
+ FileUtils.mkdir_p(config_d_dir, mode: 0700) unless File.exist?(config_d_dir)
663
+
664
+ # Create main SSH config file if it doesn't exist
665
+ unless File.exist?(@ssh_config_file)
666
+ File.write(@ssh_config_file, "# SSH Config File\n")
667
+ File.chmod(0600, @ssh_config_file)
668
+ end
669
+ end
670
+
671
+ def write_include_file(ssh_config_data)
672
+ # Prepare the SSH configuration content
673
+ content = format_ssh_config_entry(ssh_config_data)
674
+
675
+ # Write the include file
676
+ File.write(@include_file_path, content)
677
+ File.chmod(0600, @include_file_path)
678
+
679
+ @logger.debug("Wrote SSH config to include file: #{@include_file_path}")
680
+ end
681
+
682
+ # Comment markers and section management
683
+
684
+ # Comment templates for different types of markers
685
+ COMMENT_TEMPLATES = {
686
+ file_header: [
687
+ "# Vagrant SSH Config - Project: %{project_name}",
688
+ "# Generated on: %{timestamp}",
689
+ "# DO NOT EDIT MANUALLY - Managed by vagrant-ssh-config-manager",
690
+ "# Plugin Version: %{version}",
691
+ "# Project Path: %{project_path}"
692
+ ],
693
+ section_start: [
694
+ "# === START: Vagrant SSH Config Manager ===",
695
+ "# Project: %{project_name} | Machine: %{machine_name}",
696
+ "# Generated: %{timestamp}"
697
+ ],
698
+ section_end: [
699
+ "# === END: Vagrant SSH Config Manager ==="
700
+ ],
701
+ include_directive: [
702
+ "# Vagrant SSH Config Manager - Auto-generated include",
703
+ "# Include file: %{include_file}",
704
+ "# Project: %{project_name}"
705
+ ],
706
+ warning: [
707
+ "# WARNING: This section is automatically managed",
708
+ "# Manual changes will be overwritten"
709
+ ]
710
+ }.freeze
711
+
712
+ # Add comprehensive comment markers to SSH entry
713
+ def add_comment_markers_to_entry(ssh_config_data, machine_name = nil)
714
+ return ssh_config_data unless ssh_config_data
715
+
716
+ machine_name ||= @machine.name.to_s
717
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
718
+
719
+ # Create commented entry with markers
720
+ lines = []
721
+
722
+ # Section start marker
723
+ lines.concat(format_comment_block(:section_start, {
724
+ project_name: @project_name,
725
+ machine_name: machine_name,
726
+ timestamp: timestamp
727
+ }))
728
+
729
+ lines << ""
730
+
731
+ # SSH configuration
732
+ if ssh_config_data['Host']
733
+ lines << "Host #{ssh_config_data['Host']}"
734
+
735
+ # Add SSH options with inline comments for important ones
736
+ ssh_option_order = %w[
737
+ HostName User Port IdentityFile IdentitiesOnly
738
+ StrictHostKeyChecking UserKnownHostsFile PasswordAuthentication
739
+ LogLevel ProxyCommand Compression CompressionLevel
740
+ ConnectTimeout ForwardAgent ForwardX11
741
+ ]
742
+
743
+ ssh_option_order.each do |key|
744
+ if ssh_config_data[key]
745
+ value = ssh_config_data[key]
746
+ comment = get_option_comment(key, value)
747
+ line = " #{key} #{value}"
748
+ line += " # #{comment}" if comment
749
+ lines << line
750
+ end
751
+ end
752
+
753
+ # Add any remaining options
754
+ ssh_config_data.each do |key, value|
755
+ next if key == 'Host' || ssh_option_order.include?(key)
756
+ lines << " #{key} #{value}"
757
+ end
758
+ end
759
+
760
+ lines << ""
761
+
762
+ # Section end marker
763
+ lines.concat(format_comment_block(:section_end))
764
+
765
+ {
766
+ 'Host' => ssh_config_data['Host'],
767
+ 'formatted_content' => lines.join("\n"),
768
+ 'raw_config' => ssh_config_data
769
+ }
770
+ end
771
+
772
+ # Generate file header with comprehensive metadata
773
+ def generate_file_header_with_markers
774
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
775
+ version = VagrantPlugins::SshConfigManager::VERSION rescue "unknown"
776
+
777
+ format_comment_block(:file_header, {
778
+ project_name: @project_name,
779
+ timestamp: timestamp,
780
+ version: version,
781
+ project_path: @machine.env.root_path.to_s
782
+ })
783
+ end
784
+
785
+ # Add warning markers to dangerous operations
786
+ def add_warning_markers
787
+ format_comment_block(:warning)
788
+ end
789
+
790
+ # Generate include directive with markers
791
+ def generate_include_directive_with_markers
792
+ lines = []
793
+
794
+ lines.concat(format_comment_block(:include_directive, {
795
+ include_file: @include_file_path,
796
+ project_name: @project_name
797
+ }))
798
+
799
+ lines << "Include #{@include_file_path}"
800
+ lines << ""
801
+
802
+ lines
803
+ end
804
+
805
+ # Extract plugin-managed sections from file
806
+ def extract_managed_sections(file_path)
807
+ return [] unless File.exist?(file_path)
808
+
809
+ sections = []
810
+ current_section = nil
811
+ line_number = 0
812
+
813
+ File.readlines(file_path).each do |line|
814
+ line_number += 1
815
+ line_stripped = line.strip
816
+
817
+ # Detect section start
818
+ if line_stripped.include?("START: Vagrant SSH Config Manager")
819
+ current_section = {
820
+ start_line: line_number,
821
+ lines: [line],
822
+ type: :managed_section
823
+ }
824
+ elsif current_section
825
+ current_section[:lines] << line
826
+
827
+ # Detect section end
828
+ if line_stripped.include?("END: Vagrant SSH Config Manager")
829
+ current_section[:end_line] = line_number
830
+ sections << current_section
831
+ current_section = nil
832
+ end
833
+ end
834
+ end
835
+
836
+ sections
837
+ rescue => e
838
+ @logger.warn("Failed to extract managed sections from #{file_path}: #{e.message}")
839
+ []
840
+ end
841
+
842
+ # Validate comment markers integrity
843
+ def validate_comment_markers(file_path)
844
+ return { valid: true, issues: [] } unless File.exist?(file_path)
845
+
846
+ issues = []
847
+ sections = extract_managed_sections(file_path)
848
+
849
+ sections.each do |section|
850
+ # Check for orphaned start markers (no matching end)
851
+ if section[:end_line].nil?
852
+ issues << {
853
+ type: :orphaned_start,
854
+ line: section[:start_line],
855
+ message: "Found start marker without matching end marker"
856
+ }
857
+ end
858
+
859
+ # Check for corrupted section content
860
+ section_content = section[:lines].join
861
+ unless section_content.include?("Project: #{@project_name}")
862
+ issues << {
863
+ type: :corrupted_metadata,
864
+ line: section[:start_line],
865
+ message: "Section metadata appears corrupted or modified"
866
+ }
867
+ end
868
+ end
869
+
870
+ {
871
+ valid: issues.empty?,
872
+ issues: issues,
873
+ sections_count: sections.length
874
+ }
875
+ end
876
+
877
+ # Clean up orphaned or corrupted markers
878
+ def cleanup_comment_markers(file_path)
879
+ return false unless File.exist?(file_path)
880
+
881
+ lines = File.readlines(file_path)
882
+ cleaned_lines = []
883
+ skip_until_end = false
884
+ cleaned_count = 0
885
+
886
+ lines.each do |line|
887
+ line_stripped = line.strip
888
+
889
+ # Handle orphaned start markers
890
+ if line_stripped.include?("START: Vagrant SSH Config Manager")
891
+ # Check if this is our project
892
+ if line_stripped.include?("Project: #{@project_name}")
893
+ skip_until_end = true
894
+ cleaned_count += 1
895
+ next
896
+ end
897
+ end
898
+
899
+ # Handle end markers
900
+ if skip_until_end && line_stripped.include?("END: Vagrant SSH Config Manager")
901
+ skip_until_end = false
902
+ next
903
+ end
904
+
905
+ # Keep line if not in a section being cleaned
906
+ unless skip_until_end
907
+ cleaned_lines << line
908
+ end
909
+ end
910
+
911
+ if cleaned_count > 0
912
+ File.write(file_path, cleaned_lines.join)
913
+ @logger.info("Cleaned up #{cleaned_count} orphaned comment sections")
914
+ end
915
+
916
+ cleaned_count > 0
917
+ end
918
+
919
+ private
920
+
921
+ # File locking helper methods
922
+ def with_file_lock(file_path, lock_type = :exclusive, &block)
923
+ locker = FileLocker.new(file_path, @logger)
924
+
925
+ case lock_type
926
+ when :exclusive
927
+ locker.with_exclusive_lock(&block)
928
+ when :shared
929
+ locker.with_shared_lock(&block)
930
+ else
931
+ raise ArgumentError, "Invalid lock type: #{lock_type}"
932
+ end
933
+ rescue LockTimeoutError => e
934
+ @logger.error("Lock timeout: #{e.message}")
935
+ raise
936
+ rescue LockAcquisitionError => e
937
+ @logger.error("Lock acquisition failed: #{e.message}")
938
+ raise
939
+ end
940
+
941
+ def safe_read_file(file_path)
942
+ return "" unless File.exist?(file_path)
943
+
944
+ with_file_lock(file_path, :shared) do
945
+ File.read(file_path)
946
+ end
947
+ end
948
+
949
+ def safe_write_file(file_path, content)
950
+ with_file_lock(file_path, :exclusive) do
951
+ File.write(file_path, content)
952
+ end
953
+ end
954
+
955
+ def safe_read_lines(file_path)
956
+ return [] unless File.exist?(file_path)
957
+
958
+ with_file_lock(file_path, :shared) do
959
+ File.readlines(file_path)
960
+ end
961
+ end
962
+
963
+ public
964
+
965
+ # Add SSH configuration entry for a machine
966
+ def add_ssh_entry(ssh_config_data)
967
+ return false unless ssh_config_data && ssh_config_data['Host']
968
+
969
+ begin
970
+ ensure_ssh_config_structure
971
+ write_include_file(ssh_config_data)
972
+ add_include_directive
973
+
974
+ @logger.info("Added SSH entry for host: #{ssh_config_data['Host']}")
975
+ true
976
+ rescue => e
977
+ @logger.error("Failed to add SSH entry: #{e.message}")
978
+ false
979
+ end
980
+ end
981
+
982
+ # Remove SSH configuration entry for a machine
983
+ def remove_ssh_entry(host_name)
984
+ return false unless host_name
985
+
986
+ begin
987
+ removed = remove_from_include_file(host_name)
988
+ cleanup_empty_include_file
989
+ cleanup_include_directive_if_needed
990
+
991
+ @logger.info("Removed SSH entry for host: #{host_name}") if removed
992
+ removed
993
+ rescue => e
994
+ @logger.error("Failed to remove SSH entry: #{e.message}")
995
+ false
996
+ end
997
+ end
998
+
999
+ # Update SSH configuration entry for a machine
1000
+ def update_ssh_entry(ssh_config_data)
1001
+ return false unless ssh_config_data && ssh_config_data['Host']
1002
+
1003
+ begin
1004
+ host_name = ssh_config_data['Host']
1005
+ remove_ssh_entry(host_name)
1006
+ add_ssh_entry(ssh_config_data)
1007
+
1008
+ @logger.info("Updated SSH entry for host: #{host_name}")
1009
+ true
1010
+ rescue => e
1011
+ @logger.error("Failed to update SSH entry: #{e.message}")
1012
+ false
1013
+ end
1014
+ end
1015
+
1016
+ # Check if SSH entry exists for a host
1017
+ def ssh_entry_exists?(host_name)
1018
+ return false unless File.exist?(@include_file_path)
1019
+
1020
+ content = File.read(@include_file_path)
1021
+ content.include?("Host #{host_name}")
1022
+ rescue
1023
+ false
1024
+ end
1025
+
1026
+ # Get all SSH entries managed by this project
1027
+ def get_project_ssh_entries
1028
+ return [] unless File.exist?(@include_file_path)
1029
+
1030
+ entries = []
1031
+ current_entry = nil
1032
+
1033
+ File.readlines(@include_file_path).each do |line|
1034
+ line = line.strip
1035
+ next if line.empty? || line.start_with?('#')
1036
+
1037
+ if line.start_with?('Host ')
1038
+ entries << current_entry if current_entry
1039
+ current_entry = { 'Host' => line.sub(/^Host\s+/, '') }
1040
+ elsif current_entry && line.include?(' ')
1041
+ key, value = line.split(' ', 2)
1042
+ current_entry[key.strip] = value.strip
1043
+ end
1044
+ end
1045
+
1046
+ entries << current_entry if current_entry
1047
+ entries
1048
+ rescue
1049
+ []
1050
+ end
1051
+
1052
+ # Advanced include file management methods
1053
+
1054
+ # Create or update include file with multiple SSH entries
1055
+ def manage_include_file(ssh_entries)
1056
+ return false if ssh_entries.nil? || ssh_entries.empty?
1057
+
1058
+ begin
1059
+ ensure_ssh_config_structure
1060
+ write_multiple_entries_to_include_file(ssh_entries)
1061
+ add_include_directive
1062
+
1063
+ @logger.info("Managed include file with #{ssh_entries.length} SSH entries")
1064
+ true
1065
+ rescue => e
1066
+ @logger.error("Failed to manage include file: #{e.message}")
1067
+ false
1068
+ end
1069
+ end
1070
+
1071
+ # Get include file status and information
1072
+ def include_file_info
1073
+ info = {
1074
+ path: @include_file_path,
1075
+ exists: File.exist?(@include_file_path),
1076
+ size: 0,
1077
+ entries_count: 0,
1078
+ last_modified: nil
1079
+ }
1080
+
1081
+ if info[:exists]
1082
+ stat = File.stat(@include_file_path)
1083
+ info[:size] = stat.size
1084
+ info[:last_modified] = stat.mtime
1085
+ info[:entries_count] = count_entries_in_include_file
1086
+ end
1087
+
1088
+ info
1089
+ end
1090
+
1091
+ # Backup include file before operations
1092
+ def backup_include_file
1093
+ return nil unless File.exist?(@include_file_path)
1094
+
1095
+ backup_path = "#{@include_file_path}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
1096
+ FileUtils.cp(@include_file_path, backup_path)
1097
+
1098
+ @logger.debug("Created backup of include file: #{backup_path}")
1099
+ backup_path
1100
+ rescue => e
1101
+ @logger.warn("Failed to create backup: #{e.message}")
1102
+ nil
1103
+ end
1104
+
1105
+ # Restore include file from backup
1106
+ def restore_include_file(backup_path)
1107
+ return false unless File.exist?(backup_path)
1108
+
1109
+ begin
1110
+ FileUtils.cp(backup_path, @include_file_path)
1111
+ @logger.info("Restored include file from backup: #{backup_path}")
1112
+ true
1113
+ rescue => e
1114
+ @logger.error("Failed to restore from backup: #{e.message}")
1115
+ false
1116
+ end
1117
+ end
1118
+
1119
+ # Validate include file format and structure
1120
+ def validate_include_file
1121
+ return { valid: true, errors: [] } unless File.exist?(@include_file_path)
1122
+
1123
+ errors = []
1124
+ line_number = 0
1125
+ current_host = nil
1126
+
1127
+ begin
1128
+ File.readlines(@include_file_path).each do |line|
1129
+ line_number += 1
1130
+ line_stripped = line.strip
1131
+
1132
+ next if line_stripped.empty? || line_stripped.start_with?('#')
1133
+
1134
+ if line_stripped.start_with?('Host ')
1135
+ host_name = line_stripped.sub(/^Host\s+/, '')
1136
+ if host_name.empty?
1137
+ errors << "Line #{line_number}: Empty host name"
1138
+ else
1139
+ current_host = host_name
1140
+ end
1141
+ elsif current_host
1142
+ unless line_stripped.include?(' ')
1143
+ errors << "Line #{line_number}: Invalid SSH option format"
1144
+ end
1145
+ else
1146
+ errors << "Line #{line_number}: SSH option without host declaration"
1147
+ end
1148
+ end
1149
+ rescue => e
1150
+ errors << "Failed to read include file: #{e.message}"
1151
+ end
1152
+
1153
+ {
1154
+ valid: errors.empty?,
1155
+ errors: errors,
1156
+ path: @include_file_path
1157
+ }
1158
+ end
1159
+
1160
+ # Clean up orphaned include files (for debugging/maintenance)
1161
+ def cleanup_orphaned_include_files
1162
+ config_d_dir = File.dirname(@include_file_path)
1163
+ return 0 unless File.exist?(config_d_dir)
1164
+
1165
+ cleaned_count = 0
1166
+
1167
+ Dir.glob(File.join(config_d_dir, 'vagrant-*')).each do |file_path|
1168
+ next unless File.file?(file_path)
1169
+
1170
+ # Check if file is empty or only contains comments
1171
+ content = File.read(file_path).strip
1172
+ if content.empty? || content.lines.all? { |line| line.strip.empty? || line.strip.start_with?('#') }
1173
+ File.delete(file_path)
1174
+ cleaned_count += 1
1175
+ @logger.debug("Cleaned up orphaned include file: #{file_path}")
1176
+ end
1177
+ end
1178
+
1179
+ cleaned_count
1180
+ rescue => e
1181
+ @logger.warn("Failed to cleanup orphaned files: #{e.message}")
1182
+ 0
1183
+ end
1184
+
1185
+ # Advanced main SSH config file management
1186
+
1187
+ # Get information about main SSH config file
1188
+ def main_config_info
1189
+ info = {
1190
+ path: @ssh_config_file,
1191
+ exists: File.exist?(@ssh_config_file),
1192
+ size: 0,
1193
+ writable: false,
1194
+ include_directive_exists: false,
1195
+ last_modified: nil
1196
+ }
1197
+
1198
+ if info[:exists]
1199
+ stat = File.stat(@ssh_config_file)
1200
+ info[:size] = stat.size
1201
+ info[:last_modified] = stat.mtime
1202
+ info[:writable] = File.writable?(@ssh_config_file)
1203
+ info[:include_directive_exists] = include_directive_exists?
1204
+ end
1205
+
1206
+ info
1207
+ end
1208
+
1209
+ # Safely add include directive with conflict detection
1210
+ def add_include_directive_safe
1211
+ return true if include_directive_exists?
1212
+
1213
+ begin
1214
+ # Check if main config file is writable
1215
+ unless File.writable?(@ssh_config_file) || !File.exist?(@ssh_config_file)
1216
+ @logger.warn("SSH config file is not writable: #{@ssh_config_file}")
1217
+ return false
1218
+ end
1219
+
1220
+ backup_main_config
1221
+ add_include_directive_with_validation
1222
+ true
1223
+ rescue => e
1224
+ @logger.error("Failed to add include directive safely: #{e.message}")
1225
+ false
1226
+ end
1227
+ end
1228
+
1229
+ # Remove include directive and clean up
1230
+ def remove_include_directive_safe
1231
+ return true unless include_directive_exists?
1232
+
1233
+ begin
1234
+ backup_main_config
1235
+ remove_include_directive_with_validation
1236
+ true
1237
+ rescue => e
1238
+ @logger.error("Failed to remove include directive safely: #{e.message}")
1239
+ false
1240
+ end
1241
+ end
1242
+
1243
+ # Validate main SSH config file structure
1244
+ def validate_main_config
1245
+ return { valid: true, errors: [], warnings: [] } unless File.exist?(@ssh_config_file)
1246
+
1247
+ errors = []
1248
+ warnings = []
1249
+ line_number = 0
1250
+
1251
+ begin
1252
+ File.readlines(@ssh_config_file).each do |line|
1253
+ line_number += 1
1254
+ line_stripped = line.strip
1255
+
1256
+ next if line_stripped.empty? || line_stripped.start_with?('#')
1257
+
1258
+ # Check for include directive syntax
1259
+ if line_stripped.start_with?('Include ')
1260
+ include_path = line_stripped.sub(/^Include\s+/, '')
1261
+ unless File.exist?(File.expand_path(include_path))
1262
+ warnings << "Line #{line_number}: Include file does not exist: #{include_path}"
1263
+ end
1264
+ end
1265
+
1266
+ # Check for potential conflicts with our include
1267
+ if line_stripped.start_with?('Host ') && line_stripped.include?(@project_name)
1268
+ warnings << "Line #{line_number}: Potential host name conflict detected"
1269
+ end
1270
+ end
1271
+ rescue => e
1272
+ errors << "Failed to read main config file: #{e.message}"
1273
+ end
1274
+
1275
+ {
1276
+ valid: errors.empty?,
1277
+ errors: errors,
1278
+ warnings: warnings,
1279
+ path: @ssh_config_file
1280
+ }
1281
+ end
1282
+
1283
+ # Get all include directives in main config
1284
+ def get_include_directives
1285
+ return [] unless File.exist?(@ssh_config_file)
1286
+
1287
+ includes = []
1288
+ line_number = 0
1289
+
1290
+ File.readlines(@ssh_config_file).each do |line|
1291
+ line_number += 1
1292
+ line_stripped = line.strip
1293
+
1294
+ if line_stripped.start_with?('Include ')
1295
+ include_path = line_stripped.sub(/^Include\s+/, '')
1296
+ includes << {
1297
+ line_number: line_number,
1298
+ path: include_path,
1299
+ absolute_path: File.expand_path(include_path),
1300
+ exists: File.exist?(File.expand_path(include_path)),
1301
+ is_ours: include_path == @include_file_path
1302
+ }
1303
+ end
1304
+ end
1305
+
1306
+ includes
1307
+ rescue => e
1308
+ @logger.warn("Failed to get include directives: #{e.message}")
1309
+ []
1310
+ end
1311
+
1312
+ # Check for conflicts with existing SSH config
1313
+ def check_host_conflicts(host_name)
1314
+ conflicts = []
1315
+ return conflicts unless File.exist?(@ssh_config_file)
1316
+
1317
+ # Check in main config file
1318
+ conflicts.concat(find_host_in_file(@ssh_config_file, host_name, 'main config'))
1319
+
1320
+ # Check in other include files
1321
+ get_include_directives.each do |include_info|
1322
+ next if include_info[:is_ours] || !include_info[:exists]
1323
+
1324
+ conflicts.concat(find_host_in_file(
1325
+ include_info[:absolute_path],
1326
+ host_name,
1327
+ "include file: #{include_info[:path]}"
1328
+ ))
1329
+ end
1330
+
1331
+ conflicts
1332
+ end
1333
+
1334
+ # Project-based naming and isolation methods
1335
+
1336
+ # Generate unique project identifier
1337
+ def generate_project_identifier
1338
+ # Use multiple factors to ensure uniqueness
1339
+ project_path = @machine.env.root_path
1340
+ project_name = File.basename(project_path)
1341
+
1342
+ # Create a hash of the full path for uniqueness
1343
+ path_hash = Digest::SHA256.hexdigest(project_path.to_s)[0..7]
1344
+
1345
+ # Combine sanitized name with hash
1346
+ base_name = sanitize_name(project_name)
1347
+ "#{base_name}-#{path_hash}"
1348
+ end
1349
+
1350
+ # Generate host name with project isolation
1351
+ def generate_isolated_host_name(machine_name)
1352
+ project_id = generate_project_identifier
1353
+ machine_name_clean = sanitize_name(machine_name.to_s)
1354
+
1355
+ # Format: project-hash-machine
1356
+ host_name = "#{project_id}-#{machine_name_clean}"
1357
+
1358
+ # Ensure host name is not too long (SSH has practical limits)
1359
+ truncate_host_name(host_name)
1360
+ end
1361
+
1362
+ # Get all hosts managed by this project
1363
+ def get_project_hosts
1364
+ hosts = []
1365
+ project_id = generate_project_identifier
1366
+
1367
+ # Search in our include file
1368
+ if File.exist?(@include_file_path)
1369
+ hosts.concat(extract_hosts_from_file(@include_file_path, project_id))
1370
+ end
1371
+
1372
+ hosts
1373
+ end
1374
+
1375
+ # Check if a host belongs to this project
1376
+ def project_owns_host?(host_name)
1377
+ project_id = generate_project_identifier
1378
+ host_name.start_with?(project_id)
1379
+ end
1380
+
1381
+ # Clean up all hosts for this project
1382
+ def cleanup_project_hosts
1383
+ cleaned_count = 0
1384
+
1385
+ get_project_hosts.each do |host_name|
1386
+ if remove_ssh_entry(host_name)
1387
+ cleaned_count += 1
1388
+ end
1389
+ end
1390
+
1391
+ @logger.info("Cleaned up #{cleaned_count} hosts for project: #{@project_name}")
1392
+ cleaned_count
1393
+ end
1394
+
1395
+ # Get project statistics
1396
+ def get_project_stats
1397
+ {
1398
+ project_name: @project_name,
1399
+ project_id: generate_project_identifier,
1400
+ project_path: @machine.env.root_path.to_s,
1401
+ include_file: @include_file_path,
1402
+ hosts_count: get_project_hosts.count,
1403
+ include_file_exists: File.exist?(@include_file_path),
1404
+ include_file_size: File.exist?(@include_file_path) ? File.size(@include_file_path) : 0
1405
+ }
1406
+ end
1407
+
1408
+ # Migrate old naming scheme to new project-based scheme
1409
+ def migrate_to_project_naming(old_host_names)
1410
+ return false if old_host_names.nil? || old_host_names.empty?
1411
+
1412
+ migrated_count = 0
1413
+
1414
+ old_host_names.each do |old_host_name|
1415
+ # Extract machine name from old host name
1416
+ machine_name = extract_machine_name_from_host(old_host_name)
1417
+ next unless machine_name
1418
+
1419
+ # Generate new host name
1420
+ new_host_name = generate_isolated_host_name(machine_name)
1421
+
1422
+ # Skip if names are the same
1423
+ next if old_host_name == new_host_name
1424
+
1425
+ # Get SSH config for the old host
1426
+ ssh_config = find_ssh_config_for_host(old_host_name)
1427
+ next unless ssh_config
1428
+
1429
+ # Update host name in config
1430
+ ssh_config['Host'] = new_host_name
1431
+
1432
+ # Remove old entry and add new one
1433
+ if remove_ssh_entry(old_host_name) && add_ssh_entry(ssh_config)
1434
+ migrated_count += 1
1435
+ @logger.info("Migrated host: #{old_host_name} -> #{new_host_name}")
1436
+ end
1437
+ end
1438
+
1439
+ @logger.info("Migrated #{migrated_count} hosts to project-based naming")
1440
+ migrated_count > 0
1441
+ end
1442
+
1443
+ # List all Vagrant projects detected in SSH config
1444
+ def list_vagrant_projects
1445
+ projects = {}
1446
+ config_d_dir = File.dirname(@include_file_path)
1447
+
1448
+ return projects unless File.exist?(config_d_dir)
1449
+
1450
+ Dir.glob(File.join(config_d_dir, 'vagrant-*')).each do |file_path|
1451
+ next unless File.file?(file_path)
1452
+
1453
+ # Extract project info from filename
1454
+ filename = File.basename(file_path)
1455
+ if filename.match(/^vagrant-(.+)$/)
1456
+ project_info = parse_project_from_filename($1)
1457
+ next unless project_info
1458
+
1459
+ hosts = extract_hosts_from_file(file_path)
1460
+
1461
+ projects[project_info[:id]] = {
1462
+ name: project_info[:name],
1463
+ id: project_info[:id],
1464
+ include_file: file_path,
1465
+ hosts: hosts,
1466
+ hosts_count: hosts.count,
1467
+ last_modified: File.mtime(file_path)
1468
+ }
1469
+ end
1470
+ end
1471
+
1472
+ projects
1473
+ end
1474
+
1475
+ private
1476
+
1477
+ def truncate_host_name(host_name, max_length = 64)
1478
+ return host_name if host_name.length <= max_length
1479
+
1480
+ # Keep the project hash part and truncate the machine name part
1481
+ parts = host_name.split('-')
1482
+ if parts.length >= 3
1483
+ # Keep project name and hash, truncate machine name
1484
+ project_part = parts[0..-2].join('-')
1485
+ machine_part = parts[-1]
1486
+
1487
+ available_length = max_length - project_part.length - 1
1488
+ if available_length > 0
1489
+ truncated_machine = machine_part[0...available_length]
1490
+ return "#{project_part}-#{truncated_machine}"
1491
+ end
1492
+ end
1493
+
1494
+ # Fallback: simple truncation
1495
+ host_name[0...max_length]
1496
+ end
1497
+
1498
+ def extract_hosts_from_file(file_path, project_filter = nil)
1499
+ hosts = []
1500
+ return hosts unless File.exist?(file_path)
1501
+
1502
+ File.readlines(file_path).each do |line|
1503
+ line_stripped = line.strip
1504
+ if line_stripped.start_with?('Host ')
1505
+ host_name = line_stripped.sub(/^Host\s+/, '')
1506
+ # Apply project filter if specified
1507
+ if project_filter.nil? || host_name.start_with?(project_filter)
1508
+ hosts << host_name
1509
+ end
1510
+ end
1511
+ end
1512
+
1513
+ hosts
1514
+ rescue => e
1515
+ @logger.warn("Failed to extract hosts from #{file_path}: #{e.message}")
1516
+ []
1517
+ end
1518
+
1519
+ def extract_machine_name_from_host(host_name)
1520
+ # Try to extract machine name from various naming patterns
1521
+
1522
+ # New project-based pattern: project-hash-machine
1523
+ if host_name.match(/^.+-[a-f0-9]{8}-(.+)$/)
1524
+ return $1
1525
+ end
1526
+
1527
+ # Old pattern: project-machine
1528
+ parts = host_name.split('-')
1529
+ return parts.last if parts.length >= 2
1530
+
1531
+ # Single name
1532
+ host_name
1533
+ end
1534
+
1535
+ def find_ssh_config_for_host(host_name)
1536
+ entries = get_project_ssh_entries
1537
+ entries.find { |entry| entry['Host'] == host_name }
1538
+ end
1539
+
1540
+ def parse_project_from_filename(filename_part)
1541
+ # Parse project info from include filename
1542
+ # Format: project-name-hash or just project-name
1543
+
1544
+ if filename_part.match(/^(.+)-([a-f0-9]{8})$/)
1545
+ {
1546
+ name: $1,
1547
+ id: filename_part,
1548
+ hash: $2
1549
+ }
1550
+ else
1551
+ {
1552
+ name: filename_part,
1553
+ id: filename_part,
1554
+ hash: nil
1555
+ }
1556
+ end
1557
+ end
1558
+
1559
+ # Enhanced sanitization for project-based naming
1560
+ def sanitize_name(name)
1561
+ return 'unknown' if name.nil? || name.to_s.strip.empty?
1562
+
1563
+ # Remove or replace problematic characters
1564
+ sanitized = name.to_s
1565
+ .gsub(/[^a-zA-Z0-9\-_.]/, '-') # Replace invalid chars
1566
+ .gsub(/\.+/, '.') # Collapse multiple dots
1567
+ .gsub(/-+/, '-') # Collapse multiple dashes
1568
+ .gsub(/^[-._]+|[-._]+$/, '') # Remove leading/trailing special chars
1569
+ .downcase
1570
+
1571
+ # Ensure name is not empty after sanitization
1572
+ sanitized.empty? ? 'unknown' : sanitized
1573
+ end
1574
+
1575
+ def determine_ssh_config_file
1576
+ # Use custom path if specified, otherwise default to ~/.ssh/config
1577
+ if @config && @config.ssh_conf_file
1578
+ File.expand_path(@config.ssh_conf_file)
1579
+ else
1580
+ File.expand_path('~/.ssh/config')
1581
+ end
1582
+ end
1583
+
1584
+ def generate_project_name
1585
+ return @project_name if @project_name
1586
+
1587
+ # Use the new project identifier method for consistency
1588
+ @project_name = generate_project_identifier
1589
+ end
1590
+
1591
+ def generate_include_file_path
1592
+ # Create include file path: ~/.ssh/config.d/vagrant-{project-name}
1593
+ ssh_dir = File.dirname(@ssh_config_file)
1594
+ config_d_dir = File.join(ssh_dir, 'config.d')
1595
+ File.join(config_d_dir, "vagrant-#{@project_name}")
1596
+ end
1597
+
1598
+ def ensure_ssh_config_structure
1599
+ # Create SSH directory if it doesn't exist
1600
+ ssh_dir = File.dirname(@ssh_config_file)
1601
+ FileUtils.mkdir_p(ssh_dir, mode: 0700) unless File.exist?(ssh_dir)
1602
+
1603
+ # Create config.d directory if it doesn't exist
1604
+ config_d_dir = File.dirname(@include_file_path)
1605
+ FileUtils.mkdir_p(config_d_dir, mode: 0700) unless File.exist?(config_d_dir)
1606
+
1607
+ # Create main SSH config file if it doesn't exist
1608
+ unless File.exist?(@ssh_config_file)
1609
+ File.write(@ssh_config_file, "# SSH Config File\n")
1610
+ File.chmod(0600, @ssh_config_file)
1611
+ end
1612
+ end
1613
+
1614
+ def write_include_file(ssh_config_data)
1615
+ # Prepare the SSH configuration content
1616
+ content = format_ssh_config_entry(ssh_config_data)
1617
+
1618
+ # Write the include file
1619
+ File.write(@include_file_path, content)
1620
+ File.chmod(0600, @include_file_path)
1621
+
1622
+ @logger.debug("Wrote SSH config to include file: #{@include_file_path}")
1623
+ end
1624
+
1625
+ # Comment markers and section management
1626
+
1627
+ # Comment templates for different types of markers
1628
+ COMMENT_TEMPLATES = {
1629
+ file_header: [
1630
+ "# Vagrant SSH Config - Project: %{project_name}",
1631
+ "# Generated on: %{timestamp}",
1632
+ "# DO NOT EDIT MANUALLY - Managed by vagrant-ssh-config-manager",
1633
+ "# Plugin Version: %{version}",
1634
+ "# Project Path: %{project_path}"
1635
+ ],
1636
+ section_start: [
1637
+ "# === START: Vagrant SSH Config Manager ===",
1638
+ "# Project: %{project_name} | Machine: %{machine_name}",
1639
+ "# Generated: %{timestamp}"
1640
+ ],
1641
+ section_end: [
1642
+ "# === END: Vagrant SSH Config Manager ==="
1643
+ ],
1644
+ include_directive: [
1645
+ "# Vagrant SSH Config Manager - Auto-generated include",
1646
+ "# Include file: %{include_file}",
1647
+ "# Project: %{project_name}"
1648
+ ],
1649
+ warning: [
1650
+ "# WARNING: This section is automatically managed",
1651
+ "# Manual changes will be overwritten"
1652
+ ]
1653
+ }.freeze
1654
+
1655
+ # Add comprehensive comment markers to SSH entry
1656
+ def add_comment_markers_to_entry(ssh_config_data, machine_name = nil)
1657
+ return ssh_config_data unless ssh_config_data
1658
+
1659
+ machine_name ||= @machine.name.to_s
1660
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
1661
+
1662
+ # Create commented entry with markers
1663
+ lines = []
1664
+
1665
+ # Section start marker
1666
+ lines.concat(format_comment_block(:section_start, {
1667
+ project_name: @project_name,
1668
+ machine_name: machine_name,
1669
+ timestamp: timestamp
1670
+ }))
1671
+
1672
+ lines << ""
1673
+
1674
+ # SSH configuration
1675
+ if ssh_config_data['Host']
1676
+ lines << "Host #{ssh_config_data['Host']}"
1677
+
1678
+ # Add SSH options with inline comments for important ones
1679
+ ssh_option_order = %w[
1680
+ HostName User Port IdentityFile IdentitiesOnly
1681
+ StrictHostKeyChecking UserKnownHostsFile PasswordAuthentication
1682
+ LogLevel ProxyCommand Compression CompressionLevel
1683
+ ConnectTimeout ForwardAgent ForwardX11
1684
+ ]
1685
+
1686
+ ssh_option_order.each do |key|
1687
+ if ssh_config_data[key]
1688
+ value = ssh_config_data[key]
1689
+ comment = get_option_comment(key, value)
1690
+ line = " #{key} #{value}"
1691
+ line += " # #{comment}" if comment
1692
+ lines << line
1693
+ end
1694
+ end
1695
+
1696
+ # Add any remaining options
1697
+ ssh_config_data.each do |key, value|
1698
+ next if key == 'Host' || ssh_option_order.include?(key)
1699
+ lines << " #{key} #{value}"
1700
+ end
1701
+ end
1702
+
1703
+ lines << ""
1704
+
1705
+ # Section end marker
1706
+ lines.concat(format_comment_block(:section_end))
1707
+
1708
+ {
1709
+ 'Host' => ssh_config_data['Host'],
1710
+ 'formatted_content' => lines.join("\n"),
1711
+ 'raw_config' => ssh_config_data
1712
+ }
1713
+ end
1714
+
1715
+ # Generate file header with comprehensive metadata
1716
+ def generate_file_header_with_markers
1717
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
1718
+ version = VagrantPlugins::SshConfigManager::VERSION rescue "unknown"
1719
+
1720
+ format_comment_block(:file_header, {
1721
+ project_name: @project_name,
1722
+ timestamp: timestamp,
1723
+ version: version,
1724
+ project_path: @machine.env.root_path.to_s
1725
+ })
1726
+ end
1727
+
1728
+ # Add warning markers to dangerous operations
1729
+ def add_warning_markers
1730
+ format_comment_block(:warning)
1731
+ end
1732
+
1733
+ # Generate include directive with markers
1734
+ def generate_include_directive_with_markers
1735
+ lines = []
1736
+
1737
+ lines.concat(format_comment_block(:include_directive, {
1738
+ include_file: @include_file_path,
1739
+ project_name: @project_name
1740
+ }))
1741
+
1742
+ lines << "Include #{@include_file_path}"
1743
+ lines << ""
1744
+
1745
+ lines
1746
+ end
1747
+
1748
+ # Extract plugin-managed sections from file
1749
+ def extract_managed_sections(file_path)
1750
+ return [] unless File.exist?(file_path)
1751
+
1752
+ sections = []
1753
+ current_section = nil
1754
+ line_number = 0
1755
+
1756
+ File.readlines(file_path).each do |line|
1757
+ line_number += 1
1758
+ line_stripped = line.strip
1759
+
1760
+ # Detect section start
1761
+ if line_stripped.include?("START: Vagrant SSH Config Manager")
1762
+ current_section = {
1763
+ start_line: line_number,
1764
+ lines: [line],
1765
+ type: :managed_section
1766
+ }
1767
+ elsif current_section
1768
+ current_section[:lines] << line
1769
+
1770
+ # Detect section end
1771
+ if line_stripped.include?("END: Vagrant SSH Config Manager")
1772
+ current_section[:end_line] = line_number
1773
+ sections << current_section
1774
+ current_section = nil
1775
+ end
1776
+ end
1777
+ end
1778
+
1779
+ sections
1780
+ rescue => e
1781
+ @logger.warn("Failed to extract managed sections from #{file_path}: #{e.message}")
1782
+ []
1783
+ end
1784
+
1785
+ # Validate comment markers integrity
1786
+ def validate_comment_markers(file_path)
1787
+ return { valid: true, issues: [] } unless File.exist?(file_path)
1788
+
1789
+ issues = []
1790
+ sections = extract_managed_sections(file_path)
1791
+
1792
+ sections.each do |section|
1793
+ # Check for orphaned start markers (no matching end)
1794
+ if section[:end_line].nil?
1795
+ issues << {
1796
+ type: :orphaned_start,
1797
+ line: section[:start_line],
1798
+ message: "Found start marker without matching end marker"
1799
+ }
1800
+ end
1801
+
1802
+ # Check for corrupted section content
1803
+ section_content = section[:lines].join
1804
+ unless section_content.include?("Project: #{@project_name}")
1805
+ issues << {
1806
+ type: :corrupted_metadata,
1807
+ line: section[:start_line],
1808
+ message: "Section metadata appears corrupted or modified"
1809
+ }
1810
+ end
1811
+ end
1812
+
1813
+ {
1814
+ valid: issues.empty?,
1815
+ issues: issues,
1816
+ sections_count: sections.length
1817
+ }
1818
+ end
1819
+
1820
+ # Clean up orphaned or corrupted markers
1821
+ def cleanup_comment_markers(file_path)
1822
+ return false unless File.exist?(file_path)
1823
+
1824
+ lines = File.readlines(file_path)
1825
+ cleaned_lines = []
1826
+ skip_until_end = false
1827
+ cleaned_count = 0
1828
+
1829
+ lines.each do |line|
1830
+ line_stripped = line.strip
1831
+
1832
+ # Handle orphaned start markers
1833
+ if line_stripped.include?("START: Vagrant SSH Config Manager")
1834
+ # Check if this is our project
1835
+ if line_stripped.include?("Project: #{@project_name}")
1836
+ skip_until_end = true
1837
+ cleaned_count += 1
1838
+ next
1839
+ end
1840
+ end
1841
+
1842
+ # Handle end markers
1843
+ if skip_until_end && line_stripped.include?("END: Vagrant SSH Config Manager")
1844
+ skip_until_end = false
1845
+ next
1846
+ end
1847
+
1848
+ # Keep line if not in a section being cleaned
1849
+ unless skip_until_end
1850
+ cleaned_lines << line
1851
+ end
1852
+ end
1853
+
1854
+ if cleaned_count > 0
1855
+ File.write(file_path, cleaned_lines.join)
1856
+ @logger.info("Cleaned up #{cleaned_count} orphaned comment sections")
1857
+ end
1858
+
1859
+ cleaned_count > 0
1860
+ end
1861
+
1862
+ private
1863
+
1864
+ def format_comment_block(template_name, variables = {})
1865
+ template = COMMENT_TEMPLATES[template_name]
1866
+ return [] unless template
1867
+
1868
+ template.map do |line|
1869
+ formatted_line = line.dup
1870
+ variables.each do |key, value|
1871
+ formatted_line.gsub!("%{#{key}}", value.to_s)
1872
+ end
1873
+ formatted_line
1874
+ end
1875
+ end
1876
+
1877
+ def get_option_comment(key, value)
1878
+ case key
1879
+ when 'StrictHostKeyChecking'
1880
+ value == 'no' ? 'Skip host key verification for Vagrant VMs' : nil
1881
+ when 'UserKnownHostsFile'
1882
+ value == '/dev/null' ? 'Ignore known hosts for Vagrant VMs' : nil
1883
+ when 'PasswordAuthentication'
1884
+ value == 'no' ? 'Use key-based authentication only' : nil
1885
+ when 'LogLevel'
1886
+ value == 'FATAL' ? 'Minimize SSH logging output' : nil
1887
+ when 'IdentitiesOnly'
1888
+ value == 'yes' ? 'Use only specified identity file' : nil
1889
+ else
1890
+ nil
1891
+ end
1892
+ end
1893
+
1894
+ # Update existing methods to use comment markers
1895
+ def format_ssh_config_entry(ssh_config_data)
1896
+ machine_name = @machine.name.to_s
1897
+ marked_entry = add_comment_markers_to_entry(ssh_config_data, machine_name)
1898
+ marked_entry['formatted_content']
1899
+ end
1900
+
1901
+ def write_multiple_entries_to_include_file(ssh_entries)
1902
+ content_lines = []
1903
+
1904
+ # Add file header with comprehensive markers
1905
+ content_lines.concat(generate_file_header_with_markers)
1906
+ content_lines << ""
1907
+ content_lines.concat(add_warning_markers)
1908
+ content_lines << ""
1909
+ content_lines << "# Total entries: #{ssh_entries.length}"
1910
+ content_lines << ""
1911
+
1912
+ ssh_entries.each_with_index do |ssh_config_data, index|
1913
+ next unless ssh_config_data && ssh_config_data['Host']
1914
+
1915
+ # Add separator between entries
1916
+ content_lines << "" if index > 0
1917
+
1918
+ # Add entry with comment markers
1919
+ machine_name = extract_machine_name_from_host(ssh_config_data['Host'])
1920
+ marked_entry = add_comment_markers_to_entry(ssh_config_data, machine_name)
1921
+ content_lines << marked_entry['formatted_content']
1922
+ end
1923
+
1924
+ content_lines << ""
1925
+ content_lines << "# End of Vagrant SSH Config Manager entries"
1926
+
1927
+ # Write the include file
1928
+ File.write(@include_file_path, content_lines.join("\n"))
1929
+ File.chmod(0600, @include_file_path)
1930
+
1931
+ @logger.debug("Wrote #{ssh_entries.length} SSH entries with comment markers to: #{@include_file_path}")
1932
+ end
1933
+
1934
+ # Override the include directive addition to use markers
1935
+ def add_include_directive_with_validation
1936
+ # Read existing content
1937
+ existing_content = File.exist?(@ssh_config_file) ? File.read(@ssh_config_file) : ""
1938
+
1939
+ # Check if our include directive already exists
1940
+ return true if existing_content.include?("Include #{@include_file_path}")
1941
+
1942
+ # Find the best place to insert the include directive
1943
+ lines = existing_content.lines
1944
+ insert_position = find_include_insert_position(lines)
1945
+
1946
+ # Generate include directive with markers
1947
+ include_lines = generate_include_directive_with_markers
1948
+
1949
+ # Insert at the determined position
1950
+ new_lines = lines.dup
1951
+ include_lines.reverse.each do |line|
1952
+ new_lines.insert(insert_position, line + "\n")
1953
+ end
1954
+
1955
+ # Write updated content
1956
+ File.write(@ssh_config_file, new_lines.join)
1957
+
1958
+ @logger.debug("Added include directive with markers to SSH config file at position #{insert_position}")
1959
+ end
1960
+
1961
+ def remove_from_include_file(host_name)
1962
+ return false unless File.exist?(@include_file_path)
1963
+
1964
+ lines = File.readlines(@include_file_path)
1965
+ new_lines = []
1966
+ skip_until_next_host = false
1967
+ removed = false
1968
+
1969
+ lines.each do |line|
1970
+ line_stripped = line.strip
1971
+
1972
+ if line_stripped.start_with?('Host ')
1973
+ current_host = line_stripped.sub(/^Host\s+/, '')
1974
+ if current_host == host_name
1975
+ skip_until_next_host = true
1976
+ removed = true
1977
+ next
1978
+ else
1979
+ skip_until_next_host = false
1980
+ end
1981
+ end
1982
+
1983
+ unless skip_until_next_host
1984
+ new_lines << line
1985
+ end
1986
+ end
1987
+
1988
+ if removed
1989
+ File.write(@include_file_path, new_lines.join)
1990
+ @logger.debug("Removed host #{host_name} from include file")
1991
+ end
1992
+
1993
+ removed
1994
+ end
1995
+
1996
+ def cleanup_empty_include_file
1997
+ return unless File.exist?(@include_file_path)
1998
+
1999
+ content = File.read(@include_file_path).strip
2000
+ # Remove file if it only contains comments or is empty
2001
+ if content.empty? || content.lines.all? { |line| line.strip.empty? || line.strip.start_with?('#') }
2002
+ File.delete(@include_file_path)
2003
+ @logger.debug("Removed empty include file: #{@include_file_path}")
2004
+ end
2005
+ end
2006
+
2007
+ def cleanup_include_directive_if_needed
2008
+ return unless File.exist?(@ssh_config_file)
2009
+ return if File.exist?(@include_file_path) # Don't remove if include file still exists
2010
+
2011
+ lines = File.readlines(@ssh_config_file)
2012
+ new_lines = lines.reject do |line|
2013
+ line.strip == "Include #{@include_file_path}" ||
2014
+ line.strip == "# Vagrant SSH Config Manager - Include"
2015
+ end
2016
+
2017
+ if new_lines.length != lines.length
2018
+ File.write(@ssh_config_file, new_lines.join)
2019
+ @logger.debug("Removed include directive from SSH config file")
2020
+ end
2021
+ end
2022
+
2023
+ def backup_main_config
2024
+ return nil unless File.exist?(@ssh_config_file)
2025
+
2026
+ backup_path = "#{@ssh_config_file}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
2027
+ FileUtils.cp(@ssh_config_file, backup_path)
2028
+
2029
+ @logger.debug("Created backup of main SSH config: #{backup_path}")
2030
+ backup_path
2031
+ rescue => e
2032
+ @logger.warn("Failed to create main config backup: #{e.message}")
2033
+ nil
2034
+ end
2035
+
2036
+ def add_include_directive_with_validation
2037
+ # Read existing content
2038
+ existing_content = File.exist?(@ssh_config_file) ? File.read(@ssh_config_file) : ""
2039
+
2040
+ # Check if our include directive already exists
2041
+ return true if existing_content.include?("Include #{@include_file_path}")
2042
+
2043
+ # Find the best place to insert the include directive
2044
+ lines = existing_content.lines
2045
+ insert_position = find_include_insert_position(lines)
2046
+
2047
+ # Prepare include directive with comments
2048
+ include_lines = [
2049
+ "# Vagrant SSH Config Manager - Auto-generated include",
2050
+ "Include #{@include_file_path}",
2051
+ ""
2052
+ ]
2053
+
2054
+ # Insert at the determined position
2055
+ new_lines = lines.dup
2056
+ include_lines.reverse.each do |line|
2057
+ new_lines.insert(insert_position, line + "\n")
2058
+ end
2059
+
2060
+ # Write updated content
2061
+ File.write(@ssh_config_file, new_lines.join)
2062
+
2063
+ @logger.debug("Added include directive to SSH config file at position #{insert_position}")
2064
+ end
2065
+
2066
+ def remove_include_directive_with_validation
2067
+ return false unless File.exist?(@ssh_config_file)
2068
+
2069
+ lines = File.readlines(@ssh_config_file)
2070
+ new_lines = []
2071
+ skip_next_empty = false
2072
+
2073
+ lines.each do |line|
2074
+ line_stripped = line.strip
2075
+
2076
+ # Skip our include directive and its comment
2077
+ if line_stripped == "Include #{@include_file_path}" ||
2078
+ line_stripped == "# Vagrant SSH Config Manager - Auto-generated include"
2079
+ skip_next_empty = true
2080
+ next
2081
+ end
2082
+
2083
+ # Skip the empty line after our include directive
2084
+ if skip_next_empty && line_stripped.empty?
2085
+ skip_next_empty = false
2086
+ next
2087
+ end
2088
+
2089
+ skip_next_empty = false
2090
+ new_lines << line
2091
+ end
2092
+
2093
+ File.write(@ssh_config_file, new_lines.join)
2094
+ @logger.debug("Removed include directive from SSH config file")
2095
+ end
2096
+
2097
+ def find_include_insert_position(lines)
2098
+ # Try to find existing Include directives and insert after them
2099
+ last_include_position = -1
2100
+
2101
+ lines.each_with_index do |line, index|
2102
+ if line.strip.start_with?('Include ')
2103
+ last_include_position = index
2104
+ end
2105
+ end
2106
+
2107
+ # If we found includes, insert after the last one
2108
+ if last_include_position >= 0
2109
+ return last_include_position + 1
2110
+ end
2111
+
2112
+ # Otherwise, insert at the beginning (after any initial comments)
2113
+ lines.each_with_index do |line, index|
2114
+ line_stripped = line.strip
2115
+ unless line_stripped.empty? || line_stripped.start_with?('#')
2116
+ return index
2117
+ end
2118
+ end
2119
+
2120
+ # If file is empty or only comments, insert at the end
2121
+ lines.length
2122
+ end
2123
+
2124
+ def find_host_in_file(file_path, host_name, source_description)
2125
+ conflicts = []
2126
+ return conflicts unless File.exist?(file_path)
2127
+
2128
+ line_number = 0
2129
+ File.readlines(file_path).each do |line|
2130
+ line_number += 1
2131
+ line_stripped = line.strip
2132
+
2133
+ if line_stripped.start_with?('Host ') && line_stripped.include?(host_name)
2134
+ conflicts << {
2135
+ file: file_path,
2136
+ source: source_description,
2137
+ line_number: line_number,
2138
+ line_content: line_stripped
2139
+ }
2140
+ end
2141
+ end
2142
+
2143
+ conflicts
2144
+ rescue => e
2145
+ @logger.warn("Failed to search for host conflicts in #{file_path}: #{e.message}")
2146
+ []
2147
+ end
2148
+ end
2149
+ end
2150
+ end