ovirt 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,332 @@
1
+ require 'nokogiri'
2
+
3
+ module Ovirt
4
+ class Service
5
+ DEFAULT_OPTIONS = {}
6
+ REQUIRED_OPTIONS = [:server, :username, :password]
7
+ DEFAULT_PORT_3_0 = 8443
8
+ DEFAULT_PORT_3_1 = 443
9
+ DEFAULT_PORT = DEFAULT_PORT_3_1
10
+ DEFAULT_SCHEME = 'https'.freeze
11
+ SESSION_ID_KEY = 'JSESSIONID'.freeze
12
+
13
+ attr_accessor :session_id
14
+
15
+ def self.name_to_class(name)
16
+ Ovirt.const_get(name.camelize)
17
+ end
18
+
19
+ def xml_to_object(klass, xml)
20
+ klass.create_from_xml(self, xml)
21
+ end
22
+
23
+ def initialize(options={})
24
+ @options = DEFAULT_OPTIONS.merge(options)
25
+ parse_domain_name
26
+ REQUIRED_OPTIONS.each { |key| raise "No #{key.to_s} specified" unless @options.has_key?(key) }
27
+ @password = @options.delete(:password)
28
+ @session_id = @options[:session_id]
29
+ end
30
+
31
+ def inspect # just like the default inspect, but WITHOUT @password
32
+ "#<#{self.class.name}:0x#{(self.object_id << 1).to_s(16).rjust(14,'0')} @options=#{@options.inspect}>"
33
+ end
34
+
35
+ def api(reload = false)
36
+ @api = nil if reload
37
+ @api ||= xml_to_object(Api, resource_get)
38
+ end
39
+
40
+ def product_info
41
+ @product_info ||= api[:product_info]
42
+ end
43
+
44
+ def name
45
+ @name ||= product_info[:name]
46
+ end
47
+
48
+ def vendor
49
+ @vendor ||= product_info[:vendor]
50
+ end
51
+
52
+ def version
53
+ @version ||= product_info[:version]
54
+ end
55
+
56
+ def version_string
57
+ @version_string ||= "#{version[:major]}.#{version[:minor]}.#{version[:revision]}.#{version[:build]}"
58
+ end
59
+
60
+ def version_3_0?
61
+ version_string.starts_with?("3.0")
62
+ end
63
+
64
+ def summary
65
+ api(true)[:summary] # This is volatile information
66
+ end
67
+
68
+ def special_objects
69
+ @special_objects ||= api[:special_objects]
70
+ end
71
+
72
+ def blank_template
73
+ @blank_template ||= begin
74
+ href = special_objects[:"templates/blank"]
75
+ href.blank? ? nil : Template.find_by_href(self, href)
76
+ end
77
+ end
78
+
79
+ def root_tag
80
+ @root_tag ||= begin
81
+ href = special_objects[:"tags/root"]
82
+ href.blank? ? nil : Tag.find_by_href(self, href)
83
+ end
84
+ end
85
+
86
+ def iso_storage_domain
87
+ @iso_storage_domain ||= StorageDomain.iso_storage_domain(self)
88
+ end
89
+
90
+ def iso_images
91
+ iso_storage_domain.nil? ? [] : iso_storage_domain.iso_images
92
+ end
93
+
94
+ def disconnect
95
+ end
96
+
97
+ def get_resource_by_ems_ref(uri_suffix, element_name = nil)
98
+ xml = resource_get(uri_suffix)
99
+ doc = Nokogiri::XML(xml)
100
+ element_name ||= doc.root.name
101
+ klass = self.class.name_to_class(element_name)
102
+ xml_to_object(klass, doc.root)
103
+ end
104
+
105
+ def standard_collection(uri_suffix, element_name = nil, paginate=false, sort_by=:name)
106
+ if paginate
107
+ doc = paginate_resource_get(uri_suffix, sort_by)
108
+ else
109
+ xml = resource_get(uri_suffix)
110
+ doc = Nokogiri::XML(xml)
111
+ end
112
+ element_name ||= uri_suffix.singularize
113
+ klass = self.class.name_to_class(element_name)
114
+
115
+ xml_path = uri_suffix == 'api' ? element_name : "#{element_name.pluralize}/#{element_name}"
116
+ objects = doc.xpath("//#{xml_path}")
117
+ objects.collect { |obj| xml_to_object(klass, obj) }
118
+ end
119
+
120
+ def status(link)
121
+ response = resource_get(link)
122
+
123
+ node = Object.xml_to_nokogiri(response)
124
+ node.xpath('status/state').text
125
+ end
126
+
127
+ def api_uri(path = nil)
128
+ uri = "#{base_uri}/api"
129
+ unless path.nil?
130
+ parts = path.to_s.split('/')
131
+ parts.shift if parts.first == '' # Remove leading slash
132
+ parts.shift if parts.first == 'api' # We already have /api in our URI
133
+ uri += "/#{parts.join('/')}" unless parts.empty?
134
+ end
135
+ uri
136
+ end
137
+
138
+ def paginate_resource_get(path = nil, sort_by=:name, direction=:asc)
139
+ log_header = "#{self.class.name}#paginate_resource_get"
140
+ page = 1
141
+ full_xml = nil
142
+ loop do
143
+ uri = "#{path}?search=sortby%20#{sort_by}%20#{direction}%20page%20#{page}"
144
+ partial_xml_str = self.resource_get(uri)
145
+ if full_xml.nil?
146
+ full_xml = Nokogiri::XML(partial_xml_str)
147
+ else
148
+ partial_xml = Nokogiri::XML(partial_xml_str)
149
+ break if partial_xml.root.children.count == 0
150
+ $rhevm_log.debug "#{log_header}: Combining resource elements for <#{path}> from page:<#{page}>" if $rhevm_log && $rhevm_log.debug?
151
+ full_xml.root << partial_xml.root.children
152
+ end
153
+ page += 1
154
+ end
155
+ $rhevm_log.debug "#{log_header}: Combined elements for <#{path}>. Total elements:<#{full_xml.root.children.count}>" if $rhevm_log && $rhevm_log.debug?
156
+ return full_xml
157
+ end
158
+
159
+ def resource_get(path = nil)
160
+ resource_verb(path, :get)
161
+ end
162
+
163
+ def resource_put(path, payload, additional_headers={:content_type => :xml, :accept => :xml})
164
+ resource_verb(path, :put, payload, additional_headers)
165
+ end
166
+
167
+ def resource_post(path, payload, additional_headers={:content_type => :xml, :accept => :xml})
168
+ resource_verb(path, :post, payload, additional_headers)
169
+ end
170
+
171
+ def resource_delete(path)
172
+ resource_verb(path, :delete)
173
+ end
174
+
175
+ def create_resource(path = nil)
176
+ require "rest-client"
177
+ RestClient::Resource.new(api_uri(path), resource_options)
178
+ end
179
+
180
+ private
181
+
182
+ def resource_verb(path, verb, *args)
183
+ log_header = "#{self.class.name}#resource_#{verb}"
184
+
185
+ resource = create_resource(path)
186
+ $rhevm_log.info "#{log_header}: Sending URL: <#{resource.url}>" if $rhevm_log
187
+ $rhevm_log.debug "#{log_header}: With args: <#{args.inspect}>" if $rhevm_log.try(:debug?)
188
+ resource.send(verb, *args) do |response, request, result, &block|
189
+ case response.code
190
+ when 200
191
+ parse_normal_response(response, resource)
192
+ when 400, 409
193
+ parse_error_response(response)
194
+ else
195
+ response.return!(request, result, &block)
196
+ end
197
+ end
198
+ rescue RestClient::Unauthorized
199
+ if self.session_id
200
+ self.session_id = nil
201
+ retry
202
+ else
203
+ raise
204
+ end
205
+ rescue RestClient::ResourceNotFound, Ovirt::Error
206
+ raise
207
+ rescue Exception => e
208
+ msg = "#{log_header}: class = #{e.class.name}, message=#{e.message}, URI=#{resource.url}"
209
+ if $rhevm_log.nil?
210
+ puts msg
211
+ else
212
+ $rhevm_log.error msg
213
+ end
214
+ raise
215
+ end
216
+
217
+ def parse_normal_response(response, resource)
218
+ parse_set_cookie_header(response.headers[:set_cookie])
219
+ if $rhevm_log
220
+ log_header = "#{self.class.name}#parse_normal_response"
221
+ $rhevm_log.info "#{log_header}: Return from URL: <#{resource.url}> Data length:#{response.length}"
222
+ $rhevm_log.debug "#{log_header}: Return from URL: <#{resource.url}> Data:#{response}" if $rhevm_log.debug?
223
+ end
224
+ response
225
+ end
226
+
227
+ def parse_error_response(response)
228
+ doc = Nokogiri::XML(response)
229
+ action = doc.xpath("action").first
230
+ node = action || doc
231
+ reason = node.xpath("fault/detail").text
232
+ raise Ovirt::Error, reason
233
+ end
234
+
235
+ def parse_set_cookie_header(set_cookie_header)
236
+ set_cookie_header = set_cookie_header.first if set_cookie_header.kind_of?(Array)
237
+ set_cookie_header.to_s.split(";").each do |kv|
238
+ k, v = kv.strip.split("=")
239
+ self.session_id = v if k == SESSION_ID_KEY
240
+ end
241
+ end
242
+
243
+ def base_uri
244
+ if port.blank?
245
+ "#{scheme}://#{server}"
246
+ else
247
+ "#{scheme}://#{server}:#{port}"
248
+ end
249
+ end
250
+
251
+ def resource_options
252
+ headers = merge_headers({ 'Prefer' => 'persistent-auth' })
253
+ options = { :ssl_version => :SSLv3 }
254
+
255
+ if self.session_id
256
+ headers[:cookie] = "#{SESSION_ID_KEY}=#{self.session_id}"
257
+ else
258
+ options[:user] = fully_qualified_username
259
+ options[:password] = password
260
+ end
261
+
262
+ options[:headers] = headers
263
+ options[:timeout] = timeout if timeout
264
+ options[:open_timeout] = open_timeout if open_timeout
265
+ options
266
+ end
267
+
268
+ def merge_headers(hash)
269
+ h = @options[:headers] || {}
270
+ h.merge(hash)
271
+ end
272
+
273
+ def authorization_header
274
+ @authorization_header ||= { :authorization => "Basic #{authorization_value}" }
275
+ end
276
+
277
+ def authorization_value
278
+ @authorization_value ||= begin
279
+ require "base64"
280
+ Base64.encode64 "#{fully_qualified_username}:#{password}"
281
+ end
282
+ end
283
+
284
+ def scheme
285
+ @options[:scheme] || DEFAULT_SCHEME
286
+ end
287
+
288
+ def server
289
+ @options[:server]
290
+ end
291
+
292
+ def port
293
+ @options[:port] || DEFAULT_PORT
294
+ end
295
+
296
+ def fully_qualified_username
297
+ domain.blank? ? username : "#{username}@#{domain}"
298
+ end
299
+
300
+ def username
301
+ @options[:username]
302
+ end
303
+
304
+ def password
305
+ @password
306
+ end
307
+
308
+ def domain
309
+ @options[:domain]
310
+ end
311
+
312
+ def timeout
313
+ @options[:timeout] # NetHTTPSession's read_timeout
314
+ end
315
+
316
+ def open_timeout
317
+ @options[:open_timeout] # NetHTTPSessions's open_timeout
318
+ end
319
+
320
+ # Parse domain out of the username string
321
+ def parse_domain_name
322
+ if @options[:domain].blank? && !@options[:username].blank?
323
+ if @options[:username].include?('\\')
324
+ @options[:domain], @options[:username] = username.split('\\')
325
+ elsif @options[:username].include?('/')
326
+ @options[:domain], @options[:username] = username.split('/')
327
+ end
328
+ end
329
+ end
330
+
331
+ end
332
+ end
@@ -0,0 +1,28 @@
1
+ module Ovirt
2
+ class Snapshot < Object
3
+
4
+ self.top_level_strings = [:description, :snapshot_status, :type]
5
+ self.top_level_timestamps = [:date]
6
+ self.top_level_objects = [:vm]
7
+
8
+ def self.parse_xml(xml)
9
+ node, hash = xml_to_hash(xml)
10
+
11
+ hash
12
+ end
13
+
14
+ def initialize(service, options = {})
15
+ super
16
+ @relationships[:disks] = self[:href] + "/disks"
17
+ end
18
+
19
+ def delete
20
+ response = destroy
21
+ while self[:snapshot_status] == "locked" || self[:snapshot_status] == "ok"
22
+ sleep 2
23
+ break if (obj = self.class.find_by_href(@service, self[:href])).nil?
24
+ self.replace(obj)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,37 @@
1
+ module Ovirt
2
+ class Statistic < Object
3
+
4
+ self.top_level_strings = [:name, :description, :type, :unit]
5
+
6
+ def self.parse_xml(xml)
7
+ node, hash = xml_to_hash(xml)
8
+
9
+ values = []
10
+ values_node = node.xpath('values').first
11
+ values_type = values_node['type']
12
+ values = values_node.xpath('value').collect do |v|
13
+ datum = v.xpath('datum').text
14
+ case values_type
15
+ when 'INTEGER'
16
+ datum = datum.to_i
17
+ when 'DECIMAL'
18
+ datum = datum.to_f
19
+ else
20
+ raise "unknown Values TYPE of <#{values_type}>"
21
+ end
22
+ datum
23
+ end
24
+ hash[:values] = values
25
+
26
+ [:vm, :nic, :disk].each do |type|
27
+ parent_node = node.xpath(type.to_s).first
28
+ next if parent_node.nil?
29
+ parent = hash_from_id_and_href(parent_node)
30
+ parent[:type] = type
31
+ hash[:parent] = parent
32
+ end
33
+
34
+ hash
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,14 @@
1
+ module Ovirt
2
+ class Storage < Object
3
+
4
+ self.top_level_objects = [:host]
5
+
6
+ def self.parse_xml(xml)
7
+ node, hash = xml_to_hash(xml)
8
+
9
+ parse_first_node(node, :volume_group, hash, :attribute => [:id])
10
+
11
+ hash
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,48 @@
1
+ module Ovirt
2
+ class StorageDomain < Object
3
+
4
+ self.top_level_strings = [:name, :type, :storage_format]
5
+ self.top_level_booleans = [:master]
6
+ self.top_level_integers = [:available, :used, :committed]
7
+ self.top_level_objects = [:data_center]
8
+
9
+ def self.element_name
10
+ "storage_domain"
11
+ end
12
+
13
+ def self.parse_xml(xml)
14
+ node, hash = xml_to_hash(xml)
15
+
16
+ parse_first_node(node, :status, hash, :node => [:state])
17
+ parse_first_node(node, :storage, hash, :node => [:type, :address, :path])
18
+ parse_first_node(node, :storage, hash, :attribute => [:id])
19
+
20
+ node.xpath('storage/volume_group').each do |vg|
21
+ node.xpath('storage').each do |storage_node|
22
+ parse_first_node(storage_node, :volume_group, hash[:storage], :attribute => [:id])
23
+ end
24
+
25
+ vg_hash = hash[:storage][:volume_group]
26
+ unless vg_hash.blank?
27
+ parse_first_node(vg, :logical_unit, vg_hash, :attribute => [:id])
28
+
29
+ unless vg_hash.blank?
30
+ parse_first_node(vg, :logical_unit, vg_hash,
31
+ :node => [:address, :port, :target, :username, :serial, :vendor_id, :product_id, :lun_mapping, :portal, :size, :paths])
32
+ end
33
+ end
34
+ end
35
+
36
+ hash
37
+ end
38
+
39
+ def self.iso_storage_domain(service)
40
+ all(service).detect { |s| s[:type] == "iso" }
41
+ end
42
+
43
+ def iso_images
44
+ return [] if self[:type] != "iso"
45
+ @service.standard_collection(relationships[:files], 'file')
46
+ end
47
+ end
48
+ end
data/lib/ovirt/tag.rb ADDED
@@ -0,0 +1,23 @@
1
+ module Ovirt
2
+ class Tag < Object
3
+
4
+ self.top_level_strings = [:name, :description]
5
+ self.top_level_objects = [:host, :user, :vm]
6
+
7
+ def self.parse_xml(xml)
8
+ node, hash = xml_to_hash(xml)
9
+
10
+ parent_node = node.xpath('parent').first
11
+ unless parent_node.nil?
12
+ tag_node = parent_node.xpath('tag').first
13
+ unless tag_node.nil?
14
+ parent = hash_from_id_and_href(tag_node)
15
+ parent[:type] = 'tag'
16
+ hash[:parent] = parent
17
+ end
18
+ end
19
+
20
+ hash
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,185 @@
1
+ module Ovirt
2
+ class Template < Object
3
+
4
+ self.top_level_strings = [:name, :description, :type]
5
+ self.top_level_booleans = [:stateless]
6
+ self.top_level_integers = [:memory]
7
+ self.top_level_timestamps = [:creation_time]
8
+ self.top_level_objects = [:cluster]
9
+
10
+ def self.parse_xml(xml)
11
+ node, hash = xml_to_hash(xml)
12
+
13
+ parse_first_node(node, :status, hash, :node => [:state])
14
+
15
+ parse_first_node(node, :display, hash,
16
+ :node => [:type, :address],
17
+ :node_to_i => [:port, :monitors])
18
+
19
+ parse_first_node(node, :usb, hash,
20
+ :node_to_bool => [:enabled])
21
+
22
+ parse_first_node_with_hash(node, 'cpu/topology', hash.store_path(:cpu, :topology, {}),
23
+ :attribute_to_i => [:sockets, :cores])
24
+
25
+ parse_first_node(node, :high_availability, hash,
26
+ :node_to_bool => [:enabled],
27
+ :node_to_i => [:priority])
28
+
29
+ parse_first_node(node, :os, hash,
30
+ :attribute => [:type],
31
+ :node => [:kernel, :initrd, :cmdline])
32
+
33
+ hash[:os][:boot_order] = boot_order = []
34
+ #Collect boot order
35
+ node.xpath('os/boot').each do |boot|
36
+ dev = boot['dev']
37
+ boot_order << {:dev => dev} unless dev.blank?
38
+ end
39
+
40
+ hash[:custom_attributes] = []
41
+ node.xpath('custom_properties/custom_property').each do |ca|
42
+ hash[:custom_attributes] << {:name => ca[:name], :value => ca[:value]}
43
+ end
44
+
45
+
46
+ hash
47
+ end
48
+
49
+ def os_type
50
+ self.attributes.fetch_path(:os, :type) || 'unassigned'
51
+ end
52
+
53
+ def getCfg(snap=nil)
54
+ #mor = snap ? getSnapMor(snap) : @vmMor
55
+ cfgProps = self.attributes
56
+
57
+ raise MiqException::MiqVimError, "Failed to retrieve configuration information for VM" if cfgProps.nil?
58
+
59
+ cfgHash = {}
60
+ cfgHash['displayname'] = cfgProps[:name]
61
+ cfgHash['guestos'] = cfgProps.fetch_path(:os, :type)
62
+ cfgHash['memsize'] = cfgProps[:memory] / 1048576 # in MB
63
+ cfgHash['numvcpu'] = cfgProps.fetch_path(:cpu, :sockets)
64
+
65
+ # Collect disk information
66
+ self.attributes[:disks] = self.send(:disks, :disk) if self[:disks].nil?
67
+ self.disks.each_with_index do |disk, idx|
68
+ storage_domain = disk[:storage_domains].first
69
+ storage_id = storage_domain && storage_domain[:id]
70
+ disk_key = disk[:image_id].blank? ? :id : :image_id
71
+ file_path = storage_id && File.join('/dev', storage_id, disk[disk_key])
72
+
73
+ tag = "scsi0:#{idx}"
74
+ cfgHash["#{tag}.present"] = "true"
75
+ cfgHash["#{tag}.devicetype"] = "disk"
76
+ cfgHash["#{tag}.filename"] = file_path.to_s
77
+ cfgHash["#{tag}.format"] = disk[:format]
78
+ #cfgHash["#{tag}.mode"] = dev['backing']['diskMode']
79
+ end
80
+ return cfgHash
81
+ end
82
+
83
+ REQUIRED_CLONE_PARAMETERS = [:name, :cluster]
84
+ CLONE_ATTRIBUTES_WITH_SCALARS = [:memory, :stateless, :type]
85
+ CLONE_ATTRIBUTES_WITH_HASHES = [:display, :usb, :cpu, :high_availability]
86
+ ALLOWED_CLONE_TYPES = [:full, :linked, :skeletal]
87
+
88
+ def create_vm(options = {})
89
+ options = options.dup
90
+ determine_clone_type(options)
91
+ options[:storage] = Object.object_to_id(options[:storage]) if options[:storage]
92
+
93
+ case options[:clone_type]
94
+ when :full; clone_to_vm(options)
95
+ when :linked; clone_to_vm(options)
96
+ when :skeletal; clone_to_vm_via_blank_template(options)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def determine_clone_type(options)
103
+ # Return the clone_type from the options if it matches one of the types in the allowed array
104
+ # otherwise return the first type from the allowed array as a default
105
+ options[:clone_type] = ALLOWED_CLONE_TYPES.include?(options[:clone_type]) ? options[:clone_type] : ALLOWED_CLONE_TYPES.first
106
+ end
107
+
108
+ def clone_to_vm_via_blank_template(options)
109
+ # Create a VM based the VM on the blank template using parameters from this template
110
+ # Disks are created from scratch, not copied
111
+ (CLONE_ATTRIBUTES_WITH_SCALARS + CLONE_ATTRIBUTES_WITH_HASHES).each do |key|
112
+ options[key] ||= self[key]
113
+ end
114
+ options[:os_type] ||= self.os_type
115
+
116
+ skeleton_options = options.dup
117
+ skeleton_options[:clone_type] = :linked
118
+ vm = @service.blank_template.create_vm(skeleton_options)
119
+
120
+ create_new_disks_from_template(vm, options)
121
+ vm
122
+ end
123
+
124
+ def create_new_disks_from_template(vm, options)
125
+ self.disks.each do |disk_object|
126
+ disk_options = disk_object.attributes_for_new_disk
127
+ disk_options[:sparse] = options[:sparse] unless options[:sparse].nil?
128
+ disk_options[:storage] = options[:storage] unless options[:storage].blank?
129
+ vm.create_disk(disk_options)
130
+ end
131
+ end
132
+
133
+ def clone_to_vm(options)
134
+ # Create a VM based on this template
135
+ REQUIRED_CLONE_PARAMETERS.each do |key|
136
+ raise ArgumentError, "#{key.inspect} cannot be blank" if options[key].blank?
137
+ end
138
+
139
+ response = @service.resource_post(:vms, build_clone_xml(options))
140
+ Vm.create_from_xml(@service, response)
141
+ rescue Ovirt::Error => err
142
+ raise VmAlreadyExists, err.message if err.message.include?("VM with the same name already exists")
143
+ raise
144
+ end
145
+
146
+ def build_clone_xml(options)
147
+ builder = Nokogiri::XML::Builder.new do |xml|
148
+ xml.vm do
149
+ xml.name options[:name]
150
+ xml.cluster(:id => Object.object_to_id(options[:cluster]))
151
+ xml.template(:id => self[:id])
152
+
153
+ CLONE_ATTRIBUTES_WITH_SCALARS.each do |key|
154
+ xml.send("#{key}_", options[key] || self[key])
155
+ end
156
+
157
+ CLONE_ATTRIBUTES_WITH_HASHES.each do |key|
158
+ xml.send("#{key}_") do
159
+ hash = options[key] || self[key]
160
+ hash.each { |k, v| xml.send("#{k}_", v) } unless hash.nil?
161
+ end
162
+ end
163
+
164
+ xml.os(:type => options[:os_type] || self.os_type) do
165
+ xml.boot(:dev => 'hd')
166
+ end
167
+
168
+ if options[:clone_type] == :full
169
+ xml.disks do
170
+ xml.clone_ true
171
+ self.disks.each do |disk_object|
172
+ xml.disk(:id => disk_object.attributes[:id]) do
173
+ xml.sparse options[:sparse] unless options[:sparse].nil?
174
+ xml.storage_domains { xml.storage_domain(:id => options[:storage]) } if options[:storage]
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ builder.doc.root.to_xml
183
+ end
184
+ end
185
+ end
data/lib/ovirt/user.rb ADDED
@@ -0,0 +1,15 @@
1
+ module Ovirt
2
+ class User < Object
3
+
4
+ self.top_level_strings = [:name, :description, :domain, :user_name]
5
+ self.top_level_booleans = [:logged_in]
6
+
7
+ def self.parse_xml(xml)
8
+ node, hash = xml_to_hash(xml)
9
+ groups_node = node.xpath('groups').first
10
+ hash[:groups] = groups_node.xpath('group').collect { |group_node| group_node.text } unless groups_node.nil?
11
+
12
+ hash
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module Ovirt
2
+ VERSION = "0.1.0"
3
+ end