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 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: []