vagrant-ssh-config-manager 0.8.3 → 1.0.0.alpha

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -1
  3. data/.gitignore +2 -1
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +62 -0
  6. data/Gemfile +11 -9
  7. data/README.md +2 -2
  8. data/Rakefile +10 -8
  9. data/TESTING.md +82 -0
  10. data/lib/vagrant/ssh/config/manager.rb +5 -0
  11. data/lib/vagrant_ssh_config_manager/action/destroy.rb +82 -0
  12. data/lib/vagrant_ssh_config_manager/action/halt.rb +66 -0
  13. data/lib/vagrant_ssh_config_manager/action/provision.rb +81 -0
  14. data/lib/vagrant_ssh_config_manager/action/reload.rb +105 -0
  15. data/lib/vagrant_ssh_config_manager/action/up.rb +98 -0
  16. data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/config.rb +45 -49
  17. data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/file_locker.rb +35 -37
  18. data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/file_manager.rb +90 -80
  19. data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/include_manager.rb +54 -53
  20. data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/plugin.rb +15 -13
  21. data/lib/vagrant_ssh_config_manager/ssh_config_manager.rb +1152 -0
  22. data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/ssh_info_extractor.rb +129 -141
  23. data/lib/vagrant_ssh_config_manager/version.rb +7 -0
  24. data/lib/{vagrant-ssh-config-manager.rb → vagrant_ssh_config_manager.rb} +15 -12
  25. data/test-all.sh +11 -0
  26. data/test-integration.sh +4 -0
  27. data/test-unit.sh +4 -0
  28. data/vagrant-ssh-config-manager.gemspec +25 -21
  29. metadata +28 -18
  30. data/lib/vagrant-ssh-config-manager/action/destroy.rb +0 -84
  31. data/lib/vagrant-ssh-config-manager/action/halt.rb +0 -68
  32. data/lib/vagrant-ssh-config-manager/action/provision.rb +0 -82
  33. data/lib/vagrant-ssh-config-manager/action/reload.rb +0 -106
  34. data/lib/vagrant-ssh-config-manager/action/up.rb +0 -99
  35. data/lib/vagrant-ssh-config-manager/ssh_config_manager.rb +0 -2150
  36. data/lib/vagrant-ssh-config-manager/version.rb +0 -5
