/**
* Copyright (c) 2009 - 2016 Red Hat, Inc.
*
* This software is licensed to you under the GNU General Public License,
* version 2 (GPLv2). There is NO WARRANTY for this software, express or
* implied, including the implied warranties of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
* along with this software; if not, see
* http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
*
* Red Hat trademarks are not licensed under GPLv2. No permission is
* granted to use or replicate Red Hat trademarks that are incorporated
* in this software or its documentation.
*/
package org.candlepin.controller;
import org.candlepin.model.Content;
import org.candlepin.model.Owner;
import org.candlepin.model.OwnerContentCurator;
import org.candlepin.model.OwnerProduct;
import org.candlepin.model.OwnerProductCurator;
import org.candlepin.model.Product;
import org.candlepin.model.ProductContent;
import org.candlepin.model.ProductCurator;
import org.candlepin.model.dto.ContentData;
import org.candlepin.model.dto.ProductContentData;
import org.candlepin.model.dto.ProductData;
import com.google.inject.Inject;
import com.google.inject.persist.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* The ProductManager class provides methods for creating, updating and removing product instances
* which also perform the cleanup and general maintenance necessary to keep product state in sync
* with other objects which reference them.
* <p></p>
* The methods provided by this class are the prefered methods to use for CRUD operations on
* products, to ensure product versioning and linking is handled properly.
*/
public class ProductManager {
private static Logger log = LoggerFactory.getLogger(ProductManager.class);
private EntitlementCertificateGenerator entitlementCertGenerator;
private OwnerContentCurator ownerContentCurator;
private OwnerProductCurator ownerProductCurator;
private ProductCurator productCurator;
@Inject
public ProductManager(EntitlementCertificateGenerator entitlementCertGenerator,
OwnerContentCurator ownerContentCurator, OwnerProductCurator ownerProductCurator,
ProductCurator productCurator) {
this.entitlementCertGenerator = entitlementCertGenerator;
this.ownerContentCurator = ownerContentCurator;
this.ownerProductCurator = ownerProductCurator;
this.productCurator = productCurator;
}
/**
* Creates a new Product for the given owner, potentially using a different version than the
* entity provided if a matching entity has already been registered for another owner.
*
* @param productData
* A product DTO instance representing the product to create
*
* @param owner
* The owner for which to create the product
*
* @throws IllegalArgumentException
* if productData is null or incomplete, or owner is null
*
* @return
* a new Product instance representing the specified product for the given owner
*/
public Product createProduct(ProductData productData, Owner owner) {
if (productData == null) {
throw new IllegalArgumentException("productData is null");
}
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
if (productData.getId() == null || productData.getName() == null) {
throw new IllegalArgumentException("productData is incomplete");
}
if (this.ownerProductCurator.productExists(owner, productData.getId())) {
throw new IllegalStateException("product has already been created: " + productData.getId());
}
// TODO: More DTO validation here...?
Product entity = new Product(productData.getId(), productData.getName());
this.applyProductChanges(entity, productData, owner);
log.debug("Creating new product for org: {}, {}", entity, owner);
// Check if we have an alternate version we can use instead.
List<Product> alternateVersions = this.ownerProductCurator.getProductsByVersions(
owner, Collections.<String, Integer>singletonMap(entity.getId(), entity.getEntityVersion()))
.list();
for (Product alt : alternateVersions) {
if (alt.equals(entity)) {
// If we're "creating" a product, we shouldn't have any other object references to
// update for this product. Instead, we'll just add the new owner to the product.
this.ownerProductCurator.mapProductToOwner(alt, owner);
return alt;
}
}
entity = this.productCurator.create(entity);
this.ownerProductCurator.mapProductToOwner(entity, owner);
return entity;
}
/**
* Updates the product entity represented by the given DTO with the changes provided by the
* DTO.
*
* @param update
* A product DTO representing the product to update and the updates to apply
*
*
* @param owner
* The owner for which to update the product
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the product should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @throws IllegalStateException
* if this method is called with an entity does not exist in the backing database for the given
* owner
*
* @throws IllegalArgumentException
* if update or owner is null
*
* @return
* the updated product entity
*/
@Transactional
public Product updateProduct(ProductData update, Owner owner, boolean regenerateEntitlementCerts) {
if (update == null) {
throw new IllegalArgumentException("update is null");
}
if (update.getId() == null) {
throw new IllegalArgumentException("update is incomplete");
}
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
// Resolve the entity to ensure we're working with the merged entity, and to ensure it's
// already been created.
Product entity = this.ownerProductCurator.getProductById(owner, update.getId());
if (entity == null) {
// If we're doing an exclusive update, this should be an error condition
throw new IllegalStateException("Product has not yet been created");
}
// Make sure we actually have a change to apply
if (!entity.isChangedBy(update)) {
return entity;
}
log.debug("Applying product update for org: {} => {}, {}", update, entity, owner);
Product updated = this.applyProductChanges((Product) entity.clone(), update, owner);
// Check for newer versions of the same product. We want to try to dedupe as much data as we
// can, and if we have a newer version of the product (which matches the version provided by
// the caller), we can just point the given orgs to the new product instead of giving them
// their own version.
// This is probably going to be a very expensive operation, though.
List<Product> alternateVersions = this.ownerProductCurator.getProductsByVersions(
owner, Collections.<String, Integer>singletonMap(updated.getId(), updated.getEntityVersion()))
.list();
log.debug("Checking {} alternate product versions", alternateVersions.size());
for (Product alt : alternateVersions) {
if (alt.equals(updated)) {
log.debug("Converging product with existing: {} => {}", updated, alt);
this.ownerProductCurator.updateOwnerProductReferences(owner,
Collections.<String, String>singletonMap(entity.getUuid(), alt.getUuid()));
if (regenerateEntitlementCerts) {
this.entitlementCertGenerator.regenerateCertificatesOf(
Arrays.asList(owner), Arrays.asList(alt), true
);
}
return alt;
}
}
// Temporarily (?) disabled. If we ever move to clustered caching (rather than per-instance
// caching, this branch should be re-enabled.
/*
// No alternate versions with which to converge. Check if we can do an in-place update instead
if (this.ownerProductCurator.getOwnerCount(updated) < 2) {
log.debug("Applying in-place update to product: {}", updated);
updated = this.productCurator.merge(this.applyProductChanges(entity, update, owner));
if (regenerateEntitlementCerts) {
this.entitlementCertGenerator.regenerateCertificatesOf(
Arrays.asList(owner), Arrays.asList(updated), true
);
}
return updated;
}
*/
// Product is shared by multiple owners; we have to diverge here
log.debug("Forking product and applying update: {}", updated);
// Clear the UUID so Hibernate doesn't think our copy is a detached entity
updated.setUuid(null);
updated = this.productCurator.create(updated);
this.ownerProductCurator.updateOwnerProductReferences(owner,
Collections.<String, String>singletonMap(entity.getUuid(), updated.getUuid()));
if (regenerateEntitlementCerts) {
this.entitlementCertGenerator.regenerateCertificatesOf(
Arrays.asList(owner), Arrays.asList(updated), true
);
}
return updated;
}
/**
* Creates or updates products from the given products DTOs, using the provided content for
* content lookup and resolution.
* <p></p>
* The product DTOs provided in the given map should be mapped by the product's Red Hat ID. If
* the mappings are incorrect or inconsistent, the result of this method is undefined.
*
* @param owner
* The owner for which to import the given product
*
* @param productData
* A mapping of Red Hat product ID to product DTOs to import
*
* @param importedContent
* A mapping of Red Hat content ID to content instances to use to lookup and resolve content
* references on the provided product DTOs.
*
* @return
* A mapping of Red Hat content ID to content entities representing the imported content
*/
@Transactional
public ImportResult<Product> importProducts(Owner owner, Map<String, ProductData> productData,
Map<String, Content> importedContent) {
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
ImportResult<Product> importResult = new ImportResult<Product>();
if (productData == null || productData.isEmpty()) {
// Nothing to import
return importResult;
}
Map<String, Product> skippedProducts = importResult.getSkippedEntities();
Map<String, Product> createdProducts = importResult.getCreatedEntities();
Map<String, Product> updatedProducts = importResult.getUpdatedEntities();
Map<String, Integer> productVersions = new HashMap<String, Integer>();
Map<String, Product> sourceProducts = new HashMap<String, Product>();
Map<String, List<Product>> existingVersions = new HashMap<String, List<Product>>();
List<OwnerProduct> ownerProductBuffer = new LinkedList<OwnerProduct>();
// - Divide imported products into sets of updates and creates
for (Product product : this.ownerProductCurator.getProductsByIds(owner, productData.keySet())) {
ProductData update = productData.get(product.getId());
if (!product.isChangedBy(update)) {
// This product won't be changing, so we'll just pretend it's not being imported at all
skippedProducts.put(product.getId(), product);
continue;
}
sourceProducts.put(product.getId(), product);
product = this.applyProductChanges((Product) product.clone(), update, importedContent);
updatedProducts.put(product.getId(), product);
productVersions.put(product.getId(), product.getEntityVersion());
}
for (ProductData update : productData.values()) {
if (!skippedProducts.containsKey(update.getId()) &&
!updatedProducts.containsKey(update.getId())) {
// Ensure the product is minimally populated
if (update.getId() == null || update.getName() == null) {
throw new IllegalStateException("Product data is incomplete: " + update);
}
Product product = new Product(update.getId(), update.getName());
product = this.applyProductChanges(product, update, importedContent);
createdProducts.put(product.getId(), product);
productVersions.put(product.getId(), product.getEntityVersion());
}
}
for (Product alt : this.ownerProductCurator.getProductsByVersions(owner, productVersions)) {
List<Product> alternates = existingVersions.get(alt.getId());
if (alternates == null) {
alternates = new LinkedList<Product>();
existingVersions.put(alt.getId(), alternates);
}
alternates.add(alt);
}
productVersions.clear();
productVersions = null;
// We're about to start modifying the maps, so we need to clone the created set before we
// start adding the update forks to it.
Map<String, Product> stagedEntities = new HashMap<String, Product>(createdProducts);
// Process the created group...
// Check our created set for existing versions:
// - If there's an existing version, we'll remove the staged entity from the creation
// set, and stage an owner-product mapping for the existing version
// - Otherwise, we'll stage the new entity for persistence by leaving it in the created
// set, and stage an owner-product mapping to the new entity
Iterator<Product> iterator = stagedEntities.values().iterator();
createdProductLoop: while (iterator.hasNext()) {
Product created = iterator.next();
List<Product> alternates = existingVersions.get(created.getId());
if (alternates != null) {
for (Product alt : alternates) {
if (created.equals(alt)) {
ownerProductBuffer.add(new OwnerProduct(owner, alt));
createdProducts.put(alt.getId(), alt);
iterator.remove();
continue createdProductLoop;
}
}
}
ownerProductBuffer.add(new OwnerProduct(owner, created));
}
// Process the updated group...
// Check our updated set for existing versions:
// - If there's an existing versions, we'll update the update set to point to the existing
// version
// - Otherwise, we need to stage the updated entity for persistence
updatedProductLoop: for (Map.Entry<String, Product> entry : updatedProducts.entrySet()) {
Product updated = entry.getValue();
List<Product> alternates = existingVersions.get(updated.getId());
if (alternates != null) {
for (Product alt : alternates) {
if (updated.equals(alt)) {
updated = alt;
entry.setValue(alt);
continue updatedProductLoop;
}
}
}
// We need to stage the updated entity for persistence. We'll reuse the now-empty
// createdProducts map for this.
updated.setUuid(null);
stagedEntities.put(updated.getId(), updated);
}
// Persist our staged entities
// We probably don't want to evict the products yet, as they'll appear as unmanaged if
// they're used later. However, the join objects can be evicted safely since they're only
// really used here.
this.productCurator.saveAll(stagedEntities.values(), true, false);
this.ownerProductCurator.saveAll(ownerProductBuffer, true, true);
// Perform bulk reference update
Map<String, String> productUuidMap = new HashMap<String, String>();
for (Product update : updatedProducts.values()) {
Product source = sourceProducts.get(update.getId());
productUuidMap.put(source.getUuid(), update.getUuid());
}
this.ownerProductCurator.updateOwnerProductReferences(owner, productUuidMap);
// Return
return importResult;
}
/**
* Removes the specified product from the given owner. If the product is in use by multiple
* owners, the product will not actually be deleted, but, instead, will simply by removed from
* the given owner's visibility.
*
* @param owner
* The owner for which to remove the product
*
* @param entity
* The product entity to remove
*
* @throws IllegalStateException
* if this method is called with an entity does not exist in the backing database for the given
* owner, or if the product is currently in use by one or more subscriptions/pools
*
* @throws IllegalArgumentException
* if entity or owner is null
*/
public void removeProduct(Owner owner, Product entity) {
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
if (entity == null) {
throw new IllegalArgumentException("entity is null");
}
// This has to fetch a new instance, or we'll be unable to compare the objects
Product existing = this.ownerProductCurator.getProductById(owner, entity.getId());
if (existing == null) {
// If we're doing an exclusive update, this should be an error condition
throw new IllegalStateException("Product has not yet been created");
}
this.removeProductsByUuids(owner, Arrays.asList(existing.getUuid()));
}
/**
* Removes all products from the specified owner. Products which are shared will have any
* references to the owner removed, while unshared products will be deleted entirely.
*
* @param owner
* The owner from which to remove all products
*
* @throws IllegalArgumentException
* if owner is null
*/
@Transactional
public void removeAllProducts(Owner owner) {
this.removeProductsByUuids(owner, this.ownerProductCurator.getProductUuidsByOwner(owner));
}
/**
* Removes the specified products from the given owner. Products which are shared will have any
* references to the owner removed, while unshared products will be deleted entirely.
*
* @param owner
* The owner from which to remove products
*
* @param products
* A collection of products to remove from the owner
*
* @throws IllegalArgumentException
* if owner is null
*
* @throws IllegalStateException
* if any of the given products are still associated with one or more subscriptions
*/
public void removeProducts(Owner owner, Collection<Product> products) {
if (products != null && !products.isEmpty()) {
Map<String, Product> productMap = new HashMap<String, Product>();
for (Product product : products) {
productMap.put(product.getUuid(), product);
}
this.removeProductsByUuids(owner, productMap.keySet());
}
}
/**
* Removes the specified products from the given owner. Products which are shared will have any
* references to the owner removed, while unshared products will be deleted entirely.
*
* @param owner
* The owner from which to remove products
*
* @param productUuids
* A collection of product UUIDs representing the products to remove from the owner
*
* @throws IllegalArgumentException
* if owner is null
*
* @throws IllegalStateException
* if any of the given products are still associated with one or more subscriptions
*/
public void removeProductsByUuids(Owner owner, Collection<String> productUuids) {
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
if (productUuids != null && !productUuids.isEmpty()) {
// Remove owner references to all the products. This will leave the products orphaned,
// to be eventually deleted by the orphan removal job
this.ownerProductCurator.removeOwnerProductReferences(owner, productUuids);
}
}
/**
* Applies the changes from the given DTO to the specified entity
*
* @param entity
* The entity to modify
*
* @param update
* The DTO containing the modifications to apply
*
* @param content
* A mapping of Red Hat content ID to content entities to use for content resolution
*
* @throws IllegalArgumentException
* if entity, update or owner is null
*
* @return
* The updated product entity
*/
private Product applyProductChanges(Product entity, ProductData update, Owner owner) {
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
Map<String, Content> contentMap = new HashMap<String, Content>();
if (update.getProductContent() != null) {
// Grab all of the content objects at once so we don't hit the DB a few thousand times
// per update
for (ProductContentData pcd : update.getProductContent()) {
if (pcd == null || pcd.getContent() == null || pcd.getContent().getId() == null) {
throw new IllegalStateException("product content is null or incomplete");
}
// We'll populate the map later...
contentMap.put(pcd.getContent().getId(), null);
}
for (Content content : this.ownerContentCurator.getContentByIds(owner, contentMap.keySet())) {
contentMap.put(content.getId(), content);
}
}
return this.applyProductChanges(entity, update, contentMap);
}
/**
* Applies the changes from the given DTO to the specified entity
*
* @param entity
* The entity to modify
*
* @param update
* The DTO containing the modifications to apply
*
* @param content
* A mapping of Red Hat content ID to content entities to use for content resolution
*
* @throws IllegalArgumentException
* if entity, update or owner is null
*
* @return
* The updated product entity
*/
private Product applyProductChanges(Product entity, ProductData update, Map<String, Content> contentMap) {
// TODO:
// Eventually content should be considered a property of products (ala attributes), so we
// don't have to do this annoying, nested projection and owner passing. Also, it would
// solve the issue of forcing content to have only one instance per owner and this logic
// could live in Product, where it belongs.
if (entity == null) {
throw new IllegalArgumentException("entity is null");
}
if (update == null) {
throw new IllegalArgumentException("update is null");
}
if (contentMap == null) {
throw new IllegalArgumentException("contentMap is null");
}
if (update.getName() != null) {
entity.setName(update.getName());
}
if (update.getMultiplier() != null) {
entity.setMultiplier(update.getMultiplier());
}
if (update.getAttributes() != null) {
entity.setAttributes(update.getAttributes());
}
if (update.getProductContent() != null) {
Collection<ProductContent> productContent = new LinkedList<ProductContent>();
// Sort the existing ProductContent so we aren't iterating on it several times.
// TODO: Remove this if/when product content is stored as a map on products.
Map<String, ProductContent> existingLinks = new HashMap<String, ProductContent>();
for (ProductContent pc : entity.getProductContent()) {
existingLinks.put(pc.getContent().getId(), pc);
}
// Actually process our list of content...
for (ProductContentData pcd : update.getProductContent()) {
if (pcd == null) {
throw new IllegalStateException("Product data contains a null product-content mapping: " +
update);
}
ContentData contentData = pcd.getContent();
if (contentData == null || contentData.getId() == null) {
// This should only happen if something alters a ProductContentData object
// after adding it to the ProductData. This is very bad.
throw new IllegalStateException("Product data contains an incomplete product-content " +
"mapping: " + update);
}
ProductContent existingLink = existingLinks.get(contentData.getId());
Content content = contentMap.get(contentData.getId());
if (content == null) {
// Content doesn't exist yet -- it should have been created already
throw new IllegalStateException("product references content which does not exist: " +
contentData);
}
if (existingLink == null) {
existingLink = new ProductContent(
entity, content, pcd.isEnabled() != null ? pcd.isEnabled() : false
);
}
else {
existingLink.setContent(content);
if (pcd.isEnabled() != null) {
existingLink.setEnabled(pcd.isEnabled());
}
}
productContent.add(existingLink);
}
entity.setProductContent(productContent);
}
if (update.getDependentProductIds() != null) {
entity.setDependentProductIds(update.getDependentProductIds());
}
if (update.isLocked() != null) {
entity.setLocked(update.isLocked());
}
return entity;
}
/**
* Adds the specified content to the product, effective for the given owner. If the product is
* already mapped to one of the content instances provided, the mapping will be updated to
* reflect the configuration of the mapping provided.
*
* @param product
* The product to which content should be added
*
* @param content
* A collection of ProductContent instances referencing the content to add to the product
*
* @param owner
* The owner of the product to update
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the product should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @return
* the updated product entity, or a new product entity
*/
public Product addContentToProduct(Product product, Collection<ProductContent> content, Owner owner,
boolean regenerateEntitlementCerts) {
if (this.ownerProductCurator.isProductMappedToOwner(product, owner)) {
ProductData update = product.toDTO();
boolean changed = false;
for (ProductContent add : content) {
changed |= update.addProductContent(add);
}
if (changed) {
product = this.updateProduct(update, owner, regenerateEntitlementCerts);
}
}
return product;
}
/**
* Removes the specified content from the given product for a single owner. The changes made to
* the product may result in the convergence or divergence of product versions.
*
* @param product
* the product from which to remove content
*
* @param content
* the content to remove
*
* @param owner
* the owner for which the change should take effect
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the product should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @return
* the updated product instance
*/
public Product removeProductContent(Product product, Collection<Content> content, Owner owner,
boolean regenerateEntitlementCerts) {
if (this.ownerProductCurator.isProductMappedToOwner(product, owner)) {
ProductData update = product.toDTO();
boolean changed = false;
for (Content remove : content) {
changed |= update.removeContent(remove);
}
if (changed) {
product = this.updateProduct(update, owner, regenerateEntitlementCerts);
}
}
return product;
}
}