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 +4 -4
- data/README.md +40 -3
- data/lib/kitchen/driver/proxmox/api_client.rb +84 -23
- data/lib/kitchen/driver/proxmox/errors.rb +6 -0
- data/lib/kitchen/driver/proxmox.rb +195 -46
- 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: fa03e257c553dacbc24d63abaa1a21b0bdf7be8dd2434afef6d304b2925bd23e
|
|
4
|
+
data.tar.gz: eea6142b73c7f1b0d82ec61f628b0aae64fca365df598c815c282e37c47dbaaf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
req
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 #{
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
169
|
-
api_client.wait_for_task(node
|
|
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 <<
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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)
|