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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +175 -0
- data/LICENSE +21 -0
- data/README.md +628 -0
- data/lib/vagrant-orbstack/action/create.rb +177 -0
- data/lib/vagrant-orbstack/action/destroy.rb +90 -0
- data/lib/vagrant-orbstack/action/halt.rb +64 -0
- data/lib/vagrant-orbstack/action/machine_validation.rb +60 -0
- data/lib/vagrant-orbstack/action/reload.rb +56 -0
- data/lib/vagrant-orbstack/action/ssh_run.rb +70 -0
- data/lib/vagrant-orbstack/action/start.rb +68 -0
- data/lib/vagrant-orbstack/action.rb +21 -0
- data/lib/vagrant-orbstack/config.rb +161 -0
- data/lib/vagrant-orbstack/errors.rb +64 -0
- data/lib/vagrant-orbstack/plugin.rb +45 -0
- data/lib/vagrant-orbstack/provider.rb +460 -0
- data/lib/vagrant-orbstack/util/machine_namer.rb +130 -0
- data/lib/vagrant-orbstack/util/orbstack_cli.rb +283 -0
- data/lib/vagrant-orbstack/util/ssh_readiness_checker.rb +115 -0
- data/lib/vagrant-orbstack/util/state_cache.rb +106 -0
- data/lib/vagrant-orbstack/version.rb +7 -0
- data/lib/vagrant-orbstack-provider.rb +6 -0
- data/lib/vagrant-orbstack.rb +36 -0
- data/locales/en.yml +49 -0
- metadata +71 -0
|
@@ -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
|