kitchen-proxmox 0.3.2 → 0.4.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: e3ed7c8556ee380cd5845babc8b085c3a660736b81ac8f1b1ac92cc62ec4840d
4
+ data.tar.gz: 8a82f387b8a24b8dfafc4dd99cb9baaa65c62483d3b564974d46a469ec40582e
5
5
  SHA512:
6
- metadata.gz: 8da473dbe9ddf5891bff39a77fe3a55db3fd263a4413592e0c55d826387dbde2e60f77287516f31dd5083200d8d5c962ceaf811f43ad0b89e1a794311ece678b
7
- data.tar.gz: c93d6729fa61fbc286cdd6b64e03febad2ea3995c1a29e346e5c35037f51339f84aef79370c90443ce96f4ec94fb494225fbf05a3ad27942378c97b54e2d9e36
6
+ metadata.gz: 750a0b65316bcce6dccdb70c6c0c5061e830ddd7bb26011a034d1fe2e081805b6ca928ee7f027a4d5ee4e472916fed0b56ca6c7914747953596a579d64682b48
7
+ data.tar.gz: 71499dc0562fa3aa1691d6712fc4b70c9a4f608e27d1cb22f9370c7f604915aa869ca800e0adc817b8c0b3f5b5d8670a32ec2630b69d6eb30b737dad7ed5f17a
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
@@ -82,9 +107,7 @@ module Kitchen
82
107
  status = task_status(node:, upid:)
83
108
  if status['status'] == 'stopped'
84
109
  exitstatus = status['exitstatus'].to_s
85
- unless exitstatus == 'OK'
86
- raise ProxmoxErrors::ApiError.new(500, exitstatus)
87
- end
110
+ raise ProxmoxErrors::ApiError.new(500, exitstatus) unless exitstatus == 'OK'
88
111
 
89
112
  return status
90
113
  end
@@ -122,32 +145,65 @@ module Kitchen
122
145
  end
123
146
 
124
147
  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))
148
+ with_failover do |url|
149
+ uri = URI.parse("#{url}#{path}")
150
+ uri.query = URI.encode_www_form(params) unless params.empty?
151
+ http = build_http(uri)
152
+ req = Net::HTTP::Delete.new(uri.request_uri)
153
+ apply_headers(req)
154
+ handle_response(http.request(req))
155
+ end
131
156
  end
132
157
 
133
158
  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)
159
+ with_failover do |url|
160
+ uri = URI.parse("#{url}#{path}")
161
+ http = build_http(uri)
162
+ req = method_class.new(uri.request_uri)
163
+ apply_headers(req)
164
+
165
+ if body && !body.empty? && req.respond_to?(:body=)
166
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
167
+ req.body = URI.encode_www_form(body)
168
+ end
169
+
170
+ handle_response(http.request(req))
142
171
  end
172
+ end
173
+
174
+ # Tries the preferred URL first, then all others in order.
175
+ # On connection errors, advances to the next URL.
176
+ # On success, sets the working URL as preferred.
177
+ def with_failover
178
+ urls_to_try = failover_order
179
+ failures = []
180
+
181
+ urls_to_try.each_with_index do |url, idx|
182
+ return yield(url).tap { @preferred_url_index = @base_urls.index(url) }
183
+ rescue *CONNECTION_ERRORS => e
184
+ failures << [url, e]
185
+ # Reset preference if the preferred URL just failed
186
+ @preferred_url_index = nil if idx.zero? && @preferred_url_index
187
+ end
188
+
189
+ msg = "All Proxmox API URLs failed:\n"
190
+ failures.each { |url, err| msg += " #{url}: #{err.class} - #{err.message}\n" }
191
+ raise ProxmoxErrors::ApiError.new(0, msg)
192
+ end
193
+
194
+ # Returns URLs ordered with preferred first, then the rest.
195
+ def failover_order
196
+ return @base_urls unless @preferred_url_index
143
197
 
144
- handle_response(http.request(req))
198
+ preferred = @base_urls[@preferred_url_index]
199
+ [preferred] + @base_urls.reject { |u| u == preferred }
145
200
  end
146
201
 
147
202
  def build_http(uri)
148
203
  http = Net::HTTP.new(uri.host, uri.port)
149
204
  http.use_ssl = (uri.scheme == 'https')
150
205
  http.verify_mode = ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
206
+ http.open_timeout = @connect_timeout
151
207
  http
152
208
  end
153
209
 
@@ -23,9 +23,12 @@ 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
27
29
 
28
30
  default_config :ssl_verify, true
31
+ default_config :connect_timeout, 10
29
32
  default_config :pool, nil
30
33
  default_config :vm_name_prefix, 'kitchen-'
31
34
  default_config :cpus, 1
@@ -42,6 +45,8 @@ module Kitchen
42
45
  def create(state)
43
46
  return if state[:vm_id]
44
47
 
48
+ resolve_node(state)
49
+ resolve_template(state)
45
50
  validate_config!
46
51
  clone_and_start(state)
47
52
  end
@@ -50,9 +55,10 @@ module Kitchen
50
55
  return unless state[:vm_id]
51
56
 
