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 +4 -4
- data/README.md +40 -3
- data/lib/kitchen/driver/proxmox/api_client.rb +87 -20
- data/lib/kitchen/driver/proxmox/errors.rb +15 -2
- data/lib/kitchen/driver/proxmox.rb +124 -49
- metadata +2 -2
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
req
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
state[:vm_name] = vm_name
|
|
84
|
+
retries.times do |attempt|
|
|
85
|
+
info("Creating Proxmox VM from template #{template_id}...")
|
|
75
86
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
148
|
-
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)
|
|
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 <<
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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.
|