kitchen-proxmox 0.3.1 → 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: 9431e030d0300d57c9fac8a20047375865cedf9f4a48b34b3039370f2d62a98a
4
- data.tar.gz: 62e91255a7b9b2586eeef54bcd76bb7110b782ae8c948853ef0691a13e11df5e
3
+ metadata.gz: e3ed7c8556ee380cd5845babc8b085c3a660736b81ac8f1b1ac92cc62ec4840d
4
+ data.tar.gz: 8a82f387b8a24b8dfafc4dd99cb9baaa65c62483d3b564974d46a469ec40582e
5
5
  SHA512:
6
- metadata.gz: e974ac8b7626ed1232e608073c259f5bb90c65aa47bdaf9327195a255dfbcbb7eb969a6cd88c5781c929835daa84f52a63e83aa6e291199de3d32889c244300b
7
- data.tar.gz: 4b36857b3cfb877ff3bcf292049389cbe26cd43ab2393fb235d3236e8ad5d50ba74ed1ae55076961f97294f2df14e9a8d98d683a50afe4f37af5253ca3066f63
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,20 +13,49 @@ 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
27
52
  get('/api2/json/cluster/nextid')
28
53
  end
29
54
 
55
+ def validate_vmid(vm_id)
56
+ get("/api2/json/cluster/nextid?vmid=#{vm_id}")
57
+ end
58
+
30
59
  def clone_vm(node:, template_id:, new_id:, **options)
31
60
  full = options.fetch(:full, true)
32
61
  body = { newid: new_id, full: full ? 1 : 0, target: node }
@@ -76,7 +105,12 @@ module Kitchen
76
105
  deadline = Time.now + timeout
77
106
  loop do
78
107
  status = task_status(node:, upid:)
79
- return status if status['status'] == 'stopped'
108
+ if status['status'] == 'stopped'
109
+ exitstatus = status['exitstatus'].to_s
110
+ raise ProxmoxErrors::ApiError.new(500, exitstatus) unless exitstatus == 'OK'
111
+
112
+ return status
113
+ end
80
114
  raise "Task timeout after #{timeout}s: #{upid}" if Time.now > deadline
81
115
 
82
116
  sleep interval
@@ -111,32 +145,65 @@ module Kitchen
111
145
  end
112
146
 
113
147
  def delete(path, params = {})
114
- uri = URI.parse("#{base_url}#{path}")
115
- uri.query = URI.encode_www_form(params) unless params.empty?
116
- http = build_http(uri)
117
- req = Net::HTTP::Delete.new(uri.request_uri)
118
- apply_headers(req)
119
- 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
120
156
  end
121
157
 
122
158
  def request(method_class, path, body = nil)
123
- uri = URI.parse("#{base_url}#{path}")
124
- http = build_http(uri)
125
- req = method_class.new(uri.request_uri)
126
- apply_headers(req)
127
-
128
- if body && !body.empty? && req.respond_to?(:body=)
129
- req['Content-Type'] = 'application/x-www-form-urlencoded'
130
- 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))
131
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
132
197
 
133
- handle_response(http.request(req))
198
+ preferred = @base_urls[@preferred_url_index]
199
+ [preferred] + @base_urls.reject { |u| u == preferred }
134
200
  end
135
201
 
136
202
  def build_http(uri)
137
203
  http = Net::HTTP.new(uri.host, uri.port)
138
204
  http.use_ssl = (uri.scheme == 'https')
139
205
  http.verify_mode = ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
206
+ http.open_timeout = @connect_timeout
140
207
  http
141
208
  end
142
209
 
@@ -13,12 +13,25 @@ module Kitchen
13
13
  super("Proxmox API error #{status_code}: #{response_body}")
14
14
  end
15
15
 
16
- # Returns true when the error indicates a VMID is already in use.
16
+ # Returns true when the error indicates a VMID is already in use
17
+ # or cannot be locked (concurrent clone race).
17
18
  def vmid_conflict?
18
19
  return false unless status_code == 400 || status_code == 500
19
20
 
20
21
  response_body.match?(/already exists/i) ||
