kitchen-proxmox 0.3.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8592a8c21a5245c5517893fb3de6c2c2a9cae921cdb8c499fd3a7bcfcc8dc35a
4
- data.tar.gz: eb18680f687a41ef9a5a5c2f4b290e586e867de4795cdc5733998189fb3b4e85
3
+ metadata.gz: fa03e257c553dacbc24d63abaa1a21b0bdf7be8dd2434afef6d304b2925bd23e
4
+ data.tar.gz: eea6142b73c7f1b0d82ec61f628b0aae64fca365df598c815c282e37c47dbaaf
5
5
  SHA512:
6
- metadata.gz: 8da473dbe9ddf5891bff39a77fe3a55db3fd263a4413592e0c55d826387dbde2e60f77287516f31dd5083200d8d5c962ceaf811f43ad0b89e1a794311ece678b
7
- data.tar.gz: c93d6729fa61fbc286cdd6b64e03febad2ea3995c1a29e346e5c35037f51339f84aef79370c90443ce96f4ec94fb494225fbf05a3ad27942378c97b54e2d9e36
6
+ metadata.gz: 4c558a3b1e537092dcc396e610f42a7695d088c316ae1cb3bfceacd1dbaab918c2b1751adc1123bf2e5855407bd2e840e7e698c34739b2524a8e9de3a43ff987
7
+ data.tar.gz: c59edb23fa7d0f06bc247db4c10a93b6c82c29402d3138e9477e6f815538bcaae5235651f058c914928626aeb11eaeaeba511f4c1e3ec3cc3884ef6b8a51cdaa
data/README.md CHANGED
@@ -28,6 +28,26 @@ driver:
28
28
  template_id: 9000
29
29
  ```
30
30
 
31
+ ### Cluster-Aware Configuration
32
+
33
+ For multi-node clusters, the driver can auto-select a node and resolve templates by name:
34
+
35
+ ```yaml
36
+ driver:
37
+ name: proxmox
38
+ proxmox_url:
39
+ - https://pve1.example.com:8006
40
+ - https://pve2.example.com:8006
41
+ proxmox_token_id: kitchen@pam!kitchen-token
42
+ proxmox_token_secret: 00000000-0000-0000-0000-000000000000
43
+ template_name: ubuntu-2204-cloud
44
+ ```
45
+
46
+ This configuration:
47
+ - Tries `pve1` first; if unreachable, fails over to `pve2`
48
+ - Auto-selects the node with the most free memory
49
+ - Resolves `ubuntu-2204-cloud` to the template's VMID at runtime
50
+
31
51
  To avoid committing secrets, you can use ERB to inject them from environment variables. Test Kitchen processes `.kitchen.yml` as ERB before parsing the YAML:
32
52
 
33
53
  ```yaml
