700 lines
18 KiB
Ruby
700 lines
18 KiB
Ruby
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 <<self
|
|
def __new__ data
|
|
n = allocate
|
|
n.send :__update__, data
|
|
end
|
|
private :__new__
|
|
|
|
def fetch predata
|
|
__new__( predata).refresh!
|
|
end
|
|
end
|
|
|
|
def respond_to? method, also_private = false
|
|
instance_variable_defined?( "@#{method}") or super(method, also_private)
|
|
end
|
|
|
|
def method_missing method, *args, &exe
|
|
if instance_variable_defined? "@#{method}"
|
|
instance_variable_get "@#{method}"
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def __update__ data
|
|
instance_variables.each do |k|
|
|
remove_instance_variable k
|
|
end
|
|
data.each do |k,v|
|
|
instance_variable_set "@#{k}", v
|
|
end
|
|
initialize
|
|
self
|
|
end
|
|
private :__update__
|
|
|
|
def refresh!
|
|
__update__ rest_get( @rest_prefix)
|
|
end
|
|
end
|
|
|
|
class Node < Base
|
|
class <<self
|
|
def find_by_name name
|
|
all.each do |node|
|
|
return node if node.node == name
|
|
end
|
|
nil
|
|
end
|
|
|
|
def find_by_name! name
|
|
find_by_name( name) or raise( Proxmox::NotFound, "Node not found: #{name}")
|
|
end
|
|
|
|
def all
|
|
rest_get( '/nodes').map {|d| __new__ d.merge( t: 'nd') }
|
|
end
|
|
end
|
|
|
|
attr_reader :name
|
|
|
|
def === t
|
|
@name =~ t or @vmid.to_s =~ t or @sid =~ t
|
|
end
|
|
|
|
def rest_prefix
|
|
@rest_prefix ||= "/nodes/#{@node}"
|
|
end
|
|
|
|
def initialize
|
|
rest_prefix
|
|
@sid = "nd:#{@node}"
|
|
@name = @node
|
|
end
|
|
|
|
def offline?() @status.nil? or 'offline' == @status end
|
|
def online?() 'online' == @status end
|
|
|
|
def lxc
|
|
return [] if offline?
|
|
rest_get( "#{@rest_prefix}/lxc").map {|d| LXC.send :__new__, d.merge( node: self, t: 'ct') }
|
|
end
|
|
|
|
def aplinfo
|
|
return [] if offline?
|
|
rest_get( "#{@rest_prefix}/aplinfo").map do |d|
|
|
AplInfo.send :__new__, d.merge( node: self, t: 'apl')
|
|
end
|
|
end
|
|
|
|
def storage
|
|
return [] if offline?
|
|
rest_get( "#{@rest_prefix}/storage").map do |d|
|
|
Storage.send :__new__, d.merge( node: self, t: 'sm')
|
|
end
|
|
end
|
|
|
|
def qemu
|
|
return [] if offline?
|
|
rest_get( "#{@rest_prefix}/qemu").map {|d| Qemu.send :__new__, d.merge( node: self, t: 'qm') }
|
|
end
|
|
|
|
def tasks
|
|
return [] if offline?
|
|
rest_get( "/#{@rest_prefix}/tasks").map {|d| Task.send :__new__, d.merge( node: self, t: 'task') }
|
|
end
|
|
|
|
def enter *command
|
|
Kernel.system 'ssh', '-t', node, command.map( &:to_s).shelljoin
|
|
end
|
|
|
|
def exec *command
|
|
Kernel.system 'ssh', node, command.map( &:to_s).shelljoin
|
|
end
|
|
end
|
|
|
|
class Task < Base
|
|
class Status < Base
|
|
def rest_prefix
|
|
@rest_prefix ||= '/nodes/%s/tasks/%s/status' % [@node.node, @upid]
|
|
end
|
|
|
|
def refresh!
|
|
d = rest_get @rest_prefix
|
|
d[:starttime] &&= Time.at d[:starttime]
|
|
d = {exitstatus: nil}.merge d
|
|
__update__ d.merge( node: @node, t: 'status', upid: @upid, task: @task)
|
|
end
|
|
|
|
def initialize
|
|
rest_prefix
|
|
@sid = upid
|
|
end
|
|
|
|
def inspect
|
|
h = instance_variables - %i[@node @task @sid @rest_prefix @upid @t]
|
|
h.map! {|k| "#{k[1..-1]}=#{instance_variable_get(k).inspect}" }
|
|
"#<#{self.class.name}|#{@upid} node=#{@node.node} #{h.join ' '}>"
|
|
end
|
|
|
|
def running?() 'running' == @status end
|
|
def finished?() 'stopped' == @status end
|
|
alias stopped? finished?
|
|
def successfull?() stopped? ? 'OK' == @exitstatus : nil end
|
|
def failed?() stopped? ? 'OK' != @exitstatus : nil end
|
|
end
|
|
|
|
def rest_prefix
|
|
@rest_prefix = "/nodes/#{@node.node}/tasks/#{upid}"
|
|
end
|
|
|
|
def initialize
|
|
rest_prefix
|
|
@sid = upid
|
|
end
|
|
|
|
def inspect
|
|
"#<#{self.class.name} #{@upid}>"
|
|
end
|
|
|
|
def status
|
|
Status.fetch node: @node, task: self, upid: @upid
|
|
end
|
|
|
|
def log start: nil, limit: nil
|
|
rest_get( "#{@rest_prefix}/log", start: start, limit: limit)
|
|
end
|
|
end
|
|
|
|
class Hosted < Base
|
|
def refresh!
|
|
__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
|
|
|
|
def resize disk, size
|
|
upid = rest_put "#{@rest_prefix}/resize", disk: disk, size: size
|
|
Task.send :__new__, node: @node, host: self, upid: upid
|
|
end
|
|
end
|
|
|
|
class Qemu < Hosted
|
|
class <<self
|
|
def all
|
|
Node.all.flat_map {|n| n.qemu }
|
|
end
|
|
|
|
def find_by_vmid vmid
|
|
vmid = vmid.to_s
|
|
all.each do |l|
|
|
return l if l.vmid == vmid
|
|
end
|
|
nil
|
|
end
|
|
|
|
def find_by_name name
|
|
all.each do |l|
|
|
return l if l.name == name
|
|
end
|
|
nil
|
|
end
|
|
|
|
def find name_or_id = nil, name: nil, vmid: nil
|
|
if (name and vmid) or (name and name_or_id) or (name_or_id and vmid)
|
|
raise Proxmox::NotFound, "name xor vmid needed to find CT, not both."
|
|
elsif name
|
|
find_by_name name
|
|
elsif vmid
|
|
find_by_vmid vmid.to_i
|
|
elsif name_or_id =~ /\D/
|
|
find_by_name name_or_id
|
|
elsif name_or_id.is_a?( Numeric) or name_or_id =~ /\d/
|
|
find_by_vmid name_or_id.to_i
|
|
else
|
|
raise Proxmox::NotFound, "name xor vmid needed to find CT."
|
|
end
|
|
end
|
|
|
|
def find_by_vmid! name
|
|
find_by_vmid( name) or raise( Proxmox::NotFound, "Virtual Machine not found: #{name}")
|
|
end
|
|
|
|
def find_by_name! name
|
|
find_by_name( name) or raise( Proxmox::NotFound, "Virtual Machine not found: #{name}")
|
|
end
|
|
|
|
def find! name
|
|
find( name) or raise( Proxmox::NotFound, "Virtual Machine not found: #{name}")
|
|
end
|
|
end
|
|
|
|
def rest_prefix
|
|
@rest_prefix ||= "/nodes/%s/qemu/%d" % [@node.node, @vmid]
|
|
end
|
|
|
|
def initialize
|
|
rest_prefix
|
|
@sid = "qm:#{@vmid}"
|
|
end
|
|
|
|
def ha
|
|
HA.find self
|
|
end
|
|
|
|
def exec *args
|
|
node.exec 'qm', 'guest', 'exec', vmid, '--', *args
|
|
end
|
|
|
|
def resize disk, size
|
|
upid = rest_put "#{@rest_prefix}/resize", disk: disk, size: size
|
|
Task.send :__new__, node: @node, host: self, upid: upid
|
|
end
|
|
end
|
|
|
|
class LXC < Hosted
|
|
class <<self
|
|
def all
|
|
Node.all.flat_map {|n| n.lxc }
|
|
end
|
|
|
|
def find_by_vmid vmid
|
|
vmid = vmid.to_s
|
|
Node.all.each do |n|
|
|
n.lxc.each do |l|
|
|
return l if l.vmid == vmid
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
def find_by_name name
|
|
Node.all.each do |n|
|
|
n.lxc.each do |l|
|
|
return l if l.name == name
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
def find name_or_id = nil, name: nil, vmid: nil
|
|
if (name and vmid) or (name and name_or_id) or (name_or_id and vmid)
|
|
raise ArgumentError, "name xor vmid needed to find CT, not both."
|
|
elsif name
|
|
find_by_name name
|
|
elsif vmid
|
|
find_by_vmid vmid.to_i
|
|
elsif name_or_id =~ /\D/
|
|
find_by_name name_or_id
|
|
elsif name_or_id.is_a?( Numeric) or name_or_id =~ /\d/
|
|
find_by_vmid name_or_id.to_i
|
|
else
|
|
raise ArgumentError, "name xor vmid needed to find CT."
|
|
end
|
|
end
|
|
|
|
def find_by_vmid! name
|
|
find_by_vmid( name) or raise( Proxmox::NotFound, "Container not found: #{name}")
|
|
end
|
|
|
|
def find_by_name! name
|
|
find_by_name( name) or raise( Proxmox::NotFound, "Container not found: #{name}")
|
|
end
|
|
|
|
def find! name
|
|
find( name) or raise( Proxmox::NotFound, "Container not found: #{name}")
|
|
end
|
|
|
|
def create template, **options
|
|
tmplt = PVE::CTTemplate.const_get( template&.classify || 'Default').new **options
|
|
name = tmplt.name
|
|
virts = Proxmox::LXC.all + Proxmox::Qemu.all
|
|
vmid = tmplt.vmid
|
|
if virt = virts.find {|v| v.vmid == vmid }
|
|
raise Proxmox::AlreadyExists, "VT/VM with vmid [#{vmid}] already exists: #{virt.name}"
|
|
end
|
|
already_exists = virts.select {|v| v.name == name }
|
|
unless already_exists.empty?
|
|
raise Proxmox::AlreadyExists, "CT/VM named [#{name}] already exists: vmid: #{already_exists.map( &:vmid).inspect}"
|
|
end
|
|
node = Proxmox::Node.find_by_name! tmplt.node
|
|
|
|
options = {
|
|
ostemplate: tmplt.ostemplate,
|
|
vmid: vmid.to_s,
|
|
arch: tmplt.arch,
|
|
cmode: tmplt.cmode,
|
|
cores: tmplt.cores.to_i,
|
|
description: tmplt.description,
|
|
hostname: tmplt.hostname,
|
|
memory: tmplt.memory,
|
|
net0: tmplt.net0&.map {|k,v| "#{k}=#{v}" }&.join(','),
|
|
net1: tmplt.net1&.map {|k,v| "#{k}=#{v}" }&.join(','),
|
|
net2: tmplt.net2&.map {|k,v| "#{k}=#{v}" }&.join(','),
|
|
net3: tmplt.net3&.map {|k,v| "#{k}=#{v}" }&.join(','),
|
|
ostype: tmplt.ostype,
|
|
:'ssh-public-keys' => 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 rest_prefix
|
|
@rest_prefix ||= "/nodes/%s/lxc/%d" % [@node.node, @vmid]
|
|
end
|
|
|
|
def initialize
|
|
rest_prefix
|
|
@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 rest_prefix
|
|
@rest_prefix ||= "/cluster/ha/resources/#{virt.sid}"
|
|
end
|
|
|
|
def initialize
|
|
rest_prefix
|
|
end
|
|
|
|
class <<self
|
|
def find host
|
|
ha = HA.send :__new__, digest: nil, state: nil, virt: host
|
|
ha.refresh!
|
|
end
|
|
|
|
def create host, **options
|
|
find( host).create **options
|
|
end
|
|
end
|
|
|
|
def refresh!
|
|
virt = @virt
|
|
__update__( {state: nil}.merge( rest_get( "/cluster/ha/resources/#{virt.sid}")).merge( virt: virt))
|
|
self
|
|
rescue RestClient::InternalServerError
|
|
__update__ digest: nil, state: nil, virt: virt
|
|
end
|
|
|
|
def create group: nil, comment: nil, max_relocate: nil, max_restart: nil, state: nil
|
|
options = {
|
|
sid: virt.sid,
|
|
group: group,
|
|
comment: comment,
|
|
max_relocate: max_relocate,
|
|
max_restart: max_restart,
|
|
state: state
|
|
}
|
|
options.delete_if {|k,v| v.nil? }
|
|
rest_post "/cluster/ha/resources", **options
|
|
refresh!
|
|
end
|
|
|
|
def delete
|
|
rest_del "#{@rest_prefix}"
|
|
end
|
|
|
|
def state= state
|
|
rest_put "#{@rest_prefix}", state: state.to_s
|
|
refresh!
|
|
end
|
|
|
|
def started!
|
|
self.state = :started
|
|
self
|
|
end
|
|
|
|
def stopped!
|
|
self.state = :stopped
|
|
self
|
|
end
|
|
|
|
def disabled!
|
|
self.state = :disabled
|
|
self
|
|
end
|
|
|
|
def started?() 'started' == self.state end
|
|
def stopped?() 'stopped' == self.state end
|
|
def error?() 'error' == self.state end
|
|
def active?() ! ! digest end
|
|
end
|
|
|
|
class Storage < Base
|
|
def rest_prefix
|
|
@rest_prefix ||= "/nodes/#{@node.node}/storage/#{@storage}"
|
|
end
|
|
|
|
def content
|
|
rest_get( "#{@rest_prefix}/content").map do |c|
|
|
Content.send :__new__, c.merge( node: @node, storage: self, t: 'smc')
|
|
end
|
|
end
|
|
|
|
def initialize() rest_prefix end
|
|
|
|
def to_s() "#{@node.node}:#{@storage}" end
|
|
|
|
class Content < Base
|
|
def rest_prefix
|
|
@rest_prefix ||= "/nodes/#{@node.node}/storage/#{@storage}/content/#{@content}"
|
|
end
|
|
|
|
def initialize() rest_prefix end
|
|
def to_s() "#{node.node} #{volid}" end
|
|
end
|
|
end
|
|
|
|
class AplInfo < Base
|
|
def rest_prefix
|
|
@rest_prefix ||= "/nodes/#{@node.node}/aplinfo"
|
|
end
|
|
|
|
def initialize() rest_prefix end
|
|
def name() @template end
|
|
def system?() 'system' == @section end
|
|
def debian?() %r[\Adebian-] =~ @os end
|
|
def lxc?() 'lxc' == @type end
|
|
|
|
def download storage
|
|
upid = rest_post "#{@rest_prefix}", template: @template, storage: storage.to_s
|
|
Task.send :__new__, node: @node, host: self, upid: upid
|
|
end
|
|
end
|
|
end
|
|
|
|
require_relative 'templates'
|