21
- response_body.match?(/unable to create VM \d+/i)
22
+ response_body.match?(/unable to create VM \d+/i) ||
23
+ response_body.match?(/can't lock file.*lock-\d+/i)
24
+ end
25
+
26
+ # Returns true when the error indicates another process owns the VM
27
+ # (lost a VMID race — VM was started or configured by another clone).
28
+ def vmid_race_lost?
29
+ return false unless status_code == 400 || status_code == 500
30
+
31
+ response_body.match?(/already running/i) ||
32
+ response_body.match?(/hotplug problem/i) ||
33
+ response_body.match?(/does not exist/i) ||
34
+ response_body.match?(/can't lock file.*lock-\d+/i)
22
35
  end
23
36
  end
24
37
  end
@@ -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
@@ -36,10 +39,14 @@ module Kitchen
36
39
  default_config :start_timeout, 300
37
40
  default_config :ip_wait_timeout, 120
38
41
  default_config :clone_retries, 5
42
+ default_config :vmid_range_min, 900_000
43
+ default_config :vmid_range_max, 999_999
39
44
 
40
45
  def create(state)
41
46
  return if state[:vm_id]
42
47
 
48
+ resolve_node(state)
49
+ resolve_template(state)
43
50
  validate_config!
44
51
  clone_and_start(state)
45
52
  end
@@ -48,9 +55,10 @@ module Kitchen
48
55
  return unless state[:vm_id]
49
56
 
50
57
  vm_id = state[:vm_id]
58
+ node = state[:node] || config[:node]
51
59
  info("Destroying Proxmox VM #{state[:vm_name]} (#{vm_id})...")
52
- safe_stop_vm(vm_id)
53
- api_client.destroy_vm(node: config[:node], vm_id:)
60
+ safe_stop_vm(node, vm_id)
61
+ api_client.destroy_vm(node:, vm_id:)
54
62
  clear_state(state)
55
63
  info("Proxmox VM #{vm_id} destroyed.")
56
64
  end
@@ -59,27 +67,48 @@ module Kitchen
59
67
 
60
68
  def api_client
61
69
  @api_client ||= ApiClient.new(
62
- base_url: config[:proxmox_url],
70
+ base_urls: Array(config[:proxmox_url]).map(&:to_s),
63
71
  token_id: config[:proxmox_token_id],
64
72
  token_secret: config[:proxmox_token_secret],
65
- ssl_verify: config[:ssl_verify]
73
+ ssl_verify: config[:ssl_verify],
74
+ connect_timeout: config[:connect_timeout]
66
75
  )
67
76
  end
68
77
 
69
78
  def clone_and_start(state)
70
- info("Creating Proxmox VM from template #{config[:template_id]}...")
79
+ retries = config[:clone_retries]
80
+ last_error = nil
81
+ node = state[:node]
82
+ template_id = state[:template_id] || config[:template_id]
71
83
 
72
- vm_id, vm_name = allocate_and_clone
73
- state[:vm_id] = vm_id
74
- state[:vm_name] = vm_name
84
+ retries.times do |attempt|
85
+ info("Creating Proxmox VM from template #{template_id}...")
75
86
 
76
- configure_hardware(vm_id)
77
- start_and_wait_for_ip(state, vm_id)
87
+ vm_id, vm_name = allocate_and_clone(node, template_id)
88
+ state[:vm_id] = vm_id
89
+ state[:vm_name] = vm_name
90
+
91
+ begin
92
+ configure_hardware(node, vm_id)
93
+ start_and_wait_for_ip(state, node, vm_id)
94
+ info("Proxmox VM #{vm_name} (#{vm_id}) created.")
95
+ return
96
+ rescue ApiError => e
97
+ raise unless e.vmid_race_lost?
98
+
99
+ last_error = e
100
+ warn("VMID #{vm_id} race lost (attempt #{attempt + 1}/#{retries}): #{e.message}")
101
+ warn("Another process owns VM #{vm_id} — abandoning and retrying...")
102
+ clear_state(state)
103
+ state[:node] = node
104
+ sleep backoff_delay(attempt)
105
+ end
106
+ end
78
107
 
79
- info("Proxmox VM #{vm_name} (#{vm_id}) created.")
108
+ raise last_error
80
109
  end
81
110
 
82
- def allocate_and_clone
111
+ def allocate_and_clone(node, template_id)
83
112
  retries = config[:clone_retries]
84
113
  last_error = nil
85
114
 
@@ -88,7 +117,7 @@ module Kitchen
88
117
  vm_name = generate_vm_name(instance.name)
89
118
 
90
119
  begin
91
- clone_template(vm_id, vm_name)
120
+ clone_template(node, vm_id, vm_name, template_id)
92
121
  return [vm_id, vm_name]
93
122
  rescue ApiError => e
94
123
  raise unless e.vmid_conflict?
@@ -107,26 +136,27 @@ module Kitchen
107
136
  end
108
137
 
109
138
  def allocate_vm_id
110
- Integer(api_client.next_vm_id)
139
+ vm_id = rand(config[:vmid_range_min]..config[:vmid_range_max])
140
+ api_client.validate_vmid(vm_id)
141
+ vm_id
111
142
  end
112
143
 
113
144
  def generate_vm_name(suite_name)
114
145
  "#{config[:vm_name_prefix]}#{suite_name}-#{Time.now.to_i}"
115
146
  end
116
147
 
117
- def clone_template(vm_id, vm_name)
118
- node = config[:node]
148
+ def clone_template(node, vm_id, vm_name, template_id)
119
149
  upid = api_client.clone_vm(
120
- node:, template_id: config[:template_id],
150
+ node:, template_id:,
121
151
  new_id: vm_id, name: vm_name,
122
152
  pool: config[:pool], storage: config[:storage]
123
153
  )
124
154
  api_client.wait_for_task(node:, upid:, timeout: config[:clone_timeout])
125
155
  end
126
156
 
127
- def configure_hardware(vm_id)
157
+ def configure_hardware(node, vm_id)
128
158
  api_client.configure_vm(
129
- node: config[:node],
159
+ node:,
130
160
  vm_id:,
131
161
  cpus: config[:cpus],
132
162
  memory: config[:memory],
@@ -134,30 +164,30 @@ module Kitchen
134
164
  )
135
165
  end
136
166
 
137
- def start_and_wait_for_ip(state, vm_id)
138
- api_client.start_vm(node: config[:node], vm_id:)
139
- 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)
140
170
  state[:hostname] = ip
141
171
  end
142
172
 
143
- def stop_vm(vm_id)
144
- 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:)
145
175
  return unless status['status'] == 'running'
