diff --git a/definitions/checks/candlepin/product_content_association.rb b/definitions/checks/candlepin/product_content_association.rb new file mode 100644 index 000000000..064954593 --- /dev/null +++ b/definitions/checks/candlepin/product_content_association.rb @@ -0,0 +1,36 @@ +module Checks + module Candlepin + class ProductContentAssociation < ForemanMaintain::Check + metadata do + description 'Make sure Product to Repository association in Candlepin DB is complete' + label :candlepin_prod_repo_assoc + tags :post_upgrade + for_feature :candlepin_database + end + + def missing_cp_associations + feature(:candlepin_database).query(<<~SQL) + SELECT c.content_id, c.uuid, c.name + FROM cp2_content c + JOIN cp2_owner_content oc ON c.uuid=oc.content_uuid + LEFT OUTER JOIN ( + SELECT pc.content_uuid + FROM cp2_products p + JOIN cp2_owner_products op ON p.uuid=op.product_uuid + JOIN cp2_product_content pc ON p.uuid=pc.product_uuid + ) x ON c.uuid = x.content_uuid + WHERE x.content_uuid IS NULL + SQL + end + + def run + missing = missing_cp_associations + + assert(missing.empty?, + "Candlepin DB is missing some Product to Content associations!\n" \ + "Found #{missing.length} content entries with missing product association.", + :next_steps => [Procedures::Candlepin::ProductContentAssociation.new]) + end + end + end +end diff --git a/definitions/procedures/candlepin/product_content_association.rb b/definitions/procedures/candlepin/product_content_association.rb new file mode 100644 index 000000000..7c28bd4fe --- /dev/null +++ b/definitions/procedures/candlepin/product_content_association.rb @@ -0,0 +1,124 @@ +require 'set' + +module Procedures::Candlepin + class ProductContentAssociation < ForemanMaintain::Procedure + metadata do + for_feature :candlepin_database + description 'Reassociate Content to Product in CandlepinDB' + end + + # returns a Hash of candlepin product cp_id keys with a Hash of queried values + # consisting of the product's name and the number of associated content + # e.g. { '' => { 'name' => '...', count => X, ..}, ... } + def foreman_content_num_by_product + feature(:foreman_database).query(<<~SQL).map { |e| [e['cp_id'], e] }.to_h + SELECT p.cp_id as cp_id, p.name as name, COUNT(c.id) as count + FROM katello_products p + JOIN katello_product_contents pc ON p.id = pc.product_id + JOIN katello_contents c ON pc.content_id = c.id + GROUP BY p.cp_id, p.name + SQL + end + + # return Hash of query-result Hashes with the respective candlepin product_id as key + # similar to foreman_content_num_by_product() + def cp_content_count_by_product + feature(:candlepin_database).query(<<~SQL).map { |e| [e['product_id'], e] }.to_h + SELECT product.product_id, product.uuid, product.name, COUNT(content.content_id) + FROM cp_pool pool + JOIN cp2_products product ON pool.product_uuid = product.uuid + LEFT JOIN cp2_product_content pc ON product.uuid = pc.product_uuid + LEFT JOIN cp2_content content ON content.uuid = pc.content_uuid + GROUP BY product.uuid + SQL + end + + # returns a set of cp2_content ids for given product_id + def cp_product_content_ids(product_id) + feature(:candlepin_database).query(<<~SQL).map { |e| e['content_id'] }.to_set + SELECT content.content_id + FROM cp_pool pool + JOIN cp2_products product ON pool.product_uuid = product.uuid + JOIN cp2_product_content pc ON product.uuid = pc.product_uuid + JOIN cp2_content content ON content.uuid = pc.content_uuid + WHERE product.product_id = '#{product_id}' + SQL + end + + # return Set of candlepin content ids from katello_content table + # for candlepin product with cp_id + def katello_content_ids(cp_id) + feature(:foreman_database).query(<<~SQL).map { |e| e['cp_content_id'] }.to_set + SELECT c.cp_content_id + FROM katello_products p + JOIN katello_product_contents pc ON p.id = pc.product_id + JOIN katello_contents c ON pc.content_id = c.id + WHERE p.cp_id = '#{cp_id}' + SQL + end + + def assemble_restore_commands(look_closer_products) + commands = [] + look_closer_products.each do |cp_id, product| + puts "Process Product #{product['name'].inspect}" + # get content_ids from candlepin and katello + missing_ids = katello_content_ids(cp_id) - cp_product_content_ids(cp_id) + + missing_ids.each do |content_id| + commands << create_new_association_sql_inserts(product['uuid'], content_id) + + # clear entity version of affected product to avoid versioning and convergence issues + commands << 'UPDATE cp2_products SET entity_version = NULL ' \ + "WHERE uuid = '#{product['uuid']}'" + end + end + commands + end + + # returns SQL-INSERT String to recreate missing associations + def create_new_association_sql_inserts(product_uuid, content_id) + missing = feature(:candlepin_database).query( + "SELECT name, uuid FROM cp2_content WHERE content_id = '#{content_id}'" + ) + insert_sql = [] + missing.each do |content| + puts " - repair missing: #{content['name'].inspect}" + insert_sql << "(REPLACE(uuid_in((md5((random())::text))::cstring)::text, '-', '' )," \ + ' true,' \ + " '#{product_uuid}'," \ + " '#{content['uuid']}'," \ + ' NOW(), NOW())' + end + + <<~SQL + INSERT INTO cp2_product_content + (id, enabled, product_uuid, content_uuid, created, updated) + VALUES #{insert_sql.join(', ')} + SQL + end + + def run + candlepin_content_num_by_product = cp_content_count_by_product + look_closer_products = {} + + foreman_content_num_by_product.each do |product_id, foreman_product| + next unless candlepin_content_num_by_product.key?(product_id) + + candlepin_product = candlepin_content_num_by_product[product_id] + next unless foreman_product['count'] != candlepin_product['count'] + + look_closer_products[product_id] = candlepin_product + end + + res = feature(:candlepin_database).psql(<<~SQL) + BEGIN; + #{assemble_restore_commands(look_closer_products).join(";\n")}; + COMMIT; + SQL + + if res.include? 'ERROR' + warn! "Repairing Product-Content association in CandlepinDB failed. Please check the logs." + end + end + end +end diff --git a/test/definitions/checks/candlepin/product_content_association_test.rb b/test/definitions/checks/candlepin/product_content_association_test.rb new file mode 100644 index 000000000..093bee642 --- /dev/null +++ b/test/definitions/checks/candlepin/product_content_association_test.rb @@ -0,0 +1,33 @@ +require 'test_helper' + +describe Checks::Candlepin::ProductContentAssociation do + include DefinitionsTestHelper + + subject do + Checks::Candlepin::ProductContentAssociation.new + end + + it 'passes when nothing found' do + assume_feature_present(:candlepin_database) do |db| + db.any_instance.expects(:query).returns([]) + end + result = run_check(subject) + assert result.success?, 'Check expected to succeed' + end + + it 'fails when missing associations' do + assume_feature_present(:candlepin_database) do |db| + db.any_instance.expects(:query).returns([{ + 'content_id' => '123', + 'uuid' => 'feed', + 'name' => 'foo', + }]) + end + result = run_check(subject) + assert result.fail?, 'Check expected to fail' + msg = "Candlepin DB is missing some Product to Content associations!\n" + msg += 'Found 1 content entries with missing product association.' + assert_match msg, result.output + assert_equal [Procedures::Candlepin::ProductContentAssociation], subject.next_steps.map(&:class) + end +end diff --git a/test/definitions/procedures/candlepin/product_content_association_test.rb b/test/definitions/procedures/candlepin/product_content_association_test.rb new file mode 100644 index 000000000..dfbacbfd4 --- /dev/null +++ b/test/definitions/procedures/candlepin/product_content_association_test.rb @@ -0,0 +1,45 @@ +require 'test_helper' + +describe Procedures::Candlepin::ProductContentAssociation do + include DefinitionsTestHelper + + subject do + Procedures::Candlepin::ProductContentAssociation.new + end + + it 'fixes missing association' do + product_id = '12345' + product_name = 'dummy' + content_id = '67890' + content_name = 'Missing Repo' + content_uuid = 'dead' + assume_feature_present(:candlepin_database) do |db| + db.any_instance.expects(:query).with( + "SELECT name, uuid FROM cp2_content WHERE content_id = '#{content_id}'" + ).once.returns([{ + 'name' => content_name, + 'uuid' => content_uuid, + }]) + db.any_instance.expects(:psql).once.returns("BEGIN +INSERT 0 2 +UPDATE 1 +COMMIT +") + end + + subject.expects(:foreman_content_num_by_product).once.returns({ product_id => { + 'cp_id' => product_id, 'name' => product_name, 'count' => 1 + } }) + subject.expects(:cp_content_count_by_product).once.returns({ product_id => { + 'product_id' => product_id, 'uuid' => 'feed', 'name' => product_name, 'count' => 0 + } }) + subject.expects(:cp_product_content_ids).once.with(product_id).returns([].to_set) + subject.expects(:katello_content_ids).once.with(product_id).returns([content_id].to_set) + + result = run_procedure(subject) + assert result.success?, 'the procedure was expected to succeed' + msg = "Process Product #{product_name.inspect}\n" + msg += " - repair missing: #{content_name.inspect}\n" + assert_stdout msg + end +end