/**
* 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.ContentCurator;
import org.candlepin.model.Owner;
import org.candlepin.model.OwnerContent;
import org.candlepin.model.OwnerContentCurator;
import org.candlepin.model.Product;
import org.candlepin.model.ProductCurator;
import org.candlepin.model.dto.ContentData;
import org.candlepin.model.dto.ProductData;
import org.candlepin.model.dto.ProductContentData;
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.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The ContentManager class provides methods for creating, updating and removing content instances
* which also perform the cleanup and general maintenance necessary to keep content 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
* content, to ensure content versioning and linking is handled properly.
*/
public class ContentManager {
private static Logger log = LoggerFactory.getLogger(ContentManager.class);
private ContentCurator contentCurator;
private EntitlementCertificateGenerator entitlementCertGenerator;
private OwnerContentCurator ownerContentCurator;
private ProductCurator productCurator;
private ProductManager productManager;
@Inject
public ContentManager(
ContentCurator contentCurator, EntitlementCertificateGenerator entitlementCertGenerator,
OwnerContentCurator ownerContentCurator, ProductCurator productCurator,
ProductManager productManager) {
this.contentCurator = contentCurator;
this.entitlementCertGenerator = entitlementCertGenerator;
this.ownerContentCurator = ownerContentCurator;
this.productCurator = productCurator;
this.productManager = productManager;
}
/**
* Creates a new Content for the given owner, using the data in the provided DTO.
*
* @param contentData
* A content DTO representing the content to create
*
* @param owner
* The owner for which to create the content
*
* @throws IllegalArgumentException
* if contentData is null or incomplete, or owner is null
*
* @throws IllegalStateException
* if the contentData represents content that already exists
*
* @return
* a new Content instance representing the specified content for the given owner
*/
public Content createContent(ContentData contentData, Owner owner) {
if (contentData == null) {
throw new IllegalArgumentException("contentData is null");
}
if (contentData.getId() == null || contentData.getType() == null || contentData.getLabel() == null ||
contentData.getName() == null || contentData.getVendor() == null) {
throw new IllegalArgumentException("contentData is incomplete");
}
if (this.ownerContentCurator.contentExists(owner, contentData.getId())) {
throw new IllegalStateException("content has already been created: " + contentData.getId());
}
// TODO: more validation here...?
Content entity = new Content(contentData.getId());
this.applyContentChanges(entity, contentData);
log.debug("Creating new content for org: {}, {}", entity, owner);
// Check if we have an alternate version we can use instead.
List<Content> alternateVersions = this.ownerContentCurator.getContentByVersions(
owner, Collections.<String, Integer>singletonMap(entity.getId(), entity.getEntityVersion()))
.list();
log.debug("Checking {} alternate content versions", alternateVersions.size());
for (Content alt : alternateVersions) {
if (alt.equals(entity)) {
// If we're "creating" a content, we shouldn't have any other object references to
// update for this content. Instead, we'll just add the new owner to the content.
this.ownerContentCurator.mapContentToOwner(alt, owner);
return alt;
}
}
entity = this.contentCurator.create(entity);
this.ownerContentCurator.mapContentToOwner(entity, owner);
return entity;
}
/**
* Updates the specified content instance, creating a new version of the content as necessary.
* The content instance returned by this method is not guaranteed to be the same instance passed
* in. As such, once this method has been called, callers should only use the instance output by
* this method.
*
* @param owner
* The owner for which to update the content
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the content should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @throws IllegalStateException
* if the given content update references a content that does not exist for the specified owner
*
* @throws IllegalArgumentException
* if either the provided content entity or owner are null
*
* @return
* the updated content entity, or a new content entity
*/
@Transactional
public Content updateContent(ContentData 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.
// TODO: FIXME:
// There's a bug here where if changes are applied to an entity's collections, and then
// this method is called, the check below will cause the changes to be persisted.
// This needs to be re-written to use DTOs as the primary source of entity creation, rather
// than a bolted-on utility method.
// If we never edit the entity directly, however, this is safe.
Content entity = this.ownerContentCurator.getContentById(owner, update.getId());
if (entity == null) {
// If we're doing an exclusive update, this should be an error condition
throw new IllegalStateException("Content has not yet been created");
}
// Make sure we actually have a change to apply
if (!entity.isChangedBy(update)) {
return entity;
}
log.debug("Applying content update for org: {}, {}", entity, owner);
Content updated = this.applyContentChanges((Content) entity.clone(), update);
List<Content> alternateVersions = this.ownerContentCurator.getContentByVersions(
owner, Collections.<String, Integer>singletonMap(updated.getId(), updated.getEntityVersion()))
.list();
log.debug("Checking {} alternate content versions", alternateVersions.size());
for (Content alt : alternateVersions) {
if (alt.equals(updated)) {
log.debug("Converging product with existing: {} => {}", updated, alt);
// Make sure every product using the old version/entity are updated to use the new one
List<Product> affectedProducts = this.productCurator
.getProductsByContent(owner, Arrays.asList(updated.getId()))
.list();
this.ownerContentCurator.updateOwnerContentReferences(owner,
Collections.<String, String>singletonMap(entity.getUuid(), alt.getUuid()));
log.debug("Updating {} affected products", affectedProducts.size());
ContentData cdata = updated.toDTO();
for (Product product : affectedProducts) {
ProductData pdata = product.toDTO();
log.debug("Updating affected product: {}", product);
// We're taking advantage of the mutable nature of our joining objects.
// Probably not the best idea for long-term maintenance, but it works for now.
ProductContentData pcd = pdata.getProductContent(updated.getId());
if (pcd != null) {
pcd.setContent(cdata);
// Impl note: This should also take care of our entitlement cert regeneration
this.productManager.updateProduct(pdata, owner, regenerateEntitlementCerts);
}
}
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.ownerContentCurator.getOwnerCount(updated) < 2) {
log.debug("Applying in-place update to content: {}", updated);
updated = this.contentCurator.merge(this.applyContentChanges(entity, update, owner));
if (regenerateEntitlementCerts) {
// Every owner with a pool using any of the affected products needs an update.
List<Product> affectedProducts = this.productCurator
.getProductsByContent(Arrays.asList(updated.getUuid()))
.list();
this.entitlementCertGenerator.regenerateCertificatesOf(
Arrays.asList(owner), affectedProducts, true
);
}
return updated;
}
*/
log.debug("Forking content and applying update: {}", updated);
// Get products that currently use this content...
List<Product> affectedProducts = this.productCurator
.getProductsByContent(owner, Arrays.asList(updated.getId()))
.list();
// Clear the UUID so Hibernate doesn't think our copy is a detached entity
updated.setUuid(null);
updated = this.contentCurator.create(updated);
this.ownerContentCurator.updateOwnerContentReferences(owner,
Collections.<String, String>singletonMap(entity.getUuid(), updated.getUuid()));
// Impl note:
// This block is a consequence of products and contents not being strongly related.
log.debug("Updating affected products");
ContentData cdata = updated.toDTO();
for (Product product : affectedProducts) {
log.debug("Updating affected product: {}", product);
// We're taking advantage of the mutable nature of our joining objects.
// Probably not the best idea for long-term maintenance, but it works for now.
ProductData pdata = product.toDTO();
ProductContentData pcd = pdata.getProductContent(updated.getId());
if (pcd != null) {
pcd.setContent(cdata);
// Impl note: This should also take care of our entitlement cert regeneration
this.productManager.updateProduct(pdata, owner, regenerateEntitlementCerts);
}
}
return updated;
}
/**
* Creates or updates content from the given content DTOs, omitting product updates for the
* provided Red Hat product IDs.
* <p></p>
* The content DTOs provided in the given map should be mapped by the content'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 content
*
* @param contentData
* A mapping of Red Hat content ID to content DTOs to import
*
* @param importedProductIds
* A set of Red Hat product IDs specifying products which are being imported and should not be
* updated as part of this import operation
*
* @return
* A mapping of Red Hat content ID to content entities representing the imported content
*/
@SuppressWarnings("checkstyle:methodlength")
@Transactional
public ImportResult<Content> importContent(Owner owner, Map<String, ContentData> contentData,
Set<String> importedProductIds) {
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
ImportResult<Content> importResult = new ImportResult<Content>();
if (contentData == null || contentData.isEmpty()) {
// Nothing to import
return importResult;
}
Map<String, Content> skippedContent = importResult.getSkippedEntities();
Map<String, Content> createdContent = importResult.getCreatedEntities();
Map<String, Content> updatedContent = importResult.getUpdatedEntities();
Map<String, Integer> contentVersions = new HashMap<String, Integer>();
Map<String, Content> sourceContent = new HashMap<String, Content>();
Map<String, List<Content>> existingVersions = new HashMap<String, List<Content>>();
List<OwnerContent> ownerContentBuffer = new LinkedList<OwnerContent>();
// - Divide imported products into sets of updates and creates
for (Content content : this.ownerContentCurator.getContentByIds(owner, contentData.keySet())) {
ContentData update = contentData.get(content.getId());
if (!content.isChangedBy(update)) {
// This content won't be changing, so we'll just pretend it's not being imported at all
skippedContent.put(content.getId(), content);
continue;
}
// Content is coming from an upstream source; lock it so only upstream can make
// further changes to it. If we ever use this method for anything other than
// imports, we'll need to stop doing this.
sourceContent.put(content.getId(), content);
content = this.applyContentChanges((Content) content.clone(), update);
updatedContent.put(content.getId(), content);
contentVersions.put(content.getId(), content.getEntityVersion());
}
for (ContentData update : contentData.values()) {
if (!skippedContent.containsKey(update.getId()) && !updatedContent.containsKey(update.getId())) {
// Ensure content is minimally populated
if (update.getId() == null || update.getType() == null || update.getLabel() == null ||
update.getName() == null || update.getVendor() == null) {
throw new IllegalStateException("Content data is incomplete: " + update);
}
// Content is coming from an upstream source; lock it so only upstream can make
// further changes to it. If we ever use this method for anything other than
// imports, we'll need to stop doing this.
Content content = this.applyContentChanges(new Content(update.getId()), update);
createdContent.put(content.getId(), content);
contentVersions.put(content.getId(), content.getEntityVersion());
}
}
for (Content alt : this.ownerContentCurator.getContentByVersions(owner, contentVersions)) {
List<Content> alternates = existingVersions.get(alt.getId());
if (alternates == null) {
alternates = new LinkedList<Content>();
existingVersions.put(alt.getId(), alternates);
}
alternates.add(alt);
}
contentVersions.clear();
contentVersions = 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, Content> stagedEntities = new HashMap<String, Content>(createdContent);
// 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-content 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-content mapping to the new entity
Iterator<Content> iterator = stagedEntities.values().iterator();
createdContentLoop: while (iterator.hasNext()) {
Content created = iterator.next();
List<Content> alternates = existingVersions.get(created.getId());
if (alternates != null) {
for (Content alt : alternates) {
if (created.equals(alt)) {
ownerContentBuffer.add(new OwnerContent(owner, alt));
createdContent.put(alt.getId(), alt);
iterator.remove();
continue createdContentLoop;
}
}
}
// TODO: If the saveAll below doesn't update these instances with a UUID, we'll have to
// do some work after the flush to ensure we use the instances with UUIDs.
ownerContentBuffer.add(new OwnerContent(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
updatedContentLoop: for (Map.Entry<String, Content> entry : updatedContent.entrySet()) {
Content updated = entry.getValue();
List<Content> alternates = existingVersions.get(updated.getId());
if (alternates != null) {
for (Content alt : alternates) {
if (!updated.getUuid().equals(alt.getUuid()) && updated.equals(alt)) {
updated = alt;
entry.setValue(alt);
continue updatedContentLoop;
}
}
}
// We need to stage the updated entity for persistence. We'll reuse the now-empty
// createdContent map for this.
updated.setUuid(null);
stagedEntities.put(updated.getId(), updated);
}
// Persist our staged entities
// We probably don't want to evict the content 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.contentCurator.saveAll(stagedEntities.values(), true, false);
this.ownerContentCurator.saveAll(ownerContentBuffer, true, true);
// Fetch collection of products affected by this import that aren't being imported themselves
List<Product> affectedProducts = this.productCurator
.getProductsByContent(owner, sourceContent.keySet(), importedProductIds)
.list();
if (affectedProducts != null && !affectedProducts.isEmpty()) {
// Get the collection of content those products use
Map<String, Content> affectedProductsContent = new HashMap<String, Content>();
for (Content content : this.contentCurator.getContentByProducts(affectedProducts)) {
affectedProductsContent.put(content.getId(), content);
}
// Update the content map so it references the updated content
affectedProductsContent.putAll(updatedContent);
Map<String, ProductData> affectedProductData = new HashMap<String, ProductData>();
for (Product product : affectedProducts) {
ProductData productData = product.toDTO();
for (ProductContentData pcd : productData.getProductContent()) {
ContentData cdata = pcd.getContent();
Content content = updatedContent.get(cdata.getId());
if (content != null) {
// We're taking advantage of the mutable nature of our joining objects.
// Probably not the best idea for long-term maintenance, but it works for now.
pcd.setContent(content.toDTO());
}
}
affectedProductData.put(productData.getId(), productData);
}
// Perform a micro-import for these products using the content map we just built
this.productManager.importProducts(owner, affectedProductData, affectedProductsContent);
}
// Perform bulk reference update
Map<String, String> contentUuidMap = new HashMap<String, String>();
for (Content update : updatedContent.values()) {
Content source = sourceContent.get(update.getId());
contentUuidMap.put(source.getUuid(), update.getUuid());
}
this.ownerContentCurator.updateOwnerContentReferences(owner, contentUuidMap);
// Return
return importResult;
}
/**
* Removes the specified content from the given owner.
*
* @param owner
* The owner for which to remove the content
*
* @param entity
* The content entity to remove
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the content 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 entity or owner is null
*/
@Transactional
public void removeContent(Owner owner, Content entity, boolean regenerateEntitlementCerts) {
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
Content existing = this.ownerContentCurator.getContentById(owner, entity.getId());
if (existing == null) {
// If we're doing an exclusive update, this should be an error condition
throw new IllegalStateException("Content has not yet been created");
}
this.removeContentByUuids(owner, Arrays.asList(existing.getUuid()), regenerateEntitlementCerts);
}
/**
* Removes the specified content from the given owner.
*
* @param owner
* The owner for which to remove the content
*
* @param content
* The content entity to remove
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the content should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @throws IllegalArgumentException
* if entity or owner is null
*/
public void removeContent(Owner owner, Collection<Content> content, boolean regenerateEntitlementCerts) {
if (content != null && !content.isEmpty()) {
Map<String, Content> contentMap = new HashMap<String, Content>();
for (Content entity : content) {
contentMap.put(entity.getUuid(), entity);
}
this.removeContentByUuids(owner, contentMap.keySet(), regenerateEntitlementCerts);
}
}
/**
* Removes all content from the given owner.
*
* @param owner
* The owner from which to remove content
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the content should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @throws IllegalArgumentException
* if owner is null
*/
public void removeAllContent(Owner owner, boolean regenerateEntitlementCerts) {
this.removeContentByUuids(owner, this.ownerContentCurator.getContentUuidsByOwner(owner),
regenerateEntitlementCerts);
}
/**
* Removes all content with the provided UUIDs from the given owner.
*
* @param owner
* The owner from which to remove content
*
* @param contentUuids
* A collection of UUIDs representing the content to remove
*
* @param regenerateEntitlementCerts
* Whether or not changes made to the content should trigger the regeneration of entitlement
* certificates for affected consumers
*
* @throws IllegalArgumentException
* if owner is null
*/
public void removeContentByUuids(Owner owner, Collection<String> contentUuids,
boolean regenerateEntitlementCerts) {
if (owner == null) {
throw new IllegalArgumentException("owner is null");
}
if (contentUuids != null && !contentUuids.isEmpty()) {
log.debug("Deleting content with UUIDs: {}", contentUuids);
List<Product> affectedProducts = this.productCurator
.getProductsByContentUuids(owner, contentUuids)
.list();
if (!affectedProducts.isEmpty()) {
log.debug("Updating {} affected products", affectedProducts.size());
if (!(contentUuids instanceof Set)) {
// Convert this to a set so our filtering lookups aren't painfully slow
contentUuids = new HashSet<String>(contentUuids);
}
// Get the collection of content those products use, throwing out the ones we'll be
// removing shortly
Map<String, Content> affectedProductsContent = new HashMap<String, Content>();
for (Content content : this.contentCurator.getContentByProducts(affectedProducts)) {
if (!contentUuids.contains(content.getUuid())) {
affectedProductsContent.put(content.getId(), content);
}
}
// Convert our affectedProducts into DTOs (hoping Hibernate uses its entity cache
// instead of pulling down the content list for each product...)
Map<String, ProductData> affectedProductData = new HashMap<String, ProductData>();
for (Product product : affectedProducts) {
ProductData pdata = product.toDTO();
Iterator<ProductContentData> pcd = pdata.getProductContent().iterator();
while (pcd.hasNext()) {
ContentData cdata = pcd.next().getContent();
if (!affectedProductsContent.containsKey(cdata.getId())) {
pcd.remove();
}
}
affectedProductData.put(pdata.getId(), pdata);
}
// Perform a micro-import for these products using the content map we just built
log.debug("Performing micro-import for products: {}", affectedProductData);
this.productManager.importProducts(owner, affectedProductData, affectedProductsContent);
if (regenerateEntitlementCerts) {
this.entitlementCertGenerator.regenerateCertificatesOf(
Arrays.asList(owner), affectedProducts, true);
}
}
// Remove content references
this.ownerContentCurator.removeOwnerContentReferences(owner, contentUuids);
}
}
/**
* 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
*
* @throws IllegalArgumentException
* if entity, update or owner is null
*
* @return
* The updated product entity
*/
private Content applyContentChanges(Content entity, ContentData update) {
if (entity == null) {
throw new IllegalArgumentException("entity is null");
}
if (update == null) {
throw new IllegalArgumentException("update is null");
}
if (update.getType() != null) {
entity.setType(update.getType());
}
if (update.getLabel() != null) {
entity.setLabel(update.getLabel());
}
if (update.getName() != null) {
entity.setName(update.getName());
}
if (update.getVendor() != null) {
entity.setVendor(update.getVendor());
}
if (update.getContentUrl() != null) {
entity.setContentUrl(update.getContentUrl());
}
if (update.getRequiredTags() != null) {
entity.setRequiredTags(update.getRequiredTags());
}
if (update.getReleaseVersion() != null) {
entity.setReleaseVersion(update.getReleaseVersion());
}
if (update.getGpgUrl() != null) {
entity.setGpgUrl(update.getGpgUrl());
}
if (update.getMetadataExpire() != null) {
entity.setMetadataExpire(update.getMetadataExpire());
}
if (update.getModifiedProductIds() != null) {
entity.setModifiedProductIds(update.getModifiedProductIds());
}
if (update.getArches() != null) {
entity.setArches(update.getArches());
}
if (update.isLocked() != null) {
entity.setLocked(update.isLocked());
}
return entity;
}
}