Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for exporting and importing tags #16983

Merged
merged 3 commits into from
Apr 17, 2018
Merged

Add support for exporting and importing tags #16983

merged 3 commits into from
Apr 17, 2018

Conversation

branic
Copy link
Contributor

@branic branic commented Feb 9, 2018

These rake scripts and classes provide functionality for exporting/importing of the following ManageIQ object types:

  • tags (Classification)

This PR uses the framework that was implemented for PRs #14126, #15256, #16717 to export/import other ManageIQ object types.

These scripts are based on the CFME RH Consulting Scripts and are used by Red Hat consultants to enable storing customizations in Git and maintaining customizations between environments (e.g. dev/qa/prod) for an SDLC lifecycle.

Steps for Testing/QA

Exporting

  1. Create a directory for the exports
mkdir /tmp/tags
  1. Export user tags
vmdb
bin/rake evm:export:tags -- --directory /tmp/tags
  1. or Export all tags
vmdb
bin/rake evm:export:tags -- --directory /tmp/tags --all

Importing

  1. Import all tag yaml files in a directory
# vmdb
# bin/rake evm:import:tags -- --source /tmp/tags
  1. or Import specific tag yaml file
# vmdb
# bin/rake evm:import:tags -- --source /tmp/tags/Location.yaml

@branic
Copy link
Contributor Author

branic commented Feb 9, 2018

cc @gtanzillo @carbonin

