hammer_cli_import 0.10.21

Sign up to get free protection for your applications and to get access to all the features.
@@ -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