hammer_cli_import 0.10.21

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,585 @@
1
+ #
2
+ # Copyright (c) 2014 Red Hat Inc.
3
+ #
4
+ # This file is part of hammer-cli-import.
5
+ #
6
+ # hammer-cli-import is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # hammer-cli-import is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with hammer-cli-import. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+
20
+ require 'csv'
21
+ require 'json'
22
+ require 'set'
23
+
24
+ require 'apipie-bindings'
25
+ require 'hammer_cli'
26
+
27
+ module HammerCLIImport
28
+ class MissingObjectError < RuntimeError
29
+ end
30
+
31
+ class ImportRecoveryError < RuntimeError
32
+ end
33
+
34
+ class BaseCommand < HammerCLI::Apipie::Command
35
+ extend PersistentMap::Extend
36
+ extend ImportTools::ImportLogging::Extend
37
+ extend AsyncTasksReactor::Extend
38
+
39
+ include PersistentMap::Include
40
+ include ImportTools::ImportLogging::Include
41
+ include ImportTools::Task::Include
42
+ include ImportTools::Exceptional::Include
43
+ include AsyncTasksReactor::Include
44
+
45
+ def initialize(*list)
46
+ super(*list)
47
+
48
+ # wrap API parameters into extra hash
49
+ @wrap_out = {
50
+ :users => :user,
51
+ :template_snippets => :config_template
52
+ }
53
+ # APIs return objects encapsulated in extra hash
54
+ #@wrap_in = {:organizations => 'organization'}
55
+ @wrap_in = {}
56
+ # entities that needs organization to be listed
57
+ @prerequisite = {
58
+ :activation_keys => :organizations,
59
+ :content_views => :organizations,
60
+ :content_view_versions => :organizations,
61
+ :host_collections => :organizations,
62
+ :products => :organizations,
63
+ :repositories => :organizations,
64
+ :repository_sets => :products,
65
+ :systems => :organizations
66
+ }
67
+ # cache imported objects (created/lookuped)
68
+ @cache = {}
69
+ class << @cache
70
+ def []=(key, val)
71
+ raise "@cache: #{val.inspect} is not a hash!" unless val.is_a? Hash
72
+ super
73
+ end
74
+ end
75
+ @summary = {}
76
+ # Initialize AsyncTaskReactor
77
+ atr_init
78
+ end
79
+
80
+ # What spacewalk-report do we expect to use for a given subcommand
81
+ class << self; attr_accessor :reportname end
82
+
83
+ option ['--csv-file'], 'FILE_NAME', 'CSV file with data to be imported', :required => true \
84
+ do |filename|
85
+ raise ArgumentError, "File #{filename} does not exist" unless File.exist? filename
86
+ missing = CSVHelper.csv_missing_columns filename, self.class.csv_columns
87
+ raise ArgumentError, "Bad CSV file #{filename}, missing columns: #{missing.inspect}" unless missing.empty?
88
+ filename
89
+ end
90
+
91
+ option ['--delete'], :flag, 'Delete entities from CSV file', :default => false
92
+
93
+ # TODO: Implement logic for verify
94
+ # option ['--verify'], :flag, 'Verify entities from CSV file'
95
+
96
+ option ['--recover'], 'RECOVER', 'Recover strategy, can be: rename (default), map, none', :default => :rename \
97
+ do |strategy|
98
+ raise ArgumentError, "Unknown '#{strategy}' strategy argument." \
99
+ unless [:rename, :map, :none].include? strategy.to_sym
100
+ strategy.to_sym
101
+ end
102
+ add_logging_options
103
+
104
+ class << self
105
+ # Which columns have to be be present in CSV.
106
+ def csv_columns(*list)
107
+ return @csv_columns if list.empty?
108
+ raise 'set more than once' if @csv_columns
109
+ @csv_columns = list
110
+ end
111
+ end
112
+
113
+ class << self
114
+ # Initialize API. Needed to be called before any +api_call+ calls.
115
+ # If used in shell, it may be called multiple times
116
+ def api_init
117
+ @api = HammerCLIForeman.foreman_api_connection.api
118
+ nil
119
+ end
120
+
121
+ # Call API. Ideally accessed via +api_call+ instance method.
122
+ # This is supposed to be the only way to access @api.
123
+ def api_call(resource, action, params = {}, headers = {}, dbg = false)
124
+ if resource == :organizations && action == :create
125
+ params[:organization] ||= {}
126
+ params[:organization][:name] = params[:name]
127
+ end
128
+ @api.resource(resource).call(action, params, headers)
129
+ rescue
130
+ error("Error on api.resource(#{resource.inspect}).call(#{action.inspect}, #{params.inspect}):") if dbg
131
+ raise
132
+ end
133
+ end
134
+
135
+ # Call API. Convenience method for calling +api_call+ class method.
136
+ def api_call(*list)
137
+ self.class.api_call(*list)
138
+ end
139
+
140
+ # Call API on corresponding resource (defined by +map_target_entity+).
141
+ def mapped_api_call(entity_type, *list)
142
+ api_call(map_target_entity[entity_type], *list)
143
+ end
144
+
145
+ def data_dir
146
+ File.join(File.expand_path('~'), '.transition_data')
147
+ end
148
+
149
+ # This method is called to process single CSV line when
150
+ # importing.
151
+ def import_single_row(_row)
152
+ error 'Import not implemented.'
153
+ end
154
+
155
+ # This method is called to process single CSV line when
156
+ # deleting
157
+ def delete_single_row(_row)
158
+ error 'Delete not implemented.'
159
+ end
160
+
161
+ def get_cache(entity_type)
162
+ @cache[map_target_entity[entity_type]]
163
+ end
164
+
165
+ def load_cache
166
+ maps.collect { |map_sym| map_target_entity[map_sym] } .uniq.each do |entity_type|
167
+ list_server_entities entity_type
168
+ end
169
+ end
170
+
171
+ def lookup_entity(entity_type, entity_id, online_lookup = false)
172
+ if (!get_cache(entity_type)[entity_id] || online_lookup)
173
+ get_cache(entity_type)[entity_id] = mapped_api_call(entity_type, :show, {'id' => entity_id})
174
+ else
175
+ debug "#{to_singular(entity_type).capitalize} #{entity_id} taken from cache."
176
+ end
177
+ return get_cache(entity_type)[entity_id]
178
+ end
179
+
180
+ def was_translated(entity_type, import_id)
181
+ return @pm[entity_type].to_hash.value?(import_id)
182
+ end
183
+
184
+ def _compare_hash(entity_hash, search_hash)
185
+ equal = nil
186
+ search_hash.each do |key, value|
187
+ if value.is_a? Hash
188
+ equal = _compare_hash(entity_hash[key], search_hash[key])
189
+ else
190
+ equal = entity_hash[key] == value
191
+ end
192
+ return false unless equal
193
+ end
194
+ return true
195
+ end
196
+
197
+ def lookup_entity_in_cache(entity_type, search_hash)
198
+ get_cache(entity_type).each do |_entity_id, entity_hash|
199
+ return entity_hash if _compare_hash(entity_hash, search_hash)
200
+ end
201
+ return nil
202
+ end
203
+
204
+ def lookup_entity_in_array(array, search_hash)
205
+ return nil if array.nil?
206
+ array.each do |entity_hash|
207
+ return entity_hash if _compare_hash(entity_hash, search_hash)
208
+ end
209
+ return nil
210
+ end
211
+
212
+ def last_in_cache?(entity_type, id)
213
+ return get_cache(entity_type).size == 1 && get_cache(entity_type).first[0] == id
214
+ end
215
+
216
+ # Method for use when writing messages to user.
217
+ # > to_singular(:contentveiws)
218
+ # "contentview"
219
+ # > to_singular(:repositories)
220
+ # "repository"
221
+ def to_singular(plural)
222
+ return plural.to_s.gsub(/_/, ' ').sub(/s$/, '').sub(/ie$/, 'y')
223
+ end
224
+
225
+ def split_multival(multival, convert_to_int = true, separator = ';')
226
+ arr = (multival || '').split(separator).delete_if { |v| v == 'None' }
227
+ arr.map!(&:to_i) if convert_to_int
228
+ return arr
229
+ end
230
+
231
+ # Method to call when you have created/deleted/found/mapped... something.
232
+ # Collected data used for summary reporting.
233
+ #
234
+ # :found is used for situation, when you want to create something,
235
+ # but you found out, it is already created.
236
+ def report_summary(verb, item)
237
+ raise "Not summary supported action: #{verb}" unless
238
+ [:created, :deleted, :found, :mapped, :skipped, :uploaded, :wrote, :failed].include? verb
239
+ @summary[verb] ||= {}
240
+ @summary[verb][item] = @summary[verb].fetch(item, 0) + 1
241
+ end
242
+
243
+ def print_summary
244
+ progress 'Summary'
245
+ @summary.each do |verb, what|
246
+ what.each do |entity, count|
247
+ noun = if count == 1
248
+ to_singular entity
249
+ else
250
+ entity
251
+ end
252
+ report = " #{verb.to_s.capitalize} #{count} #{noun}."
253
+ if verb == :found
254
+ info report
255
+ else
256
+ progress report
257
+ end
258
+ end
259
+ end
260
+ progress ' No action taken.' if (@summary.keys - [:found]).empty?
261
+ end
262
+
263
+ def get_translated_id(entity_type, entity_id)
264
+ if @pm[entity_type] && @pm[entity_type][entity_id]
265
+ return @pm[entity_type][entity_id]
266
+ end
267
+ raise MissingObjectError, 'Unable to import, first import ' + to_singular(entity_type) + \
268
+ ' with id ' + entity_id.inspect
269
+ end
270
+
271
+ # this method returns a *first* found original_id
272
+ # (since we're able to map several organizations into one)
273
+ def get_original_id(entity_type, import_id)
274
+ if was_translated(entity_type, import_id)
275
+ # find original_ids
276
+ @pm[entity_type].to_hash.each do |key, value|
277
+ return key if value == import_id
278
+ end
279
+ else
280
+ debug "Unknown imported #{to_singular(entity_type)} [#{import_id}]."
281
+ end
282
+ return nil
283
+ end
284
+
285
+ def list_server_entities(entity_type, extra_hash = {}, use_cache = false)
286
+ if @prerequisite[entity_type]
287
+ list_server_entities(@prerequisite[entity_type]) unless @cache[@prerequisite[entity_type]]
288
+ end
289
+
290
+ @cache[entity_type] ||= {}
291
+ results = []
292
+
293
+ if !extra_hash.empty? || @prerequisite[entity_type].nil?
294
+ if use_cache
295
+ @list_cache ||= {}
296
+ if @list_cache[entity_type]
297
+ return @list_cache[entity_type][extra_hash] if @list_cache[entity_type][extra_hash]
298
+ else
299
+ @list_cache[entity_type] ||= {}
300
+ end
301
+ end
302
+ entities = api_call(entity_type, :index, {'per_page' => 999999}.merge(extra_hash))
303
+ results = entities['results']
304
+ @list_cache[entity_type][extra_hash] = results if use_cache
305
+ elsif @prerequisite[entity_type] == :organizations
306
+ # check only entities in imported orgs (not all of them)
307
+ @pm[:organizations].to_hash.values.each do |org_id|
308
+ entities = api_call(entity_type, :index, {'per_page' => 999999, 'organization_id' => org_id})
309
+ results += entities['results']
310
+ end
311
+ else
312
+ @cache[@prerequisite[entity_type]].each do |pre_id, _|
313
+ entities = api_call(
314
+ entity_type,
315
+ :index,
316
+ {
317
+ 'per_page' => 999999,
318
+ @prerequisite[entity_type].to_s.sub(/s$/, '_id').to_sym => pre_id
319
+ })
320
+ results += entities['results']
321
+ end
322
+ end
323
+
324
+ results.each do |entity|
325
+ entity['id'] = entity['uuid'] if entity_type == :systems
326
+ @cache[entity_type][entity['id']] = entity
327
+ end
328
+ end
329
+
330
+ def map_entity(entity_type, original_id, id)
331
+ if @pm[entity_type][original_id]
332
+ info "#{to_singular(entity_type).capitalize} [#{original_id}->#{@pm[entity_type][original_id]}] already mapped. " \
333
+ 'Skipping.'
334
+ report_summary :found, entity_type
335
+ return
336
+ end
337
+ info "Mapping #{to_singular(entity_type)} [#{original_id}->#{id}]."
338
+ @pm[entity_type][original_id] = id
339
+ report_summary :mapped, entity_type
340
+ return get_cache(entity_type)[id]
341
+ end
342
+
343
+ def unmap_entity(entity_type, target_id)
344
+ deleted = @pm[entity_type].delete_value(target_id)
345
+ info " Unmapped #{to_singular(entity_type)} with id #{target_id}: #{deleted}x" if deleted > 1
346
+ end
347
+
348
+ def find_uniq(arr)
349
+ uniq = nil
350
+ uniq = arr[0] if arr[1].is_a?(Array) &&
351
+ (arr[1][0] =~ /has already been taken/ ||
352
+ arr[1][0] =~ /already exists/ ||
353
+ arr[1][0] =~ /must be unique within one organization/)
354
+ return uniq
355
+ end
356
+
357
+ def found_errors(err)
358
+ return err && err['errors'] && err['errors'].respond_to?(:each)
359
+ end
360
+
361
+ def recognizable_error(arr)
362
+ return arr.is_a?(Array) && arr.size >= 2
363
+ end
364
+
365
+ def process_error(err, entity_hash)
366
+ uniq = nil
367
+ err['errors'].each do |arr|
368
+ next unless recognizable_error(arr)
369
+ uniq = find_uniq(arr)
370
+ break if uniq && entity_hash.key?(uniq.to_sym)
371
+ uniq = nil # otherwise uniq is not usable
372
+ end
373
+ return uniq
374
+ end
375
+
376
+ # Create entity, with recovery strategy.
377
+ #
378
+ # * +:map+ - Use existing entity
379
+ # * +:rename+ - Change name
380
+ # * +nil+ - Fail
381
+ def create_entity(entity_type, entity_hash, original_id, recover = nil, retries = 2)
382
+ raise ImportRecoveryError, "Creation of #{entity_type} not recovered by " \
383
+ "'#{recover || option_recover.to_sym}' strategy" if retries < 0
384
+ uniq = nil
385
+ begin
386
+ return _create_entity(entity_type, entity_hash, original_id)
387
+ rescue RestClient::UnprocessableEntity => ue
388
+ error " Creation of #{to_singular(entity_type)} failed."
389
+ uniq = nil
390
+ err = JSON.parse(ue.response)
391
+ err = err['error'] if err.key?('error')
392
+ if found_errors(err)
393
+ uniq = process_error(err, entity_hash)
394
+ end
395
+ raise ue unless uniq
396
+ end
397
+
398
+ uniq = uniq.to_sym
399
+
400
+ case recover || option_recover.to_sym
401
+ when :rename
402
+ entity_hash[uniq] = original_id.to_s + '-' + entity_hash[uniq]
403
+ info " Recovering by renaming to: \"#{uniq}\"=\"#{entity_hash[uniq]}\""
404
+ return create_entity(entity_type, entity_hash, original_id, recover, retries - 1)
405
+ when :map
406
+ entity = lookup_entity_in_cache(entity_type, {uniq.to_s => entity_hash[uniq]})
407
+ if entity
408
+ info " Recovering by remapping to: #{entity['id']}"
409
+ return map_entity(entity_type, original_id, entity['id'])
410
+ else
411
+ warn "Creation of #{entity_type} not recovered by \'#{recover}\' strategy."
412
+ raise ImportRecoveryError, "Creation of #{entity_type} not recovered by \'#{recover}\' strategy."
413
+ end
414
+ else
415
+ fatal 'No recover strategy.'
416
+ raise ue
417
+ end
418
+ nil
419
+ end
420
+
421
+ # Use +create_entity+ instead.
422
+ def _create_entity(entity_type, entity_hash, original_id)
423
+ type = to_singular(entity_type)
424
+ if @pm[entity_type][original_id]
425
+ info type.capitalize + ' [' + original_id.to_s + '->' + @pm[entity_type][original_id].to_s + '] already imported.'
426
+ report_summary :found, entity_type
427
+ return get_cache(entity_type)[@pm[entity_type][original_id]]
428
+ else
429
+ info 'Creating new ' + type + ': ' + entity_hash.values_at(:name, :label, :login).compact[0]
430
+ entity_hash = {@wrap_out[entity_type] => entity_hash} if @wrap_out[entity_type]
431
+ debug "entity_hash: #{entity_hash.inspect}"
432
+ entity = mapped_api_call(entity_type, :create, entity_hash)
433
+ debug "created entity: #{entity.inspect}"
434
+ entity = entity[@wrap_in[entity_type]] if @wrap_in[entity_type]
435
+ # workaround for Bug
436
+ entity['id'] = entity['uuid'] if entity_type == :systems
437
+ @pm[entity_type][original_id] = entity['id']
438
+ get_cache(entity_type)[entity['id']] = entity
439
+ debug "@pm[#{entity_type}]: #{@pm[entity_type].inspect}"
440
+ report_summary :created, entity_type
441
+ return entity
442
+ end
443
+ end
444
+
445
+ def update_entity(entity_type, id, entity_hash)
446
+ info "Updating #{to_singular(entity_type)} with id: #{id}"
447
+ mapped_api_call(entity_type, :update, {:id => id}.merge!(entity_hash))
448
+ end
449
+
450
+ # Delete entity by original (Sat5) id
451
+ def delete_entity(entity_type, original_id)
452
+ type = to_singular(entity_type)
453
+ unless @pm[entity_type][original_id]
454
+ error 'Unknown ' + type + ' to delete [' + original_id.to_s + '].'
455
+ return nil
456
+ end
457
+ info 'Deleting imported ' + type + ' [' + original_id.to_s + '->' + @pm[entity_type][original_id].to_s + '].'
458
+ begin
459
+ mapped_api_call(entity_type, :destroy, {:id => @pm[entity_type][original_id]})
460
+ # delete from cache
461
+ get_cache(entity_type).delete(@pm[entity_type][original_id])
462
+ # delete from pm
463
+ unmap_entity(entity_type, @pm[entity_type][original_id])
464
+ report_summary :deleted, entity_type
465
+ rescue => e
466
+ warn "Delete of #{to_singular(entity_type)} [#{original_id}] failed with #{e.class}: #{e.message}"
467
+ report_summary :failed, entity_type
468
+ end
469
+ end
470
+
471
+ # Delete entity by target (Sat6) id
472
+ def delete_entity_by_import_id(entity_type, import_id, delete_key = 'id')
473
+ type = to_singular(entity_type)
474
+ original_id = get_original_id(entity_type, import_id)
475
+ if original_id.nil?
476
+ error 'Unknown imported ' + type + ' to delete [' + import_id.to_s + '].'
477
+ return nil
478
+ end
479
+ info "Deleting imported #{type} [#{original_id}->#{@pm[entity_type][original_id]}]."
480
+ if delete_key == 'id'
481
+ delete_id = import_id
482
+ else
483
+ delete_id = get_cache(entity_type)[import_id][delete_key]
484
+ end
485
+ begin
486
+ mapped_api_call(entity_type, :destroy, {:id => delete_id})
487
+ # delete from cache
488
+ get_cache(entity_type).delete(import_id)
489
+ # delete from pm
490
+ @pm[entity_type].delete original_id
491
+ report_summary :deleted, entity_type
492
+ rescue => e
493
+ warn "Delete of #{to_singular(entity_type)} [#{delete_id}] failed with #{e.class}: #{e.message}"
494
+ report_summary :failed, entity_type
495
+ end
496
+ end
497
+
498
+ # Wait for asynchronous task.
499
+ #
500
+ # * +uuid+ - UUID of async task.
501
+ # * +start_wait+ - Seconds to wait before first check.
502
+ # * +delta_wait+ - How much longer will every next wait be (unless +max_wait+ is reached).
503
+ # * +max_wait+ - Maximum time to wait between two checks.
504
+ def wait_for_task(uuid, start_wait = 0, delta_wait = 1, max_wait = 10)
505
+ wait_time = start_wait
506
+ if option_quiet?
507
+ info "Waiting for the task [#{uuid}] "
508
+ else
509
+ print "Waiting for the task [#{uuid}] "
510
+ end
511
+
512
+ loop do
513
+ sleep wait_time
514
+ wait_time = [wait_time + delta_wait, max_wait].min
515
+ print '.' unless option_quiet?
516
+ STDOUT.flush unless option_quiet?
517
+ task = api_call(:foreman_tasks, :show, {:id => uuid})
518
+ next unless task['state'] == 'stopped'
519
+ print "\n" unless option_quiet?
520
+ return task['return'] == 'success'
521
+ end
522
+ end
523
+
524
+ def cvs_iterate(filename, action)
525
+ CSVHelper.csv_each filename, self.class.csv_columns do |data|
526
+ handle_missing_and_supress "processing CSV line:\n#{data.inspect}" do
527
+ action.call(data)
528
+ end
529
+ end
530
+ end
531
+
532
+ def import(filename)
533
+ cvs_iterate(filename, (method :import_single_row))
534
+ end
535
+
536
+ def post_import(_csv_file)
537
+ # empty by default
538
+ end
539
+
540
+ def post_delete(_csv_file)
541
+ # empty by default
542
+ end
543
+
544
+ def delete(filename)
545
+ cvs_iterate(filename, (method :delete_single_row))
546
+ end
547
+
548
+ def execute
549
+ # Get set up to do logging as soon as reasonably possible
550
+ setup_logging
551
+ # create a storage directory if not exists yet
552
+ Dir.mkdir data_dir unless File.directory? data_dir
553
+
554
+ # initialize apipie binding
555
+ self.class.api_init
556
+ load_persistent_maps
557
+ load_cache
558
+ prune_persistent_maps @cache
559
+ # TODO: This big ugly thing might need some cleanup
560
+ begin
561
+ if option_delete?
562
+ info "Deleting from #{option_csv_file}"
563
+ delete option_csv_file
564
+ handle_missing_and_supress 'post_delete' do
565
+ post_delete option_csv_file
566
+ end
567
+ else
568
+ info "Importing from #{option_csv_file}"
569
+ import option_csv_file
570
+ handle_missing_and_supress 'post_import' do
571
+ post_import option_csv_file
572
+ end
573
+ end
574
+ atr_exit
575
+ rescue StandardError, SystemExit, Interrupt => e
576
+ error "Exiting: #{e}"
577
+ logtrace e
578
+ end
579
+ save_persistent_maps
580
+ print_summary
581
+ HammerCLI::EX_OK
582
+ end
583
+ end
584
+ end
585
+ # vim: autoindent tabstop=2 shiftwidth=2 expandtab softtabstop=2 filetype=ruby