52
57
  vm_id = state[:vm_id]
58
+ node = state[:node] || config[:node]
53
59
  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:)
60
+ safe_stop_vm(node, vm_id)
61
+ api_client.destroy_vm(node:, vm_id:)
56
62
  clear_state(state)
57
63
  info("Proxmox VM #{vm_id} destroyed.")
58
64
  end
@@ -61,27 +67,30 @@ module Kitchen
61
67
 
62
68
  def api_client
63
69
  @api_client ||= ApiClient.new(
64
- base_url: config[:proxmox_url],
70
+ base_urls: Array(config[:proxmox_url]).map(&:to_s),
65
71
  token_id: config[:proxmox_token_id],
66
72
  token_secret: config[:proxmox_token_secret],
67
- ssl_verify: config[:ssl_verify]
73
+ ssl_verify: config[:ssl_verify],
74
+ connect_timeout: config[:connect_timeout]
68
75
  )
69
76
  end
70
77
 
71
78
  def clone_and_start(state)
72
79
  retries = config[:clone_retries]
73
80
  last_error = nil
81
+ node = state[:node]
82
+ template_id = state[:template_id] || config[:template_id]
74
83
 
75
84
  retries.times do |attempt|
76
- info("Creating Proxmox VM from template #{config[:template_id]}...")
85
+ info("Creating Proxmox VM from template #{template_id}...")
77
86
 
78
- vm_id, vm_name = allocate_and_clone
87
+ vm_id, vm_name = allocate_and_clone(node, template_id)
79
88
  state[:vm_id] = vm_id
80
89
  state[:vm_name] = vm_name
81
90
 
82
91
  begin
83
- configure_hardware(vm_id)
84
- start_and_wait_for_ip(state, vm_id)
92
+ configure_hardware(node, vm_id)
93
+ start_and_wait_for_ip(state, node, vm_id)
85
94
  info("Proxmox VM #{vm_name} (#{vm_id}) created.")
86
95
  return
87
96
  rescue ApiError => e
@@ -91,6 +100,7 @@ module Kitchen
91
100
  warn("VMID #{vm_id} race lost (attempt #{attempt + 1}/#{retries}): #{e.message}")
92
101
  warn("Another process owns VM #{vm_id} — abandoning and retrying...")
93
102
  clear_state(state)
103
+ state[:node] = node
94
104
  sleep backoff_delay(attempt)
95
105
  end
96
106
  end
@@ -98,7 +108,7 @@ module Kitchen
98
108
  raise last_error
99
109
  end
100
110
 
101
- def allocate_and_clone
111
+ def allocate_and_clone(node, template_id)
102
112
  retries = config[:clone_retries]
103
113
  last_error = nil
104
114
 
@@ -107,7 +117,7 @@ module Kitchen
107
117
  vm_name = generate_vm_name(instance.name)
108
118
 
109
119
  begin
110
- clone_template(vm_id, vm_name)
120
+ clone_template(node, vm_id, vm_name, template_id)
111
121
  return [vm_id, vm_name]
112
122
  rescue ApiError => e
113
123
  raise unless e.vmid_conflict?
@@ -135,19 +145,18 @@ module Kitchen
135
145
  "#{config[:vm_name_prefix]}#{suite_name}-#{Time.now.to_i}"
136
146
  end
137
147
 
138
- def clone_template(vm_id, vm_name)
139
- node = config[:node]
148
+ def clone_template(node, vm_id, vm_name, template_id)
140
149
  upid = api_client.clone_vm(
141
- node:, template_id: config[:template_id],
150
+ node:, template_id:,
142
151
  new_id: vm_id, name: vm_name,
143
152
  pool: config[:pool], storage: config[:storage]
144
153
  )
145
154
  api_client.wait_for_task(node:, upid:, timeout: config[:clone_timeout])
146
155
  end
147
156
 
148
- def configure_hardware(vm_id)
157
+ def configure_hardware(node, vm_id)
149
158
  api_client.configure_vm(
150
- node: config[:node],
159
+ node:,
151
160
  vm_id:,
152
161
  cpus: config[:cpus],
153
162
  memory: config[:memory],
@@ -155,30 +164,30 @@ module Kitchen
155
164
  )
156
165
  end
157
166
 
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)
167
+ def start_and_wait_for_ip(state, node, vm_id)
168
+ api_client.start_vm(node:, vm_id:)
169
+ ip = wait_for_ip(node, vm_id)
161
170
  state[:hostname] = ip
162
171
  end
163
172
 
164
- def stop_vm(vm_id)
165
- status = api_client.vm_status(node: config[:node], vm_id:)
173
+ def stop_vm(node, vm_id)
174
+ status = api_client.vm_status(node:, vm_id:)
166
175
  return unless status['status'] == 'running'
167
176
 
168
- upid = api_client.stop_vm(node: config[:node], vm_id:)
169
- api_client.wait_for_task(node: config[:node], upid:, timeout: 60)
177
+ upid = api_client.stop_vm(node:, vm_id:)
178
+ api_client.wait_for_task(node:, upid:, timeout: 60)
170
179
  end
171
180
 
