vagrant-eryph 0.1.0

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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'digest'
5
+
6
+ module VagrantPlugins
7
+ module Eryph
8
+ module Helpers
9
+ class CloudInit
10
+ VAGRANT_PUBLIC_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzIw+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoPkcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NOTd0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcWyLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQ== vagrant'
11
+
12
+ def initialize(machine)
13
+ @machine = machine
14
+ @config = machine.provider_config
15
+ @ui = machine.ui
16
+ end
17
+
18
+ # Generate cloud-init fodder for Vagrant user setup
19
+ def generate_vagrant_user_fodder
20
+ return [] unless @config.auto_config
21
+
22
+ if detect_os_type == :windows
23
+ generate_windows_user_fodder
24
+ else
25
+ generate_linux_user_fodder
26
+ end
27
+ end
28
+
29
+ # Merge auto-generated fodder with user-provided fodder
30
+ def merge_fodder_with_user_config(auto_fodder)
31
+ @config.merged_fodder(auto_fodder)
32
+ end
33
+
34
+ # Generate complete fodder configuration including user config
35
+ def generate_complete_fodder
36
+ auto_fodder = generate_vagrant_user_fodder
37
+
38
+ # Add Vagrant cloud-init configuration if present
39
+ cloud_init_fodder = @config.extract_vagrant_cloud_init_config(@machine)
40
+ auto_fodder.concat(cloud_init_fodder) if cloud_init_fodder.any?
41
+
42
+ merge_fodder_with_user_config(auto_fodder)
43
+ end
44
+
45
+ # Detect OS type using Vagrant's guest detection
46
+ def detect_os_type
47
+ guest = @machine.config.vm.guest
48
+ guest if %i[windows linux].include?(guest)
49
+ end
50
+
51
+ # Get effective password - resolves :auto based on OS type
52
+ def effective_password
53
+ return @config.vagrant_password unless @config.vagrant_password == :auto
54
+
55
+ guest = @machine.config.vm.guest
56
+ if guest == :windows
57
+ 'InitialPassw0rd' # Eryph Windows default
58
+ else
59
+ 'vagrant' # Standard Vagrant for Linux
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def generate_linux_user_fodder
66
+ cloud_config = {
67
+ 'users' => [
68
+ {
69
+ 'name' => 'vagrant',
70
+ 'sudo' => ['ALL=(ALL) NOPASSWD:ALL'],
71
+ 'shell' => '/bin/bash',
72
+ 'groups' => ['adm'],
73
+ 'lock_passwd' => false,
74
+ 'plain_text_passwd' => effective_password,
75
+ 'ssh_authorized_keys' => [VAGRANT_PUBLIC_KEY]
76
+ }
77
+ ]
78
+ }
79
+
80
+ [
81
+ {
82
+ name: 'vagrant-user-setup',
83
+ type: 'cloud-config',
84
+ content: cloud_config
85
+ }
86
+ ]
87
+ end
88
+
89
+ def generate_windows_user_fodder
90
+ # Always create vagrant user with config password
91
+ cloud_config = {
92
+ 'users' => [
93
+ {
94
+ 'name' => 'vagrant',
95
+ 'groups' => ['Administrators'],
96
+ 'passwd' => effective_password,
97
+ 'ssh_authorized_keys' => [VAGRANT_PUBLIC_KEY]
98
+ }
99
+ ]
100
+ }
101
+
102
+ [
103
+ {
104
+ name: 'vagrant-user-setup-windows',
105
+ type: 'cloud-config',
106
+ content: cloud_config
107
+ }
108
+ ]
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'eryph'
4
+ require 'set'
5
+ require 'log4r'
6
+ require 'yaml'
7
+ require_relative '../errors'
8
+
9
+ module VagrantPlugins
10
+ module Eryph
11
+ module Helpers
12
+ class EryphClient
13
+ # Permission scopes for minimal access
14
+ SCOPES = {
15
+ # Catlet-specific scopes
16
+ CATLETS_READ: 'compute:catlets:read',
17
+ CATLETS_WRITE: 'compute:catlets:write',
18
+ CATLETS_CONTROL: 'compute:catlets:control',
19
+
20
+ # Project-specific scopes
21
+ PROJECTS_READ: 'compute:projects:read',
22
+ PROJECTS_WRITE: 'compute:projects:write'
23
+ }.freeze
24
+
25
+ def initialize(machine)
26
+ @machine = machine
27
+ @config = machine.provider_config
28
+ @ui = machine.ui
29
+ @client = nil
30
+ @logger = Log4r::Logger.new('vagrant::eryph::client')
31
+ end
32
+
33
+ def client(scopes = nil)
34
+ # Create a new client if scopes changed or client doesn't exist
35
+ requested_scopes = scopes || [SCOPES[:CATLETS_WRITE], SCOPES[:PROJECTS_WRITE]]
36
+
37
+ if @client.nil? || @last_scopes != requested_scopes
38
+ @client = create_client(requested_scopes)
39
+ @last_scopes = requested_scopes
40
+ end
41
+
42
+ @client
43
+ end
44
+
45
+ def list_catlets
46
+ handle_api_errors do
47
+ response = client([SCOPES[:CATLETS_READ]]).catlets.catlets_list
48
+ # CatletList object contains 'value' array with actual catlets
49
+ response.respond_to?(:value) ? response.value : []
50
+ end
51
+ rescue Errors::EryphError
52
+ [] # Return empty array on API errors to allow graceful degradation
53
+ end
54
+
55
+ def get_catlet(catlet_id)
56
+ handle_api_errors do
57
+ client([SCOPES[:CATLETS_READ]]).catlets.catlets_get(catlet_id)
58
+ end
59
+ rescue Errors::EryphError
60
+ nil # Return nil on errors to allow graceful handling
61
+ end
62
+
63
+ def create_catlet(catlet_config_hash)
64
+ SecureRandom.uuid
65
+ @ui.info("Creating catlet: #{catlet_config_hash[:name]}")
66
+
67
+ # Validate configuration first
68
+ raise 'Catlet configuration validation failed' unless validate_catlet_config(catlet_config_hash)
69
+
70
+ # Create proper NewCatletRequest object with hash configuration
71
+ request_obj = ::Eryph::ComputeClient::NewCatletRequest.new(
72
+ configuration: catlet_config_hash
73
+ )
74
+
75
+ operation = client([SCOPES[:CATLETS_WRITE]]).catlets.catlets_create({ new_catlet_request: request_obj })
76
+
77
+ raise 'Failed to create catlet: No operation returned' unless operation&.id
78
+
79
+ @logger.info("Operation ID: #{operation.id} - Creating catlet...")
80
+ result = wait_for_operation(operation.id)
81
+
82
+ if result.completed?
83
+ # Use OperationResult's catlet accessor
84
+ catlet = result.catlet
85
+ raise "Operation ID: #{operation.id} - Catlet creation completed but catlet not found" unless catlet
86
+
87
+ @logger.info("Operation ID: #{operation.id} - created catlet with ID: #{catlet.id}")
88
+ result
89
+
90
+
91
+
92
+ else
93
+ error_msg = result.status_message || 'Operation failed'
94
+ raise "Operation ID: #{operation.id} - Catlet creation failed: #{error_msg}"
95
+ end
96
+ end
97
+
98
+ def start_catlet(catlet_id)
99
+ @logger.info("Starting catlet: #{catlet_id}")
100
+
101
+ operation = client([SCOPES[:CATLETS_CONTROL]]).catlets.catlets_start(catlet_id)
102
+
103
+ raise 'Failed to start catlet: No operation returned' unless operation&.id
104
+
105
+ wait_for_operation(operation.id)
106
+ end
107
+
108
+ def stop_catlet(catlet_id, stop_mode = 'graceful')
109
+ @logger.info("Stopping catlet: #{catlet_id}")
110
+
111
+ # Map string modes to proper enum values
112
+ api_mode = case stop_mode.to_s.downcase
113
+ when 'graceful', 'shutdown'
114
+ ::Eryph::ComputeClient::CatletStopMode::SHUTDOWN
115
+ when 'hard'
116
+ ::Eryph::ComputeClient::CatletStopMode::HARD
117
+ when 'kill'
118
+ ::Eryph::ComputeClient::CatletStopMode::KILL
119
+ else
120
+ ::Eryph::ComputeClient::CatletStopMode::SHUTDOWN
121
+ end
122
+
123
+ # Create proper StopCatletRequestBody object
124
+ stop_request = ::Eryph::ComputeClient::StopCatletRequestBody.new(
125
+ mode: api_mode
126
+ )
127
+ operation = client([SCOPES[:CATLETS_CONTROL]]).catlets.catlets_stop(catlet_id, stop_request)
128
+
129
+ raise 'Failed to stop catlet: No operation returned' unless operation&.id
130
+
131
+ wait_for_operation(operation.id)
132
+ end
133
+
134
+ def destroy_catlet(catlet_id)
135
+ @logger.info("Destroying catlet: #{catlet_id}")
136
+
137
+ operation = client([SCOPES[:CATLETS_WRITE]]).catlets.catlets_delete(catlet_id)
138
+
139
+ raise 'Failed to destroy catlet: No operation returned' unless operation&.id
140
+
141
+ wait_for_operation(operation.id)
142
+ end
143
+
144
+ def list_projects
145
+ response = client([SCOPES[:PROJECTS_READ]]).projects.projects_list
146
+ # Handle the response structure - ProjectList has 'value' property with array
147
+ response.respond_to?(:value) ? response.value : response
148
+ rescue StandardError => e
149
+ @ui.error("Failed to list projects: #{e.message}")
150
+ []
151
+ end
152
+
153
+ def get_project(project_name)
154
+ projects = list_projects
155
+ projects.find { |p| p.name == project_name }
156
+ rescue StandardError => e
157
+ @ui.error("Failed to get project #{project_name}: #{e.message}")
158
+ nil
159
+ end
160
+
161
+ def create_project(project_name)
162
+ @logger.info("Creating project: #{project_name}")
163
+
164
+ # Create proper NewProjectRequest object
165
+ project_request = ::Eryph::ComputeClient::NewProjectRequest.new(
166
+ name: project_name
167
+ )
168
+
169
+ operation = client([SCOPES[:PROJECTS_WRITE]]).projects.projects_create(new_project_request: project_request)
170
+
171
+ raise 'Failed to create project: No operation returned' unless operation&.id
172
+
173
+ @logger.info("Operation ID: #{operation.id} - Creating project...")
174
+ result = wait_for_operation(operation.id)
175
+
176
+ if result.completed?
177
+ # Use OperationResult's project accessor
178
+ project = result.project
179
+ raise "Operation ID: #{operation.id} - Project creation completed but project not found" unless project
180
+
181
+ @logger.info("Operation ID: #{operation.id} - created project with ID: #{project.id}")
182
+ project # Return the project, not the result
183
+ else
184
+ error_msg = result.status_message || 'Operation failed'
185
+ raise "Operation ID: #{operation.id} - Project creation failed: #{error_msg}"
186
+ end
187
+ end
188
+
189
+ def ensure_project_exists(project_name)
190
+ return unless project_name
191
+
192
+ project = get_project(project_name)
193
+ return project if project
194
+
195
+ unless @config.auto_create_project
196
+ raise "Project '#{project_name}' not found and auto_create_project is disabled"
197
+ end
198
+
199
+ @ui.info("Project '#{project_name}' not found, creating automatically...")
200
+ create_project(project_name) # Now returns the project directly, no race condition!
201
+ end
202
+
203
+ def remove_project(project_name)
204
+ @logger.info("Removing project: #{project_name}")
205
+
206
+ project = get_project(project_name)
207
+ raise "Project '#{project_name}' not found" unless project
208
+
209
+ operation = client([SCOPES[:PROJECTS_WRITE]]).projects.projects_delete(project.id)
210
+ raise 'Failed to remove project: No operation returned' unless operation&.id
211
+
212
+ @logger.info("Operation ID: #{operation.id} - Removing project...")
213
+ result = wait_for_operation(operation.id)
214
+
215
+ if result.completed?
216
+ @logger.info("Operation ID: #{operation.id} - project removed successfully")
217
+ result
218
+ else
219
+ error_msg = result.status_message || 'Operation failed'
220
+ raise "Operation ID: #{operation.id} - Project removal failed: #{error_msg}"
221
+ end
222
+ end
223
+
224
+ def get_network_config(project_name)
225
+ project = get_project(project_name)
226
+ raise "Project '#{project_name}' not found" unless project
227
+
228
+ response = client([SCOPES[:PROJECTS_READ]]).virtual_networks.virtual_networks_get_config(project.id)
229
+ response.respond_to?(:configuration) ? response.configuration : response
230
+ rescue StandardError => e
231
+ @ui.error("Failed to get network configuration for project #{project_name}: #{e.message}")
232
+ raise e
233
+ end
234
+
235
+ def set_network_config(project_name, config_yaml)
236
+ project = get_project(project_name)
237
+ raise "Project '#{project_name}' not found" unless project
238
+
239
+ # Parse YAML to hash (encoding should be handled by caller)
240
+ config_hash = YAML.safe_load(config_yaml)
241
+
242
+ # Create proper VirtualNetworkConfiguration object
243
+ network_config = ::Eryph::ComputeClient::VirtualNetworkConfiguration.new(
244
+ configuration: config_hash
245
+ )
246
+
247
+ operation = client([SCOPES[:PROJECTS_WRITE]]).virtual_networks.virtual_networks_set_config(
248
+ project.id,
249
+ virtual_network_configuration: network_config
250
+ )
251
+
252
+ raise 'Failed to set network configuration: No operation returned' unless operation&.id
253
+
254
+ @logger.info("Operation ID: #{operation.id} - Setting network configuration...")
255
+ result = wait_for_operation(operation.id)
256
+
257
+ if result.completed?
258
+ @logger.info("Operation ID: #{operation.id} - network configuration set successfully")
259
+ result
260
+ else
261
+ error_msg = result.status_message || 'Operation failed'
262
+ raise "Operation ID: #{operation.id} - Network configuration failed: #{error_msg}"
263
+ end
264
+ rescue StandardError => e
265
+ @ui.error("Failed to set network configuration for project #{project_name}: #{e.message}")
266
+ raise e
267
+ end
268
+
269
+ def validate_catlet_config(catlet_config)
270
+ @ui.info('Validating catlet configuration...')
271
+
272
+ begin
273
+ validation_result = handle_api_errors do
274
+ client([SCOPES[:CATLETS_READ]]).validate_catlet_config(catlet_config)
275
+ end
276
+
277
+ if validation_result.respond_to?(:is_valid) && validation_result.is_valid
278
+ @ui.success('Configuration validated successfully')
279
+ true
280
+ elsif validation_result.respond_to?(:errors) && validation_result.errors
281
+ @ui.error('Configuration validation failed:')
282
+ validation_result.errors.each do |error|
283
+ @ui.error(" - #{error}")
284
+ end
285
+ false
286
+ else
287
+ @ui.detail("Validation result: #{validation_result}")
288
+ true # Assume valid if we can't determine otherwise
289
+ end
290
+ rescue Errors::EryphError => e
291
+ @ui.error("Config validation failed: #{e.friendly_message}")
292
+ @ui.detail('Proceeding with catlet creation...')
293
+ true # Don't block creation if validation service unavailable
294
+ end
295
+ end
296
+
297
+ def wait_for_operation(operation_id, timeout = 600)
298
+ start_time = Time.now
299
+ current_tasks = {}
300
+
301
+ @logger.info("Waiting for operation #{operation_id}...")
302
+
303
+ result = client([SCOPES[:CATLETS_READ]]).wait_for_operation(operation_id, timeout: timeout) do |event_type, data|
304
+ case event_type
305
+
306
+ when :resource_new
307
+ resource_type = data.resource_type || 'Resource'
308
+ resource_id = data.resource_id || data.id || 'unknown'
309
+ @logger.debug("Attached #{resource_type} '#{resource_id}' to operation")
310
+
311
+ when :task_new, :task_update
312
+ # Track current tasks by ID
313
+ if data.respond_to?(:id) && data.id
314
+ current_tasks[data.id] = {
315
+ name: data.display_name || data.name,
316
+ progress: data.respond_to?(:progress) ? data.progress : nil
317
+ }
318
+
319
+ @logger.debug("Task update #{current_tasks[data.id].inspect}")
320
+ end
321
+
322
+ when :status
323
+ # Report current task with progress if available
324
+ elapsed = Time.now - start_time
325
+
326
+ # Find active tasks with progress
327
+ active_task = current_tasks.values.find do |task|
328
+ task[:progress]&.positive? && task[:progress] < 100
329
+ end
330
+
331
+ if active_task
332
+ @ui.info("Working... - #{active_task[:name]} #{active_task[:progress]}% - #{elapsed.round}s total elapsed")
333
+ end
334
+ end
335
+ end
336
+
337
+ # Show final result
338
+ if result.completed?
339
+ @logger.info("Operation #{operation_id} completed successfully")
340
+ elsif result.failed?
341
+ error_msg = result.status_message || 'Operation failed'
342
+ @ui.error("Operation failed: #{error_msg}")
343
+ raise "Operation #{operation_id} failed: #{error_msg}"
344
+ else
345
+ @ui.warn("Operation finished with status: #{result.status}")
346
+ end
347
+
348
+ result
349
+ rescue StandardError => e
350
+ @ui.error("Error waiting for operation: #{e.message}")
351
+ raise e
352
+ end
353
+
354
+ private
355
+
356
+ def create_client(scopes = nil)
357
+ config_name = @config.configuration_name
358
+
359
+ # Build options for client creation
360
+ client_options = {}
361
+
362
+ # Add SSL configuration options
363
+ ssl_config = {}
364
+ ssl_config[:verify_ssl] = @config.ssl_verify unless @config.ssl_verify.nil?
365
+ ssl_config[:ca_file] = @config.ssl_ca_file if @config.ssl_ca_file
366
+ client_options[:ssl_config] = ssl_config if ssl_config.any?
367
+
368
+ # Add minimal scopes - use provided scopes or default to catlets+projects write
369
+ client_options[:scopes] = scopes || [SCOPES[:CATLETS_WRITE], SCOPES[:PROJECTS_WRITE]]
370
+
371
+ # Add client_id if specified
372
+ client_options[:client_id] = @config.client_id if @config.client_id
373
+
374
+ info_msg = if config_name
375
+ "Connecting to eryph using configuration: #{config_name}"
376
+ else
377
+ 'Connecting to eryph using automatic credential discovery'
378
+ end
379
+ @logger.debug(info_msg)
380
+
381
+ begin
382
+ client = ::Eryph.compute_client(config_name, **client_options)
383
+ @logger.debug('Successfully connected to eryph.')
384
+ client
385
+ rescue StandardError => e
386
+ @ui.error("Failed to connect to eryph: #{e.message}")
387
+ @ui.info('Make sure eryph is running and your credentials are configured')
388
+ raise "Failed to connect to eryph: #{e.message}"
389
+ end
390
+ end
391
+
392
+ # Enhanced error handling that converts API errors to user-friendly messages
393
+ def handle_api_errors
394
+ yield
395
+ rescue StandardError => e
396
+ if e.is_a?(::Eryph::Compute::ProblemDetailsError)
397
+ @ui.error("API Error: #{e.friendly_message}")
398
+ if e.has_problem_details?
399
+ @ui.detail("Problem Type: #{e.problem_type}") if e.problem_type
400
+ @ui.detail("Instance: #{e.instance}") if e.instance
401
+ end
402
+ raise Errors::EryphError.new(e.friendly_message, e)
403
+ else
404
+ # Re-raise other errors as-is but with better context
405
+ @ui.error("Unexpected error: #{e.message}")
406
+ @logger.debug("Error class: #{e.class}")
407
+ raise e
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VagrantPlugins
4
+ module Eryph
5
+ class Plugin < Vagrant.plugin('2')
6
+ name 'Eryph'
7
+ description <<-DESC
8
+ This plugin installs a provider that allows Vagrant to manage
9
+ catlets using Eryph's compute API.
10
+ DESC
11
+
12
+ config(:eryph, :provider) do
13
+ require_relative 'config'
14
+ Config
15
+ end
16
+
17
+ provider(:eryph, parallel: true, defaultable: false, box_optional: true) do
18
+ require_relative 'provider'
19
+ Provider
20
+ end
21
+
22
+ command('eryph') do
23
+ require_relative 'command'
24
+ Command
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'actions'
4
+ require_relative 'helpers/eryph_client'
5
+ require 'eryph'
6
+
7
+ # Import Vagrant's UNSET_VALUE constant
8
+ UNSET_VALUE = Vagrant::Plugin::V2::Config::UNSET_VALUE
9
+
10
+ module VagrantPlugins
11
+ module Eryph
12
+ class Provider < Vagrant.plugin('2', :provider)
13
+ # This class method caches status for all catlets within
14
+ # the Eryph project. A specific catlet's status
15
+ # may be refreshed by passing :refresh => true as an option.
16
+ def self.eryph_catlet(machine, opts = {})
17
+ client = Helpers::EryphClient.new(machine)
18
+
19
+ # load status of catlets if it has not been done before
20
+ @eryph_catlets ||= client.list_catlets || []
21
+
22
+ if opts[:refresh] && machine.id
23
+ # refresh the catlet status for the given machine
24
+ @eryph_catlets.delete_if { |c| c.id == machine.id }
25
+ catlet = client.get_catlet(machine.id)
26
+ @eryph_catlets << catlet if catlet
27
+ elsif machine.id
28
+ # lookup catlet status for the given machine
29
+ catlet = Array(@eryph_catlets).find { |c| c.id == machine.id }
30
+ end
31
+
32
+ # if lookup by id failed, check for a catlet with a matching name
33
+ # and set the id to ensure vagrant stores locally
34
+ unless catlet
35
+ name = machine.config.vm.hostname || machine.name
36
+ catlet = @eryph_catlets.find { |c| c.name == name.to_s }
37
+ machine.id = catlet.id.to_s if catlet
38
+ end
39
+
40
+ catlet || OpenStruct.new(status: 'not_created')
41
+ end
42
+
43
+ def initialize(machine)
44
+ @machine = machine
45
+ end
46
+
47
+ def action(name)
48
+ # Attempt to get the action method from the Actions module if it
49
+ # exists, otherwise return nil to show that we don't support the
50
+ # given action.
51
+ action_method = "action_#{name}"
52
+ return Actions.send(action_method) if Actions.respond_to?(action_method)
53
+
54
+ nil
55
+ end
56
+
57
+ # This method is called if the underlying machine ID changes. Providers
58
+ # can use this method to load in new data for the actual backing
59
+ # machine or to realize that the machine is now gone (the ID can
60
+ # become `nil`).
61
+ def machine_id_changed
62
+ # Clear cached catlets when machine ID changes
63
+ @eryph_catlets = nil if defined?(@eryph_catlets)
64
+ end
65
+
66
+ # This should return a hash of information that explains how to
67
+ # SSH into the machine. If the machine is not at a point where
68
+ # SSH is even possible, then `nil` should be returned.
69
+ def ssh_info
70
+ require 'log4r'
71
+ logger = Log4r::Logger.new('vagrant::eryph::provider')
72
+
73
+ catlet = Provider.eryph_catlet(@machine)
74
+ logger.debug("ssh_info catlet status: #{catlet&.status}")
75
+
76
+ # Return nil if catlet doesn't exist or isn't running
77
+ return nil unless catlet
78
+ return nil unless catlet.status&.downcase == 'running'
79
+
80
+ # Get IP address from catlet networks
81
+ ip_address = extract_ip_address(catlet)
82
+ logger.debug("ssh_info extracted IP: #{ip_address}")
83
+ return nil unless ip_address
84
+
85
+ {
86
+ host: ip_address,
87
+ username: 'vagrant'
88
+ }
89
+ end
90
+
91
+ # This should return the state of the machine within this provider.
92
+ # The state must be an instance of {MachineState}.
93
+ def state
94
+ catlet = Provider.eryph_catlet(@machine) if @machine.id
95
+
96
+ state_id = if catlet&.status
97
+ map_catlet_state_to_vagrant(catlet.status)
98
+ else
99
+ :not_created
100
+ end
101
+
102
+ long = short = state_id.to_s
103
+ Vagrant::MachineState.new(state_id, short, long)
104
+ end
105
+
106
+ private
107
+
108
+ def extract_ip_address(catlet)
109
+ # Extract IP address from catlet networks
110
+ return nil unless catlet.respond_to?(:networks) && catlet.networks
111
+
112
+ catlet.networks.each_with_index do |network, _idx|
113
+ # Only check floating port IP addresses (internal IPs are not accessible from outside)
114
+ next unless network.respond_to?(:floating_port) && network.floating_port
115
+
116
+ next unless network.floating_port.respond_to?(:ip_v4_addresses) &&
117
+ network.floating_port.ip_v4_addresses &&
118
+ !network.floating_port.ip_v4_addresses.empty?
119
+
120
+ ip = network.floating_port.ip_v4_addresses.first
121
+ return ip
122
+ end
123
+ nil
124
+ end
125
+
126
+ def map_catlet_state_to_vagrant(eryph_status)
127
+ case eryph_status.downcase
128
+ when 'running'
129
+ :running
130
+ when 'stopped'
131
+ :stopped
132
+ when 'pending'
133
+ :unknown # Pending could be starting or stopping - we don't know which
134
+ when 'error'
135
+ :error
136
+ else
137
+ :unknown
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end