class Exports
class Tags
# Description attribute of Tag Categories that are not visible in the UI
SPECIAL_TAGS = ['Parent Folder Path (VMs & Templates)', 'Parent Folder Path (Hosts & Clusters)', 'User roles'].freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do these strings come from? Could we "accidentally" change them in message catalogs or fixtures can cause you problems here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jrafanie These category descriptions were determined by exporting all of the tag classifications and finding ones that are not shown in the UI (Configuration -> Settings -> ManageIQ Region -> Tags -> My Company Categories.

You are right that the descriptions could change, but when I looked at the Classification I was not able to find anything that I could use to identify them. I'm also not sure how the UI decides to not show them when displaying the tag categories. I know that they are used and maintained internally by MIQ so the goal is to prevent a user from modifying them and causing lager problems.

From what I have been able to determine the two "Parent Folder Path ..." categories are not created until an infrastructure (or maybe cloud) provider has been added and the "User roles" category is created when setting up a region.

If the description did change the only effect would be that they would be exported and could be imported.

I'm open to handling this check another way that is more robust and not dependant on the name or description of the category. I just need a little help in what to look at to make the determination.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to do this instead -

SPECIAL_TAGS = ["/managed/folder_path_yellow", "/managed/folder_path_blue", "/managed/user/role"]
Classification.includes(:tag).where(:parent_id => 0).where.not(:tags => {:name => SPECIAL_TAGS})

cat.entries.each do |entry|
unless entry.default
export_tags << cat
break
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following why we break out after the first non-default entry...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is hard to follow and is probably why I usually forget how classifications work. Classifications containing both categories and entries and both could be default is confusing to me. As long as we have tests for this, I guess it's ok.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal with this code is to not export default tag categories unless a tag category contains an entry that is not default.

I break out of the loop once one non-default entry is found because at that point the category and all entries will be exported so there is no need to continue looping through the entries.

The test "exports user tags to a given directory" does cover checking this case, but does not check the contents of the exported file. I'll add an expect to ensure that the exported files contents are as expected.

class ClassificationNameError < StandardError; end
class ClassificationEntryDescError < StandardError; end
class ClassificationEntryNameError < StandardError; end
class ClassificationYamlError < StandardError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these exceptions are needed... see below..

begin
tag_categories = YAML.load_file(fname)
import_tags(tag_categories)
rescue ClassificationDescError
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to rescue all of these exceptions... see below

private

# Description attribute of Tag Categories that are not visible in the UI
SPECIAL_TAGS = ['Parent Folder Path (VMs & Templates)', 'Parent Folder Path (Hosts & Clusters)', 'User roles'].freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, hardcoded strings that we might accidentally change elsewhere that might cause problems in this script. I can't tell.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment on the same issue for the export script above. The same applies.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See suggestion above ...


def import_classification(tag_category)
raise ClassificationDescError unless tag_category['description']
raise ClassificationNameError unless tag_category['name']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to raise exceptions here. We already have validations on classification. I believe we should be able to do

c = Classification.new(...) # passing the values we want to import from each classification
unless c.valid?
  # inspect c.errors to see what failed validation
end

I don't know if we're testing more or less things here vs. what's in the existing validations or if there's a reason we're skipping the activerecord validations and doing them manually here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, you might have to treat "categories" different than "entries" but because they're all classifications in the end, they all must pass the existing validations so we should only have to add any additional validations you care about. In other words, we shouldn't need to check name and description above, it should invalid and activerecord will have why it's invalid.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These exceptions are here to prevent a user from trying to import a file that they have modified and removed the name or description. The

classification = Classification.find_by_name(tag_category['name'])

will generate a TypeError if tag_category['name'] is nil. Having these checks here I think simplifies the error handling later in the script. The import will never succeed if either of these is nil so why even try. There is a check if errors were generated

raise ClassificationYamlError.new("Tag Category error", classification.errors.details) if classification.errors.count.positive?

to handle situations where the user changed a value to something illegal (like having a capital letter in the name).

I don't mind changing the error handling to using the c.valid? if that would be a better way to handle the error checking.

(In a quick test I found I can get around the TypeError in the Classification.find_by_name by using to_s on the tag_category['name'] so maybe that would be better and then just handle the error of the classification not being created because the name is nil)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do something like this if the new classification object is not valid -

warn("Error importing #{c.errors.full_messages.inspect") unless c.valid?

That'll output all the invalid attributes at one time which would eliminate the need for a user to fix them one by one. This way you're allowing the validations in the model to handle it. See if that works for you and then you can get rid of all of the special exception classes you created.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave the check for tag_category['name'] being nil because creating a Classification with name being nil causes an explosion. Having description nil or name being an invalid string (e.g. inValid) is handled by the create and the error is retuned as expected so I can get rid of those error classes.

irb(main):085:0> a
=> {"description"=>nil, "icon"=>nil, "read_only"=>false, "syntax"=>"string", "single_value"=>true, "example_text"=>"Brant Test", "parent_id"=>0, "show"=>true, "default"=>nil, "perf_by_tag"=>false, "name"=>nil}
irb(main):086:0> classification = Classification.create(a)
NoMethodError: undefined method `name' for nil:NilClass
	from /home/bevans/my-repos/manageiq/app/models/classification.rb:326:in `name'
	from /home/bevans/.gem/ruby/2.3.5/gems/activemodel-5.0.6/lib/active_model/validator.rb:149:in `block in validate'
	from /home/bevans/.gem/ruby/2.3.5/gems/activemodel-5.0.6/lib/active_model/validator.rb:148:in `each'
	from /home/bevans/.gem/ruby/2.3.5/gems/activemodel-5.0.6/lib/active_model/validator.rb:148:in `validate'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:405:in `public_send'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:405:in `block in make_lambda'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:169:in `block (2 levels) in halting'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:547:in `block (2 levels) in default_terminator'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:546:in `catch'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:546:in `block in default_terminator'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:170:in `block in halting'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:454:in `block in call'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:454:in `each'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:454:in `call'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:101:in `__run_callbacks__'
	from /home/bevans/.gem/ruby/2.3.5/gems/activesupport-5.0.6/lib/active_support/callbacks.rb:750:in `_run_validate_callbacks'
... 17 levels...
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/connection_adapters/abstract/database_statements.rb:232:in `transaction'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/transactions.rb:211:in `transaction'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/transactions.rb:392:in `with_transaction_returning_status'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/transactions.rb:319:in `block in save'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/transactions.rb:334:in `rollback_active_record_state!'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/transactions.rb:318:in `save'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/suppressor.rb:41:in `save'
	from /home/bevans/.gem/ruby/2.3.5/gems/activerecord-5.0.6/lib/active_record/persistence.rb:34:in `create'
	from (irb):86
	from /home/bevans/.gem/ruby/2.3.5/gems/railties-5.0.6/lib/rails/commands/console.rb:65:in `start'
	from /home/bevans/.gem/ruby/2.3.5/gems/railties-5.0.6/lib/rails/commands/console_helper.rb:9:in `start'
	from /home/bevans/.gem/ruby/2.3.5/gems/railties-5.0.6/lib/rails/commands/commands_tasks.rb:78:in `console'
	from /home/bevans/.gem/ruby/2.3.5/gems/railties-5.0.6/lib/rails/commands/commands_tasks.rb:49:in `run_command!'
	from /home/bevans/.gem/ruby/2.3.5/gems/railties-5.0.6/lib/rails/commands.rb:18:in `<top (required)>'
	from bin/rails:4:in `require'
	from bin/rails:4:in `<main>'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found a better way to handle a name being nil during import. I'm going to do a to_s before the import for the name.

class Exports
class Tags
# Description attribute of Tag Categories that are not visible in the UI
SPECIAL_TAGS = ['Parent Folder Path (VMs & Templates)', 'Parent Folder Path (Hosts & Clusters)', 'User roles'].freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to do this instead -

SPECIAL_TAGS = ["/managed/folder_path_yellow", "/managed/folder_path_blue", "/managed/user/role"]
Classification.includes(:tag).where(:parent_id => 0).where.not(:tags => {:name => SPECIAL_TAGS})

cat.entries.each do |entry|
unless entry.default
export_tags << cat
break
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why there's a break here. Don't you need to check the rest of the entries to get any others that are not "default"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once one non-defualt entry is found the category will be exported which will include all entries.

I could adjust the export to only ever export non-default entries, but it is possible to change the name and description so I felt it was best to export all entries if one non-default was found. Granted there is an edge case that isn't covered where a name or description of a default is changed and no entries are added. In that case the category will never be exported.

This logic is a little different than the script in rhtconsulting which exports all but the "special" categories.

private

# Description attribute of Tag Categories that are not visible in the UI
SPECIAL_TAGS = ['Parent Folder Path (VMs & Templates)', 'Parent Folder Path (Hosts & Clusters)', 'User roles'].freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See suggestion above ...


def import_classification(tag_category)
raise ClassificationDescError unless tag_category['description']
raise ClassificationNameError unless tag_category['name']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do something like this if the new classification object is not valid -

warn("Error importing #{c.errors.full_messages.inspect") unless c.valid?

That'll output all the invalid attributes at one time which would eliminate the need for a user to fix them one by one. This way you're allowing the validations in the model to handle it. See if that works for you and then you can get rid of all of the special exception classes you created.

classification.update_attributes!(tag_category.select { |k| UPDATE_FIELDS.include?(k) })
else
classification = Classification.create(tag_category)
raise ClassificationYamlError.new("Tag Category error", classification.errors.full_messages) unless classification.valid?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found a better way to handle a name being nil during import. I'm going to do a to_s before the import for the name.

I still think we can remove the checking for nil name altogether because we already have a validation for presence. We also validate :validate_format_of_name, which does additional checks. If you try to .update_attributes or .create (without the !), you should be able to fetch the validation errors like you're doing here and re-raise your own exception if you want, or you can just call the ! methods and see if the raised validation failures are good enough for you. Finally, you could also just call Classification.new and try to set the various values and call classification.valid? to check if it's valid.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only reason to have your own validation in these scripts is if you want the export/import to have stricter rules about what is imported/exported or add logging of specific types of data. If the model already has the validation you are trying to use, we should have a single place define that validation so if we change the model validation due to a bug, this script automatically gets fixed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I'm completely following. Are you saying to call Classification.new regardless of if the Classification already exists or not?

I've removed all of the additional checks I was doing and letting the model handle that. I do have to make sure that the name attribute for a Classification or Entry is a string. If it is nil from reading in the YAML file then the find_by_name and find_entry_by_name (lines 53 & 71) calls cause the rake task to be aborted with a TypeError: no implicit conversion of nil into String. So by making sure name is a string I can just depend on the models validations.

I see what you mean with the update_attributes so I'll make a change to handle errors when updating the attributes.

I left the one error class ClassificationYamlError so that I could output a bit nicer format of the validation errors for the user.

@miq-bot
Copy link
Member

miq-bot commented Mar 22, 2018

Checked commits https://github.com/branic/manageiq/compare/0c1b181244a4e8cc9481a980fa258334d7ca6920~...ad3a957af6f73345791508cb116286bb6037ce09 with ruby 2.3.3, rubocop 0.52.1, haml-lint 0.20.0, and yamllint 1.10.0
5 files checked, 4 offenses detected

lib/task_helpers/imports/tags.rb

spec/lib/task_helpers/imports/tags_spec.rb

@branic
Copy link
Contributor Author

branic commented Mar 30, 2018

@gtanzillo @jrafanie Anything else you would like me to address?

@branic
Copy link
Contributor Author

branic commented Apr 16, 2018

@gtanzillo @jrafanie Can this PR be merged or is there additional changes that you would like made?

@jrafanie
Copy link
Member

@branic I'm ok with the changes. As an aside, I have no idea what in this area would ever need to be backported to gaprindashvili, so keep that in mind when working on these tools. I'll assume no to backporting unless you say so.

Also, do all of these export/import PRs support being run with inter-region (global exported/imported into region or vice versa). I'm curious what is the use case for either

  • region -> region (seems like the obvious use case)
  • global -> region (???)
  • region -> global (???)

@jrafanie jrafanie self-assigned this Apr 17, 2018
@jrafanie jrafanie added the tools label Apr 17, 2018
@jrafanie jrafanie added this to the Sprint 84 Ending Apr 23, 2018 milestone Apr 17, 2018
@jrafanie jrafanie merged commit 4cdb9e9 into ManageIQ:master Apr 17, 2018
@branic
Copy link
Contributor Author

branic commented Apr 17, 2018

@jrafanie There shouldn't be anything preventing these from being backported to Gaprindashvili, but I wasn't planning on requesting them to be backported.

These scripts are meant to be able to move the pieces between regions. So for example to go from the development region to the production region. There is some use for some of the scripts going between global and region, but for the most part the OOTB replication will handle anything that needs to be made available in the global from the regional.

@branic branic deleted the tags_export_import branch April 18, 2018 01:06
@cben cben mentioned this pull request Jan 8, 2019
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants