durable-proxmox-ruby 0.1.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.
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableProxmox
4
+ # DSL for declaratively defining Proxmox VMs.
5
+ #
6
+ # Example:
7
+ # DurableProxmox::DSL.configure do
8
+ # vm 'web-server-1' do
9
+ # ip '192.168.2.100'
10
+ # template_id 911
11
+ # end
12
+ # end
13
+ class DSL
14
+ # @return [Array<VMDefinition>] List of defined VMs
15
+ attr_reader :vms
16
+
17
+ # @return [DurableProxmox::Client] The Proxmox client
18
+ attr_reader :client
19
+
20
+ # Executes a DSL configuration block.
21
+ #
22
+ # @param client [DurableProxmox::Client, nil] The Proxmox client (creates one if nil)
23
+ # @param insecure [Boolean] Skip SSL certificate validation (default: false)
24
+ # @yield Block with VM definitions
25
+ # @return [DSL] The DSL instance
26
+ def self.configure(client = nil, insecure: false, &block)
27
+ dsl = new(client, insecure: insecure)
28
+ dsl.instance_eval(&block)
29
+ dsl
30
+ end
31
+
32
+ # Creates a new DSL instance.
33
+ #
34
+ # @param client [DurableProxmox::Client, nil] The Proxmox client
35
+ # @param insecure [Boolean] Skip SSL certificate validation (default: false)
36
+ def initialize(client = nil, insecure: false)
37
+ @client = client || Client.new(nil, insecure: insecure)
38
+ @vms = []
39
+ end
40
+
41
+ # Defines a VM in the DSL.
42
+ #
43
+ # @param name [String] The VM hostname
44
+ # @yield Block with VM configuration
45
+ # @return [VMDefinition] The VM definition
46
+ def vm(name, &block)
47
+ vm_def = VMDefinition.new(name, @client)
48
+ vm_def.instance_eval(&block) if block_given?
49
+ @vms << vm_def
50
+ vm_def
51
+ end
52
+
53
+ # Applies all VM definitions (create or update VMs).
54
+ #
55
+ # @param node [DurableProxmox::Node, nil] The target node (uses first node if nil)
56
+ # @return [Array<Hash>] Results for each VM
57
+ def apply(node = nil)
58
+ target_node = node || @client.nodes.first
59
+ @vms.map { |vm_def| vm_def.apply(target_node) }
60
+ end
61
+
62
+ # Plans all VM definitions (shows what would be done without executing).
63
+ #
64
+ # @param node [DurableProxmox::Node, nil] The target node (uses first node if nil)
65
+ # @return [Array<Hash>] Plan for each VM
66
+ def plan(node = nil)
67
+ target_node = node || @client.nodes.first
68
+ @vms.map { |vm_def| vm_def.plan(target_node) }
69
+ end
70
+ end
71
+
72
+ # Represents a VM definition in the DSL.
73
+ class VMDefinition
74
+ # @return [String] VM hostname
75
+ attr_reader :name
76
+
77
+ # @return [String, nil] IP address
78
+ attr_reader :ip
79
+
80
+ # @return [String, nil] Gateway address
81
+ attr_reader :gateway
82
+
83
+ # @return [Integer, nil] Template VM ID for cloning
84
+ attr_reader :template_id
85
+
86
+ # @return [Integer, nil] Number of CPU cores
87
+ attr_reader :cores
88
+
89
+ # @return [Integer, nil] Memory in MB
90
+ attr_reader :memory
91
+
92
+ # @return [Integer, nil] Specific VMID to use
93
+ attr_reader :vmid
94
+
95
+ # @return [Array<String>, nil] DNS server addresses
96
+ attr_reader :dns_servers
97
+
98
+ # @return [DurableProxmox::Client] The Proxmox client
99
+ attr_reader :client
100
+
101
+ # Creates a new VM definition.
102
+ #
103
+ # @param name [String] The VM hostname
104
+ # @param client [DurableProxmox::Client] The Proxmox client
105
+ def initialize(name, client)
106
+ @name = name
107
+ @client = client
108
+ @template_id = (ENV["SOURCE_VMID"] || 911).to_i
109
+ end
110
+
111
+ # Sets the IP address for the VM.
112
+ #
113
+ # @param value [String, nil] The IP address (returns current value if nil)
114
+ # @return [String, nil] The IP address
115
+ def ip(value = nil)
116
+ return @ip if value.nil?
117
+ @ip = value
118
+ end
119
+
120
+ # Sets the gateway address for the VM.
121
+ #
122
+ # @param value [String, nil] The gateway address (returns current value if nil)
123
+ # @return [String, nil] The gateway address
124
+ def gateway(value = nil)
125
+ return @gateway if value.nil?
126
+ @gateway = value
127
+ end
128
+
129
+ # Sets the template ID for cloning.
130
+ #
131
+ # @param value [Integer, nil] The template VM ID (returns current value if nil)
132
+ # @return [Integer, nil] The template ID
133
+ def template_id(value = nil)
134
+ return @template_id if value.nil?
135
+ @template_id = value
136
+ end
137
+
138
+ # Sets the number of CPU cores.
139
+ #
140
+ # @param value [Integer, nil] Number of cores (returns current value if nil)
141
+ # @return [Integer, nil] The number of cores
142
+ def cores(value = nil)
143
+ return @cores if value.nil?
144
+ @cores = value
145
+ end
146
+
147
+ # Sets the memory size.
148
+ #
149
+ # @param value [Integer, nil] Memory in MB (returns current value if nil)
150
+ # @return [Integer, nil] The memory size
151
+ def memory(value = nil)
152
+ return @memory if value.nil?
153
+ @memory = value
154
+ end
155
+
156
+ # Sets a specific VMID.
157
+ #
158
+ # @param value [Integer, nil] The VMID (returns current value if nil)
159
+ # @return [Integer, nil] The VMID
160
+ def vmid(value = nil)
161
+ return @vmid if value.nil?
162
+ @vmid = value
163
+ end
164
+
165
+ # Sets DNS server addresses.
166
+ #
167
+ # @param servers [Array<String>, String] DNS server IP address(es) (returns current value if empty)
168
+ # @return [Array<String>, nil] The DNS servers
169
+ def dns_servers(*servers)
170
+ return @dns_servers if servers.empty?
171
+ @dns_servers = servers.flatten
172
+ end
173
+
174
+ # Applies this VM definition (creates or updates the VM).
175
+ #
176
+ # @param node [DurableProxmox::Node] The target node
177
+ # @return [Hash] Result with VM details
178
+ def apply(node)
179
+ # Check if VM already exists
180
+ existing_vm = node.vms.find { |vm| vm.name == @name }
181
+
182
+ if existing_vm
183
+ update_vm(existing_vm, node)
184
+ else
185
+ create_vm(node)
186
+ end
187
+ end
188
+
189
+ # Plans this VM definition (shows what would be done without executing).
190
+ #
191
+ # @param node [DurableProxmox::Node] The target node
192
+ # @return [Hash] Plan with VM details
193
+ def plan(node)
194
+ # Check if VM already exists
195
+ existing_vm = node.vms.find { |vm| vm.name == @name }
196
+
197
+ if existing_vm
198
+ plan_update(existing_vm, node)
199
+ else
200
+ plan_create(node)
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ # Creates a new VM from this definition.
207
+ #
208
+ # @param node [DurableProxmox::Node] The target node
209
+ # @return [Hash] Result with VM details
210
+ def create_vm(node)
211
+ # Clone from template
212
+ new_vmid = @vmid || node.get_next_vmid
213
+ puts "Creating VM '#{@name}' (vmid: #{new_vmid}) from template #{@template_id}..."
214
+
215
+ params = {
216
+ newid: new_vmid,
217
+ target: node.node_id,
218
+ name: @name,
219
+ full: 1
220
+ }
221
+
222
+ response = @client.post("nodes/#{node.node_id}/qemu/#{@template_id}/clone", params)
223
+ resp = response.body.dig("data")
224
+
225
+ if resp
226
+ print "Cloning VM"
227
+ @client.wait_for_upid(node.node_id, resp)
228
+ end
229
+
230
+ # Update configuration
231
+ node.clear_cache
232
+ vm = node.vms.find { |v| v.vm_id == new_vmid }
233
+
234
+ if vm
235
+ puts "VM '#{@name}' created successfully (vmid: #{new_vmid})"
236
+ apply_configuration(vm, node)
237
+ end
238
+
239
+ {
240
+ action: "created",
241
+ name: @name,
242
+ vmid: new_vmid,
243
+ ip: @ip
244
+ }
245
+ end
246
+
247
+ # Updates an existing VM with this definition.
248
+ #
249
+ # @param vm [DurableProxmox::VM] The existing VM
250
+ # @param node [DurableProxmox::Node] The node
251
+ # @return [Hash] Result with VM details
252
+ def update_vm(vm, node)
253
+ puts "Updating VM '#{@name}' (vmid: #{vm.vm_id})..."
254
+ apply_configuration(vm, node)
255
+ puts "VM '#{@name}' updated successfully"
256
+
257
+ {
258
+ action: "updated",
259
+ name: @name,
260
+ vmid: vm.vm_id,
261
+ ip: @ip
262
+ }
263
+ end
264
+
265
+ # Applies configuration to a VM.
266
+ #
267
+ # @param vm [DurableProxmox::VM] The VM to configure
268
+ # @param node [DurableProxmox::Node] The node
269
+ def apply_configuration(vm, node)
270
+ config_params = {}
271
+
272
+ config_params[:cores] = @cores if @cores
273
+ config_params[:memory] = @memory if @memory
274
+
275
+ # Set IP via cloud-init ipconfig
276
+ if @ip
277
+ # Format: ip=192.168.1.100/24,gw=192.168.1.1
278
+ # If IP already has a netmask, use it as-is; otherwise default to /24
279
+ ip_with_netmask = @ip.include?('/') ? @ip : "#{@ip}/24"
280
+ ipconfig = "ip=#{ip_with_netmask}"
281
+
282
+ # Use specified gateway, or preserve existing gateway if not specified
283
+ gateway_to_use = @gateway
284
+ if gateway_to_use.nil? && vm.config["ipconfig0"]
285
+ # Extract existing gateway from ipconfig0 (format: ip=x.x.x.x/24,gw=y.y.y.y)
286
+ existing_ipconfig = vm.config["ipconfig0"]
287
+ if existing_ipconfig =~ /gw=([^,]+)/
288
+ gateway_to_use = ::Regexp.last_match(1)
289
+ end
290
+ end
291
+
292
+ ipconfig += ",gw=#{gateway_to_use}" if gateway_to_use
293
+ config_params[:ipconfig0] = ipconfig
294
+ end
295
+
296
+ # Set DNS servers via cloud-init nameserver
297
+ if @dns_servers && !@dns_servers.empty?
298
+ config_params[:nameserver] = @dns_servers.join(" ")
299
+ end
300
+
301
+ unless config_params.empty?
302
+ puts " Applying configuration: #{config_params.inspect}"
303
+ @client.post("nodes/#{node.node_id}/qemu/#{vm.vm_id}/config", config_params)
304
+ puts " Configuration applied successfully"
305
+ end
306
+ end
307
+
308
+ # Plans VM creation without executing.
309
+ #
310
+ # @param node [DurableProxmox::Node] The target node
311
+ # @return [Hash] Plan with VM details
312
+ def plan_create(node)
313
+ planned_vmid = @vmid || node.get_next_vmid
314
+ planned_config = build_config_params
315
+
316
+ {
317
+ action: "create",
318
+ name: @name,
319
+ vmid: planned_vmid,
320
+ template_id: @template_id,
321
+ configuration: planned_config
322
+ }
323
+ end
324
+
325
+ # Plans VM update without executing.
326
+ #
327
+ # @param vm [DurableProxmox::VM] The existing VM
328
+ # @param node [DurableProxmox::Node] The node
329
+ # @return [Hash] Plan with VM details
330
+ def plan_update(vm, node)
331
+ current_config = vm.config
332
+ planned_config = build_config_params
333
+
334
+ # Only include changes
335
+ changes = {}
336
+ planned_config.each do |key, value|
337
+ current_value = if key == :ipconfig0
338
+ current_config["ipconfig0"]
339
+ else
340
+ current_config[key.to_s]
341
+ end
342
+
343
+ changes[key] = { from: current_value, to: value } if current_value != value
344
+ end
345
+
346
+ {
347
+ action: "update",
348
+ name: @name,
349
+ vmid: vm.vm_id,
350
+ changes: changes
351
+ }
352
+ end
353
+
354
+ # Builds configuration parameters from the definition.
355
+ #
356
+ # @return [Hash] Configuration parameters
357
+ def build_config_params
358
+ config_params = {}
359
+
360
+ config_params[:cores] = @cores if @cores
361
+ config_params[:memory] = @memory if @memory
362
+ if @ip
363
+ # If IP already has a netmask, use it as-is; otherwise default to /24
364
+ ip_with_netmask = @ip.include?('/') ? @ip : "#{@ip}/24"
365
+ ipconfig = "ip=#{ip_with_netmask}"
366
+ ipconfig += ",gw=#{@gateway}" if @gateway
367
+ config_params[:ipconfig0] = ipconfig
368
+ end
369
+ config_params[:nameserver] = @dns_servers.join(" ") if @dns_servers && !@dns_servers.empty?
370
+
371
+ config_params
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableProxmox
4
+ # Base error class for all DurableProxmox errors.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when authentication fails or credentials are invalid.
8
+ class AuthenticationError < Error; end
9
+
10
+ # Raised when a requested resource (VM, node, etc.) is not found.
11
+ class NotFoundError < Error; end
12
+
13
+ # Raised when input validation fails.
14
+ class ValidationError < Error; end
15
+
16
+ # Raised when the Proxmox API returns an error response.
17
+ class APIError < Error
18
+ # @return [Integer, nil] HTTP status code
19
+ attr_reader :status_code
20
+
21
+ # @return [Hash, nil] Response body from the API
22
+ attr_reader :response_body
23
+
24
+ # Creates a new APIError.
25
+ #
26
+ # @param message [String] Error message
27
+ # @param status_code [Integer, nil] HTTP status code
28
+ # @param response_body [Hash, nil] Response body from the API
29
+ def initialize(message, status_code: nil, response_body: nil)
30
+ super(message)
31
+ @status_code = status_code
32
+ @response_body = response_body
33
+ end
34
+ end
35
+
36
+ # Raised when a request times out.
37
+ class TimeoutError < Error; end
38
+
39
+ # Raised when the QEMU guest agent is not available or not responding.
40
+ class GuestAgentError < Error; end
41
+
42
+ # Raised when a network connection fails.
43
+ class ConnectionError < Error; end
44
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module DurableProxmox
6
+ # Utility class for managing groups of IP addresses.
7
+ #
8
+ # Provides methods for filtering, finding ranges, and identifying missing IPs.
9
+ class IPAddressGroup
10
+ # @return [Array] The group of IP addresses or interface data
11
+ attr_accessor :group
12
+
13
+ # Creates a new IP address group.
14
+ #
15
+ # @param group [Array] Array of IP addresses or interface data
16
+ def initialize(group)
17
+ @group = group
18
+ end
19
+
20
+ # Returns the number of items in the group.
21
+ #
22
+ # @return [Integer] The length of the group
23
+ def length
24
+ @group.length
25
+ end
26
+
27
+ # Converts the group to an array.
28
+ #
29
+ # @return [Array] The group as an array
30
+ def to_a
31
+ @group
32
+ end
33
+
34
+ # Filters the group to include only IPv4 addresses (for interfaces).
35
+ #
36
+ # @return [DurableProxmox::IPAddressGroup] New group with only IPv4 addresses
37
+ def ipv4
38
+ IPAddressGroup.new(
39
+ @group.select { |ip| ip["ip-address-type"] == "ipv4" }
40
+ )
41
+ end
42
+
43
+ # Returns the minimum IP address in the group.
44
+ #
45
+ # @return [IPAddr] The minimum IP address
46
+ def min
47
+ ip_addresses.min
48
+ end
49
+
50
+ # Returns the maximum IP address in the group.
51
+ #
52
+ # @return [IPAddr] The maximum IP address
53
+ def max
54
+ ip_addresses.max
55
+ end
56
+
57
+ # Returns the IP address range from min to max.
58
+ #
59
+ # @return [Range] The IP address range
60
+ def range
61
+ min..max
62
+ end
63
+
64
+ # Finds IP addresses that are missing from the range.
65
+ #
66
+ # @return [Array<IPAddr>] Array of missing IP addresses
67
+ def missing
68
+ range.to_a - ip_addresses
69
+ end
70
+
71
+ # Converts the group to IPAddr objects.
72
+ #
73
+ # @return [Array<IPAddr>] Array of IPAddr objects
74
+ def ip_addresses
75
+ octets.map { |octet| IPAddr.new(octet) }
76
+ end
77
+
78
+ # Filters the group to include only addresses within specified subnets.
79
+ #
80
+ # @param subnets [Array<String>] Array of subnet specifications
81
+ # @return [DurableProxmox::IPAddressGroup] New group filtered by subnets
82
+ def in_subnets(*subnets)
83
+ subnets = subnets.map { |subnet| IPAddr.new(subnet) }
84
+ IPAddressGroup.new(
85
+ @group.select do |interface|
86
+ ip = IPAddr.new(interface["ip-address"] || interface["ipaddr"])
87
+ subnets.any? { |subnet| subnet.include?(ip) }
88
+ end
89
+ )
90
+ end
91
+
92
+ # Extracts IP address octets from the group data.
93
+ #
94
+ # @return [Array<String>] Array of IP address strings
95
+ def octets
96
+ @group.map { |item| item["ip-address"] || item["ipaddr"] || item }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module DurableProxmox
7
+ # Represents a Proxmox node (physical server).
8
+ #
9
+ # Provides access to VMs, LXC containers, and node operations.
10
+ class Node
11
+ # @return [String] Node name
12
+ attr_reader :name
13
+
14
+ # @return [String] Node status
15
+ attr_reader :status
16
+
17
+ # @return [Float] CPU usage
18
+ attr_reader :cpu
19
+
20
+ # @return [Float] Memory usage
21
+ attr_reader :memory
22
+
23
+ # @return [DurableProxmox::Client] The client instance
24
+ attr_reader :client
25
+
26
+ # @return [String] Node identifier
27
+ attr_reader :node_id
28
+
29
+ # Creates a new node instance.
30
+ #
31
+ # @param data [Hash] Node data from the API
32
+ # @param client [DurableProxmox::Client] The client instance
33
+ def initialize(data, client)
34
+ @client = client
35
+ @name = data["name"]
36
+ @node_id = data["node"]
37
+ @status = data["status"]
38
+ @cpu = data["cpu"]
39
+ @memory = data["memory"]
40
+ @cache = {}
41
+ end
42
+
43
+ # Clears the internal cache.
44
+ #
45
+ # Useful when the node state has changed externally.
46
+ def clear_cache
47
+ @cache.clear
48
+ end
49
+
50
+ # Deletes a VM from this node.
51
+ #
52
+ # @param vm [DurableProxmox::VM] The VM to delete
53
+ # @return [Hash] The response body
54
+ def delete_vm(vm)
55
+ puts "Deleting VM #{vm.vm_id} (#{vm.name}) from node #{node_id}..."
56
+ resp = @client.delete("nodes/#{node_id}/qemu/#{vm.vm_id}")
57
+ upid = resp.body.dig("data")
58
+
59
+ if upid
60
+ @client.wait_for_upid(node_id, upid)
61
+ puts "VM #{vm.vm_id} (#{vm.name}) deleted successfully"
62
+ end
63
+
64
+ resp.body
65
+ end
66
+
67
+ # Clones a VM from the source template and assigns it a hostname.
68
+ #
69
+ # @param hostname [String] The hostname for the new VM
70
+ # @return [Integer] The new VM ID
71
+ def spawn_vm(hostname)
72
+ source_vmid = (ENV["SOURCE_VMID"] || 911).to_i
73
+ new_vmid = get_next_vmid
74
+
75
+ puts "Spawning VM '#{hostname}' (vmid: #{new_vmid}) from template #{source_vmid}..."
76
+
77
+ params = { newid: new_vmid, target: @node_id, name: hostname, full: 1 }
78
+
79
+ resp = @client.post("nodes/#{node_id}/qemu/#{source_vmid}/clone", params).body.dig("data")
80
+
81
+ sleep 1
82
+
83
+ if resp
84
+ print "Cloning VM"
85
+ @client.wait_for_upid(node_id, resp)
86
+ end
87
+
88
+ data = @client.get("nodes/#{node_id}/qemu/#{new_vmid}/config").body["data"] || {}
89
+
90
+ puts "VM '#{hostname}' spawned successfully (vmid: #{new_vmid})"
91
+
92
+ new_vmid
93
+ end
94
+
95
+ # Finds the next available VM ID.
96
+ #
97
+ # @return [Integer] The next available VM ID
98
+ def get_next_vmid
99
+ vmids = vms.map { |vm| vm.vm_id.to_i }.sort
100
+ lxcids = lxcs.map { |lxc| lxc["vmid"].to_i }.sort
101
+ excluded_vmids = [148]
102
+
103
+ upper = vmids.max || 100
104
+ lower = vmids.min || 0
105
+
106
+ missing = (lower..upper).to_a - vmids - lxcids - excluded_vmids
107
+ missing.first || upper + 1 || 101
108
+ end
109
+
110
+ # Retrieves LXC containers on this node.
111
+ #
112
+ # @return [Array<Hash>] Array of LXC container data
113
+ def lxcs
114
+ @cache[:lxcs] ||= @client.get("nodes/#{node_id}/lxc").body["data"]
115
+ end
116
+
117
+ # Retrieves VMs on this node.
118
+ #
119
+ # @return [Array<DurableProxmox::VM>] Array of VM objects
120
+ def vms
121
+ @cache[:vms] ||= @client.get("nodes/#{node_id}/qemu").body["data"].map do |data|
122
+ VM.new(data, self, @client)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableProxmox
4
+ VERSION = "0.1.0"
5
+ end