commit 626e4b60b315279906eb72ce73b15e98e8325b60 Author: Denis Knauf Date: Mon Apr 19 20:35:39 2021 +0200 pve initialized diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de3303e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sw[pomnqrst] +*.gem diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..a53b350 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' +gem 'dencli' +gem 'rest-client' +gem 'ipaddress' +gem 'activesupport' +gem 'pmap' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..7238d37 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,49 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (6.1.3.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + concurrent-ruby (1.1.8) + dencli (0.3.1) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + http-accept (1.7.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + i18n (1.8.10) + concurrent-ruby (~> 1.0) + ipaddress (0.8.3) + mime-types (3.3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2021.0225) + minitest (5.14.4) + netrc (0.11.0) + pmap (1.1.1) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.7) + zeitwerk (2.4.2) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + activesupport + dencli + ipaddress + pmap + rest-client + +BUNDLED WITH + 2.2.15 diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..5ca075b --- /dev/null +++ b/README.adoc @@ -0,0 +1,22 @@ +Proxmox Virtual Environment High Level API for Ruby +=================================================== + +This is a limited, but easier to use library for ruby. +It provides additional a command line interface for administration named `pvecli`. +The Rest-API will be used for controlling your server. + +You need to provide a config-file `/etc/pve/pvecli.yml`: + + auth: + username: USERNAME + password: PASSWORD + realm: pve or something like that + connect: + verify_tls: no if you do not use known CA-signed X509-Certificates + +pvecli +====== + +This tool should usable like PVE-WebGUI, instead of low-level-tools like `pct` +or user-unfriendlier tools like `pvesh`. +So `pvecli` provides a global control over your cluster on command line. diff --git a/bin/pvecli b/bin/pvecli new file mode 100755 index 0000000..b7e7fd4 --- /dev/null +++ b/bin/pvecli @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require 'pathname' +$:.unshift Pathname.new(__FILE__).dirname.dirname.join('lib').to_s +require 'pve/cli' +PVE::Cli.new.call *ARGV diff --git a/lib/pve.rb b/lib/pve.rb new file mode 100755 index 0000000..ddfa3e1 --- /dev/null +++ b/lib/pve.rb @@ -0,0 +1,4 @@ +module PVE +end + +require_relative 'pve/proxmox' diff --git a/lib/pve/cli.rb b/lib/pve/cli.rb new file mode 100644 index 0000000..27f7978 --- /dev/null +++ b/lib/pve/cli.rb @@ -0,0 +1,159 @@ +require 'dencli' +require 'yaml' + +require 'pve' +require_relative 'helper' +require_relative 'cli/base' +require_relative 'cli/ct' +require_relative 'cli/ha' +require_relative 'cli/task' +require_relative 'cli/qm' +require_relative 'cli/node' + +class UsageError v + %w[KiB MiBy GiByt TiByte ExiByte PetiByte].each_with_index do |m| + v /= 1024 + #return "%.1f %s" % [v, m] if 10 > v + #return "%d %s" % [v, m] if 512 > v + return "%.1f %s" % [v, m] if 512 > v + end + "%d PetiByte" % v + end + + def bytes2 v + r = (v.to_i / 1024 / 1024).to_s + return '·' if 0 == r + r. + reverse. + each_char. + each_slice( 3). + to_a. + reverse. + map {|a| a.reverse.join }. + join " " + end + alias bytes bytes2 + + def seconds i + i = i.to_i + return '·' if 0 == i + return "%d s" % i if 90 > i + i /= 60 + return "%d mi" % i if 90 > i + i /= 60 + return "%d hou" % i if 36 > i + i /= 24 + return "%d days" % i if 14 > i + j = i / 7 + return "%d weeks" % j if 25 > j + i /= 365 + return "%.1f years" if 550 > i + "%dy" % i + end + end +end + +class ColoredString + attr_reader :string, :color_codes + + def initialize string, color_codes + @string, @color_codes = string, color_codes + end + + def inspect + "#" + end + + def length() @string.length end + alias size length + #def to_str() self end + def to_s() "\e[#{@color_codes}m#{@string}\e[0m" end + alias to_str to_s + #alias inspect to_str + + include Comparable + def <=>(o) @string <=> o.string end +end + + + +class TablizedOutput + def initialize header, stdout: nil + @header = header.map &:to_s + @columnc = header.size + @maxs = header.map &:length + @stdout ||= STDOUT + @lines = [] + end + + class B + include Comparable + def <=>(o) @v <=> o.v end + end + + class V < B + attr_reader :v, :s + def initialize( v, s=nil) @v, @s = v, s || "#{v}" end + def to_s() @s end + def length() @s.length end + def inspect() "#" end + end + + class Percentage < B + attr_reader :v, :w + def initialize( v, w=nil) @v, @w = v, w || 10 end + def length() @w end + def inspect() "#" end + + def to_s + y = w - (v*w).round + x = (100*v).round + r = "%*s" % [w, 0==x ? '·' : x] + "\e[0m#{r[0...y]}\e[1;4;#{0.75>v ? 32 : 31}m#{r[y..-1]}\e[0m" + end + end + + def push fields + fields = + fields.map do |x| + case x + when String, ColoredString, B then x + else V.new x + end + end + @maxs = @columnc.times.map {|i| [@maxs[i], fields[i].length].max } + @lines.push fields + end + + def pushs lines + lines.each &method( :push) + end + + def print order: nil + format = "#{(["\e[%%sm%% %ds\e[0m"] * @columnc).join( ' ') % @maxs}\n" + ls = @lines + if order + eval <<-EOC, binding, __FILE__, 1+__LINE__ + ls = ls.sort {|a,b| + [#{order.map {|i| 0 < i ? "a[#{i-1}]" : "b[#{-i-1}]" }.join ', '}] <=> + [#{order.map {|i| 0 < i ? "b[#{i-1}]" : "a[#{-i-1}]" }.join ', '}] + } + EOC + end + #ls = ls.sort_by {|e| p e; order.map &e.method(:[]) } if order + @stdout.printf format, *@header.flat_map {|s|['',s]} + ls.each {|l| @stdout.printf format, *l.flat_map {|s| s.is_a?(ColoredString) ? [s.color_codes, s.string] : ["", s.to_s] } } + end + + def virt v + ha = v.respond_to?( :ha) ? v.ha : nil + unknown = V.new 0, '-' + push [ + case v.status + when "running", "online" then ColoredString.new v.status, "32" + when "stopped" then ColoredString.new v.status, "31" + else v.status + end, + ha&.state || '·', + case v.t + when "nd" then ColoredString.new v.sid, "33" + when "qm" then ColoredString.new v.sid, "35" + when "ct" then ColoredString.new v.sid, "36" + else v.sid + end, + v.name, v.node.is_a?(String) ? v.node : v.node.node, + v.respond_to?(:uptime) ? V.new( v.uptime, Measured.seconds( v.uptime)) : unknown, + v.respond_to?(:cpu) ? Percentage.new( v.cpu) : unknown, + v.respond_to?(:mem) ? V.new( v.mem, Measured.bytes( v.mem)) : unknown, + v.respond_to?(:maxmem) ? Percentage.new( v.mem/v.maxmem.to_f) : unknown, + v.respond_to?(:disk) ? V.new( v.disk.to_i, Measured.bytes( v.disk.to_i)) : unknown, + if v.respond_to?(:maxdisk) and 0 < v.maxdisk.to_i + Percentage.new( v.disk.to_f/v.maxdisk.to_f) + else unknown end, + ] + end +end diff --git a/lib/pve/proxmox.rb b/lib/pve/proxmox.rb new file mode 100644 index 0000000..9a1cc18 --- /dev/null +++ b/lib/pve/proxmox.rb @@ -0,0 +1,597 @@ +require 'rest_client' +require 'cgi' +require 'json' +require 'ipaddress' +require 'shellwords' +require 'active_support/all' + +module Proxmox + class Exception < ::Exception + end + class NotFound < Exception + end + class AlreadyExists < Exception + end + + def self.connect username, password, realm: nil, verify_tls: nil, uri: nil + uri ||= 'https://localhost:8006/api2/json' + cred = + { + username: username, + password: password, + realm: realm || 'pve' + } + @@connection = + RestClient::Resource.new( uri, verify_ssl: ! ! verify_tls).tap do |rs| + resp = rs['/access/ticket'].post cred + case resp.code + when 200 + data = JSON.parse resp.body, symbolize_names: true + @@api_ticket = + { + CSRFPreventionToken: data[:data][:CSRFPreventionToken], + cookie: "PVEAuthCookie=#{CGI.escape data[:data][:ticket]}" + } + else + raise Exception, "Authentication failed" + end + end + end + + def self.connection + @@connection + end + + def self.api_ticket + @@api_ticket + end + + def self.find_by_name name + Proxmox::LXC.find_by_name( name) || Proxmox::Qemu.find_by_name( name) || Proxmox::Node.find_by_name( name) + end + + def self.find_by_vmid vmid + Proxmox::LXC.find_by_vmid( vmid) || Proxmox::Qemu.find_by_vmid( vmid) || Proxmox::Node.find_by_vmid( vmid) + end + + def self.find name_or_id + Proxmox::LXC.find( name_or_id) || Proxmox::Qemu.find( name_or_id) || Proxmox::Node.find( name_or_id) + end + + module RestConnection + def __response__ resp + case resp.code + when 200 + JSON.parse( resp.body, symbolize_names: true)[:data] + when 500 + nil + else + raise "Request failed of #{req.url} [#{resp.code}]: #{resp.body.inspect}" + end + end + + def __headers__ **hdrs + Proxmox.api_ticket.merge( 'Accept' => 'application/json').merge( hdrs) + end + + def __data__ **data + case data + when String + data + else + data.to_json + end + end + + private :__response__, :__headers__, :__data__ + + def bench path + #t = Time.now + r = yield + #p path => Time.now-t + r + end + + def rest_get path, **data, &exe + data = data.delete_if {|k,v|v.nil?} + path += "#{path.include?( ??) ? ?& : ??}#{data.map{|k,v|"#{CGI.escape k.to_s}=#{CGI.escape v.to_s}"}.join '&'}" unless data.empty? + __response__ Proxmox.connection[path].get( __headers__( :'Content-Type' => 'application/json')) + end + + def rest_put path, **data, &exe + __response__ Proxmox.connection[path].put( __data__( data), __headers__( :'Content-Type' => 'application/json')) + end + + def rest_del path, &exe + __response__ Proxmox.connection[path].delete( __headers__( :'Content-Type' => 'application/json')) + end + + def rest_post path, **data, &exe + __response__ Proxmox.connection[path].post( __data__( data), __headers__( :'Content-Type' => 'application/json')) + end + end + + class Base + include RestConnection + extend RestConnection + + attr_reader :sid + + class <" + end + + #def finished? + # rest_get( "/nodes/#{node}/tasks/") + #end + + def status + rest_get( "#{@rest_prefix}/status") + end + + def log start: nil, limit: nil + rest_get( "#{@rest_prefix}/log", start: start, limit: limit) + end + end + + class Hosted < Base + def refresh! + node, t = @node, @t + __update__ rest_get( "#{@rest_prefix}/status/current").merge( node: node, t: t) + end + + def === t + @name =~ t or @vmid.to_s =~ t or @sid =~ t + end + + def migrate node + node = + case node + when Node then node + else Node.find!( node.to_s) + end + Task.send :__new__, node: @node, host: self, upid: rest_post( "#{@rest_prefix}/migrate", target: node.node) + end + + def start + Task.send :__new__, node: @node, host: self, upid: rest_post( "#{@rest_prefix}/status/start") + end + + def stop + Task.send :__new__, node: @node, host: self, upid: rest_post( "#{@rest_prefix}/status/stop") + end + + def destroy + Task.send :__new__, node: @node, host: self, upid: rest_del( "#{@rest_prefix}") + end + + def current_status + rest_get "#{@rest_prefix}/status/current" + end + + def running? + current_status[:status] == 'running' + end + + def stopped? + current_status[:status] == 'stopped' + end + + def wait forstatus, timeout: nil, secs: nil, lock: nil, &exe + forstatus = forstatus.to_s + secs ||= 0.2 + b = Time.now + timeout.to_i + secs + loop do + d = current_status + return true if d[:status] == forstatus and (not lock or lock == d[:lock]) + exe.call d[:status], d[:lock] if exe + return false if timeout and b <= Time.now + sleep secs + end + nil + end + + def config + cnf = rest_get "#{@rest_prefix}/config" + cnf[:network] = + cnf. + keys. + map( &:to_s). + grep( /\Anet\d+\z/). + map do |k| + nc = {card: k} + cnf.delete( k.to_sym). + split( ','). + each do |f| + k, v = f.split( '=', 2) + nc[k.to_sym] = v + end + nc[:ip] &&= IPAddress::IPv4.new nc[:ip] + nc[:gw] &&= IPAddress::IPv4.new nc[:gw] + nc[:mtu] &&= nc[:mtu].to_i + nc[:tag] &&= nc[:tag].to_i + nc[:firewall] &&= 1 == nc[:firewall].to_i + nc + end + cnf[:unprivileged] &&= 1 == cnf[:unprivileged] + cnf[:memory] &&= cnf[:memory].to_i + cnf[:cores] &&= cnf[:cores].to_i + cnf + end + + def cnfset **cnf + r = {delete: []} + cnf.each do |k,v| + case v + when true then r[k] = 1 + when false then r[k] = 0 + when nil then r[:delete].push k + else r[k] = v + end + end + r.delete :delete if r[:delete].empty? + rest_put "#{@rest_prefix}/config", r + end + end + + class Qemu < Hosted + class < tmplt.ssh_public_keys, + storage: tmplt.storage, + #rootfs: { + # volume: "root:vm-#{vmid}-disk-0", size: '4G' + #}.map {|k,v| "#{k}=#{v}" }.join( ','), + swap: tmplt.swap, + unprivileged: tmplt.unprivileged, + }.delete_if {|k,v| v.nil? } + @temp = LXC.send :__new__, node: node, vmid: options[:vmid], name: name, hostname: options[:hostname] + upid = rest_post( "/nodes/%s/lxc" % node.node, **options) + Task.send :__new__, node: node, host: @temp, upid: upid + end + end + + def initialize + @rest_prefix = "/nodes/%s/lxc/%d" % [@node.node, @vmid] + @sid = "ct:#{@vmid}" + end + + def ha + HA.find self + end + + def exec *args + node.exec 'pct', 'exec', vmid, '--', *args + end + + def enter + node.enter 'pct', 'enter', vmid + end + end + + class HA < Base + def initialize + @rest_prefix = "/cluster/ha/resources/#{virt.sid}" + end + + class < [:string, false, "SSH-Public-Keys, which should be added to root-user in CT."], + :'ssh-public-keys-file' => [:string, false, "Read SSH-Public-Keys from file."], + ipv4: [:string, false, "IPv4-Address with net-size."], + gateway4: [:string, false, "IPv4-Address of gateway."], + ipv6: [:string, false, "IPv6-Address with net-size."], + gateway6: [:string, false, "IPv6-Address of gateway."], + storage: [:string, false, "Device will be create on this Storage (default: local"], + } + end + end + + class Datacenter < Base + def self.requirements + { + node: [:string, false, "Create CT on this node."], + name: [:string, true, "Set (uniq) name"], + arch: [:enum, false, "Architecture", %w[amd64 i386 arm64 armhf]], + vmid: [:numeric, true, "VM-ID. Proxmox internal number (100...)"], + ostype: [:string, true, "OS-Type (OS or distribution)"], + cmode: [:enum, false, "Console-mode", %w[shell console tty]], + cores: [:numeric, false, "Count of cores"], + description: [:string, false, "Description. Eg. What should this CT do?"], + hostname: [:string, false, "Hostname"], + memory: [:numeric, false, "How much memory CT could use?"], + swap: [:numeric, false, "How much CT can swap?"], + unprivileged: [:boolean, false, "Unprivileged are restricted to own UID/GID-space."], + :'ssh-public-keys' => [:string, false, "SSH-Public-Keys, which should be added to root-user in CT."], + :'ssh-public-keys-file' => [:string, false, "Read SSH-Public-Keys from file."], + :'network-id' => [:numeric, true, "Put Container to this VLAN and use a random IPv4-Address for this CT."], + ipv4: [:string, false, "IPv4-Address with net-size."], + gateway4: [:string, false, "IPv4-Address of gateway."], + ipv6: [:string, false, "IPv6-Address with net-size."], + gateway6: [:string, false, "IPv6-Address of gateway."], + storage: [:string, false, "Device will be create on this Storage (default: root)"], + } + end + + def node() options.node || 'svc1' end + def ostype() options.ostype || 'debian' end + def memory() options.memory || 2048 end + def storage() options.storage || 'root' end + + def network_id + return @network_id if @network_id + expect = 0..12 + nid = options[:'network-id'] + unless nid == nid.to_i.to_s && expect.include?( nid.to_i) + raise ArgumentError, "Network ID must be #{expect.inspect}. Given: #{nid.inspect}" + end + @network_id = nid.to_i + end + + def net0 + { + name: 'eth0', + bridge: 'vmbr1', + tag: 2000+network_id, + mtu: 9166, + firewall: 1, + ip: ipv4.to_string, + gw: ipv4.hosts.last.to_s, + } + end + + def vmid + super || ((0...100).map {|i| "#{100*network_id+i}" } - @virts.map( &:vmid)).first + end + + def network + IPAddress::IPv4.new "10.#{network_id}.255.0/24" + end + + def ipv4 + return options.ipv4 if options.ipv4 + return @ipv4 if @ipv4 + ipv4s = network.hosts + @virts.each do |v| + v.config[:network].each {|n| ipv4s.delete n[:ip] if n[:ip] } + end + @ipv4 = ipv4s.first + end + + def ostemplate + @ostemplate ||= + options.ostemplate || + case ostype + when 'debian' + 'local:vztmpl/debian-10-standard_10.5-1_amd64.tar.gz' + else + raise ArgumentError, "OS-Template for ostype #{ostype} not found or ostemplate not provided." + end + end + end +end diff --git a/lib/pve/version.rb b/lib/pve/version.rb new file mode 100644 index 0000000..de294e3 --- /dev/null +++ b/lib/pve/version.rb @@ -0,0 +1,3 @@ +module PVE + VERSION = '0.1.1' +end diff --git a/pve.gemspec b/pve.gemspec new file mode 100644 index 0000000..7fd567b --- /dev/null +++ b/pve.gemspec @@ -0,0 +1,32 @@ +require_relative 'lib/pve/version' + +Gem::Specification.new do |spec| + spec.name = "pve" + spec.version = PVE::VERSION + spec.authors = ["Denis Knauf"] + spec.email = ["git+pve@denkn.at"] + spec.licenses = ["LGPL-3.0"] + + spec.summary = %q{Proxmox Virtual Environment higher level API } + spec.description = %q{Provides a higher level API with objects for controlling PVE} + spec.homepage = "https://git.denkn.at/deac/pve" + # Ruby3 interpret **opts in another way than before. + # 2.7.0 should work with both(?) behaviours. + # PVE based on debian, so ruby 2.5 is default. + spec.required_ruby_version = Gem::Requirement.new "~> 2.5.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = spec.homepage + + spec.add_development_dependency "rspec", "~> 3.2" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "bin" + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] +end