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.
- checksums.yaml +4 -4
- data/.bundle/config +1 -1
- data/.gitignore +2 -1
- data/.rspec +2 -0
- data/.rubocop.yml +62 -0
- data/Gemfile +11 -9
- data/README.md +2 -2
- data/Rakefile +10 -8
- data/TESTING.md +82 -0
- data/lib/vagrant/ssh/config/manager.rb +5 -0
- data/lib/vagrant_ssh_config_manager/action/destroy.rb +82 -0
- data/lib/vagrant_ssh_config_manager/action/halt.rb +66 -0
- data/lib/vagrant_ssh_config_manager/action/provision.rb +81 -0
- data/lib/vagrant_ssh_config_manager/action/reload.rb +105 -0
- data/lib/vagrant_ssh_config_manager/action/up.rb +98 -0
- data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/config.rb +45 -49
- data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/file_locker.rb +35 -37
- data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/file_manager.rb +90 -80
- data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/include_manager.rb +54 -53
- data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/plugin.rb +15 -13
- data/lib/vagrant_ssh_config_manager/ssh_config_manager.rb +1152 -0
- data/lib/{vagrant-ssh-config-manager → vagrant_ssh_config_manager}/ssh_info_extractor.rb +129 -141
- data/lib/vagrant_ssh_config_manager/version.rb +7 -0
- data/lib/{vagrant-ssh-config-manager.rb → vagrant_ssh_config_manager.rb} +15 -12
- data/test-all.sh +11 -0
- data/test-integration.sh +4 -0
- data/test-unit.sh +4 -0
- data/vagrant-ssh-config-manager.gemspec +25 -21
- metadata +28 -18
- data/lib/vagrant-ssh-config-manager/action/destroy.rb +0 -84
- data/lib/vagrant-ssh-config-manager/action/halt.rb +0 -68
- data/lib/vagrant-ssh-config-manager/action/provision.rb +0 -82
- data/lib/vagrant-ssh-config-manager/action/reload.rb +0 -106
- data/lib/vagrant-ssh-config-manager/action/up.rb +0 -99
- data/lib/vagrant-ssh-config-manager/ssh_config_manager.rb +0 -2150
- 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
|