@@ -0,0 +1,1152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'digest'
6
+ require_relative 'file_locker'
7
+
8
+ module VagrantPlugins
9
+ module SshConfigManager
10
+ class SshConfigManager
11
+ attr_reader :ssh_config_file, :project_name
12
+
13
+ def initialize(machine, config = nil)
14
+ @machine = machine
15
+ @config = config || machine.config.sshconfigmanager
16
+ @logger = Log4r::Logger.new('vagrant::plugins::ssh_config_manager::ssh_config_manager')
17
+
18
+ # Determine SSH config file path
19
+ @ssh_config_file = determine_ssh_config_file
20
+ @project_name = generate_project_name
21
+ @include_file_path = generate_include_file_path
22
+ end
23
+
24
+ # Add SSH configuration entry for a machine
25
+ def add_ssh_entry(ssh_config_data)
26
+ return false unless ssh_config_data && ssh_config_data['Host']
27
+
28
+ begin
29
+ ensure_ssh_config_structure
30
+ write_include_file(ssh_config_data)
31
+ add_include_directive
32
+
33
+ @logger.info("Added SSH entry for host: #{ssh_config_data['Host']}")
34
+ true
35
+ rescue StandardError => e
36
+ @logger.error("Failed to add SSH entry: #{e.message}")
37
+ false
38
+ end
39
+ end
40
+
41
+ # Remove SSH configuration entry for a machine
42
+ def remove_ssh_entry(host_name)
43
+ return false unless host_name
44
+
45
+ begin
46
+ removed = remove_from_include_file(host_name)
47
+ cleanup_empty_include_file
48
+ cleanup_include_directive_if_needed
49
+
50
+ @logger.info("Removed SSH entry for host: #{host_name}") if removed
51
+ removed
52
+ rescue StandardError => e
53
+ @logger.error("Failed to remove SSH entry: #{e.message}")
54
+ false
55
+ end
56
+ end
57
+
58
+ # Update SSH configuration entry for a machine
59
+ def update_ssh_entry(ssh_config_data)
60
+ return false unless ssh_config_data && ssh_config_data['Host']
61
+
62
+ begin
63
+ host_name = ssh_config_data['Host']
64
+ remove_ssh_entry(host_name)
65
+ add_ssh_entry(ssh_config_data)
66
+
67
+ @logger.info("Updated SSH entry for host: #{host_name}")
68
+ true
69
+ rescue StandardError => e
70
+ @logger.error("Failed to update SSH entry: #{e.message}")
71
+ false
72
+ end
73
+ end
74
+
75
+ # Check if SSH entry exists for a host
76
+ def ssh_entry_exists?(host_name)
77
+ return false unless File.exist?(@include_file_path)
78
+
79
+ content = File.read(@include_file_path)
80
+ content.include?("Host #{host_name}")
81
+ rescue StandardError
82
+ false
83
+ end
84
+
85
+ # List all SSH entries managed by this project
86
+ def project_ssh_entries
87
+ return [] unless File.exist?(@include_file_path)
88
+
89
+ entries = []
90
+ current_entry = nil
91
+
92
+ File.readlines(@include_file_path).each do |line|
93
+ line = line.strip
94
+ next if line.empty? || line.start_with?('#')
95
+
96
+ if line.start_with?('Host ')
97
+ entries << current_entry if current_entry
98
+ current_entry = { 'Host' => line.sub(/^Host\s+/, '') }
99
+ elsif current_entry && line.include?(' ')
100
+ key, value = line.split(' ', 2)
101
+ current_entry[key.strip] = value.strip
102
+ end
103
+ end
104
+
105
+ entries << current_entry if current_entry
106
+ entries
107
+ rescue StandardError
108
+ []
109
+ end
110
+
111
+ # alias old name for backward compatibility if needed
112
+ alias get_project_ssh_entries project_ssh_entries
113
+
114
+ # Get include file status and information
115
+ def include_file_info
116
+ info = {
117
+ path: @include_file_path,
118
+ exists: File.exist?(@include_file_path),
119
+ size: 0,
120
+ entries_count: 0,
121
+ last_modified: nil
122
+ }
123
+
124
+ if info[:exists]
125
+ stat = File.stat(@include_file_path)
126
+ info[:size] = stat.size
127
+ info[:last_modified] = stat.mtime
128
+ info[:entries_count] = count_entries_in_include_file
129
+ end
130
+
131
+ info
132
+ end
133
+
134
+ # Backup include file before operations
135
+ def backup_include_file
136
+ return nil unless File.exist?(@include_file_path)
137
+
138
+ backup_path = "#{@include_file_path}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
139
+ FileUtils.cp(@include_file_path, backup_path)
140
+
141
+ @logger.debug("Created backup of include file: #{backup_path}")
142
+ backup_path
143
+ rescue StandardError => e
144
+ @logger.warn("Failed to create backup: #{e.message}")
145
+ nil
146
+ end
147
+
148
+ # Restore include file from backup
149
+ def restore_include_file(backup_path)
150
+ return false unless File.exist?(backup_path)
151
+
152
+ begin
153
+ FileUtils.cp(backup_path, @include_file_path)
154
+ @logger.info("Restored include file from backup: #{backup_path}")
155
+ true
156
+ rescue StandardError => e
157
+ @logger.error("Failed to restore from backup: #{e.message}")
158
+ false
159
+ end
160
+ end
161
+
162
+ # Validate include file format and structure
163
+ def validate_include_file
164
+ return { valid: true, errors: [] } unless File.exist?(@include_file_path)
165
+
166
+ errors = []
167
+ line_number = 0
168
+ current_host = nil
169
+
170
+ begin
171
+ File.readlines(@include_file_path).each do |line|
172
+ line_number += 1
173
+ line_stripped = line.strip
174
+
175
+ next if line_stripped.empty? || line_stripped.start_with?('#')
176
+
177
+ if line_stripped.start_with?('Host ')
178
+ host_name = line_stripped.sub(/^Host\s+/, '')
179
+ if host_name.empty?
180
+ errors << "Line #{line_number}: Empty host name"
181
+ else
182
+ current_host = host_name
183
+ end
184
+ elsif current_host
185
+ errors << "Line #{line_number}: Invalid SSH option format" unless line_stripped.include?(' ')
186
+ else
187
+ errors << "Line #{line_number}: SSH option without host declaration"
188
+ end
189
+ end
190
+ rescue StandardError => e
191
+ errors << "Failed to read include file: #{e.message}"
192
+ end
193
+
194
+ {
195
+ valid: errors.empty?,
196
+ errors: errors,
197
+ path: @include_file_path
198
+ }
199
+ end
200
+
201
+ # Clean up orphaned include files (for debugging/maintenance)
202
+ def cleanup_orphaned_include_files
203
+ config_d_dir = File.dirname(@include_file_path)
204
+ return 0 unless File.exist?(config_d_dir)
205
+
206
+ cleaned_count = 0
207
+
208
+ Dir.glob(File.join(config_d_dir, 'vagrant-*')).each do |file_path|
209
+ next unless File.file?(file_path)
210
+
211
+ # Check if file is empty or only contains comments
212
+ content = File.read(file_path).strip
213
+ next unless content.empty? || content.lines.all? { |line| line.strip.empty? || line.strip.start_with?('#') }
214
+
215
+ File.delete(file_path)
216
+ cleaned_count += 1
217
+ @logger.debug("Cleaned up orphaned include file: #{file_path}")
218
+ end
219
+
220
+ cleaned_count
221
+ rescue StandardError => e
222
+ @logger.warn("Failed to cleanup orphaned files: #{e.message}")
223
+ 0
224
+ end
225
+
226
+ # Advanced main SSH config file management
227
+
228
+ # Get information about main SSH config file
229
+ def main_config_info
230
+ info = {
231
+ path: @ssh_config_file,
232
+ exists: File.exist?(@ssh_config_file),
233
+ size: 0,
234
+ writable: false,
235
+ include_directive_exists: false,
236
+ last_modified: nil
237
+ }
238
+
239
+ if info[:exists]
240
+ stat = File.stat(@ssh_config_file)
241
+ info[:size] = stat.size
242
+ info[:last_modified] = stat.mtime
243
+ info[:writable] = File.writable?(@ssh_config_file)
244
+ info[:include_directive_exists] = include_directive_exists?
245
+ end
246
+
247
+ info
248
+ end
249
+
250
+ # Safely add include directive with conflict detection
251
+ def add_include_directive_safe
252
+ return true if include_directive_exists?
253
+
254
+ begin
255
+ # Check if main config file is writable
256
+ unless File.writable?(@ssh_config_file) || !File.exist?(@ssh_config_file)
257
+ @logger.warn("SSH config file is not writable: #{@ssh_config_file}")
258
+ return false
259
+ end
260
+
261
+ backup_main_config
262
+ add_include_directive_with_validation
263
+ true
264
+ rescue StandardError => e
265
+ @logger.error("Failed to add include directive safely: #{e.message}")
266
+ false
267
+ end
268
+ end
269
+
270
+ # Remove include directive and clean up
271
+ def remove_include_directive_safe
272
+ return true unless include_directive_exists?
273
+
274
+ begin
275
+ backup_main_config
276
+ remove_include_directive_with_validation
277
+ true
278
+ rescue StandardError => e
279
+ @logger.error("Failed to remove include directive safely: #{e.message}")
280
+ false
281
+ end
282
+ end
283
+
284
+ # Validate main SSH config file structure
285
+ def validate_main_config
286
+ return { valid: true, errors: [], warnings: [] } unless File.exist?(@ssh_config_file)
287
+
288
+ errors = []
289
+ warnings = []
290
+ line_number = 0
291
+
292
+ begin
293
+ File.readlines(@ssh_config_file).each do |line|
294
+ line_number += 1
295
+ line_stripped = line.strip
296
+
297
+ next if line_stripped.empty? || line_stripped.start_with?('#')
298
+
299
+ # Check for include directive syntax
300
+ if line_stripped.start_with?('Include ')
301
+ include_path = line_stripped.sub(/^Include\s+/, '')
302
+ unless File.exist?(File.expand_path(include_path))
303
+ warnings << "Line #{line_number}: Include file does not exist: #{include_path}"
304
+ end
305
+ end
306
+
307
+ # Check for potential conflicts with our include
308
+ if line_stripped.start_with?('Host ') && line_stripped.include?(@project_name)
309
+ warnings << "Line #{line_number}: Potential host name conflict detected"
310
+ end
311
+ end
312
+ rescue StandardError => e
313
+ errors << "Failed to read main config file: #{e.message}"
314
+ end
315
+
316
+ {
317
+ valid: errors.empty?,
318
+ errors: errors,
319
+ warnings: warnings,
320
+ path: @ssh_config_file
321
+ }
322
+ end
323
+
324
+ # Fetch Include directives from the SSH include file
325
+ def include_directives
326
+ return [] unless File.exist?(@include_file_path)
327
+
328
+ directives = []
329
+ line_number = 0
330
+
331
+ File.readlines(@include_file_path).each do |line|
332
+ line_number += 1
333
+ line_stripped = line.strip
334
+
335
+ next unless line_stripped.start_with?('Include ')
336
+
337
+ include_path = line_stripped.sub(/^Include\s+/, '')
338
+ directives << {
339
+ line_number: line_number,
340
+ path: include_path,
341
+ absolute_path: File.expand_path(include_path),
342
+ exists: File.exist?(File.expand_path(include_path)),
343
+ is_ours: include_path == @include_file_path
344
+ }
345
+ end
346
+
347
+ directives
348
+ rescue StandardError => e
349
+ @logger.warn("Failed to get include directives: #{e.message}")
350
+ []
351
+ end
352
+ alias get_include_directives include_directives
353
+
354
+ # Check for conflicts with existing SSH config
355
+ def check_host_conflicts(host_name)
356
+ conflicts = []
357
+ return conflicts unless File.exist?(@ssh_config_file)
358
+
359
+ # Check in main config file
360
+ conflicts.concat(find_host_in_file(@ssh_config_file, host_name, 'main config'))
361
+
362
+ # Check in other include files
363
+ include_directives.each do |include_info|
364
+ next if include_info[:is_ours] || !include_info[:exists]
365
+
366
+ conflicts.concat(find_host_in_file(
367
+ include_info[:absolute_path],
368
+ host_name,
369
+ "include file: #{include_info[:path]}"
370
+ ))
371
+ end
372
+
373
+ conflicts
374
+ end
375
+
376
+ # Project-based naming and isolation methods
377
+
378
+ # Generate unique project identifier
379
+ def generate_project_identifier
380
+ # Use multiple factors to ensure uniqueness
381
+ project_path = @machine.env.root_path
382
+ project_name = File.basename(project_path)
383
+
384
+ # Create a hash of the full path for uniqueness
385
+ path_hash = Digest::SHA256.hexdigest(project_path.to_s)[0..7]
386
+
387
+ # Combine sanitized name with hash
388
+ base_name = sanitize_name(project_name)
389
+ "#{base_name}-#{path_hash}"
390
+ end
391
+
392
+ # Generate host name with project isolation
393
+ def generate_isolated_host_name(machine_name)
394
+ project_id = generate_project_identifier
395
+ machine_name_clean = sanitize_name(machine_name.to_s)
396
+
397
+ # Format: project-hash-machine
398
+ host_name = "#{project_id}-#{machine_name_clean}"
399
+
400
+ # Ensure host name is not too long (SSH has practical limits)
401
+ truncate_host_name(host_name)
402
+ end
403
+
404
+ # List project host names
405
+ def project_hosts
406
+ hosts = []
407
+ project_id = generate_project_identifier
408
+
409
+ # Search in our include file
410
+ hosts.concat(extract_hosts_from_file(@include_file_path, project_id)) if File.exist?(@include_file_path)
411
+
412
+ hosts
413
+ end
414
+ alias get_project_hosts project_hosts
415
+
416
+ # Check if a host belongs to this project
417
+ def project_owns_host?(host_name)
418
+ project_id = generate_project_identifier
419
+ host_name.start_with?(project_id)
420
+ end
421
+
422
+ # Clean up all hosts for this project
423
+ def cleanup_project_hosts
424
+ cleaned_count = 0
425
+
426
+ get_project_hosts.each do |host_name|
427
+ cleaned_count += 1 if remove_ssh_entry(host_name)
428
+ end
429
+
430
+ @logger.info("Cleaned up #{cleaned_count} hosts for project: #{@project_name}")
431
+ cleaned_count
432
+ end
433
+
434
+ # Get project statistics
435
+ # Gather statistics about the project SSH entries
436
+ def project_stats
437
+ {
438
+ project_name: @project_name,
439
+ project_id: generate_project_identifier,
440
+ project_path: @machine.env.root_path.to_s,
441
+ include_file: @include_file_path,
442
+ hosts_count: project_hosts.count,
443
+ include_file_exists: File.exist?(@include_file_path),
444
+ include_file_size: File.exist?(@include_file_path) ? File.size(@include_file_path) : 0
445
+ }
446
+ end
447
+ alias get_project_stats project_stats
448
+
449
+ # Migrate old naming scheme to new project-based scheme
450
+ def migrate_to_project_naming?(old_host_names)
451
+ return false if old_host_names.nil? || old_host_names.empty?
452
+
453
+ migrated_count = 0
454
+
455
+ old_host_names.each do |old_host_name|
456
+ # Extract machine name from old host name
457
+ machine_name = extract_machine_name_from_host(old_host_name)
458
+ next unless machine_name
459
+
460
+ # Generate new host name
461
+ new_host_name = generate_isolated_host_name(machine_name)
462
+
463
+ # Skip if names are the same
464
+ next if old_host_name == new_host_name
465
+
466
+ # Get SSH config for the old host
467
+ ssh_config = find_ssh_config_for_host(old_host_name)
468
+ next unless ssh_config
469
+
470
+ # Update host name in config
471
+ ssh_config['Host'] = new_host_name
472
+
473
+ # Remove old entry and add new one
474
+ if remove_ssh_entry(old_host_name) && add_ssh_entry(ssh_config)
475
+ migrated_count += 1
476
+ @logger.info("Migrated host: #{old_host_name} -> #{new_host_name}")
477
+ end
478
+ end
479
+
480
+ @logger.info("Migrated #{migrated_count} hosts to project-based naming")
481
+ migrated_count.positive?
482
+ end
483
+ alias migrate_to_project_naming migrate_to_project_naming?
484
+
485
+ # List all Vagrant projects detected in SSH config
486
+ def list_vagrant_projects
487
+ projects = {}
488
+ config_d_dir = File.dirname(@include_file_path)
489
+
490
+ return projects unless File.exist?(config_d_dir)
491
+
492
+ Dir.glob(File.join(config_d_dir, 'vagrant-*')).each do |file_path|
493
+ next unless File.file?(file_path)
494
+
495
+ # Extract project info from filename
496
+ filename = File.basename(file_path)
497
+ next unless filename.match(/^vagrant-(.+)$/)
498
+
499
+ project_info = parse_project_from_filename(::Regexp.last_match(1))
500
+ next unless project_info
501
+
502
+ hosts = extract_hosts_from_file(file_path)
503
+
504
+ projects[project_info[:id]] = {
505
+ name: project_info[:name],
506
+ id: project_info[:id],
507
+ include_file: file_path,
508
+ hosts: hosts,
509
+ hosts_count: hosts.count,
510
+ last_modified: File.mtime(file_path)
511
+ }
512
+ end
513
+
514
+ projects
515
+ end
516
+
517
+ # Private API for helper methods
518
+
519
+ private
520
+
521
+ def truncate_host_name(host_name, max_length = 64)
522
+ return host_name if host_name.length <= max_length
523
+
524
+ # Keep the project hash part and truncate the machine name part
525
+ parts = host_name.split('-')
526
+ if parts.length >= 3
527
+ # Keep project name and hash, truncate machine name
528
+ project_part = parts[0..-2].join('-')
529
+ machine_part = parts[-1]
530
+
531
+ available_length = max_length - project_part.length - 1
532
+ if available_length.positive?
533
+ truncated_machine = machine_part[0...available_length]
534
+ return "#{project_part}-#{truncated_machine}"
535
+ end
536
+ end
537
+
538
+ # Fallback: simple truncation
539
+ host_name[0...max_length]
540
+ end
541
+
542
+ def extract_hosts_from_file(file_path, project_filter = nil)
543
+ hosts = []
544
+ return hosts unless File.exist?(file_path)
545
+
546
+ File.readlines(file_path).each do |line|
547
+ line_stripped = line.strip
548
+ next unless line_stripped.start_with?('Host ')
549
+
550
+ host_name = line_stripped.sub(/^Host\s+/, '')
551
+ # Apply project filter if specified
552
+ hosts << host_name if project_filter.nil? || host_name.start_with?(project_filter)
553
+ end
554
+
555
+ hosts
556
+ rescue StandardError => e
557
+ @logger.warn("Failed to extract hosts from #{file_path}: #{e.message}")
558
+ []
559
+ end
560
+
561
+ def extract_machine_name_from_host(host_name)
562
+ # Try to extract machine name from various naming patterns
563
+
564
+ # New project-based pattern: project-hash-machine
565
+ return ::Regexp.last_match(1) if host_name.match(/^.+-[a-f0-9]{8}-(.+)$/)
566
+
567
+ # Old pattern: project-machine
568
+ parts = host_name.split('-')
569
+ return parts.last if parts.length >= 2
570
+
571
+ # Single name
572
+ host_name
573
+ end
574
+
575
+ def find_ssh_config_for_host(host_name)
576
+ entries = get_project_ssh_entries
577
+ entries.find { |entry| entry['Host'] == host_name }
578
+ end
579
+
580
+ def parse_project_from_filename(filename_part)
581
+ # Parse project info from include filename
582
+ # Format: project-name-hash or just project-name
583
+
584
+ if filename_part.match(/^(.+)-([a-f0-9]{8})$/)
585
+ {
586
+ name: ::Regexp.last_match(1),
587
+ id: filename_part,
588
+ hash: ::Regexp.last_match(2)
589
+ }
590
+ else
591
+ {
592
+ name: filename_part,
593
+ id: filename_part,
594
+ hash: nil
595
+ }
596
+ end
597
+ end
598
+
599
+ # Enhanced sanitization for project-based naming
600
+ def sanitize_name(name)
601
+ return 'unknown' if name.nil? || name.to_s.strip.empty?
602
+
603
+ # Remove or replace problematic characters
604
+ sanitized = name.to_s
605
+ .gsub(/[^a-zA-Z0-9\-_.]/, '-') # Replace invalid chars
606
+ .gsub(/\.+/, '.') # Collapse multiple dots
607
+ .gsub(/-+/, '-') # Collapse multiple dashes
608
+ .gsub(/^[-._]+|[-._]+$/, '') # Remove leading/trailing special chars
609
+ .downcase
610
+
611
+ # Ensure name is not empty after sanitization
612
+ sanitized.empty? ? 'unknown' : sanitized
613
+ end
614
+
615
+ def determine_ssh_config_file
616
+ # Use custom path if specified, otherwise default to ~/.ssh/config
617
+ if @config&.ssh_conf_file
618
+ File.expand_path(@config.ssh_conf_file)
619
+ else
620
+ File.expand_path('~/.ssh/config')
621
+ end
622
+ end
623
+
624
+ def generate_project_name
625
+ return @project_name if @project_name
626
+
627
+ # Use the new project identifier method for consistency
628
+ @project_name = generate_project_identifier
629
+ end
630
+
631
+ def generate_include_file_path
632
+ # Create include file path: ~/.ssh/config.d/vagrant-{project-name}
633
+ ssh_dir = File.dirname(@ssh_config_file)
634
+ config_d_dir = File.join(ssh_dir, 'config.d')
635
+ File.join(config_d_dir, "vagrant-#{@project_name}")
636
+ end
637
+
638
+ def ensure_ssh_config_structure
639
+ # Create SSH directory if it doesn't exist
640
+ ssh_dir = File.dirname(@ssh_config_file)
641
+ FileUtils.mkdir_p(ssh_dir, mode: 0o700)
642
+
643
+ # Create config.d directory if it doesn't exist
644
+ config_d_dir = File.dirname(@include_file_path)
645
+ FileUtils.mkdir_p(config_d_dir, mode: 0o700)
646
+
647
+ # Create main SSH config file if it doesn't exist
648
+ return if File.exist?(@ssh_config_file)
649
+
650
+ File.write(@ssh_config_file, "# SSH Config File\n")
651
+ File.chmod(0o600, @ssh_config_file)
652
+ end
653
+
654
+ def write_include_file(ssh_config_data)
655
+ # Prepare the SSH configuration content
656
+ content = format_ssh_config_entry(ssh_config_data)
657
+
658
+ # Write the include file
659
+ File.write(@include_file_path, content)
660
+ File.chmod(0o600, @include_file_path)
661
+
662
+ @logger.debug("Wrote SSH config to include file: #{@include_file_path}")
663
+ end
664
+
665
+ # Comment markers and section management
666
+
667
+ # Comment templates for different types of markers
668
+ COMMENT_TEMPLATES = {
669
+ file_header: [
670
+ '# Vagrant SSH Config - Project: %<project_name>s',
671
+ '# Generated on: %<timestamp>s',
672
+ '# DO NOT EDIT MANUALLY - Managed by vagrant-ssh-config-manager',
673
+ '# Plugin Version: %<version>s',
674
+ '# Project Path: %<project_path>s'
675
+ ],
676
+ section_start: [
677
+ '# === START: Vagrant SSH Config Manager ===',
678
+ '# Project: %<project_name>s | Machine: %<machine_name>s',
679
+ '# Generated: %<timestamp>s'
680
+ ],
681
+ section_end: [
682
+ '# === END: Vagrant SSH Config Manager ==='
683
+ ],
684
+ include_directive: [
685
+ '# Vagrant SSH Config Manager - Auto-generated include',
686
+ '# Include file: %<include_file>s',
687
+ '# Project: %<project_name>s'
688
+ ],
689
+ warning: [
690
+ '# WARNING: This section is automatically managed',
691
+ '# Manual changes will be overwritten'
692
+ ]
693
+ }.freeze
694
+
695
+ # Helper methods should be private
696
+ private
697
+
698
+ # Add comprehensive comment markers to SSH entry
699
+ def add_comment_markers_to_entry(ssh_config_data, machine_name = nil)
700
+ return ssh_config_data unless ssh_config_data
701
+
702
+ machine_name ||= @machine.name.to_s
703
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
704
+
705
+ # Create commented entry with markers
706
+ lines = []
707
+
708
+ # Section start marker
709
+ lines.concat(format_comment_block(:section_start, {
710
+ project_name: @project_name,
711
+ machine_name: machine_name,
712
+ timestamp: timestamp
713
+ }))
714
+
715
+ lines << ''
716
+
717
+ # SSH configuration
718
+ if ssh_config_data['Host']
719
+ lines << "Host #{ssh_config_data['Host']}"
720
+
721
+ # Add SSH options with inline comments for important ones
722
+ ssh_option_order = %w[
723
+ HostName User Port IdentityFile IdentitiesOnly
724
+ StrictHostKeyChecking UserKnownHostsFile PasswordAuthentication
725
+ LogLevel ProxyCommand Compression CompressionLevel
726
+ ConnectTimeout ForwardAgent ForwardX11
727
+ ]
728
+
729
+ ssh_option_order.each do |key|
730
+ next unless ssh_config_data[key]
731
+
732
+ value = ssh_config_data[key]
733
+ comment = get_option_comment(key, value)
734
+ line = " #{key} #{value}"
735
+ line += " # #{comment}" if comment
736
+ lines << line
737
+ end
738
+
739
+ # Add any remaining options
740
+ ssh_config_data.each do |key, value|
741
+ next if key == 'Host' || ssh_option_order.include?(key)
742
+
743
+ lines << " #{key} #{value}"
744
+ end
745
+ end
746
+
747
+ lines << ''
748
+
749
+ # Section end marker
750
+ lines.concat(format_comment_block(:section_end))
751
+
752
+ {
753
+ 'Host' => ssh_config_data['Host'],
754
+ 'formatted_content' => lines.join("\n"),
755
+ 'raw_config' => ssh_config_data
756
+ }
757
+ end
758
+
759
+ # Generate file header with comprehensive metadata
760
+ def generate_file_header_with_markers
761
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
762
+ version = begin
763
+ VagrantPlugins::SshConfigManager::VERSION
764
+ rescue StandardError
765
+ 'unknown'
766
+ end
767
+
768
+ format_comment_block(:file_header, {
769
+ project_name: @project_name,
770
+ timestamp: timestamp,
771
+ version: version,
772
+ project_path: @machine.env.root_path.to_s
773
+ })
774
+ end
775
+
776
+ # Add warning markers to dangerous operations
777
+ def add_warning_markers
778
+ format_comment_block(:warning)
779
+ end
780
+
781
+ # Generate include directive with markers
782
+ def generate_include_directive_with_markers
783
+ lines = []
784
+
785
+ lines.concat(format_comment_block(:include_directive, {
786
+ include_file: @include_file_path,
787
+ project_name: @project_name
788
+ }))
789
+
790
+ lines << "Include #{@include_file_path}"
791
+ lines << ''
792
+
793
+ lines
794
+ end
795
+
796
+ # Extract plugin-managed sections from file
797
+ def extract_managed_sections(file_path)
798
+ return [] unless File.exist?(file_path)
799
+
800
+ sections = []
801
+ current_section = nil
802
+ line_number = 0
803
+
804
+ File.readlines(file_path).each do |line|
805
+ line_number += 1
806
+ line_stripped = line.strip
807
+
808
+ # Detect section start
809
+ if line_stripped.include?('START: Vagrant SSH Config Manager')
810
+ current_section = {
811
+ start_line: line_number,
812
+ lines: [line],
813
+ type: :managed_section
814
+ }
815
+ elsif current_section
816
+ current_section[:lines] << line
817
+
818
+ # Detect section end
819
+ if line_stripped.include?('END: Vagrant SSH Config Manager')
820
+ current_section[:end_line] = line_number
821
+ sections << current_section
822
+ current_section = nil
823
+ end
824
+ end
825
+ end
826
+
827
+ sections
828
+ rescue StandardError => e
829
+ @logger.warn("Failed to extract managed sections from #{file_path}: #{e.message}")
830
+ []
831
+ end
832
+
833
+ # Validate comment markers integrity
834
+ def validate_comment_markers(file_path)
835
+ return { valid: true, issues: [] } unless File.exist?(file_path)
836
+
837
+ issues = []
838
+ sections = extract_managed_sections(file_path)
839
+
840
+ sections.each do |section|
841
+ # Check for orphaned start markers (no matching end)
842
+ if section[:end_line].nil?
843
+ issues << {
844
+ type: :orphaned_start,
845
+ line: section[:start_line],
846
+ message: 'Found start marker without matching end marker'
847
+ }
848
+ end
849
+
850
+ # Check for corrupted section content
851
+ section_content = section[:lines].join
852
+ next if section_content.include?("Project: #{@project_name}")
853
+
854
+ issues << {
855
+ type: :corrupted_metadata,
856
+ line: section[:start_line],
857
+ message: 'Section metadata appears corrupted or modified'
858
+ }
859
+ end
860
+
861
+ {
862
+ valid: issues.empty?,
863
+ issues: issues,
864
+ sections_count: sections.length
865
+ }
866
+ end
867
+
868
+ # Clean up orphaned or corrupted markers
869
+ def cleanup_comment_markers?(file_path)
870
+ return false unless File.exist?(file_path)
871
+
872
+ lines = File.readlines(file_path)
873
+ cleaned_lines = []
874
+ skip_until_end = false
875
+ cleaned_count = 0
876
+
877
+ lines.each do |line|
878
+ line_stripped = line.strip
879
+
880
+ # Handle orphaned start markers
881
+ # Check if this is our project
882
+ if line_stripped.include?('START: Vagrant SSH Config Manager') && line_stripped.include?("Project: #{@project_name}")
883
+ skip_until_end = true
884
+ cleaned_count += 1
885
+ next
886
+ end
887
+
888
+ # Handle end markers
889
+ if skip_until_end && line_stripped.include?('END: Vagrant SSH Config Manager')
890
+ skip_until_end = false
891
+ next
892
+ end
893
+
894
+ # Keep line if not in a section being cleaned
895
+ cleaned_lines << line unless skip_until_end
896
+ end
897
+
898
+ if cleaned_count.positive?
899
+ File.write(file_path, cleaned_lines.join)
900
+ @logger.info("Cleaned up #{cleaned_count} orphaned comment sections")
901
+ end
902
+
903
+ cleaned_count.positive?
904
+ end
905
+ alias cleanup_comment_markers cleanup_comment_markers?
906
+
907
+ def format_comment_block(template_name, variables = {})
908
+ template = COMMENT_TEMPLATES[template_name]
909
+ return [] unless template
910
+
911
+ template.map do |line|
912
+ formatted_line = line.dup
913
+ variables.each do |key, value|
914
+ formatted_line.gsub!("%{#{key}}", value.to_s)
915
+ end
916
+ formatted_line
917
+ end
918
+ end
919
+
920
+ def get_option_comment(key, value)
921
+ case key
922
+ when 'StrictHostKeyChecking'
923
+ value == 'no' ? 'Skip host key verification for Vagrant VMs' : nil
924
+ when 'UserKnownHostsFile'
925
+ value == File::NULL ? 'Ignore known hosts for Vagrant VMs' : nil
926
+ when 'PasswordAuthentication'
927
+ value == 'no' ? 'Use key-based authentication only' : nil
928
+ when 'LogLevel'
929
+ value == 'FATAL' ? 'Minimize SSH logging output' : nil
930
+ when 'IdentitiesOnly'
931
+ value == 'yes' ? 'Use only specified identity file' : nil
932
+ end
933
+ end
934
+
935
+ # Update existing methods to use comment markers
936
+ def format_ssh_config_entry(ssh_config_data)
937
+ machine_name = @machine.name.to_s
938
+ marked_entry = add_comment_markers_to_entry(ssh_config_data, machine_name)
939
+ marked_entry['formatted_content']
940
+ end
941
+
942
+ def write_multiple_entries_to_include_file(ssh_entries)
943
+ content_lines = []
944
+
945
+ # Add file header with comprehensive markers
946
+ content_lines.concat(generate_file_header_with_markers)
947
+ content_lines << ''
948
+ content_lines.concat(add_warning_markers)
949
+ content_lines << ''
950
+ content_lines << "# Total entries: #{ssh_entries.length}"
951
+ content_lines << ''
952
+
953
+ ssh_entries.each_with_index do |ssh_config_data, index|
954
+ next unless ssh_config_data && ssh_config_data['Host']
955
+
956
+ # Add separator between entries
957
+ content_lines << '' if index.positive?
958
+
959
+ # Add entry with comment markers
960
+ machine_name = extract_machine_name_from_host(ssh_config_data['Host'])
961
+ marked_entry = add_comment_markers_to_entry(ssh_config_data, machine_name)
962
+ content_lines << marked_entry['formatted_content']
963
+ end
964
+
965
+ content_lines << ''
966
+ content_lines << '# End of Vagrant SSH Config Manager entries'
967
+
968
+ # Write the include file
969
+ File.write(@include_file_path, content_lines.join("\n"))
970
+ File.chmod(0o600, @include_file_path)
971
+
972
+ @logger.debug("Wrote #{ssh_entries.length} SSH entries with comment markers to: #{@include_file_path}")
973
+ end
974
+
975
+ # Override the include directive addition to use markers
976
+ def add_include_directive_with_validation
977
+ # Read existing content
978
+ existing_content = File.exist?(@ssh_config_file) ? File.read(@ssh_config_file) : ''
979
+
980
+ # Check if our include directive already exists
981
+ return true if existing_content.include?("Include #{@include_file_path}")
982
+
983
+ # Find the best place to insert the include directive
984
+ lines = existing_content.lines
985
+ insert_position = find_include_insert_position(lines)
986
+
987
+ # Generate include directive with markers
988
+ include_lines = generate_include_directive_with_markers
989
+
990
+ # Insert at the determined position
991
+ new_lines = lines.dup
992
+ include_lines.reverse.each do |line|
993
+ new_lines.insert(insert_position, "#{line}\n")
994
+ end
995
+
996
+ # Write updated content
997
+ File.write(@ssh_config_file, new_lines.join)
998
+
999
+ @logger.debug("Added include directive with markers to SSH config file at position #{insert_position}")
1000
+ end
1001
+
1002
+ def remove_from_include_file(host_name)
1003
+ return false unless File.exist?(@include_file_path)
1004
+
1005
+ lines = File.readlines(@include_file_path)
1006
+ new_lines = []
1007
+ skip_until_next_host = false
1008
+ removed = false
1009
+
1010
+ lines.each do |line|
1011
+ line_stripped = line.strip
1012
+
1013
+ if line_stripped.start_with?('Host ')
1014
+ current_host = line_stripped.sub(/^Host\s+/, '')
1015
+ if current_host == host_name
1016
+ skip_until_next_host = true
1017
+ removed = true
1018
+ next
1019
+ else
1020
+ skip_until_next_host = false
1021
+ end
1022
+ end
1023
+
1024
+ new_lines << line unless skip_until_next_host
1025
+ end
1026
+
1027
+ if removed
1028
+ File.write(@include_file_path, new_lines.join)
1029
+ @logger.debug("Removed host #{host_name} from include file")
1030
+ end
1031
+
1032
+ removed
1033
+ end
1034
+
1035
+ def cleanup_empty_include_file
1036
+ return unless File.exist?(@include_file_path)
1037
+
1038
+ content = File.read(@include_file_path).strip
1039
+ # Remove file if it only contains comments or is empty
1040
+ return unless content.empty? || content.lines.all? { |line| line.strip.empty? || line.strip.start_with?('#') }
1041
+
1042
+ File.delete(@include_file_path)
1043
+ @logger.debug("Removed empty include file: #{@include_file_path}")
1044
+ end
1045
+
1046
+ def cleanup_include_directive_if_needed
1047
+ return unless File.exist?(@ssh_config_file)
1048
+ return if File.exist?(@include_file_path) # Don't remove if include file still exists
1049
+
1050
+ lines = File.readlines(@ssh_config_file)
1051
+ new_lines = lines.reject do |line|
1052
+ ["Include #{@include_file_path}", '# Vagrant SSH Config Manager - Include'].include?(line.strip)
1053
+ end
1054
+
1055
+ return unless new_lines.length != lines.length
1056
+
1057
+ File.write(@ssh_config_file, new_lines.join)
1058
+ @logger.debug('Removed include directive from SSH config file')
1059
+ end
1060
+
1061
+ def backup_main_config
1062
+ return nil unless File.exist?(@ssh_config_file)
1063
+
1064
+ backup_path = "#{@ssh_config_file}.backup.#{Time.now.strftime('%Y%m%d_%H%M%S')}"
1065
+ FileUtils.cp(@ssh_config_file, backup_path)
1066
+
1067
+ @logger.debug("Created backup of main SSH config: #{backup_path}")
1068
+ backup_path
1069
+ rescue StandardError => e
1070
+ @logger.warn("Failed to create main config backup: #{e.message}")
1071
+ nil
1072
+ end
1073
+
1074
+ def remove_include_directive_with_validation
1075
+ return false unless File.exist?(@ssh_config_file)
1076
+
1077
+ lines = File.readlines(@ssh_config_file)
1078
+ new_lines = []
1079
+ skip_next_empty = false
1080
+
1081
+ lines.each do |line|
1082
+ line_stripped = line.strip
1083
+
1084
+ # Skip our include directive and its comment
1085
+ if ["Include #{@include_file_path}",
1086
+ '# Vagrant SSH Config Manager - Auto-generated include'].include?(line_stripped)
1087
+ skip_next_empty = true
1088
+ next
1089
+ end
1090
+
1091
+ # Skip the empty line after our include directive
1092
+ if skip_next_empty && line_stripped.empty?
1093
+ skip_next_empty = false
1094
+ next
1095
+ end
1096
+
1097
+ skip_next_empty = false
1098
+ new_lines << line
1099
+ end
1100
+
1101
+ File.write(@ssh_config_file, new_lines.join)
1102
+ @logger.debug('Removed include directive from SSH config file')
1103
+ end
1104
+
1105
+ def find_include_insert_position(lines)
1106
+ # Try to find existing Include directives and insert after them
1107
+ last_include_position = -1
1108
+
1109
+ lines.each_with_index do |line, index|
1110
+ last_include_position = index if line.strip.start_with?('Include ')
1111
+ end
1112
+
1113
+ # If we found includes, insert after the last one
1114
+ return last_include_position + 1 if last_include_position >= 0
1115
+
1116
+ # Otherwise, insert at the beginning (after any initial comments)
1117
+ lines.each_with_index do |line, index|
1118
+ line_stripped = line.strip
1119
+ return index unless line_stripped.empty? || line_stripped.start_with?('#')
1120
+ end
1121
+
1122
+ # If file is empty or only comments, insert at the end
1123
+ lines.length
1124
+ end
1125
+
1126
+ def find_host_in_file(file_path, host_name, source_description)
1127
+ conflicts = []
1128
+ return conflicts unless File.exist?(file_path)
1129
+
1130
+ line_number = 0
1131
+ File.readlines(file_path).each do |line|
1132
+ line_number += 1
1133
+ line_stripped = line.strip
1134
+
1135
+ next unless line_stripped.start_with?('Host ') && line_stripped.include?(host_name)
1136
+
1137
+ conflicts << {
1138
+ file: file_path,
1139
+ source: source_description,
1140
+ line_number: line_number,
1141
+ line_content: line_stripped
1142
+ }
1143
+ end
1144
+
1145
+ conflicts
1146
+ rescue StandardError => e
1147
+ @logger.warn("Failed to search for host conflicts in #{file_path}: #{e.message}")
1148
+ []
1149
+ end
1150
+ end
1151
+ end
1152
+ end