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.
data/exe/proxmox ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Only require bundler/setup when not running as a packaged tebako executable
5
+ # Check if we're running from the tebako memfs
6
+ unless $LOAD_PATH.first&.start_with?('/__tebako_memfs__')
7
+ require "bundler/setup"
8
+ end
9
+
10
+ require "durable_proxmox"
11
+ require "optparse"
12
+
13
+ class ProxmoxCLI
14
+ def initialize
15
+ @options = {}
16
+ @parser = create_parser
17
+ end
18
+
19
+ def run(args)
20
+ @parser.parse!(args)
21
+ command = args.shift
22
+
23
+ case command
24
+ when "nodes"
25
+ list_nodes
26
+ when "vms"
27
+ list_vms(args.shift)
28
+ when "start"
29
+ start_vm(args)
30
+ when "stop"
31
+ stop_vm(args)
32
+ when "delete"
33
+ delete_vm(args)
34
+ when "spawn"
35
+ spawn_vm(args)
36
+ when "ips"
37
+ show_ips(args)
38
+ when "export"
39
+ export_dsl(args)
40
+ when "version"
41
+ puts "durable-proxmox-ruby v#{DurableProxmox::VERSION}"
42
+ else
43
+ puts @parser.help
44
+ exit 1
45
+ end
46
+ rescue StandardError => e
47
+ puts "Error: #{e.message}"
48
+ puts e.backtrace if @options[:debug]
49
+ exit 1
50
+ end
51
+
52
+ private
53
+
54
+ def create_parser
55
+ OptionParser.new do |opts|
56
+ opts.banner = "Usage: proxmox [options] COMMAND [args]"
57
+ opts.separator ""
58
+ opts.separator "Commands:"
59
+ opts.separator " nodes List all Proxmox nodes"
60
+ opts.separator " vms [NODE] List VMs (optionally filter by node name)"
61
+ opts.separator " start NODE VMID Start a VM"
62
+ opts.separator " stop NODE VMID Stop a VM"
63
+ opts.separator " delete NODE VMID Delete a VM"
64
+ opts.separator " spawn NODE HOSTNAME Spawn a new VM from template"
65
+ opts.separator " ips NODE VMID Show IP addresses for a VM"
66
+ opts.separator " export [NODE] [FILE] Export infrastructure as DSL script"
67
+ opts.separator " version Show version"
68
+ opts.separator ""
69
+ opts.separator "Options:"
70
+
71
+ opts.on("-d", "--debug", "Enable debug mode") do
72
+ @options[:debug] = true
73
+ end
74
+
75
+ opts.on("--insecure", "Skip SSL certificate validation and expiry checks") do
76
+ @options[:insecure] = true
77
+ end
78
+
79
+ opts.on("-h", "--help", "Show this help message") do
80
+ puts opts
81
+ exit
82
+ end
83
+ end
84
+ end
85
+
86
+ def client
87
+ @client ||= DurableProxmox.client(nil, insecure: @options[:insecure])
88
+ end
89
+
90
+ def list_nodes
91
+ nodes = client.nodes
92
+ puts "Nodes:"
93
+ nodes.each do |node|
94
+ puts " #{node.node_id} (#{node.status}) - CPU: #{(node.cpu * 100).round(2)}%, Memory: #{node.memory}"
95
+ end
96
+ end
97
+
98
+ def list_vms(node_filter = nil)
99
+ nodes = client.nodes
100
+ nodes = nodes.select { |n| n.node_id == node_filter } if node_filter
101
+
102
+ nodes.each do |node|
103
+ puts "Node: #{node.node_id}"
104
+ vms = node.vms
105
+ if vms.empty?
106
+ puts " No VMs"
107
+ else
108
+ vms.each do |vm|
109
+ puts " [#{vm.vm_id}] #{vm.name} (#{vm.status})"
110
+ end
111
+ end
112
+ puts
113
+ end
114
+ end
115
+
116
+ def find_vm(node_name, vmid)
117
+ node = client.nodes.find { |n| n.node_id == node_name }
118
+ raise "Node '#{node_name}' not found" unless node
119
+
120
+ vm = node.vms.find { |v| v.vm_id.to_s == vmid.to_s }
121
+ raise "VM '#{vmid}' not found on node '#{node_name}'" unless vm
122
+
123
+ [node, vm]
124
+ end
125
+
126
+ def start_vm(args)
127
+ raise "Usage: proxmox start NODE VMID" if args.length < 2
128
+
129
+ node_name, vmid = args
130
+ _node, vm = find_vm(node_name, vmid)
131
+
132
+ puts "Starting VM #{vmid} on node #{node_name}..."
133
+ vm.start
134
+ puts "VM started successfully"
135
+ end
136
+
137
+ def stop_vm(args)
138
+ raise "Usage: proxmox stop NODE VMID" if args.length < 2
139
+
140
+ node_name, vmid = args
141
+ _node, vm = find_vm(node_name, vmid)
142
+
143
+ puts "Stopping VM #{vmid} on node #{node_name}..."
144
+ vm.stop
145
+ puts "VM stopped successfully"
146
+ end
147
+
148
+ def delete_vm(args)
149
+ raise "Usage: proxmox delete NODE VMID" if args.length < 2
150
+
151
+ node_name, vmid = args
152
+ node, vm = find_vm(node_name, vmid)
153
+
154
+ puts "Deleting VM #{vmid} on node #{node_name}..."
155
+ node.delete_vm(vm)
156
+ puts "VM deleted successfully"
157
+ end
158
+
159
+ def spawn_vm(args)
160
+ raise "Usage: proxmox spawn NODE HOSTNAME" if args.length < 2
161
+
162
+ node_name, hostname = args
163
+ node = client.nodes.find { |n| n.node_id == node_name }
164
+ raise "Node '#{node_name}' not found" unless node
165
+
166
+ puts "Spawning VM '#{hostname}' on node #{node_name}..."
167
+ new_vmid = node.spawn_vm(hostname)
168
+ puts "VM spawned successfully with ID: #{new_vmid}"
169
+ end
170
+
171
+ def show_ips(args)
172
+ raise "Usage: proxmox ips NODE VMID" if args.length < 2
173
+
174
+ node_name, vmid = args
175
+ _node, vm = find_vm(node_name, vmid)
176
+
177
+ puts "IP addresses for VM #{vmid} (#{vm.name}):"
178
+ vm.network_interfaces.each do |iface|
179
+ next if iface["name"] == "lo"
180
+
181
+ puts " Interface: #{iface['name']}"
182
+ if iface["ip-addresses"]
183
+ iface["ip-addresses"].each do |ip|
184
+ puts " #{ip['ip-address']}/#{ip['prefix']}"
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ def export_dsl(args)
191
+ node_filter = args.shift
192
+ output_file = args.shift
193
+
194
+ # Find the node if specified
195
+ target_node = nil
196
+ if node_filter
197
+ target_node = client.nodes.find { |n| n.node_id == node_filter }
198
+ raise "Node '#{node_filter}' not found" unless target_node
199
+ puts "Exporting configuration for node: #{node_filter}..."
200
+ else
201
+ puts "Exporting configuration for all nodes..."
202
+ end
203
+
204
+ # Generate the DSL script
205
+ script = client.export_configuration(target_node)
206
+
207
+ # Output to file or stdout
208
+ if output_file
209
+ File.write(output_file, script)
210
+ puts "Configuration exported to: #{output_file}"
211
+ puts "To apply: ruby #{output_file}"
212
+ else
213
+ puts script
214
+ end
215
+ end
216
+ end
217
+
218
+ ProxmoxCLI.new.run(ARGV)
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module DurableProxmox
7
+ # Main client for interacting with Proxmox API.
8
+ #
9
+ # Handles authentication, HTTP requests, and provides access to nodes and VMs.
10
+ class Client
11
+ # @return [String] The base URL for the Proxmox API
12
+ attr_reader :url_base
13
+
14
+ # @return [Faraday::Connection] The HTTP connection
15
+ attr_reader :connection
16
+
17
+ # @return [Hash] Authentication parameters
18
+ attr_reader :auth_params
19
+
20
+ # Creates a new Proxmox client with automatic authentication.
21
+ #
22
+ # @param url_base [String, nil] The base URL for the Proxmox API.
23
+ # Defaults to ENV['PROXMOX_HOSTNAME'] or a default URL.
24
+ # @param insecure [Boolean] Skip SSL certificate validation and expiry checks (default: false).
25
+ # Set to true for self-signed or expired certificates.
26
+ def initialize(url_base = nil, insecure: false)
27
+ hostname = ENV['PROXMOX_HOSTNAME'] || 'fuzzyfireball.durablelan'
28
+ @url_base = url_base || "https://#{hostname}:8006/api2/json/"
29
+ @insecure = insecure
30
+
31
+ @connection = create_connection
32
+ @auth_params = create_auth_header
33
+ end
34
+
35
+ # Creates a new Faraday connection with appropriate SSL settings.
36
+ #
37
+ # @return [Faraday::Connection] A new connection instance
38
+ def create_connection
39
+ ssl_options = if @insecure
40
+ {
41
+ verify: false,
42
+ version: :TLSv1_2
43
+ }
44
+ else
45
+ { verify: true }
46
+ end
47
+
48
+ Faraday.new(@url_base, headers: { "user_agent": 'DurableProxmox - As Suave As They Come', "Accept-Encoding": 'identity' }, ssl: ssl_options) do |f|
49
+ f.request :url_encoded
50
+ f.response :json, content_type: /\bjson$/
51
+ f.adapter :net_http do |client|
52
+ client.keep_alive_timeout = 5
53
+ client.read_timeout = 60
54
+ client.write_timeout = 60
55
+ end
56
+ end
57
+ end
58
+
59
+ # Creates an authentication header for API token access.
60
+ #
61
+ # @return [Hash] Authentication parameters with Authorization header
62
+ # @raise [RuntimeError] If required environment variables are missing
63
+ def create_auth_header
64
+ username = ENV['PROXMOX_USERNAME']
65
+ token_name = ENV['PROXMOX_API_TOKEN_NAME']
66
+ token_secret = ENV['PROXMOX_API_TOKEN_SECRET']
67
+
68
+ raise AuthenticationError, 'PROXMOX_USERNAME environment variable is required' unless username
69
+ raise AuthenticationError, 'PROXMOX_API_TOKEN_NAME environment variable is required' unless token_name
70
+ raise AuthenticationError, 'PROXMOX_API_TOKEN_SECRET environment variable is required' unless token_secret
71
+
72
+ token = "#{username}!#{token_name}=#{token_secret}"
73
+ {
74
+ 'Authorization' => "PVEAPIToken=#{token}"
75
+ }
76
+ end
77
+
78
+ # Performs an authenticated POST request.
79
+ #
80
+ # @param path [String] The API endpoint path
81
+ # @param params [Hash] Request parameters
82
+ # @return [Faraday::Response] The response
83
+ def post(path, params = {})
84
+ @connection.post(path, params) do |req|
85
+ req.headers.merge!(@auth_params)
86
+ end
87
+ end
88
+
89
+ # Performs an authenticated DELETE request.
90
+ #
91
+ # @param path [String] The API endpoint path
92
+ # @param params [Hash] Request parameters
93
+ # @return [Faraday::Response] The response
94
+ def delete(path, params = {})
95
+ @connection.delete(path, params) do |req|
96
+ req.headers.merge!(@auth_params)
97
+ end
98
+ end
99
+
100
+ # Performs an authenticated GET request.
101
+ #
102
+ # @param path [String] The API endpoint path
103
+ # @param params [Hash] Request parameters
104
+ # @return [Faraday::Response] The response
105
+ def get(path, params = {})
106
+ retries = 0
107
+ max_retries = 3
108
+ begin
109
+ # Create a new connection for each request to avoid connection reuse issues
110
+ conn = create_connection
111
+ conn.get(path, params) do |req|
112
+ req.headers.merge!(@auth_params)
113
+ end
114
+ rescue EOFError, OpenSSL::SSL::SSLError, Faraday::ConnectionFailed, Faraday::SSLError => e
115
+ retries += 1
116
+ $stderr.puts "Request failed (attempt #{retries}/#{max_retries}): #{e.class} - #{e.message}" if ENV['DEBUG']
117
+ if retries <= max_retries
118
+ sleep 0.5
119
+ retry
120
+ else
121
+ raise
122
+ end
123
+ end
124
+ end
125
+
126
+ # Retrieves all nodes from the Proxmox cluster.
127
+ #
128
+ # @return [Array<DurableProxmox::Node>] Array of node objects
129
+ def nodes
130
+ get('nodes').body['data'].map { |data| Node.new(data, self) }
131
+ end
132
+
133
+ # Waits for an asynchronous Proxmox task to complete.
134
+ #
135
+ # @param node_or_node_id [DurableProxmox::Node, String] The node or node ID
136
+ # @param upid [String] The task UPID
137
+ # @param show_progress [Boolean] Show a progress bar while waiting (default: true)
138
+ def wait_for_upid(node_or_node_id, upid, show_progress: true)
139
+ node_id = node_or_node_id.respond_to?(:node_id) ? node_or_node_id.node_id : node_or_node_id
140
+ elapsed = 0
141
+
142
+ if show_progress
143
+ print "Waiting for task to complete "
144
+ end
145
+
146
+ loop do
147
+ task_status = get("nodes/#{node_id}/tasks/#{upid}/status").body
148
+ status = task_status.dig('data', 'status')
149
+ break unless status == 'running'
150
+
151
+ if show_progress
152
+ print "."
153
+ $stdout.flush
154
+ end
155
+
156
+ sleep 1
157
+ elapsed += 1
158
+ end
159
+
160
+ if show_progress
161
+ puts " done (#{elapsed}s)"
162
+ end
163
+
164
+ # Check if task succeeded or failed
165
+ task_status = get("nodes/#{node_id}/tasks/#{upid}/status").body
166
+ exitstatus = task_status.dig('data', 'exitstatus')
167
+
168
+ if exitstatus && exitstatus != 'OK'
169
+ $stderr.puts "Warning: Task completed with status: #{exitstatus}"
170
+ end
171
+ end
172
+
173
+ # Exports the current Proxmox infrastructure as a DSL script.
174
+ #
175
+ # @param node [DurableProxmox::Node, nil] Optional specific node to export (exports all if nil)
176
+ # @param options [Hash] Export options
177
+ # @option options [Boolean] :include_stopped Include stopped VMs (default: true)
178
+ # @option options [Boolean] :include_templates Include template VMs (default: false)
179
+ # @return [String] The generated DSL script
180
+ def export_configuration(node = nil, options = {})
181
+ include_stopped = options.fetch(:include_stopped, true)
182
+ include_templates = options.fetch(:include_templates, false)
183
+
184
+ target_nodes = node ? [node] : nodes
185
+
186
+ script_lines = []
187
+ script_lines << '#!/usr/bin/env ruby'
188
+ script_lines << '# frozen_string_literal: true'
189
+ script_lines << ''
190
+ script_lines << "# Generated by durable-proxmox-ruby on #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
191
+ script_lines << '# This script defines the current Proxmox infrastructure'
192
+ script_lines << ''
193
+ script_lines << "require 'durable_proxmox'"
194
+ script_lines << ''
195
+ script_lines << '# Apply this configuration with: ruby <filename>.rb'
196
+ script_lines << ''
197
+ script_lines << 'DurableProxmox::DSL.configure do'
198
+
199
+ target_nodes.each do |target_node|
200
+ vms = target_node.vms
201
+
202
+ # Filter VMs based on options
203
+ vms = vms.reject { |vm| vm.status == 'stopped' } unless include_stopped
204
+ vms = vms.reject { |vm| vm.data['template'] == 1 } unless include_templates
205
+
206
+ vms.each do |vm|
207
+ script_lines << generate_vm_dsl(vm, target_node)
208
+ end
209
+ end
210
+
211
+ script_lines << 'end.apply'
212
+ script_lines << ''
213
+
214
+ script_lines.join("\n")
215
+ end
216
+
217
+ private
218
+
219
+ # Generates DSL code for a single VM.
220
+ #
221
+ # @param vm [DurableProxmox::VM] The VM to export
222
+ # @param node [DurableProxmox::Node] The node the VM is on
223
+ # @return [String] The DSL code for the VM
224
+ def generate_vm_dsl(vm, node)
225
+ config = vm.config
226
+ lines = []
227
+
228
+ lines << ''
229
+ lines << " # Node: #{node.node_id}"
230
+ lines << " # Status: #{vm.status}"
231
+ lines << " vm '#{vm.name}' do"
232
+ lines << " vmid #{vm.vm_id}"
233
+
234
+ # Extract cores
235
+ lines << " cores #{config['cores']}" if config['cores']
236
+
237
+ # Extract memory (convert from bytes to MB if needed)
238
+ if config['memory']
239
+ memory_mb = config['memory']
240
+ lines << " memory #{memory_mb}"
241
+ end
242
+
243
+ # Extract IP address if available
244
+ begin
245
+ ips = vm.ip_addresses
246
+ if ips && !ips.octets.empty?
247
+ primary_ip = ips.octets.first
248
+ lines << " ip '#{primary_ip}'" if primary_ip
249
+ end
250
+ rescue StandardError
251
+ # IP address may not be available, skip
252
+ end
253
+
254
+ # Note about template
255
+ lines << ' # This is a template VM' if config['template'] == 1
256
+
257
+ lines << ' end'
258
+
259
+ lines.join("\n")
260
+ end
261
+ end
262
+ end