vagrant-orbstack-provider 0.1.1

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,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'vagrant-orbstack/errors'
6
+ require 'vagrant-orbstack/util/state_cache'
7
+ require 'vagrant-orbstack/util/orbstack_cli'
8
+ require 'vagrant-orbstack/action'
9
+
10
+ module VagrantPlugins
11
+ module OrbStack
12
+ # Vagrant provider implementation for OrbStack.
13
+ #
14
+ # This class implements the Vagrant provider interface, delegating
15
+ # machine lifecycle operations to OrbStack via CLI commands.
16
+ #
17
+ # @api public
18
+ class Provider < Vagrant.plugin('2', :provider)
19
+ # Initialize the provider with a machine instance.
20
+ #
21
+ # @param [Vagrant::Machine] machine The machine this provider is for
22
+ # @api public
23
+ def initialize(machine)
24
+ @machine = machine
25
+ @logger = Log4r::Logger.new('vagrant_orbstack::provider')
26
+ end
27
+
28
+ # Return action middleware for requested operation.
29
+ #
30
+ # Creates and returns an Action::Builder containing the appropriate
31
+ # middleware stack for the requested operation. Unsupported actions
32
+ # return nil.
33
+ #
34
+ # @param [Symbol] name The action name (:up, :halt, :destroy, etc.)
35
+ # @return [Vagrant::Action::Builder, nil] Action middleware builder or nil
36
+ # @api public
37
+ def action(name)
38
+ # Returns nil for unsupported actions (future stories, etc.)
39
+ action_builders[name]&.call
40
+ end
41
+
42
+ # Provide SSH connection information for the machine.
43
+ #
44
+ # Returns SSH connection parameters for Vagrant to connect to the machine
45
+ # using OrbStack's SSH proxy architecture.
46
+ #
47
+ # CRITICAL: OrbStack uses SSH proxy at localhost:32222, NOT direct SSH to VM IP.
48
+ #
49
+ # Returns nil if the machine is not running.
50
+ #
51
+ # @return [Hash, nil] SSH connection parameters with keys:
52
+ # - :host - Always '127.0.0.1' (OrbStack SSH proxy, NOT VM IP)
53
+ # - :port - Always 32222 (OrbStack SSH proxy port, NOT 22)
54
+ # - :username - Machine ID for proxy routing
55
+ # - :private_key_path - OrbStack's auto-generated ED25519 key
56
+ # - :forward_agent - Whether to forward SSH agent (from config)
57
+ # @api public
58
+ def ssh_info
59
+ # Return nil if machine is not running
60
+ current_state = state
61
+ return nil if %i[not_created stopped].include?(current_state.id)
62
+
63
+ # Return OrbStack SSH proxy configuration
64
+ {
65
+ host: '127.0.0.1',
66
+ port: 32_222,
67
+ username: @machine.id,
68
+ private_key_path: File.expand_path('~/.orbstack/ssh/id_ed25519'),
69
+ proxy_command: orbstack_proxy_command,
70
+ forward_agent: @machine.provider_config.forward_agent
71
+ }
72
+ end
73
+
74
+ # Return current machine state.
75
+ #
76
+ # Queries OrbStack CLI to determine the current state of the machine.
77
+ # Results are cached with a 5-second TTL to reduce redundant CLI calls.
78
+ # State is automatically invalidated when state-changing actions occur.
79
+ #
80
+ # @return [Vagrant::MachineState] Current state of the machine
81
+ # @api public
82
+ def state
83
+ # Return early if machine ID is nil
84
+ return not_created_state('The machine has not been created') if @machine.id.nil?
85
+
86
+ # Check cache first
87
+ cached_state = state_cache.get(@machine.id)
88
+ return cached_state if cached_state
89
+
90
+ # Cache miss: Query OrbStack CLI
91
+ query_and_cache_state
92
+ rescue StandardError => e
93
+ # Handle query errors gracefully
94
+ handle_state_query_error(e)
95
+ end
96
+
97
+ # Invalidate the state cache.
98
+ #
99
+ # Clears all cached state entries, forcing the next state query to
100
+ # fetch fresh data from OrbStack CLI. This is typically called by
101
+ # action middleware after state-changing operations (create, start, stop).
102
+ #
103
+ # @return [void]
104
+ # @api public
105
+ def invalidate_state_cache
106
+ state_cache.invalidate_all
107
+ end
108
+
109
+ # Human-readable provider description.
110
+ #
111
+ # @return [String] Provider name
112
+ # @api public
113
+ def to_s
114
+ 'OrbStack'
115
+ end
116
+
117
+ # Callback invoked when the machine ID changes.
118
+ #
119
+ # Persists the new machine ID to the data directory for retrieval
120
+ # in future Vagrant sessions. This is called by Vagrant core when
121
+ # a machine is created or its ID is updated.
122
+ #
123
+ # The guard clause ensures we only persist when the machine has a valid ID,
124
+ # as some test scenarios may not have @machine.id available.
125
+ #
126
+ # @return [void]
127
+ # @api public
128
+ def machine_id_changed
129
+ # Guard clause: Only persist if machine has an ID
130
+ # Some test scenarios may not have @machine.id available
131
+ return unless @machine.respond_to?(:id) && !@machine.id.nil?
132
+
133
+ write_machine_id(@machine.id)
134
+ end
135
+
136
+ # Read the machine ID from persistent storage.
137
+ #
138
+ # Reads the machine ID from the id file in the data directory.
139
+ # Returns nil if the file doesn't exist or cannot be read.
140
+ #
141
+ # @return [String, nil] The machine ID if found, nil otherwise
142
+ # @raise [Errno::EACCES] If permission denied (logged and returns nil)
143
+ # @raise [Errno::ENOENT] If file not found (logged and returns nil)
144
+ # @raise [Encoding::InvalidByteSequenceError] If file contains invalid data (logged and returns nil)
145
+ # @api public
146
+ def read_machine_id
147
+ return nil unless File.exist?(id_file_path)
148
+
149
+ File.read(id_file_path).strip
150
+ rescue Errno::EACCES, Errno::ENOENT, Encoding::InvalidByteSequenceError => e
151
+ # Log error and return nil - graceful degradation for non-critical errors
152
+ @machine.ui&.warn("OrbStack: Could not read machine ID: #{e.message}")
153
+ nil
154
+ end
155
+
156
+ # Write the machine ID to persistent storage.
157
+ #
158
+ # Writes the machine ID to the id file in the data directory.
159
+ # Creates the directory if it doesn't exist.
160
+ #
161
+ # @param [String] machine_id The machine ID to persist
162
+ # @return [void]
163
+ # @raise [Errno::EACCES] If permission denied
164
+ # @raise [Errno::ENOSPC] If disk is full
165
+ # @raise [Errno::EROFS] If filesystem is read-only
166
+ # @api public
167
+ def write_machine_id(machine_id)
168
+ ensure_data_dir_exists
169
+ File.write(id_file_path, machine_id)
170
+ rescue Errno::EACCES, Errno::ENOSPC, Errno::EROFS => e
171
+ # Log error and re-raise - critical errors that cannot be ignored
172
+ @machine.ui&.error("OrbStack: Could not write machine ID: #{e.message}")
173
+ raise
174
+ end
175
+
176
+ # Read machine metadata from persistent storage.
177
+ #
178
+ # Reads metadata from the metadata.json file in the data directory.
179
+ # Returns an empty hash if the file doesn't exist or contains invalid JSON.
180
+ #
181
+ # @return [Hash] The metadata hash, or empty hash if not found
182
+ # @raise [JSON::ParserError] If JSON is invalid (logged and returns {})
183
+ # @raise [Errno::EACCES] If permission denied (logged and returns {})
184
+ # @raise [Errno::ENOENT] If file not found (logged and returns {})
185
+ # @raise [Encoding::InvalidByteSequenceError] If file contains invalid data (logged and returns {})
186
+ # @api public
187
+ def read_metadata
188
+ return {} unless File.exist?(metadata_file_path)
189
+
190
+ JSON.parse(File.read(metadata_file_path))
191
+ rescue JSON::ParserError, Errno::EACCES, Errno::ENOENT, Encoding::InvalidByteSequenceError => e
192
+ # Log error and return empty hash - graceful degradation for non-critical errors
193
+ @machine.ui&.warn("OrbStack: Could not read metadata: #{e.message}")
194
+ {}
195
+ end
196
+
197
+ # Write machine metadata to persistent storage.
198
+ #
199
+ # Writes metadata to the metadata.json file in the data directory.
200
+ # Creates the directory if it doesn't exist. Formats JSON for readability.
201
+ #
202
+ # @param [Hash] metadata The metadata hash to persist
203
+ # @return [void]
204
+ # @raise [JSON::ParserError] If JSON generation fails
205
+ # @raise [Errno::EACCES] If permission denied
206
+ # @raise [Errno::ENOSPC] If disk is full
207
+ # @raise [Errno::EROFS] If filesystem is read-only
208
+ # @api public
209
+ def write_metadata(metadata)
210
+ ensure_data_dir_exists
211
+ File.write(metadata_file_path, JSON.pretty_generate(metadata))
212
+ rescue JSON::ParserError, Errno::EACCES, Errno::ENOSPC, Errno::EROFS => e
213
+ # Log error and re-raise - critical errors that cannot be ignored
214
+ @machine.ui&.error("OrbStack: Could not write metadata: #{e.message}")
215
+ raise
216
+ end
217
+
218
+ # Path to the machine ID file.
219
+ #
220
+ # @return [Pathname] Path to the ID file
221
+ # @api public
222
+ def id_file_path
223
+ @machine.data_dir.join('id')
224
+ end
225
+
226
+ # Path to the metadata JSON file.
227
+ #
228
+ # @return [Pathname] Path to the metadata file
229
+ # @api public
230
+ def metadata_file_path
231
+ @machine.data_dir.join('metadata.json')
232
+ end
233
+
234
+ private
235
+
236
+ # Get the state cache instance.
237
+ #
238
+ # Lazy-initializes a StateCache instance with 5-second TTL.
239
+ # The cache is shared across all state queries for this provider instance.
240
+ #
241
+ # @return [Util::StateCache] The state cache instance
242
+ # @api private
243
+ def state_cache
244
+ @state_cache ||= Util::StateCache.new(ttl: 5)
245
+ end
246
+
247
+ # Generate OrbStack SSH ProxyCommand.
248
+ #
249
+ # OrbStack routes all SSH connections through the OrbStack Helper app
250
+ # using a ProxyCommand. This returns the correctly formatted command
251
+ # that Vagrant's SSH layer will use.
252
+ #
253
+ # @return [String] ProxyCommand string for SSH config
254
+ # @api private
255
+ def orbstack_proxy_command
256
+ helper_path = '/Applications/OrbStack.app/Contents/Frameworks/' \
257
+ 'OrbStack Helper.app/Contents/MacOS/OrbStack Helper'
258
+ "'#{helper_path}' ssh-proxy-fdpass #{Process.uid}"
259
+ end
260
+
261
+ # Map OrbStack machine info to Vagrant state tuple.
262
+ #
263
+ # Converts OrbStack machine status to Vagrant state representation.
264
+ # Returns a tuple of [state_id, short_description, long_description].
265
+ #
266
+ # @param machine_info [Hash, nil] Machine info from OrbStack CLI with :name and :status,
267
+ # or nil if machine was not found
268
+ # @return [Array<Symbol, String, String>] Tuple of [state_id, short_desc, long_desc]
269
+ # @api private
270
+ def map_orbstack_state_to_vagrant(machine_info)
271
+ if machine_info.nil?
272
+ [:not_created, 'not created', 'Machine does not exist in OrbStack']
273
+ elsif machine_info[:status] == 'running'
274
+ [:running, 'running', 'Machine is running in OrbStack']
275
+ elsif machine_info[:status] == 'stopped'
276
+ [:stopped, 'stopped', 'Machine is stopped']
277
+ else
278
+ # Unknown state - treat as not created
279
+ [:not_created, 'unknown', 'Machine state is unknown']
280
+ end
281
+ end
282
+
283
+ # Create a :not_created MachineState with appropriate description.
284
+ #
285
+ # @param reason [String] The reason the machine is not created
286
+ # @return [Vagrant::MachineState] A not_created state object
287
+ # @api private
288
+ def not_created_state(reason)
289
+ Vagrant::MachineState.new(:not_created, 'not created', reason)
290
+ end
291
+
292
+ # Query OrbStack CLI for current machine state and cache result.
293
+ #
294
+ # @return [Vagrant::MachineState] The queried machine state
295
+ # @api private
296
+ def query_and_cache_state
297
+ machines = Util::OrbStackCLI.list_machines
298
+ our_machine = machines.find { |m| m[:name] == @machine.id }
299
+
300
+ # Map OrbStack state to Vagrant state
301
+ state_id, short_desc, long_desc = map_orbstack_state_to_vagrant(our_machine)
302
+
303
+ # Create MachineState and cache it
304
+ machine_state = Vagrant::MachineState.new(state_id, short_desc, long_desc)
305
+ state_cache.set(@machine.id, machine_state)
306
+
307
+ machine_state
308
+ end
309
+
310
+ # Handle error during state query and return not_created state.
311
+ #
312
+ # @param error [Exception] The error that occurred
313
+ # @return [Vagrant::MachineState] A not_created state object
314
+ # @api private
315
+ def handle_state_query_error(error)
316
+ if error.is_a?(CommandTimeoutError)
317
+ @machine.ui.warn('OrbStack: Command timeout querying machine state')
318
+ @logger.warn("State query timed out: #{error.message}")
319
+ not_created_state('Timeout querying machine state')
320
+ else
321
+ @machine.ui.warn("OrbStack: Error querying machine state: #{error.message}")
322
+ @logger.error("Failed to query machine state: #{error.message}")
323
+ not_created_state('Error querying machine state')
324
+ end
325
+ end
326
+
327
+ # Ensure the data directory exists.
328
+ #
329
+ # Creates the data directory if it doesn't exist.
330
+ #
331
+ # @return [void]
332
+ # @api private
333
+ def ensure_data_dir_exists
334
+ dir = @machine.data_dir
335
+ FileUtils.mkdir_p(dir)
336
+ end
337
+
338
+ # Map action names to their builder methods.
339
+ #
340
+ # @return [Hash{Symbol => Method}] Action name to builder method mapping
341
+ # @api private
342
+ def action_builders
343
+ {
344
+ up: method(:build_up_action),
345
+ halt: method(:build_halt_action),
346
+ reload: method(:build_reload_action),
347
+ ssh: method(:build_ssh_action),
348
+ ssh_run: method(:build_ssh_run_action),
349
+ provision: method(:build_provision_action),
350
+ destroy: method(:build_destroy_action)
351
+ }
352
+ end
353
+
354
+ # Build the :up action: Create.
355
+ #
356
+ # @return [Vagrant::Action::Builder] Configured action builder
357
+ # @api private
358
+ def build_up_action
359
+ Vagrant::Action::Builder.new.tap do |b|
360
+ b.use Action::Create
361
+ end
362
+ end
363
+
364
+ # Build the :halt action: Halt.
365
+ #
366
+ # @return [Vagrant::Action::Builder] Configured action builder
367
+ # @api private
368
+ def build_halt_action
369
+ Vagrant::Action::Builder.new.tap do |b|
370
+ b.use Action::Halt
371
+ end
372
+ end
373
+
374
+ # Build reload action: Halt → Start → (optional) Provision
375
+ #
376
+ # @param env [Hash] Environment hash (unused, for consistency)
377
+ # @return [Vagrant::Action::Builder] Configured action builder
378
+ # @api private
379
+ def build_reload_action(_env = nil)
380
+ Vagrant::Action::Builder.new.tap do |b|
381
+ b.use Action::Halt
382
+ b.use Action::Start
383
+ include_provisioning(b)
384
+ end
385
+ end
386
+
387
+ # Build the :ssh action: validate readiness, then open an interactive SSH session.
388
+ #
389
+ # @return [Vagrant::Action::Builder] Configured action builder
390
+ # @api private
391
+ def build_ssh_action
392
+ Vagrant::Action::Builder.new.tap do |b|
393
+ b.use Action::SSHRun
394
+ include_builtin(b, :SSHExec)
395
+ end
396
+ end
397
+
398
+ # Build the :ssh_run action: validate readiness, then run a single SSH command.
399
+ #
400
+ # @return [Vagrant::Action::Builder] Configured action builder
401
+ # @api private
402
+ def build_ssh_run_action
403
+ Vagrant::Action::Builder.new.tap do |b|
404
+ b.use Action::SSHRun
405
+ include_builtin(b, :SSHRun)
406
+ end
407
+ end
408
+
409
+ # Build the :provision action: validate readiness, then run provisioners.
410
+ #
411
+ # @return [Vagrant::Action::Builder] Configured action builder
412
+ # @api private
413
+ def build_provision_action
414
+ Vagrant::Action::Builder.new.tap do |b|
415
+ b.use Action::SSHRun
416
+ include_provisioning(b)
417
+ end
418
+ end
419
+
420
+ # Build the :destroy action: Destroy.
421
+ #
422
+ # @return [Vagrant::Action::Builder] Configured action builder
423
+ # @api private
424
+ def build_destroy_action
425
+ Vagrant::Action::Builder.new.tap do |b|
426
+ b.use Action::Destroy
427
+ end
428
+ end
429
+
430
+ # Include one of Vagrant's built-in action middlewares if available.
431
+ #
432
+ # Defensive check ensures compatibility when Vagrant::Action::Builtin
433
+ # isn't loaded (e.g. in unit tests that mock the Vagrant module).
434
+ #
435
+ # @param builder [Vagrant::Action::Builder] Builder to modify
436
+ # @param name [Symbol] Constant name under Vagrant::Action::Builtin
437
+ # @return [void]
438
+ # @api private
439
+ def include_builtin(builder, name)
440
+ return unless defined?(Vagrant::Action::Builtin) && Vagrant::Action::Builtin.const_defined?(name)
441
+
442
+ builder.use Vagrant::Action::Builtin.const_get(name)
443
+ end
444
+
445
+ # Include provisioning middleware if available.
446
+ #
447
+ # Conditionally includes Vagrant's built-in Provision middleware.
448
+ # Defensive check ensures compatibility if middleware isn't available.
449
+ #
450
+ # @param builder [Vagrant::Action::Builder] Builder to modify
451
+ # @return [void]
452
+ # @api private
453
+ def include_provisioning(builder)
454
+ return unless defined?(Vagrant::Action::Builtin::Provision)
455
+
456
+ builder.use Vagrant::Action::Builtin::Provision
457
+ end
458
+ end
459
+ end
460
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'orbstack_cli'
5
+ require_relative '../errors'
6
+
7
+ module VagrantPlugins
8
+ module OrbStack
9
+ module Util
10
+ # Utility class for generating unique machine names with collision avoidance
11
+ #
12
+ # Generates machine names in the format: vagrant-<sanitized-name>-<short-id>
13
+ # where short-id is a 6-character random hex string. Implements collision
14
+ # detection and automatic retry with new IDs.
15
+ #
16
+ # @example Generate a unique machine name
17
+ # machine = double('machine', name: 'web_server')
18
+ # name = MachineNamer.generate(machine)
19
+ # # => "vagrant-web-server-a3b2c1"
20
+ #
21
+ # @example Collision handling
22
+ # # If "vagrant-default-a3b2c1" exists, generates new ID automatically
23
+ # name = MachineNamer.generate(machine)
24
+ # # => "vagrant-default-d4e5f6" (different ID)
25
+ #
26
+ # @api public
27
+ class MachineNamer
28
+ # Maximum number of retry attempts for collision avoidance
29
+ MAX_RETRIES = 3
30
+
31
+ # Maximum machine name length (DNS hostname limit)
32
+ MAX_NAME_LENGTH = 63
33
+
34
+ # Generate a unique machine name with collision avoidance.
35
+ #
36
+ # Creates a machine name in the format vagrant-<name>-<id> where:
37
+ # - name is sanitized from machine.name (lowercase, hyphens, alphanumeric)
38
+ # - id is a 6-character random hex string
39
+ #
40
+ # If a collision is detected (name already exists in OrbStack), retries
41
+ # with a new random ID up to MAX_RETRIES times.
42
+ #
43
+ # @param machine [Vagrant::Machine] The machine object with name attribute
44
+ # @return [String] A unique machine name
45
+ # @raise [MachineNameCollisionError] If all retry attempts are exhausted
46
+ # @raise [OrbStackNotInstalled] If OrbStack CLI is not available
47
+ # @raise [CommandTimeoutError] If OrbStack CLI times out
48
+ # @api public
49
+ def self.generate(machine)
50
+ machine_name = machine.name.to_s
51
+ sanitized = sanitize_name(machine_name)
52
+
53
+ MAX_RETRIES.times do
54
+ short_id = SecureRandom.hex(3)
55
+ candidate = "vagrant-#{sanitized}-#{short_id}"
56
+
57
+ return candidate unless check_collision?(candidate)
58
+ end
59
+
60
+ # All retries exhausted - raise error
61
+ raise MachineNameCollisionError,
62
+ "Failed to generate unique machine name after #{MAX_RETRIES} attempts (machine: #{machine_name})"
63
+ end
64
+
65
+ # Sanitize a machine name for use in hostname.
66
+ #
67
+ # Applies the following transformations:
68
+ # - Convert to lowercase
69
+ # - Replace underscores with hyphens
70
+ # - Remove all characters except alphanumeric and hyphens
71
+ # - Strip leading/trailing whitespace
72
+ # - Collapse consecutive hyphens to single hyphen
73
+ # - Truncate to fit within MAX_NAME_LENGTH minus prefix/suffix space
74
+ # - Default to "default" if result is empty
75
+ #
76
+ # @param name [String, nil] The name to sanitize
77
+ # @return [String] The sanitized name
78
+ # @api private
79
+ class << self
80
+ private
81
+
82
+ # Method length acceptable: Each transformation step is clear and well-documented.
83
+ # Combining steps would reduce readability.
84
+ # rubocop:disable Metrics/MethodLength
85
+ def sanitize_name(name)
86
+ # Handle nil or empty input → default
87
+ return 'default' if name.nil? || name.strip.empty?
88
+
89
+ # Apply sanitization rules:
90
+ # 1. Strip whitespace
91
+ # 2. Convert to lowercase
92
+ # 3. Replace underscores with hyphens (DNS-safe)
93
+ # 4. Remove non-alphanumeric (except hyphens)
94
+ # 5. Collapse consecutive hyphens
95
+ # 6. Remove leading/trailing hyphens
96
+ sanitized = name.to_s
97
+ .strip # Rule 1
98
+ .downcase # Rule 2
99
+ .gsub('_', '-') # Rule 3
100
+ .gsub(/[^a-z0-9-]/, '') # Rule 4
101
+ .gsub(/-+/, '-') # Rule 5
102
+ .gsub(/^-|-$/, '') # Rule 6
103
+
104
+ return 'default' if sanitized.empty?
105
+
106
+ # DNS limit (63) - "vagrant-" (8) - "-XXXXXX" (7) = 48 max
107
+ max_length = MAX_NAME_LENGTH - 15
108
+ sanitized[0...max_length]
109
+ end
110
+ # rubocop:enable Metrics/MethodLength
111
+
112
+ # Check if a machine name already exists in OrbStack.
113
+ #
114
+ # Queries OrbStack CLI for list of existing machines and checks
115
+ # if the candidate name is already in use.
116
+ #
117
+ # @param name [String] The candidate name to check
118
+ # @return [Boolean] true if collision detected, false otherwise
119
+ # @raise [OrbStackNotInstalled] If OrbStack CLI is not available
120
+ # @raise [CommandTimeoutError] If OrbStack CLI times out
121
+ # @api private
122
+ def check_collision?(name)
123
+ machines = OrbStackCLI.list_machines
124
+ machines.any? { |m| m[:name] == name }
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end