kitchen-proxmox 0.0.3
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 +7 -0
- data/LICENSE +17 -0
- data/README.md +131 -0
- data/lib/kitchen/driver/proxmox/api_client.rb +155 -0
- data/lib/kitchen/driver/proxmox.rb +208 -0
- data/lib/kitchen/driver/proxmox_version.rb +18 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e90a80692b09f43b76dcfe31d232c387bc40900248143b15e4bffedb797430b7
|
|
4
|
+
data.tar.gz: 5ac6f95f78c6d89bc8f2e5d74d8931d0b02be08e4c17b3eea001955022c3de0c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f4448ddcc9d0760db69e454565d57f77fc5d2dd8af23af7f380d32d23b6b4c36c3a8a48628b6a1ba557579cd11b86562a0baeec96dedfed5a86d6b4010732aaf
|
|
7
|
+
data.tar.gz: 0af5563d6925584dc91fe2e5ac54bed521fa5ece39ca22de87f912ea44390fc9b9073cd2b8d8a70ec6ffa481f21bbb864b5d034e6b722330addfd6af7dbd8059
|
data/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2025 Richard Nixon
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# kitchen-proxmox
|
|
2
|
+
|
|
3
|
+
A [Test Kitchen](https://kitchen.ci/) driver for [Proxmox VE](https://www.proxmox.com/en/proxmox-virtual-environment/overview). Creates and destroys VMs by cloning templates via the Proxmox REST API.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- [Chef Workstation](https://docs.chef.io/workstation/) (provides Ruby, Test Kitchen, and all dependencies)
|
|
8
|
+
- Proxmox VE 7+ with API token authentication
|
|
9
|
+
- A VM template to clone (with `qemu-guest-agent` installed for IP detection)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
chef exec gem install kitchen-proxmox
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Add the driver to your `.kitchen.yml`:
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
driver:
|
|
23
|
+
name: proxmox
|
|
24
|
+
proxmox_url: https://proxmox.example.com:8006
|
|
25
|
+
proxmox_token_id: kitchen@pam!kitchen-token
|
|
26
|
+
proxmox_token_secret: 00000000-0000-0000-0000-000000000000
|
|
27
|
+
node: pve
|
|
28
|
+
template_id: 9000
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
driver:
|
|
35
|
+
name: proxmox
|
|
36
|
+
proxmox_url: https://proxmox.example.com:8006
|
|
37
|
+
proxmox_token_id: <%= ENV['PROXMOX_TOKEN_ID'] %>
|
|
38
|
+
proxmox_token_secret: <%= ENV['PROXMOX_TOKEN_SECRET'] %>
|
|
39
|
+
node: pve
|
|
40
|
+
template_id: 9000
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then export the variables before running Kitchen:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
export PROXMOX_TOKEN_ID='kitchen@pam!kitchen-token'
|
|
47
|
+
export PROXMOX_TOKEN_SECRET='00000000-0000-0000-0000-000000000000'
|
|
48
|
+
kitchen converge
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You can also put the exports in a `.envrc` (if using [direnv](https://direnv.net/)) or a `.env` file sourced by your shell. Add these files to `.gitignore` so secrets are never committed.
|
|
52
|
+
|
|
53
|
+
### Required Settings
|
|
54
|
+
|
|
55
|
+
| Key | Description |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `proxmox_url` | Proxmox API URL (e.g. `https://host:8006`) |
|
|
58
|
+
| `proxmox_token_id` | API token ID (`user@realm!token-name`) |
|
|
59
|
+
| `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 |
|
|
62
|
+
|
|
63
|
+
### Optional Settings
|
|
64
|
+
|
|
65
|
+
| Key | Default | Description |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `ssl_verify` | `true` | Verify TLS certificates |
|
|
68
|
+
| `pool` | `nil` | Proxmox resource pool |
|
|
69
|
+
| `vm_name_prefix` | `kitchen-` | Prefix for generated VM names |
|
|
70
|
+
| `cpus` | `1` | Number of CPU cores |
|
|
71
|
+
| `memory` | `1024` | Memory in MB |
|
|
72
|
+
| `storage` | `nil` | Target storage for the clone |
|
|
73
|
+
| `network_bridge` | `vmbr0` | Network bridge for the VM |
|
|
74
|
+
| `clone_timeout` | `300` | Seconds to wait for clone task |
|
|
75
|
+
| `start_timeout` | `300` | Seconds to wait for VM start |
|
|
76
|
+
| `ip_wait_timeout` | `120` | Seconds to wait for guest IP |
|
|
77
|
+
|
|
78
|
+
## Proxmox Setup
|
|
79
|
+
|
|
80
|
+
### Create a Role
|
|
81
|
+
|
|
82
|
+
1. Go to **Datacenter → Permissions → Roles** and click **Create**.
|
|
83
|
+
2. Name the role (e.g. `KitchenDriver`).
|
|
84
|
+
3. Select these privileges:
|
|
85
|
+
- `VM.Allocate`, `VM.Clone`, `VM.Audit`, `VM.PowerMgmt`
|
|
86
|
+
- `VM.Config.CPU`, `VM.Config.Memory`, `VM.Config.Disk`, `VM.Config.Network`
|
|
87
|
+
- `Datastore.AllocateSpace`, `Datastore.Audit`
|
|
88
|
+
- `Pool.Allocate` (only if you use the `pool` config option)
|
|
89
|
+
4. Click **Create**.
|
|
90
|
+
|
|
91
|
+
### Create a User (optional)
|
|
92
|
+
|
|
93
|
+
You can use an existing user or create a dedicated one:
|
|
94
|
+
|
|
95
|
+
1. Go to **Datacenter → Permissions → Users** and click **Add**.
|
|
96
|
+
2. Set **User name** (e.g. `kitchen`), **Realm** to `pam` or `pve`, and a password.
|
|
97
|
+
3. Click **Add**.
|
|
98
|
+
|
|
99
|
+
### Create an API Token
|
|
100
|
+
|
|
101
|
+
1. Go to **Datacenter → Permissions → API Tokens** and click **Add**.
|
|
102
|
+
2. Select the user, set a **Token ID** (e.g. `kitchen-token`).
|
|
103
|
+
3. **Uncheck** "Privilege Separation" — the token inherits the user's permissions.
|
|
104
|
+
4. Click **Add** and copy the displayed secret. It is shown only once.
|
|
105
|
+
|
|
106
|
+
The resulting token ID for `.kitchen.yml` is `kitchen@pam!kitchen-token`.
|
|
107
|
+
|
|
108
|
+
### Assign Permissions
|
|
109
|
+
|
|
110
|
+
1. Go to **Datacenter → Permissions** and click **Add → User Permission**.
|
|
111
|
+
2. Set **Path** to `/` (or scope to `/vms` and `/storage` as needed).
|
|
112
|
+
3. Select the user and the `KitchenDriver` role.
|
|
113
|
+
4. Check **Propagate**.
|
|
114
|
+
5. Click **Add**.
|
|
115
|
+
|
|
116
|
+
### Template
|
|
117
|
+
|
|
118
|
+
The template VM must have `qemu-guest-agent` installed and enabled. The driver uses the guest agent to detect the VM's IP address after boot.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
make spec # run tests
|
|
124
|
+
make style # run rubocop
|
|
125
|
+
make # run both
|
|
126
|
+
make install # build and install the gem
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
Apache-2.0
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'kitchen'
|
|
8
|
+
|
|
9
|
+
module Kitchen
|
|
10
|
+
module Driver
|
|
11
|
+
class Proxmox < Kitchen::Driver::Base
|
|
12
|
+
# HTTP client for the Proxmox VE REST API.
|
|
13
|
+
# Authenticates via API tokens. Provides convenience
|
|
14
|
+
# methods for VM lifecycle operations.
|
|
15
|
+
class ApiClient
|
|
16
|
+
attr_reader :base_url, :token_id, :token_secret, :ssl_verify
|
|
17
|
+
|
|
18
|
+
def initialize(base_url:, token_id:, token_secret:, ssl_verify: true)
|
|
19
|
+
@base_url = base_url.chomp('/')
|
|
20
|
+
@token_id = token_id
|
|
21
|
+
@token_secret = token_secret
|
|
22
|
+
@ssl_verify = ssl_verify
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def next_vm_id
|
|
26
|
+
get('/api2/json/cluster/nextid')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def clone_vm(node:, template_id:, new_id:, **options)
|
|
30
|
+
full = options.fetch(:full, true)
|
|
31
|
+
body = { newid: new_id, full: full ? 1 : 0, target: node }
|
|
32
|
+
body[:name] = options[:name] if options[:name]
|
|
33
|
+
body[:pool] = options[:pool] if options[:pool]
|
|
34
|
+
body[:storage] = options[:storage] if options[:storage]
|
|
35
|
+
post("/api2/json/nodes/#{node}/qemu/#{template_id}/clone", body)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def configure_vm(node:, vm_id:, cpus: nil, memory: nil, network_bridge: nil)
|
|
39
|
+
body = {}
|
|
40
|
+
body[:cores] = cpus if cpus
|
|
41
|
+
body[:memory] = memory if memory
|
|
42
|
+
body[:net0] = "virtio,bridge=#{network_bridge}" if network_bridge
|
|
43
|
+
return nil if body.empty?
|
|
44
|
+
|
|
45
|
+
put("/api2/json/nodes/#{node}/qemu/#{vm_id}/config", body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def start_vm(node:, vm_id:)
|
|
49
|
+
post("/api2/json/nodes/#{node}/qemu/#{vm_id}/status/start")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stop_vm(node:, vm_id:)
|
|
53
|
+
post("/api2/json/nodes/#{node}/qemu/#{vm_id}/status/stop")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def destroy_vm(node:, vm_id:, purge: true)
|
|
57
|
+
params = purge ? { purge: 1 } : {}
|
|
58
|
+
delete("/api2/json/nodes/#{node}/qemu/#{vm_id}", params)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def vm_status(node:, vm_id:)
|
|
62
|
+
get("/api2/json/nodes/#{node}/qemu/#{vm_id}/status/current")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def vm_config(node:, vm_id:)
|
|
66
|
+
get("/api2/json/nodes/#{node}/qemu/#{vm_id}/config")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def task_status(node:, upid:)
|
|
70
|
+
encoded = URI.encode_www_form_component(upid)
|
|
71
|
+
get("/api2/json/nodes/#{node}/tasks/#{encoded}/status")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wait_for_task(node:, upid:, timeout: 300, interval: 2)
|
|
75
|
+
deadline = Time.now + timeout
|
|
76
|
+
loop do
|
|
77
|
+
status = task_status(node:, upid:)
|
|
78
|
+
return status if status['status'] == 'stopped'
|
|
79
|
+
raise "Task timeout after #{timeout}s: #{upid}" if Time.now > deadline
|
|
80
|
+
|
|
81
|
+
sleep interval
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def agent_network_interfaces(node:, vm_id:)
|
|
86
|
+
get("/api2/json/nodes/#{node}/qemu/#{vm_id}/agent/network-get-interfaces")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def list_nodes
|
|
90
|
+
get('/api2/json/nodes')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def list_templates
|
|
94
|
+
resources = get('/api2/json/cluster/resources?type=vm')
|
|
95
|
+
resources.select { |r| r['template'] == 1 }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def get(path)
|
|
101
|
+
request(Net::HTTP::Get, path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def post(path, body = {})
|
|
105
|
+
request(Net::HTTP::Post, path, body)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def put(path, body = {})
|
|
109
|
+
request(Net::HTTP::Put, path, body)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def delete(path, params = {})
|
|
113
|
+
uri = URI.parse("#{base_url}#{path}")
|
|
114
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
115
|
+
http = build_http(uri)
|
|
116
|
+
req = Net::HTTP::Delete.new(uri.request_uri)
|
|
117
|
+
apply_headers(req)
|
|
118
|
+
handle_response(http.request(req))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def request(method_class, path, body = nil)
|
|
122
|
+
uri = URI.parse("#{base_url}#{path}")
|
|
123
|
+
http = build_http(uri)
|
|
124
|
+
req = method_class.new(uri.request_uri)
|
|
125
|
+
apply_headers(req)
|
|
126
|
+
|
|
127
|
+
if body && !body.empty? && req.respond_to?(:body=)
|
|
128
|
+
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
129
|
+
req.body = URI.encode_www_form(body)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
handle_response(http.request(req))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_http(uri)
|
|
136
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
137
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
138
|
+
http.verify_mode = ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
139
|
+
http
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def apply_headers(req)
|
|
143
|
+
req['Authorization'] = "PVEAPIToken=#{token_id}=#{token_secret}"
|
|
144
|
+
req['Accept'] = 'application/json'
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def handle_response(response)
|
|
148
|
+
raise "Proxmox API error #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
149
|
+
|
|
150
|
+
JSON.parse(response.body)['data']
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'kitchen'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative 'proxmox_version'
|
|
6
|
+
require_relative 'proxmox/api_client'
|
|
7
|
+
|
|
8
|
+
module Kitchen
|
|
9
|
+
module Driver
|
|
10
|
+
# Proxmox VE driver for Test Kitchen.
|
|
11
|
+
#
|
|
12
|
+
# Manages VM lifecycle via the Proxmox REST API:
|
|
13
|
+
# - create: clone a template, configure hardware, start, wait for IP
|
|
14
|
+
# - destroy: stop and delete the VM
|
|
15
|
+
class Proxmox < Kitchen::Driver::Base
|
|
16
|
+
kitchen_driver_api_version 2
|
|
17
|
+
|
|
18
|
+
plugin_version Kitchen::Driver::PROXMOX_VERSION
|
|
19
|
+
|
|
20
|
+
required_config :proxmox_url
|
|
21
|
+
required_config :proxmox_token_id
|
|
22
|
+
required_config :proxmox_token_secret
|
|
23
|
+
default_config :node, nil
|
|
24
|
+
default_config :template_id, nil
|
|
25
|
+
|
|
26
|
+
default_config :ssl_verify, true
|
|
27
|
+
default_config :pool, nil
|
|
28
|
+
default_config :vm_name_prefix, 'kitchen-'
|
|
29
|
+
default_config :cpus, 1
|
|
30
|
+
default_config :memory, 1024
|
|
31
|
+
default_config :storage, nil
|
|
32
|
+
default_config :network_bridge, 'vmbr0'
|
|
33
|
+
default_config :clone_timeout, 300
|
|
34
|
+
default_config :start_timeout, 300
|
|
35
|
+
default_config :ip_wait_timeout, 120
|
|
36
|
+
|
|
37
|
+
def create(state)
|
|
38
|
+
return if state[:vm_id]
|
|
39
|
+
|
|
40
|
+
validate_config!
|
|
41
|
+
clone_and_start(state)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def destroy(state)
|
|
45
|
+
return unless state[:vm_id]
|
|
46
|
+
|
|
47
|
+
vm_id = state[:vm_id]
|
|
48
|
+
info("Destroying Proxmox VM #{state[:vm_name]} (#{vm_id})...")
|
|
49
|
+
safe_stop_vm(vm_id)
|
|
50
|
+
api_client.destroy_vm(node: config[:node], vm_id:)
|
|
51
|
+
clear_state(state)
|
|
52
|
+
info("Proxmox VM #{vm_id} destroyed.")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def api_client
|
|
58
|
+
@api_client ||= ApiClient.new(
|
|
59
|
+
base_url: config[:proxmox_url],
|
|
60
|
+
token_id: config[:proxmox_token_id],
|
|
61
|
+
token_secret: config[:proxmox_token_secret],
|
|
62
|
+
ssl_verify: config[:ssl_verify]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clone_and_start(state)
|
|
67
|
+
info("Creating Proxmox VM from template #{config[:template_id]}...")
|
|
68
|
+
|
|
69
|
+
vm_id = allocate_vm_id
|
|
70
|
+
vm_name = generate_vm_name(instance.name)
|
|
71
|
+
|
|
72
|
+
clone_template(vm_id, vm_name)
|
|
73
|
+
configure_hardware(vm_id)
|
|
74
|
+
start_and_wait_for_ip(state, vm_id)
|
|
75
|
+
|
|
76
|
+
state[:vm_id] = vm_id
|
|
77
|
+
state[:vm_name] = vm_name
|
|
78
|
+
|
|
79
|
+
info("Proxmox VM #{vm_name} (#{vm_id}) created.")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def allocate_vm_id
|
|
83
|
+
Integer(api_client.next_vm_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def generate_vm_name(suite_name)
|
|
87
|
+
"#{config[:vm_name_prefix]}#{suite_name}-#{SecureRandom.hex(4)}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def clone_template(vm_id, vm_name)
|
|
91
|
+
node = config[:node]
|
|
92
|
+
upid = api_client.clone_vm(
|
|
93
|
+
node:, template_id: config[:template_id],
|
|
94
|
+
new_id: vm_id, name: vm_name,
|
|
95
|
+
pool: config[:pool], storage: config[:storage]
|
|
96
|
+
)
|
|
97
|
+
api_client.wait_for_task(node:, upid:, timeout: config[:clone_timeout])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def configure_hardware(vm_id)
|
|
101
|
+
api_client.configure_vm(
|
|
102
|
+
node: config[:node],
|
|
103
|
+
vm_id:,
|
|
104
|
+
cpus: config[:cpus],
|
|
105
|
+
memory: config[:memory],
|
|
106
|
+
network_bridge: config[:network_bridge]
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def start_and_wait_for_ip(state, vm_id)
|
|
111
|
+
api_client.start_vm(node: config[:node], vm_id:)
|
|
112
|
+
ip = wait_for_ip(vm_id)
|
|
113
|
+
state[:hostname] = ip
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def stop_vm(vm_id)
|
|
117
|
+
status = api_client.vm_status(node: config[:node], vm_id:)
|
|
118
|
+
return unless status['status'] == 'running'
|
|
119
|
+
|
|
120
|
+
upid = api_client.stop_vm(node: config[:node], vm_id:)
|
|
121
|
+
api_client.wait_for_task(node: config[:node], upid:, timeout: 60)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def safe_stop_vm(vm_id)
|
|
125
|
+
stop_vm(vm_id)
|
|
126
|
+
rescue ::StandardError => e
|
|
127
|
+
warn("Failed to stop VM #{vm_id}: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def wait_for_ip(vm_id)
|
|
131
|
+
deadline = Time.now + config[:ip_wait_timeout]
|
|
132
|
+
loop do
|
|
133
|
+
ip = fetch_ip(vm_id)
|
|
134
|
+
return ip if ip
|
|
135
|
+
raise "Timed out waiting for IP on VM #{vm_id}" if Time.now > deadline
|
|
136
|
+
|
|
137
|
+
sleep 3
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fetch_ip(vm_id)
|
|
142
|
+
interfaces = api_client.agent_network_interfaces(
|
|
143
|
+
node: config[:node],
|
|
144
|
+
vm_id:
|
|
145
|
+
)
|
|
146
|
+
return nil unless interfaces.is_a?(Hash) && interfaces['result']
|
|
147
|
+
|
|
148
|
+
extract_ipv4_from_interfaces(interfaces['result'])
|
|
149
|
+
rescue ::StandardError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_ipv4_from_interfaces(interfaces)
|
|
154
|
+
interfaces.each do |iface|
|
|
155
|
+
next if iface['name'] == 'lo'
|
|
156
|
+
|
|
157
|
+
(iface['ip-addresses'] || []).each do |addr|
|
|
158
|
+
return addr['ip-address'] if addr['ip-address-type'] == 'ipv4'
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_config!
|
|
165
|
+
errors = []
|
|
166
|
+
errors << validate_node if config[:node].nil?
|
|
167
|
+
errors << validate_template_id if config[:template_id].nil?
|
|
168
|
+
return if errors.empty?
|
|
169
|
+
|
|
170
|
+
raise Kitchen::UserError, errors.join("\n\n")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validate_node
|
|
174
|
+
msg = "Missing required config: node\n"
|
|
175
|
+
begin
|
|
176
|
+
nodes = api_client.list_nodes
|
|
177
|
+
msg += "Available Proxmox nodes:\n"
|
|
178
|
+
nodes.each { |n| msg += " - #{n['node']} (#{n['status']})\n" }
|
|
179
|
+
rescue ::StandardError
|
|
180
|
+
msg += " (could not retrieve node list from Proxmox API)\n"
|
|
181
|
+
end
|
|
182
|
+
msg
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def validate_template_id
|
|
186
|
+
msg = "Missing required config: template_id\n"
|
|
187
|
+
msg + format_template_list
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def format_template_list
|
|
191
|
+
templates = api_client.list_templates
|
|
192
|
+
return " No templates found on the Proxmox cluster.\n" if templates.empty?
|
|
193
|
+
|
|
194
|
+
lines = "Available templates:\n"
|
|
195
|
+
templates.each { |t| lines += " - #{t['vmid']}: #{t['name']} (node: #{t['node']})\n" }
|
|
196
|
+
lines
|
|
197
|
+
rescue ::StandardError
|
|
198
|
+
" (could not retrieve template list from Proxmox API)\n"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def clear_state(state)
|
|
202
|
+
state.delete(:vm_id)
|
|
203
|
+
state.delete(:vm_name)
|
|
204
|
+
state.delete(:hostname)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kitchen
|
|
4
|
+
module Driver
|
|
5
|
+
PROXMOX_VERSION = begin
|
|
6
|
+
# When loaded from a git checkout, derive the version from git tags.
|
|
7
|
+
# When installed as a gem, git isn't available so fall back to the
|
|
8
|
+
# version baked into the gemspec at build time.
|
|
9
|
+
dir = File.expand_path('../../..', __dir__)
|
|
10
|
+
if File.directory?(File.join(dir, '.git'))
|
|
11
|
+
tag = `git -C #{dir} describe --tags --match 'v*' 2>/dev/null`.strip
|
|
12
|
+
tag.empty? ? '0.0.0' : tag.sub(/^v/, '').sub(/-(\d+)-g/, '.\1.dev.')
|
|
13
|
+
else
|
|
14
|
+
Gem.loaded_specs['kitchen-proxmox']&.version&.to_s || '0.0.0'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kitchen-proxmox
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Richard Nixon
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A Test Kitchen driver for Proxmox VE. Manages VM lifecycle (create, destroy)
|
|
14
|
+
via the Proxmox REST API. Supports cloning from templates.
|
|
15
|
+
email:
|
|
16
|
+
- richard.nixon@btinternet.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/kitchen/driver/proxmox.rb
|
|
24
|
+
- lib/kitchen/driver/proxmox/api_client.rb
|
|
25
|
+
- lib/kitchen/driver/proxmox_version.rb
|
|
26
|
+
homepage: https://github.com/trickyearlobe-chef/kitchen-proxmox
|
|
27
|
+
licenses:
|
|
28
|
+
- Apache-2.0
|
|
29
|
+
metadata:
|
|
30
|
+
rubygems_mfa_required: 'true'
|
|
31
|
+
source_code_uri: https://github.com/trickyearlobe-chef/kitchen-proxmox
|
|
32
|
+
bug_tracker_uri: https://github.com/trickyearlobe-chef/kitchen-proxmox/issues
|
|
33
|
+
changelog_uri: https://github.com/trickyearlobe-chef/kitchen-proxmox/blob/main/CHANGELOG.md
|
|
34
|
+
post_install_message:
|
|
35
|
+
rdoc_options: []
|
|
36
|
+
require_paths:
|
|
37
|
+
- lib
|
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '3.1'
|
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
requirements: []
|
|
49
|
+
rubygems_version: 3.3.27
|
|
50
|
+
signing_key:
|
|
51
|
+
specification_version: 4
|
|
52
|
+
summary: Test Kitchen driver for Proxmox VE
|
|
53
|
+
test_files: []
|