@@ -54,17 +74,34 @@ You can also put the exports in a `.envrc` (if using [direnv](https://direnv.net
54
74
 
55
75
  | Key | Description |
56
76
  |---|---|
57
- | `proxmox_url` | Proxmox API URL (e.g. `https://host:8006`) |
77
+ | `proxmox_url` | Proxmox API URL (String or Array for failover) |
58
78
  | `proxmox_token_id` | API token ID (`user@realm!token-name`) |
59
79
  | `proxmox_token_secret` | API token secret (UUID) |
60
- | `node` | Proxmox node to create VMs on |
61
- | `template_id` | VM ID of the template to clone |
80
+
81
+ ### Node Selection
82
+
83
+ | Key | Default | Description |
84
+ |---|---|---|
85
+ | `node` | `nil` | Pin to a specific Proxmox node (bypasses auto-selection) |
86
+ | `node_pool` | `nil` | Restrict auto-selection to these node names (Array) |
87
+
88
+ When `node` is omitted, the driver queries the cluster and selects the online node with the most free memory.
89
+
90
+ ### Template Selection
91
+
92
+ | Key | Default | Description |
93
+ |---|---|---|
94
+ | `template_id` | `nil` | VM ID of the template to clone |
95
+ | `template_name` | `nil` | Template name to resolve (mutually exclusive with `template_id`) |
96
+
97
+ Set one of `template_id` or `template_name`. When using `template_name`, the driver resolves the name to a VMID via the cluster API, preferring a template on the target node.
62
98
 
63
99
  ### Optional Settings
64
100
 
65
101
  | Key | Default | Description |
66
102
  |---|---|---|
67
103
  | `ssl_verify` | `true` | Verify TLS certificates |
104
+ | `connect_timeout` | `10` | Per-URL connection timeout in seconds |
68
105
  | `pool` | `nil` | Proxmox resource pool |
69
106
  | `vm_name_prefix` | `kitchen-` | Prefix for generated VM names |
70
107
  | `cpus` | `1` | Number of CPU cores |
@@ -13,14 +13,39 @@ module Kitchen
13
13
  # HTTP client for the Proxmox VE REST API.
14
14
  # Authenticates via API tokens. Provides convenience
15
15
  # methods for VM lifecycle operations.
16
+ #
17
+ # Supports multiple API URLs for failover. On connection
18
+ # errors the client tries the next URL. Once a URL works
19
+ # it becomes the sticky preference for subsequent calls.
16
20
  class ApiClient
17
- attr_reader :base_url, :token_id, :token_secret, :ssl_verify
18
-
19
- def initialize(base_url:, token_id:, token_secret:, ssl_verify: true)
20
- @base_url = base_url.chomp('/')
21
+ CONNECTION_ERRORS = [
22
+ Errno::ECONNREFUSED,
23
+ Errno::ECONNRESET,
24
+ Errno::ETIMEDOUT,
25
+ Errno::EHOSTUNREACH,
26
+ Net::OpenTimeout,
27
+ Net::ReadTimeout,
28
+ SocketError,
29
+ OpenSSL::SSL::SSLError
30
+ ].freeze
31
+
32
+ attr_reader :base_urls, :token_id, :token_secret, :ssl_verify, :connect_timeout
33
+
34
+ # Accepts either base_url (String) or base_urls (Array).
35
+ # The String form is normalized to a one-element array.
36
+ def initialize(token_id:, token_secret:, base_url: nil, base_urls: nil, ssl_verify: true, connect_timeout: 10)
37
+ urls = base_urls || Array(base_url)
38
+ @base_urls = urls.map { |u| u.chomp('/') }
21
39
  @token_id = token_id
22
40
  @token_secret = token_secret
23
41
  @ssl_verify = ssl_verify
42
+ @connect_timeout = connect_timeout
43
+ @preferred_url_index = nil
44
+ end
45
+
46
+ # Backward-compat reader: returns the first (or preferred) URL.
47
+ def base_url
48
+ @base_urls[@preferred_url_index || 0]
24
49
  end
25
50
 
26
51
  def next_vm_id
@@ -33,7 +58,8 @@ module Kitchen
33
58
 
34
59
  def clone_vm(node:, template_id:, new_id:, **options)
35
60
  full = options.fetch(:full, true)
36
- body = { newid: new_id, full: full ? 1 : 0, target: node }
61
+ target = options.fetch(:target, node)
62
+ body = { newid: new_id, full: full ? 1 : 0, target: }
37
63
  body[:name] = options[:name] if options[:name]
38
64
  body[:pool] = options[:pool] if options[:pool]
39
65
  body[:storage] = options[:storage] if options[:storage]
@@ -82,9 +108,7 @@ module Kitchen
82
108
  status = task_status(node:, upid:)
83
109
  if status['status'] == 'stopped'
84
110
  exitstatus = status['exitstatus'].to_s
85
- unless exitstatus == 'OK'
86
- raise ProxmoxErrors::ApiError.new(500, exitstatus)
87
- end
111
+ raise ProxmoxErrors::ApiError.new(500, exitstatus) unless exitstatus == 'OK'
88
112
 
89
113
  return status
90
114
  end
@@ -107,6 +131,10 @@ module Kitchen
107
131
  resources.select { |r| r['template'] == 1 }
108
132
  end
109
133
 
134
+ def list_storage(node:)
135
+ get("/api2/json/nodes/#{node}/storage")
136
+ end
137
+
110
138
  private
111
139
 
112
140
  def get(path)
@@ -122,32 +150,65 @@ module Kitchen
122
150
  end
123
151
 
124
152
  def delete(path, params = {})
125
- uri = URI.parse("#{base_url}#{path}")
126
- uri.query = URI.encode_www_form(params) unless params.empty?
127
- http = build_http(uri)
128
- req = Net::HTTP::Delete.new(uri.request_uri)
129
- apply_headers(req)
130
- handle_response(http.request(req))
153
+ with_failover do |url|
154
+ uri = URI.parse("#{url}#{path}")
155
+ uri.query = URI.encode_www_form(params) unless params.empty?
156
+ http = build_http(uri)
157
+ req = Net::HTTP::Delete.new(uri.request_uri)
158
+ apply_headers(req)
159
+ handle_response(http.request(req))
160
+ end
131
161
  end
132
162
 
133
163
  def request(method_class, path, body = nil)
134
- uri = URI.parse("#{base_url}#{path}")
135
- http = build_http(uri)
136
- req = method_class.new(uri.request_uri)
137
- apply_headers(req)
138
-
139
- if body && !body.empty? && req.respond_to?(:body=)
140
- req['Content-Type'] = 'application/x-www-form-urlencoded'
141
- req.body = URI.encode_www_form(body)
164
+ with_failover do |url|
165
+ uri = URI.parse("#{url}#{path}")
166
+ http = build_http(uri)
167
+ req = method_class.new(uri.request_uri)
168
+ apply_headers(req)
169
+
170
+ if body && !body.empty? && req.respond_to?(:body=)
171
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
172
+ req.body = URI.encode_www_form(body)
173
+ end
174
+
175
+ handle_response(http.request(req))
142
176
  end
177
+ end
178
+
179
+ # Tries the preferred URL first, then all others in order.
180
+ # On connection errors, advances to the next URL.
181
+ # On success, sets the working URL as preferred.
182
+ def with_failover
183
+ urls_to_try = failover_order
184
+ failures = []
185
+
186
+ urls_to_try.each_with_index do |url, idx|
187
+ return yield(url).tap { @preferred_url_index = @base_urls.index(url) }
188
+ rescue *CONNECTION_ERRORS => e
189
+ failures << [url, e]
190
+ # Reset preference if the preferred URL just failed
191
+ @preferred_url_index = nil if idx.zero? && @preferred_url_index
192
+ end
193
+
194
+ msg = "All Proxmox API URLs failed:\n"
195
+ failures.each { |url, err| msg += " #{url}: #{err.class} - #{err.message}\n" }
196
+ raise ProxmoxErrors::ApiError.new(0, msg)
197
+ end
198
+
199
+ # Returns URLs ordered with preferred first, then the rest.
200
+ def failover_order
201
+ return @base_urls unless @preferred_url_index
143
202
 
144
- handle_response(http.request(req))
203
+ preferred = @base_urls[@preferred_url_index]
204
+ [preferred] + @base_urls.reject { |u| u == preferred }
145
205
  end
146
206
 
147
207
  def build_http(uri)
148
208
  http = Net::HTTP.new(uri.host, uri.port)
149
209
  http.use_ssl = (uri.scheme == 'https')
150
210
  http.verify_mode = ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
211
+ http.open_timeout = @connect_timeout
151
212
  http
152
213
  end
153
214
 
@@ -33,6 +33,12 @@ module Kitchen
33
33
  response_body.match?(/does not exist/i) ||
34
34
  response_body.match?(/can't lock file.*lock-\d+/i)
35
35
  end
36
+
37
+ # Returns true when clone fails because the template uses local storage
38
+ # and cannot be cloned to a different node.
39
+ def local_storage_conflict?
40
+ response_body.match?(/uses local storage/i)
41
+ end
36
42
  end
37
43
  end
38
44
  end
@@ -23,9 +23,13 @@ module Kitchen
23
23
  required_config :proxmox_token_id
24
24
  required_config :proxmox_token_secret
25
25
  default_config :node, nil
26
+ default_config :node_pool, nil
26
27
  default_config :template_id, nil
28
+ default_config :template_name, nil
29
+ default_config :clone_type, 'auto'
27
30
 
28
31
  default_config :ssl_verify, true
32
+ default_config :connect_timeout, 10
29
33
  default_config :pool, nil
30
34
  default_config :vm_name_prefix, 'kitchen-'
31
35
  default_config :cpus, 1
@@ -42,7 +46,10 @@ module Kitchen
42
46
  def create(state)
43
47
  return if state[:vm_id]
44
48
 
49
+ resolve_node(state)
50
+ resolve_template(state)
45
51
  validate_config!
52
+ resolve_clone_strategy(state)
46
53
  clone_and_start(state)
47
54
  end
48
55
 
@@ -50,9 +57,10 @@ module Kitchen
50
57
  return unless state[:vm_id]
51
58
 
52
59
  vm_id = state[:vm_id]
60
+ node = state[:node] || config[:node]
53
61
  info("Destroying Proxmox VM #{state[:vm_name]} (#{vm_id})...")
54
- safe_stop_vm(vm_id)
55
- api_client.destroy_vm(node: config[:node], vm_id:)
62
+ safe_stop_vm(node, vm_id)
63
+ api_client.destroy_vm(node:, vm_id:)
56
64
  clear_state(state)
57
65
  info("Proxmox VM #{vm_id} destroyed.")
58
66
  end
@@ -61,27 +69,32 @@ module Kitchen
61
69
 
62
70
  def api_client
63
71
  @api_client ||= ApiClient.new(
64
- base_url: config[:proxmox_url],
72
+ base_urls: Array(config[:proxmox_url]).map(&:to_s),
65
73
  token_id: config[:proxmox_token_id],
66
74
  token_secret: config[:proxmox_token_secret],
67
- ssl_verify: config[:ssl_verify]
75
+ ssl_verify: config[:ssl_verify],
76
+ connect_timeout: config[:connect_timeout]
68
77
  )
69
78
  end
70
79
 
71
80
  def clone_and_start(state)
72
81
  retries = config[:clone_retries]
73
82
  last_error = nil
83
+ node = state[:node]
84
+ template_id = state[:template_id] || config[:template_id]
85
+ template_node = state[:template_node] || node
86
+ full_clone = state[:full_clone]
74
87
 
75
88
  retries.times do |attempt|
76
- info("Creating Proxmox VM from template #{config[:template_id]}...")
89
+ info("Creating Proxmox VM from template #{template_id}...")
77
90
 
78
- vm_id, vm_name = allocate_and_clone
91
+ vm_id, vm_name = allocate_and_clone(node, template_id, template_node, full_clone)
79
92
  state[:vm_id] = vm_id
80
93
  state[:vm_name] = vm_name
81
94
 
82
95
  begin
83
- configure_hardware(vm_id)
84
- start_and_wait_for_ip(state, vm_id)
96
+ configure_hardware(node, vm_id)
97
+ start_and_wait_for_ip(state, node, vm_id)
85
98
  info("Proxmox VM #{vm_name} (#{vm_id}) created.")
86
99
  return
87
100
  rescue ApiError => e
@@ -91,6 +104,7 @@ module Kitchen
91
104
  warn("VMID #{vm_id} race lost (attempt #{attempt + 1}/#{retries}): #{e.message}")
92
105
  warn("Another process owns VM #{vm_id} — abandoning and retrying...")
93
106
  clear_state(state)
107
+ state[:node] = node
94
108
  sleep backoff_delay(attempt)
95
109
  end
96
110
  end
@@ -98,7 +112,7 @@ module Kitchen
98
112
  raise last_error
99
113
  end
100
114
 
101
- def allocate_and_clone
115
+ def allocate_and_clone(node, template_id, template_node, full_clone)
102
116
  retries = config[:clone_retries]
103
117
  last_error = nil
104
118
 
@@ -107,7 +121,7 @@ module Kitchen
107
121
  vm_name = generate_vm_name(instance.name)
108
122
 
109
123
  begin
110
- clone_template(vm_id, vm_name)
124
+ clone_template(node, vm_id, vm_name, template_id, template_node, full_clone)
111
125
  return [vm_id, vm_name]
112
126
  rescue ApiError => e
113
127
  raise unless e.vmid_conflict?
@@ -135,19 +149,34 @@ module Kitchen
135
149
  "#{config[:vm_name_prefix]}#{suite_name}-#{Time.now.to_i}"
136
150
  end
137
151
 
138
- def clone_template(vm_id, vm_name)
139
- node = config[:node]
152
+ def clone_template(target_node, vm_id, vm_name, template_id, template_node, full_clone)
140
153
  upid = api_client.clone_vm(
141
- node:, template_id: config[:template_id],
154
+ node: template_node, template_id:,
142
155
  new_id: vm_id, name: vm_name,
156
+ target: target_node, full: full_clone,
143
157
  pool: config[:pool], storage: config[:storage]
144
158
  )
145
- api_client.wait_for_task(node:, upid:, timeout: config[:clone_timeout])
159
+ api_client.wait_for_task(node: template_node, upid:, timeout: config[:clone_timeout])
160
+ rescue ApiError => e
161
+ raise local_storage_error(template_id, template_node, target_node) if e.local_storage_conflict?
162
+
163
+ raise
164
+ end
165
+
166
+ def local_storage_error(template_id, template_node, target_node)
167
+ Kitchen::UserError.new(
168
+ "Cannot clone template #{template_id} from node '#{template_node}' to node '#{target_node}' " \
169
+ "because the template uses local storage.\n\n" \
170
+ "To fix this, either:\n" \
171
+ " - Move the template disk to shared storage (e.g. NFS, Ceph, iSCSI)\n" \
172
+ " - Pin the node to '#{template_node}' in .kitchen.yml: node: #{template_node}\n" \
173
+ " - Create a copy of the template on '#{target_node}'"
174
+ )
146
175
  end
147
176
 
148
- def configure_hardware(vm_id)
177
+ def configure_hardware(node, vm_id)
149
178
  api_client.configure_vm(
150
- node: config[:node],
179
+ node:,
151
180
  vm_id:,
152
181
  cpus: config[:cpus],
153
182
  memory: config[:memory],
@@ -155,30 +184,30 @@ module Kitchen
155
184
  )
156
185
  end
157
186
 
158
- def start_and_wait_for_ip(state, vm_id)
159
- api_client.start_vm(node: config[:node], vm_id:)
160
- ip = wait_for_ip(vm_id)
187
+ def start_and_wait_for_ip(state, node, vm_id)
188
+ api_client.start_vm(node:, vm_id:)
189
+ ip = wait_for_ip(node, vm_id)
161
190
  state[:hostname] = ip
162
191
  end
163
192
 
164
- def stop_vm(vm_id)
165
- status = api_client.vm_status(node: config[:node], vm_id:)
193
+ def stop_vm(node, vm_id)
194
+ status = api_client.vm_status(node:, vm_id:)
166
195
  return unless status['status'] == 'running'
167
196
 
168
- upid = api_client.stop_vm(node: config[:node], vm_id:)
169
- api_client.wait_for_task(node: config[:node], upid:, timeout: 60)
197
+ upid = api_client.stop_vm(node:, vm_id:)
198
+ api_client.wait_for_task(node:, upid:, timeout: 60)
170
199
  end
171
200
 
172
- def safe_stop_vm(vm_id)
173
- stop_vm(vm_id)
201
+ def safe_stop_vm(node, vm_id)
202
+ stop_vm(node, vm_id)
174
203
  rescue ::StandardError => e
175
204
  warn("Failed to stop VM #{vm_id}: #{e.message}")
176
205
  end
177
206
 
178
- def wait_for_ip(vm_id)
207
+ def wait_for_ip(node, vm_id)
179
208
  deadline = Time.now + config[:ip_wait_timeout]
180
209
  loop do
181
- ip = fetch_ip(vm_id)
210
+ ip = fetch_ip(node, vm_id)
182
211
  return ip if ip
183
212
  raise "Timed out waiting for IP on VM #{vm_id}" if Time.now > deadline
184
213
 
@@ -186,11 +215,8 @@ module Kitchen
186
215
  end
187
216
  end
188
217
 
189
- def fetch_ip(vm_id)
190
- interfaces = api_client.agent_network_interfaces(
191
- node: config[:node],
192
- vm_id:
193
- )
218
+ def fetch_ip(node, vm_id)
219
+ interfaces = api_client.agent_network_interfaces(node:, vm_id:)
194
220
  return nil unless interfaces.is_a?(Hash) && interfaces['result']
195
221
 
196
222
  extract_ipv4_from_interfaces(interfaces['result'])
@@ -209,41 +235,164 @@ module Kitchen
209
235
  nil
210
236
  end
211
237
 
238
+ def resolve_clone_strategy(state)
239
+ return if state[:full_clone] != nil # rubocop:disable Style/NonNilCheck
240
+
241
+ # Can't determine strategy without a template
242
+ template_id = state[:template_id] || config[:template_id]
243
+ return unless template_id
244
+
245
+ case config[:clone_type]
246
+ when 'full'
247
+ state[:full_clone] = true
248
+ when 'linked'
249
+ state[:full_clone] = false
250
+ else
251
+ detect_clone_strategy(state)
252
+ end
253
+ end
254
+
255
+ def detect_clone_strategy(state)
256
+ template_id = state[:template_id] || config[:template_id]
257
+ template_node = state[:template_node] || state[:node]
258
+ storage_info = template_storage_info(template_node, template_id)
259
+ shared = storage_info && storage_info['shared'] == 1
260
+ supports_linked = shared && linked_clone_capable_type?(storage_info['type'])
261
+
262
+ if supports_linked
263
+ state[:full_clone] = false
264
+ elsif shared
265
+ # Shared but doesn't support linked clones (e.g. plain LVM)
266
+ state[:full_clone] = true
267
+ warn("Storage '#{storage_info['storage']}' (type: #{storage_info['type']}) does not support linked clones. " \
268
+ 'Using full clone. For faster clones, use lvmthin, ZFS, Ceph RBD, or file-based storage (NFS/dir).')
269
+ else
270
+ # Local storage — full clone and pin to template node
271
+ state[:full_clone] = true
272
+ pin_to_template_node(state, template_node)
273
+ end
274
+ end
275
+
276
+ LINKED_CLONE_STORAGE_TYPES = %w[lvmthin nfs dir cephfs rbd zfspool btrfs].freeze
277
+
278
+ def linked_clone_capable_type?(type)
279
+ LINKED_CLONE_STORAGE_TYPES.include?(type.to_s)
280
+ end
281
+
282
+ def template_storage_info(template_node, template_id)
283
+ vm_conf = api_client.vm_config(node: template_node, vm_id: template_id)
284
+ storage_name = extract_storage_name(vm_conf)
285
+ return nil unless storage_name
286
+
287
+ storages = api_client.list_storage(node: template_node)
288
+ storages.find { |s| s['storage'] == storage_name }
289
+ end
290
+
291
+ def extract_storage_name(vm_conf)
292
+ # Find the first real disk field (scsi0, virtio0, ide0, sata0, etc.)
293
+ disk_field = vm_conf.find do |k, v|
294
+ k.match?(/^(scsi|virtio|ide|sata)\d+$/) && !v.start_with?('none')
295
+ end
296
+ return nil unless disk_field
297
+
298
+ # Format: "StorageName:disk-name,option=val,..."
299
+ disk_field[1].split(':').first
300
+ end
301
+
302
+ def pin_to_template_node(state, template_node)
303
+ return if state[:node] == template_node
304
+
305
+ warn("Template uses local storage on node '#{template_node}' — " \
306
+ "pinning clone target to '#{template_node}'. " \
307
+ 'Move template to shared storage for cross-node flexibility.')
308
+ state[:node] = template_node
309
+ end
310
+
212
311
  def validate_config!
213
312
  errors = []
214
- errors << validate_node if config[:node].nil?
215
- errors << validate_template_id if config[:template_id].nil?
313
+ errors << 'Set template_id OR template_name, not both.' if config[:template_id] && config[:template_name]
314
+ errors << validate_template_id if config[:template_id].nil? && config[:template_name].nil?
216
315
  return if errors.empty?
217
316
 
218
317
  raise Kitchen::UserError, errors.join("\n\n")
219
318
  end
220
319
 
221
- def validate_node
222
- msg = "Missing required config: node\n"
223
- begin
224
- nodes = api_client.list_nodes
225
- msg += "Available Proxmox nodes:\n"
226
- nodes.each { |n| msg += " - #{n['node']} (#{n['status']})\n" }
227
- rescue ::StandardError
228
- msg += " (could not retrieve node list from Proxmox API)\n"
320
+ def resolve_template(state)
321
+ # Already resolved (e.g. from a previous create attempt)
322
+ return if state[:template_id]
323
+
324
+ # If template_id is set in config, use it directly
325
+ if config[:template_id]
326
+ state[:template_id] = config[:template_id]
327
+ return
328
+ end
329
+
330
+ # Resolve template_name to VMID
331
+ return unless config[:template_name]
332
+
333
+ templates = api_client.list_templates
334
+ matches = templates.select { |t| t['name'] == config[:template_name] }
335
+
336
+ if matches.empty?
337
+ msg = "Template '#{config[:template_name]}' not found.\n#{format_template_list_from(templates)}"
338
+ raise Kitchen::UserError, msg
339
+ end
340
+
341
+ # Prefer template on the target node
342
+ node = state[:node]
343
+ selected = matches.find { |t| t['node'] == node } || matches.first
344
+ state[:template_id] = selected['vmid']
345
+ state[:template_node] = selected['node']
346
+ info("Resolved template '#{config[:template_name]}' to VMID #{state[:template_id]} " \
347
+ "on node #{state[:template_node]}")
348
+ end
349
+
350
+ def resolve_node(state)
351
+ # Use pinned node from config
352
+ if config[:node]
353
+ state[:node] = config[:node]
354
+ return
229
355
  end
230
- msg
356
+
357
+ # Use previously resolved node from state
358
+ return if state[:node]
359
+
360
+ # Auto-select: query cluster and pick least-loaded online node
361
+ nodes = api_client.list_nodes
362
+ candidates = nodes.select { |n| n['status'] == 'online' }
363
+
364
+ # Filter by node_pool if set
365
+ candidates = candidates.select { |n| config[:node_pool].include?(n['node']) } if config[:node_pool]
366
+
367
+ if candidates.empty?
368
+ statuses = nodes.map { |n| " - #{n['node']} (#{n['status']})" }.join("\n")
369
+ raise Kitchen::UserError, "No online nodes available. Node statuses:\n#{statuses}"
370
+ end
371
+
372
+ # Select by least memory allocation ratio
373
+ selected = candidates.min_by { |n| n['mem'].to_f / n['maxmem'] }
374
+ state[:node] = selected['node']
375
+ info("Auto-selected node: #{state[:node]}")
231
376
  end
232
377
 
233
378
  def validate_template_id
234
- msg = "Missing required config: template_id\n"
379
+ msg = "Missing required config: template_id or template_name\n"
235
380
  msg + format_template_list
236
381
  end
237
382
 
238
383
  def format_template_list
239
384
  templates = api_client.list_templates
385
+ format_template_list_from(templates)
386
+ rescue ::StandardError
387
+ " (could not retrieve template list from Proxmox API)\n"
388
+ end
389
+
390
+ def format_template_list_from(templates)
240
391
  return " No templates found on the Proxmox cluster.\n" if templates.empty?
241
392
 
242
393
  lines = "Available templates:\n"
243
394
  templates.each { |t| lines += " - #{t['vmid']}: #{t['name']} (node: #{t['node']})\n" }
244
395
  lines
245
- rescue ::StandardError
246
- " (could not retrieve template list from Proxmox API)\n"
247
396
  end
248
397
 
249
398
  def clear_state(state)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-proxmox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Nixon