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 +4 -4
- data/README.md +40 -3
- data/lib/kitchen/driver/proxmox/api_client.rb +78 -22
- data/lib/kitchen/driver/proxmox.rb +99 -45
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3ed7c8556ee380cd5845babc8b085c3a660736b81ac8f1b1ac92cc62ec4840d
|
|
4
|
+
data.tar.gz: 8a82f387b8a24b8dfafc4dd99cb9baaa65c62483d3b564974d46a469ec40582e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
req
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 #{
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
169
|
-
api_client.wait_for_task(node
|
|
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 <<
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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)
|