146
176
 
147
- upid = api_client.stop_vm(node: config[:node], vm_id:)
148
- 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)
149
179
  end
150
180
 
151
- def safe_stop_vm(vm_id)
152
- stop_vm(vm_id)
181
+ def safe_stop_vm(node, vm_id)
182
+ stop_vm(node, vm_id)
153
183
  rescue ::StandardError => e
154
184
  warn("Failed to stop VM #{vm_id}: #{e.message}")
155
185
  end
156
186
 
157
- def wait_for_ip(vm_id)
187
+ def wait_for_ip(node, vm_id)
158
188
  deadline = Time.now + config[:ip_wait_timeout]
159
189
  loop do
160
- ip = fetch_ip(vm_id)
190
+ ip = fetch_ip(node, vm_id)
161
191
  return ip if ip
162
192
  raise "Timed out waiting for IP on VM #{vm_id}" if Time.now > deadline
163
193
 
@@ -165,11 +195,8 @@ module Kitchen
165
195
  end
166
196
  end
167
197
 
168
- def fetch_ip(vm_id)
169
- interfaces = api_client.agent_network_interfaces(
170
- node: config[:node],
171
- vm_id:
172
- )
198
+ def fetch_ip(node, vm_id)
199
+ interfaces = api_client.agent_network_interfaces(node:, vm_id:)
173
200
  return nil unless interfaces.is_a?(Hash) && interfaces['result']
174
201
 
175
202
  extract_ipv4_from_interfaces(interfaces['result'])
@@ -190,39 +217,87 @@ module Kitchen
190
217
 
191
218
  def validate_config!
192
219
  errors = []
193
- errors << validate_node if config[:node].nil?
194
- 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?
195
222
  return if errors.empty?
196
223
 
197
224
  raise Kitchen::UserError, errors.join("\n\n")
198
225
  end
199
226
 
200
- def validate_node
201
- msg = "Missing required config: node\n"
202
- begin
203
- nodes = api_client.list_nodes
204
- msg += "Available Proxmox nodes:\n"
205
- nodes.each { |n| msg += " - #{n['node']} (#{n['status']})\n" }
206
- rescue ::StandardError
207
- 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
208
235
  end
209
- msg
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
246
+ end
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]}")
210
281
  end
211
282
 
212
283
  def validate_template_id
213
- msg = "Missing required config: template_id\n"
284
+ msg = "Missing required config: template_id or template_name\n"
214
285
  msg + format_template_list
215
286
  end
216
287
 
217
288
  def format_template_list
218
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)
219
296
  return " No templates found on the Proxmox cluster.\n" if templates.empty?
220
297
 
221
298
  lines = "Available templates:\n"
222
299
  templates.each { |t| lines += " - #{t['vmid']}: #{t['name']} (node: #{t['node']})\n" }
223
300
  lines
224
- rescue ::StandardError
225
- " (could not retrieve template list from Proxmox API)\n"
226
301
  end
227
302
 
228
303
  def clear_state(state)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kitchen-proxmox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Nixon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-03 00:00:00.000000000 Z
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A Test Kitchen driver for Proxmox VE. Manages VM lifecycle (create, destroy)
14
14
  via the Proxmox REST API. Supports cloning from templates.