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,99 @@
1
+ module VagrantPlugins
2
+ module SshConfigManager
3
+ module Action
4
+ class Up
5
+ def initialize(app, env)
6
+ @app = app
7
+ @env = env
8
+ @logger = Log4r::Logger.new("vagrant::plugins::ssh_config_manager::action::up")
9
+ end
10
+
11
+ def call(env)
12
+ # Call the next middleware first
13
+ @app.call(env)
14
+
15
+ # Only proceed if the machine is running and SSH is ready
16
+ machine = env[:machine]
17
+ return unless machine
18
+ return unless machine.state.id == :running
19
+
20
+ # Check if plugin is enabled
21
+ config = machine.config.sshconfigmanager
22
+ return unless config && config.enabled
23
+
24
+ @logger.info("SSH Config Manager: Creating SSH config file for machine: #{machine.name}")
25
+
26
+ # Handle SSH config file creation
27
+ handle_ssh_config_creation(machine, config)
28
+ rescue => e
29
+ @logger.error("SSH Config Manager: Error in Up action: #{e.message}")
30
+ @logger.debug("Backtrace: #{e.backtrace.join("\n")}")
31
+ end
32
+
33
+ private
34
+
35
+ def handle_ssh_config_creation(machine, config)
36
+ begin
37
+ @logger.info("Creating SSH config file for machine: #{machine.name}")
38
+
39
+ # Lazy load required classes with error handling
40
+ begin
41
+ require 'vagrant-ssh-config-manager/ssh_info_extractor'
42
+ require 'vagrant-ssh-config-manager/file_manager'
43
+ require 'vagrant-ssh-config-manager/include_manager'
44
+ rescue LoadError => e
45
+ @logger.error("Failed to load required classes: #{e.message}")
46
+ machine.ui.warn("SSH config manager: Failed to load required components, skipping SSH config creation")
47
+ return
48
+ end
49
+
50
+ # Extract SSH information
51
+ extractor = SshInfoExtractor.new(machine)
52
+
53
+ # Check if machine supports SSH
54
+ unless extractor.ssh_capable?
55
+ @logger.debug("Machine #{machine.name} does not support SSH, skipping")
56
+ return
57
+ end
58
+
59
+ # Create file manager and include manager
60
+ file_manager = FileManager.new(config)
61
+ include_manager = IncludeManager.new(config)
62
+
63
+ # Write SSH config file
64
+ @logger.info("Attempting to create SSH config file for #{machine.name}")
65
+
66
+ if file_manager.write_ssh_config_file(machine)
67
+ host_name = file_manager.send(:generate_host_name, machine)
68
+ machine.ui.info("SSH config file created for machine '#{machine.name}' as '#{host_name}'")
69
+ machine.ui.info("You can now connect with: ssh #{host_name}")
70
+ @logger.info("Successfully created SSH config file for #{machine.name}")
71
+
72
+ # Manage Include directive after file creation
73
+ include_manager.manage_include_directive
74
+ else
75
+ machine.ui.warn("Failed to create SSH config file for machine: #{machine.name}")
76
+ @logger.warn("Failed to create SSH config file for #{machine.name}")
77
+ end
78
+
79
+ rescue Errno::EACCES => e
80
+ @logger.error("Permission denied accessing SSH config for #{machine.name}: #{e.message}")
81
+ machine.ui.warn("SSH config manager: Permission denied. Check file permissions.")
82
+ rescue Errno::ENOSPC => e
83
+ @logger.error("No space left on device for #{machine.name}: #{e.message}")
84
+ machine.ui.warn("SSH config manager: No space left on device.")
85
+ rescue Errno::EIO => e
86
+ @logger.error("I/O error for #{machine.name}: #{e.message}")
87
+ machine.ui.warn("SSH config manager: I/O error accessing SSH config files.")
88
+ rescue => e
89
+ @logger.error("Error creating SSH config for #{machine.name}: #{e.message}")
90
+ @logger.debug("Backtrace: #{e.backtrace.join("\n")}")
91
+
92
+ # Don't fail the vagrant up process, just warn
93
+ machine.ui.warn("SSH config manager encountered an error: #{e.message}")
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,194 @@
1
+ require 'fileutils'
2
+ require 'vagrant'
3
+
4
+ module VagrantPlugins
5
+ module SshConfigManager
6
+ class Config < Vagrant.plugin("2", :config)
7
+ # Plugin enabled/disabled flag
8
+ attr_accessor :enabled
9
+
10
+ # SSH config directory configuration
11
+ attr_accessor :ssh_config_dir
12
+ attr_accessor :manage_includes
13
+ attr_accessor :auto_create_dir
14
+ attr_accessor :cleanup_empty_dir
15
+
16
+ # Additional configuration options
17
+ attr_accessor :auto_remove_on_destroy
18
+ attr_accessor :update_on_reload
19
+ attr_accessor :refresh_on_provision
20
+ attr_accessor :keep_config_on_halt
21
+ attr_accessor :project_isolation
22
+
23
+ def initialize
24
+ @enabled = Vagrant::Plugin::V2::Config::UNSET_VALUE
25
+ @ssh_config_dir = Vagrant::Plugin::V2::Config::UNSET_VALUE
26
+ @manage_includes = Vagrant::Plugin::V2::Config::UNSET_VALUE
27
+ @auto_create_dir = Vagrant::Plugin::V2::Config::UNSET_VALUE
28
+ @cleanup_empty_dir = Vagrant::Plugin::V2::Config::UNSET_VALUE
29
+ @auto_remove_on_destroy = Vagrant::Plugin::V2::Config::UNSET_VALUE
30
+ @update_on_reload = Vagrant::Plugin::V2::Config::UNSET_VALUE
31
+ @refresh_on_provision = Vagrant::Plugin::V2::Config::UNSET_VALUE
32
+ @keep_config_on_halt = Vagrant::Plugin::V2::Config::UNSET_VALUE
33
+ @project_isolation = Vagrant::Plugin::V2::Config::UNSET_VALUE
34
+ end
35
+
36
+ def finalize!
37
+ # Set default values for unset configuration options
38
+ @enabled = true if @enabled == Vagrant::Plugin::V2::Config::UNSET_VALUE
39
+ @ssh_config_dir = File.expand_path("~/.ssh/config.d/vagrant") if @ssh_config_dir == Vagrant::Plugin::V2::Config::UNSET_VALUE
40
+ @manage_includes = false if @manage_includes == Vagrant::Plugin::V2::Config::UNSET_VALUE
41
+ @auto_create_dir = true if @auto_create_dir == Vagrant::Plugin::V2::Config::UNSET_VALUE
42
+ @cleanup_empty_dir = true if @cleanup_empty_dir == Vagrant::Plugin::V2::Config::UNSET_VALUE
43
+ @auto_remove_on_destroy = true if @auto_remove_on_destroy == Vagrant::Plugin::V2::Config::UNSET_VALUE
44
+ @update_on_reload = true if @update_on_reload == Vagrant::Plugin::V2::Config::UNSET_VALUE
45
+ @refresh_on_provision = true if @refresh_on_provision == Vagrant::Plugin::V2::Config::UNSET_VALUE
46
+ @keep_config_on_halt = true if @keep_config_on_halt == Vagrant::Plugin::V2::Config::UNSET_VALUE
47
+ @project_isolation = true if @project_isolation == Vagrant::Plugin::V2::Config::UNSET_VALUE
48
+
49
+ # Expand and validate file paths
50
+ @ssh_config_dir = File.expand_path(@ssh_config_dir) if @ssh_config_dir.is_a?(String)
51
+
52
+ # Ensure SSH config directory exists if auto_create_dir is enabled
53
+ ensure_ssh_config_directory if @auto_create_dir && @ssh_config_dir
54
+ end
55
+
56
+ def validate(machine)
57
+ errors = _detected_errors
58
+
59
+ # Validate enabled flag
60
+ unless [true, false].include?(@enabled)
61
+ errors << "sshconfigmanager.enabled must be true or false"
62
+ end
63
+
64
+ # Validate SSH config directory
65
+ if @ssh_config_dir
66
+ unless @ssh_config_dir.is_a?(String)
67
+ errors << "sshconfigmanager.ssh_config_dir must be a string path"
68
+ else
69
+ # Validate directory path format
70
+ expanded_path = File.expand_path(@ssh_config_dir)
71
+ if expanded_path.include?("..") || expanded_path.include?("//")
72
+ errors << "sshconfigmanager.ssh_config_dir contains invalid path components: #{@ssh_config_dir}"
73
+ end
74
+
75
+ # Check if the directory exists or can be created
76
+ unless File.directory?(@ssh_config_dir)
77
+ if @auto_create_dir
78
+ begin
79
+ # Try to create the directory to validate the path
80
+ FileUtils.mkdir_p(@ssh_config_dir, mode: 0700)
81
+ rescue => e
82
+ errors << "sshconfigmanager.ssh_config_dir cannot be created: #{e.message}"
83
+ end
84
+ else
85
+ errors << "sshconfigmanager.ssh_config_dir does not exist and auto_create_dir is disabled: #{@ssh_config_dir}"
86
+ end
87
+ else
88
+ # Check directory permissions
89
+ unless File.readable?(@ssh_config_dir) && File.writable?(@ssh_config_dir)
90
+ errors << "sshconfigmanager.ssh_config_dir is not readable/writable: #{@ssh_config_dir}"
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ # Validate boolean options
97
+ boolean_options = {
98
+ 'auto_remove_on_destroy' => @auto_remove_on_destroy,
99
+ 'update_on_reload' => @update_on_reload,
100
+ 'refresh_on_provision' => @refresh_on_provision,
101
+ 'keep_config_on_halt' => @keep_config_on_halt,
102
+ 'project_isolation' => @project_isolation,
103
+ 'manage_includes' => @manage_includes,
104
+ 'auto_create_dir' => @auto_create_dir,
105
+ 'cleanup_empty_dir' => @cleanup_empty_dir
106
+ }
107
+
108
+ boolean_options.each do |option_name, value|
109
+ unless [true, false].include?(value)
110
+ errors << "sshconfigmanager.#{option_name} must be true or false"
111
+ end
112
+ end
113
+
114
+ # Return validation results
115
+ { "SSH Config Manager" => errors }
116
+ end
117
+
118
+ # Get configuration summary for debugging
119
+ def to_hash
120
+ {
121
+ enabled: @enabled,
122
+ ssh_config_dir: @ssh_config_dir,
123
+ manage_includes: @manage_includes,
124
+ auto_create_dir: @auto_create_dir,
125
+ cleanup_empty_dir: @cleanup_empty_dir,
126
+ auto_remove_on_destroy: @auto_remove_on_destroy,
127
+ update_on_reload: @update_on_reload,
128
+ refresh_on_provision: @refresh_on_provision,
129
+ keep_config_on_halt: @keep_config_on_halt,
130
+ project_isolation: @project_isolation
131
+ }
132
+ end
133
+
134
+ # Check if the plugin should operate for a given action
135
+ def enabled_for_action?(action_name)
136
+ return false unless @enabled
137
+
138
+ case action_name.to_sym
139
+ when :up, :resume
140
+ true
141
+ when :destroy
142
+ @auto_remove_on_destroy
143
+ when :reload
144
+ @update_on_reload
145
+ when :provision
146
+ @refresh_on_provision
147
+ when :halt, :suspend
148
+ @keep_config_on_halt
149
+ else
150
+ false
151
+ end
152
+ end
153
+
154
+ # Merge configuration from another config object (for inheritance)
155
+ def merge(other)
156
+ result = self.class.new
157
+
158
+ # Merge each attribute, preferring the other config's values if set
159
+ result.enabled = other.enabled != UNSET_VALUE ? other.enabled : @enabled
160
+ result.ssh_config_dir = other.ssh_config_dir != UNSET_VALUE ? other.ssh_config_dir : @ssh_config_dir
161
+ result.manage_includes = other.manage_includes != UNSET_VALUE ? other.manage_includes : @manage_includes
162
+ result.auto_create_dir = other.auto_create_dir != UNSET_VALUE ? other.auto_create_dir : @auto_create_dir
163
+ result.cleanup_empty_dir = other.cleanup_empty_dir != UNSET_VALUE ? other.cleanup_empty_dir : @cleanup_empty_dir
164
+ result.auto_remove_on_destroy = other.auto_remove_on_destroy != UNSET_VALUE ? other.auto_remove_on_destroy : @auto_remove_on_destroy
165
+ result.update_on_reload = other.update_on_reload != UNSET_VALUE ? other.update_on_reload : @update_on_reload
166
+ result.refresh_on_provision = other.refresh_on_provision != UNSET_VALUE ? other.refresh_on_provision : @refresh_on_provision
167
+ result.keep_config_on_halt = other.keep_config_on_halt != UNSET_VALUE ? other.keep_config_on_halt : @keep_config_on_halt
168
+ result.project_isolation = other.project_isolation != UNSET_VALUE ? other.project_isolation : @project_isolation
169
+
170
+ result
171
+ end
172
+
173
+ # Create SSH config directory with proper permissions
174
+ def ensure_ssh_config_directory
175
+ return false unless @auto_create_dir
176
+ return true if File.directory?(@ssh_config_dir)
177
+
178
+ begin
179
+ FileUtils.mkdir_p(@ssh_config_dir, mode: 0700)
180
+ true
181
+ rescue => e
182
+ false
183
+ end
184
+ end
185
+
186
+ # Get the appropriate manager instance
187
+ def get_ssh_manager_instance(machine)
188
+ # Use separate file approach with FileManager
189
+ require_relative 'file_manager'
190
+ FileManager.new(self)
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,140 @@
1
+ require 'fcntl'
2
+ require 'timeout'
3
+
4
+ module VagrantPlugins
5
+ module SshConfigManager
6
+ class FileLocker
7
+ # Default timeout for acquiring locks (in seconds)
8
+ DEFAULT_TIMEOUT = 30
9
+
10
+ # Lock types
11
+ LOCK_SHARED = File::LOCK_SH
12
+ LOCK_EXCLUSIVE = File::LOCK_EX
13
+ LOCK_NON_BLOCKING = File::LOCK_NB
14
+
15
+ def initialize(file_path, logger = nil)
16
+ @file_path = file_path
17
+ @logger = logger || Log4r::Logger.new("vagrant::plugins::ssh_config_manager::file_locker")
18
+ @lock_file = nil
19
+ @locked = false
20
+ end
21
+
22
+ # Acquire an exclusive lock on the file
23
+ def with_exclusive_lock(timeout: DEFAULT_TIMEOUT)
24
+ with_lock(LOCK_EXCLUSIVE, timeout: timeout) do
25
+ yield
26
+ end
27
+ end
28
+
29
+ # Acquire a shared lock on the file
30
+ def with_shared_lock(timeout: DEFAULT_TIMEOUT)
31
+ with_lock(LOCK_SHARED, timeout: timeout) do
32
+ yield
33
+ end
34
+ end
35
+
36
+ # Check if file is currently locked by another process
37
+ def locked?
38
+ return false unless File.exist?(@file_path)
39
+
40
+ begin
41
+ File.open(@file_path, 'r') do |file|
42
+ # Try to acquire a non-blocking exclusive lock
43
+ file.flock(LOCK_EXCLUSIVE | LOCK_NON_BLOCKING)
44
+ false # Not locked if we could acquire the lock
45
+ end
46
+ rescue Errno::EAGAIN, Errno::EACCES
47
+ true # File is locked
48
+ rescue => e
49
+ @logger.debug("Error checking lock status: #{e.message}")
50
+ false
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def with_lock(lock_type, timeout: DEFAULT_TIMEOUT)
57
+ acquire_lock(lock_type, timeout: timeout)
58
+
59
+ begin
60
+ yield
61
+ ensure
62
+ release_lock
63
+ end
64
+ end
65
+
66
+ def acquire_lock(lock_type, timeout: DEFAULT_TIMEOUT)
67
+ ensure_directory_exists
68
+
69
+ @logger.debug("Acquiring #{lock_type_name(lock_type)} lock on #{@file_path}")
70
+
71
+ # Use timeout to prevent infinite waiting
72
+ Timeout.timeout(timeout) do
73
+ @lock_file = File.open(@file_path, File::RDWR | File::CREAT, 0600)
74
+ @lock_file.flock(lock_type)
75
+ @locked = true
76
+ @logger.debug("Successfully acquired lock on #{@file_path}")
77
+ end
78
+
79
+ rescue Timeout::Error
80
+ cleanup_lock_file
81
+ raise LockTimeoutError.new("Timeout waiting for lock on #{@file_path} (waited #{timeout}s)")
82
+ rescue => e
83
+ cleanup_lock_file
84
+ @logger.error("Failed to acquire lock on #{@file_path}: #{e.message}")
85
+ raise LockAcquisitionError.new("Could not acquire lock: #{e.message}")
86
+ end
87
+
88
+ def release_lock
89
+ return unless @locked && @lock_file
90
+
91
+ @logger.debug("Releasing lock on #{@file_path}")
92
+
93
+ begin
94
+ @lock_file.flock(File::LOCK_UN)
95
+ rescue => e
96
+ @logger.warn("Error releasing lock: #{e.message}")
97
+ ensure
98
+ cleanup_lock_file
99
+ end
100
+ end
101
+
102
+ def cleanup_lock_file
103
+ if @lock_file
104
+ begin
105
+ @lock_file.close unless @lock_file.closed?
106
+ rescue => e
107
+ @logger.debug("Error closing lock file: #{e.message}")
108
+ ensure
109
+ @lock_file = nil
110
+ @locked = false
111
+ end
112
+ end
113
+ end
114
+
115
+ def ensure_directory_exists
116
+ dir = File.dirname(@file_path)
117
+ unless File.directory?(dir)
118
+ FileUtils.mkdir_p(dir, mode: 0700)
119
+ @logger.debug("Created directory: #{dir}")
120
+ end
121
+ end
122
+
123
+ def lock_type_name(lock_type)
124
+ case lock_type
125
+ when LOCK_SHARED
126
+ "shared"
127
+ when LOCK_EXCLUSIVE
128
+ "exclusive"
129
+ else
130
+ "unknown"
131
+ end
132
+ end
133
+ end
134
+
135
+ # Custom exception classes
136
+ class LockError < StandardError; end
137
+ class LockTimeoutError < LockError; end
138
+ class LockAcquisitionError < LockError; end
139
+ end
140
+ end
@@ -0,0 +1,245 @@
1
+ require 'fileutils'
2
+ require 'digest'
3
+ require 'tempfile'
4
+ require 'log4r'
5
+
6
+ module VagrantPlugins
7
+ module SshConfigManager
8
+ class FileManager
9
+ # Initialize FileManager with configuration
10
+ def initialize(config)
11
+ @config = config
12
+ @logger = Log4r::Logger.new("vagrant::plugins::sshconfigmanager::filemanager")
13
+ end
14
+
15
+ # Generate unique filename for VM SSH config
16
+ # Format: {project_hash}-{vm_name}.conf
17
+ def generate_filename(machine)
18
+ project_hash = generate_project_hash(machine.env.root_path.to_s)
19
+ vm_name = machine.name.to_s
20
+ "#{project_hash}-#{vm_name}.conf"
21
+ end
22
+
23
+ # Get full path for VM SSH config file
24
+ def get_file_path(machine)
25
+ filename = generate_filename(machine)
26
+ File.join(@config.ssh_config_dir, filename)
27
+ end
28
+
29
+ # Generate SSH config content for a VM
30
+ def generate_ssh_config_content(machine)
31
+ ssh_info = machine.ssh_info
32
+ return nil unless ssh_info
33
+
34
+ content = []
35
+ content << "# Managed by vagrant-ssh-config-manager plugin"
36
+ content << "# Project: #{File.basename(machine.env.root_path)}"
37
+ content << "# VM: #{machine.name}"
38
+ content << "# Generated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
39
+ content << ""
40
+
41
+ host_name = generate_host_name(machine)
42
+ content << "Host #{host_name}"
43
+ content << " HostName #{ssh_info[:host]}"
44
+ content << " Port #{ssh_info[:port]}"
45
+ content << " User #{ssh_info[:username]}"
46
+
47
+ if ssh_info[:private_key_path] && ssh_info[:private_key_path].first
48
+ content << " IdentityFile #{ssh_info[:private_key_path].first}"
49
+ content << " IdentitiesOnly yes"
50
+ end
51
+
52
+ content << " UserKnownHostsFile /dev/null"
53
+ content << " StrictHostKeyChecking no"
54
+ content << " PasswordAuthentication no"
55
+ content << " LogLevel FATAL"
56
+ content << ""
57
+
58
+ content.join("\n")
59
+ end
60
+
61
+ # Write SSH config file for VM with atomic operation
62
+ def write_ssh_config_file(machine)
63
+ return false unless @config.enabled
64
+
65
+ file_path = get_file_path(machine)
66
+ content = generate_ssh_config_content(machine)
67
+ return false unless content
68
+
69
+ begin
70
+ # Ensure directory exists
71
+ FileUtils.mkdir_p(File.dirname(file_path), mode: 0700)
72
+
73
+ # Use atomic write with temporary file
74
+ write_file_atomically(file_path, content)
75
+
76
+ @logger.info("SSH config file created: #{file_path}")
77
+ true
78
+ rescue => e
79
+ @logger.error("Failed to write SSH config file #{file_path}: #{e.message}")
80
+ false
81
+ end
82
+ end
83
+
84
+ # Remove SSH config file for VM
85
+ def remove_ssh_config_file(machine)
86
+ file_path = get_file_path(machine)
87
+
88
+ begin
89
+ if File.exist?(file_path)
90
+ File.delete(file_path)
91
+ @logger.info("SSH config file removed: #{file_path}")
92
+
93
+ # Clean up empty directory if configured
94
+ cleanup_empty_directory if @config.cleanup_empty_dir
95
+ true
96
+ else
97
+ @logger.debug("SSH config file does not exist: #{file_path}")
98
+ false
99
+ end
100
+ rescue => e
101
+ @logger.error("Failed to remove SSH config file #{file_path}: #{e.message}")
102
+ false
103
+ end
104
+ end
105
+
106
+ # Check if SSH config file exists for VM
107
+ def ssh_config_file_exists?(machine)
108
+ File.exist?(get_file_path(machine))
109
+ end
110
+
111
+ # Validate SSH config file content
112
+ def validate_ssh_config_content(content)
113
+ return false if content.nil? || content.empty?
114
+
115
+ # Basic validation - check for required SSH config elements
116
+ content.include?("Host ") && content.include?("HostName ") && content.include?("Port ")
117
+ end
118
+
119
+ # Detect and clean up orphaned SSH config files
120
+ def cleanup_orphaned_files
121
+ return unless Dir.exist?(@config.ssh_config_dir)
122
+
123
+ orphaned_files = []
124
+ config_files = Dir.glob(File.join(@config.ssh_config_dir, "*.conf"))
125
+
126
+ config_files.each do |file_path|
127
+ filename = File.basename(file_path, ".conf")
128
+
129
+ # Parse filename to extract project hash and VM name
130
+ if filename.match(/^([a-f0-9]{8})-(.+)$/)
131
+ project_hash = $1
132
+ vm_name = $2
133
+
134
+ # Check if this looks like an orphaned file
135
+ # (This is a basic heuristic - in practice, you might want more sophisticated detection)
136
+ file_age = Time.now - File.mtime(file_path)
137
+
138
+ # Consider files older than 30 days as potentially orphaned
139
+ if file_age > (30 * 24 * 60 * 60) # 30 days in seconds
140
+ orphaned_files << {
141
+ path: file_path,
142
+ project_hash: project_hash,
143
+ vm_name: vm_name,
144
+ age_days: (file_age / (24 * 60 * 60)).round
145
+ }
146
+ end
147
+ end
148
+ end
149
+
150
+ # Log detected orphaned files
151
+ unless orphaned_files.empty?
152
+ @logger.info("Detected #{orphaned_files.length} potentially orphaned SSH config files")
153
+ orphaned_files.each do |file_info|
154
+ @logger.debug("Orphaned file: #{file_info[:path]} (#{file_info[:age_days]} days old)")
155
+ end
156
+ end
157
+
158
+ orphaned_files
159
+ end
160
+
161
+ # Remove orphaned SSH config files
162
+ def remove_orphaned_files
163
+ orphaned_files = cleanup_orphaned_files
164
+ removed_count = 0
165
+
166
+ orphaned_files.each do |file_info|
167
+ begin
168
+ File.delete(file_info[:path])
169
+ @logger.info("Removed orphaned SSH config file: #{file_info[:path]}")
170
+ removed_count += 1
171
+ rescue => e
172
+ @logger.error("Failed to remove orphaned file #{file_info[:path]}: #{e.message}")
173
+ end
174
+ end
175
+
176
+ # Clean up empty directory if configured
177
+ cleanup_empty_directory if @config.cleanup_empty_dir && removed_count > 0
178
+
179
+ removed_count
180
+ end
181
+
182
+ # Get all config files in the directory
183
+ def get_all_config_files
184
+ return [] unless Dir.exist?(@config.ssh_config_dir)
185
+ Dir.glob(File.join(@config.ssh_config_dir, "*.conf"))
186
+ end
187
+
188
+ private
189
+
190
+ # Generate unique project hash from root path
191
+ def generate_project_hash(root_path)
192
+ Digest::MD5.hexdigest(root_path)[0, 8]
193
+ end
194
+
195
+ # Generate SSH host name for machine
196
+ def generate_host_name(machine)
197
+ if @config.project_isolation
198
+ project_name = File.basename(machine.env.root_path)
199
+ "#{project_name}-#{machine.name}"
200
+ else
201
+ machine.name.to_s
202
+ end
203
+ end
204
+
205
+ # Write file atomically using temporary file and rename
206
+ def write_file_atomically(file_path, content)
207
+ temp_file = Tempfile.new(File.basename(file_path), File.dirname(file_path))
208
+ begin
209
+ temp_file.write(content)
210
+ temp_file.close
211
+
212
+ # Set proper permissions before moving
213
+ File.chmod(0600, temp_file.path)
214
+
215
+ # Atomic move
216
+ FileUtils.mv(temp_file.path, file_path)
217
+ ensure
218
+ temp_file.unlink if temp_file && File.exist?(temp_file.path)
219
+ end
220
+ end
221
+
222
+ # Clean up empty directory if no config files remain
223
+ def cleanup_empty_directory
224
+ return unless Dir.exist?(@config.ssh_config_dir)
225
+
226
+ entries = Dir.entries(@config.ssh_config_dir) - %w[. ..]
227
+ if entries.empty?
228
+ begin
229
+ # Remove Include directive before removing directory
230
+ if @config.manage_includes
231
+ require_relative 'include_manager'
232
+ include_manager = IncludeManager.new(@config)
233
+ include_manager.remove_include_directive
234
+ end
235
+
236
+ Dir.rmdir(@config.ssh_config_dir)
237
+ @logger.info("Removed empty SSH config directory: #{@config.ssh_config_dir}")
238
+ rescue => e
239
+ @logger.error("Failed to remove empty directory #{@config.ssh_config_dir}: #{e.message}")
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end