172
- def safe_stop_vm(vm_id)
173
- stop_vm(vm_id)
181
+ def safe_stop_vm(node, vm_id)
182
+ stop_vm(node, vm_id)
174
183
  rescue ::StandardError => e
175
184
  warn("Failed to stop VM #{vm_id}: #{e.message}")
176
185
  end
177
186
 
178
- def wait_for_ip(vm_id)
187
+ def wait_for_ip(node, vm_id)
179
188
  deadline = Time.now + config[:ip_wait_timeout]
180
189
  loop do
181
- ip = fetch_ip(vm_id)
190
+ ip = fetch_ip(node, vm_id)
182
191
  return ip if ip
183
192
  raise "Timed out waiting for IP on VM #{vm_id}" if Time.now > deadline
184
193
 
@@ -186,11 +195,8 @@ module Kitchen
186
195
  end
187
196
  end
188
197
 
189
- def fetch_ip(vm_id)
190
- interfaces = api_client.agent_network_interfaces(
191
- node: config[:node],
192
- vm_id:
193
- )
198
+ def fetch_ip(node, vm_id)
199
+ interfaces = api_client.agent_network_interfaces(node:, vm_id:)
194
200
  return nil unless interfaces.is_a?(Hash) && interfaces['result']
195
201
 
196
202
  extract_ipv4_from_interfaces(interfaces['result'])
@@ -211,39 +217,87 @@ module Kitchen
211
217
 
212
218
  def validate_config!
213
219
  errors = []
214
- errors << validate_node if config[:node].nil?
215
- errors << validate_template_id if config[:template_id].nil?
220
+ errors << 'Set template_id OR template_name, not both.' if config[:template_id] && config[:template_name]
221
+ errors << validate_template_id if config[:template_id].nil? && config[:template_name].nil?
216
222
  return if errors.empty?
217
223
 
218
224
  raise Kitchen::UserError, errors.join("\n\n")
219
225
  end
220
226
 
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"
227
+ def resolve_template(state)
228
+ # Already resolved (e.g. from a previous create attempt)
229
+ return if state[:template_id]
230
+
231
+ # If template_id is set in config, use it directly
232
+ if config[:template_id]
233
+ state[:template_id] = config[:template_id]
234
+ return
235
+ end
236
+
237
+ # Resolve template_name to VMID
238
+ return unless config[:template_name]
239
+
240
+ templates = api_client.list_templates
241
+ matches = templates.select { |t| t['name'] == config[:template_name] }
242
+
243
+ if matches.empty?
244
+ msg = "Template '#{config[:template_name]}' not found.\n#{format_template_list_from(templates)}"
245
+ raise Kitchen::UserError, msg
229
246
  end
230
- msg
247
+
248
+ # Prefer template on the target node
249
+ node = state[:node]
250
+ selected = matches.find { |t| t['node'] == node } || matches.first
251
+ state[:template_id] = selected['vmid']
252
+ info("Resolved template '#{config[:template_name]}' to VMID #{state[:template_id]}")
253
+ end
254
+
255
+ def resolve_node(state)
256
+ # Use pinned node from config
257
+ if config[:node]
258
+ state[:node] = config[:node]
259
+ return
260
+ end
261
+
262
+ # Use previously resolved node from state
263
+ return if state[:node]
264
+
265
+ # Auto-select: query cluster and pick least-loaded online node
266
+ nodes = api_client.list_nodes
267
+ candidates = nodes.select { |n| n['status'] == 'online' }
268
+
269
+ # Filter by node_pool if set
270
+ candidates = candidates.select { |n| config[:node_pool].include?(n['node']) } if config[:node_pool]
271
+
272
+ if candidates.empty?
273
+ statuses = nodes.map { |n| " - #{n['node']} (#{n['status']})" }.join("\n")
274
+ raise Kitchen::UserError, "No online nodes available. Node statuses:\n#{statuses}"
275
+ end
276
+
277
+ # Select by least memory allocation ratio
278
+ selected = candidates.min_by { |n| n['mem'].to_f / n['maxmem'] }
279
+ state[:node] = selected['node']
280
+ info("Auto-selected node: #{state[:node]}")
231
281
  end
232
282
 
233
283
  def validate_template_id
234
- msg = "Missing required config: template_id\n"
284
+ msg = "Missing required config: template_id or template_name\n"
235
285
  msg + format_template_list
236
286
  end
237
287
 
238
288
  def format_template_list
239
289
  templates = api_client.list_templates
290
+ format_template_list_from(templates)
291
+ rescue ::StandardError
292
+ " (could not retrieve template list from Proxmox API)\n"
293
+ end
294
+
295
+ def format_template_list_from(templates)
240
296
  return " No templates found on the Proxmox cluster.\n" if templates.empty?
241
297
 
242
298
  lines = "Available templates:\n"
243
299
  templates.each { |t| lines += " - #{t['vmid']}: #{t['name']} (node: #{t['node']})\n" }
244
300
  lines
245
- rescue ::StandardError
246
- " (could not retrieve template list from Proxmox API)\n"
247
301
  end
248
302
 
249
303
  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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Nixon