// Copyright 2016 The Domain Registry Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package google.registry.monitoring.whitebox; import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.Iterables.getOnlyElement; import static com.googlecode.objectify.Key.getKind; import static google.registry.model.EppResourceUtils.isActive; import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.util.DateTimeUtils.END_OF_TIME; import static google.registry.util.DateTimeUtils.START_OF_TIME; import static google.registry.util.DateTimeUtils.earliestOf; import static google.registry.util.DateTimeUtils.isAtOrAfter; import static google.registry.util.DateTimeUtils.isBeforeOrAt; import static google.registry.util.FormattingLogger.getLoggerForCallerClass; import static google.registry.util.PipelineUtils.createJobPath; import static org.joda.time.DateTimeZone.UTC; import com.google.appengine.tools.mapreduce.Input; import com.google.appengine.tools.mapreduce.Mapper; import com.google.appengine.tools.mapreduce.Reducer; import com.google.appengine.tools.mapreduce.ReducerInput; import com.google.appengine.tools.mapreduce.inputs.DatastoreKeyInput; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Sets; import com.googlecode.objectify.Key; import com.googlecode.objectify.Ref; import google.registry.mapreduce.MapreduceRunner; import google.registry.mapreduce.inputs.EppResourceInputs; import google.registry.model.EppResource; import google.registry.model.ImmutableObject; import google.registry.model.contact.ContactResource; import google.registry.model.domain.DomainApplication; import google.registry.model.domain.DomainBase; import google.registry.model.domain.DomainResource; import google.registry.model.domain.GracePeriod; import google.registry.model.host.HostResource; import google.registry.model.index.DomainApplicationIndex; import google.registry.model.index.EppResourceIndex; import google.registry.model.index.ForeignKeyIndex; import google.registry.model.index.ForeignKeyIndex.ForeignKeyContactIndex; import google.registry.model.index.ForeignKeyIndex.ForeignKeyDomainIndex; import google.registry.model.index.ForeignKeyIndex.ForeignKeyHostIndex; import google.registry.model.transfer.TransferData.TransferServerApproveEntity; import google.registry.request.Action; import google.registry.request.Response; import google.registry.util.FormattingLogger; import google.registry.util.NonFinalForTesting; import java.io.Serializable; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import javax.annotation.Nullable; import javax.inject.Inject; import org.joda.time.DateTime; /** * A mapreduce to verify integrity of entities in Datastore. * *

Specifically this validates all of the following system invariants that are expected to hold * true for all {@link EppResource} entities and their related indexes: *

*/ @Action(path = "/_dr/task/verifyEntityIntegrity") public class VerifyEntityIntegrityAction implements Runnable { private static final FormattingLogger logger = getLoggerForCallerClass(); private static final int NUM_SHARDS = 200; @NonFinalForTesting @VisibleForTesting static WhiteboxComponent component = DaggerWhiteboxComponent.create(); private static final ImmutableSet> RESOURCE_CLASSES = ImmutableSet.>of( ForeignKeyDomainIndex.class, DomainApplicationIndex.class, ForeignKeyHostIndex.class, ForeignKeyContactIndex.class, DomainBase.class, HostResource.class, ContactResource.class); static final String KIND_CONTACT_RESOURCE = getKind(ContactResource.class); static final String KIND_CONTACT_INDEX = getKind(ForeignKeyContactIndex.class); static final String KIND_DOMAIN_APPLICATION_INDEX = getKind(DomainApplicationIndex.class); static final String KIND_DOMAIN_BASE_RESOURCE = getKind(DomainBase.class); static final String KIND_DOMAIN_INDEX = getKind(ForeignKeyDomainIndex.class); static final String KIND_EPPRESOURCE_INDEX = getKind(EppResourceIndex.class); static final String KIND_HOST_RESOURCE = getKind(HostResource.class); static final String KIND_HOST_INDEX = getKind(ForeignKeyHostIndex.class); @Inject MapreduceRunner mrRunner; @Inject Response response; @Inject VerifyEntityIntegrityAction() {} @Override public void run() { DateTime scanTime = DateTime.now(UTC); response.sendJavaScriptRedirect(createJobPath(mrRunner .setJobName("Verify entity integrity") .setModuleName("backend") .setDefaultReduceShards(NUM_SHARDS) .runMapreduce( new VerifyEntityIntegrityMapper(scanTime), new VerifyEntityIntegrityReducer(scanTime), getInputs()))); } private static ImmutableSet> getInputs() { ImmutableSet.Builder> builder = new ImmutableSet.Builder>() .add(EppResourceInputs.createIndexInput()); for (Class clazz : RESOURCE_CLASSES) { builder.add(new DatastoreKeyInput(getKind(clazz), NUM_SHARDS)); } return builder.build(); } /** * The mapreduce key that the mapper outputs. Each {@link EppResource} has two different * mapreduce keys that are output for it: one for its specific type (domain, application, host, or * contact), which is used to check {@link ForeignKeyIndex} constraints, and one that is common * for all EppResources, to check {@link EppResourceIndex} constraints. */ private static enum EntityKind { DOMAIN, APPLICATION, CONTACT, HOST, /** * Used to verify 1-to-1 constraints between all types of EPP resources and their indexes. */ EPP_RESOURCE } private static class MapperKey implements Serializable { private static final long serialVersionUID = 3222302549441420932L; /** * The relevant id for this mapper key, which is either the foreign key of the EppResource (for * verifying foreign key indexes) or its repoId (for verifying EppResourceIndexes). */ public String id; public EntityKind kind; public static MapperKey create(EntityKind kind, String id) { MapperKey instance = new MapperKey(); instance.kind = kind; instance.id = id; return instance; } } /** * Mapper that checks validity of references on all resources and outputs key/value pairs used to * check integrity of foreign key entities. */ public static class VerifyEntityIntegrityMapper extends Mapper> { private static final long serialVersionUID = -5413882340475018051L; private final DateTime scanTime; private transient VerifyEntityIntegrityStreamer integrityStreamer; // The integrityStreamer field must be marked as transient so that instances of the Mapper class // can be serialized by the MapReduce framework. Thus, every time is used, lazily construct it // if it doesn't exist yet. private VerifyEntityIntegrityStreamer integrity() { if (integrityStreamer == null) { integrityStreamer = component.verifyEntityIntegrityStreamerFactory().create(scanTime); } return integrityStreamer; } public VerifyEntityIntegrityMapper(DateTime scanTime) { this.scanTime = scanTime; } @Override public final void map(Object keyOrEntity) { try { // We use different inputs, some that return keys and some that return entities. Load any // keys that we get so we're dealing only with entities. if (keyOrEntity instanceof com.google.appengine.api.datastore.Key) { Key key = Key.create((com.google.appengine.api.datastore.Key) keyOrEntity); keyOrEntity = ofy().load().key(key).now(); } mapEntity(keyOrEntity); } catch (Throwable e) { // Log and swallow so that the mapreduce doesn't abort on first error. logger.severefmt(e, "Exception while checking integrity of entity: %s", keyOrEntity); } } private void mapEntity(Object entity) { if (entity instanceof EppResource) { mapEppResource((EppResource) entity); } else if (entity instanceof ForeignKeyIndex) { mapForeignKeyIndex((ForeignKeyIndex) entity); } else if (entity instanceof DomainApplicationIndex) { mapDomainApplicationIndex((DomainApplicationIndex) entity); } else if (entity instanceof EppResourceIndex) { mapEppResourceIndex((EppResourceIndex) entity); } else { throw new IllegalStateException( String.format("Unknown entity in integrity mapper: %s", entity)); } } private void mapEppResource(EppResource resource) { emit(MapperKey.create(EntityKind.EPP_RESOURCE, resource.getRepoId()), Key.create(resource)); if (resource instanceof DomainBase) { DomainBase domainBase = (DomainBase) resource; Key key = Key.create(domainBase); verifyExistence(key, refsToKeys(domainBase.getReferencedContacts())); verifyExistence(key, refsToKeys(domainBase.getNameservers())); verifyExistence(key, domainBase.getTransferData().getServerApproveAutorenewEvent()); verifyExistence(key, domainBase.getTransferData().getServerApproveAutorenewPollMessage()); verifyExistence(key, domainBase.getTransferData().getServerApproveBillingEvent()); verifyExistence(key, FluentIterable .from(domainBase.getTransferData().getServerApproveEntities()) .transform( new Function, Key>() { @SuppressWarnings("unchecked") @Override public Key apply( Key key) { return (Key) key; }}) .toSet()); if (domainBase instanceof DomainApplication) { getContext().incrementCounter("domain applications"); DomainApplication application = (DomainApplication) domainBase; emit( MapperKey.create(EntityKind.APPLICATION, application.getFullyQualifiedDomainName()), Key.create(application)); } else if (domainBase instanceof DomainResource) { getContext().incrementCounter("domain resources"); DomainResource domain = (DomainResource) domainBase; verifyExistence(key, domain.getApplication()); verifyExistence(key, domain.getAutorenewBillingEvent()); for (GracePeriod gracePeriod : domain.getGracePeriods()) { verifyExistence(key, gracePeriod.getOneTimeBillingEvent()); verifyExistence(key, gracePeriod.getRecurringBillingEvent()); } emit( MapperKey.create(EntityKind.DOMAIN, domain.getFullyQualifiedDomainName()), Key.create(domain)); } } else if (resource instanceof ContactResource) { getContext().incrementCounter("contact resources"); ContactResource contact = (ContactResource) resource; emit( MapperKey.create(EntityKind.CONTACT, contact.getContactId()), Key.create(contact)); } else if (resource instanceof HostResource) { getContext().incrementCounter("host resources"); HostResource host = (HostResource) resource; verifyExistence(Key.create(host), host.getSuperordinateDomain()); emit( MapperKey.create(EntityKind.HOST, host.getFullyQualifiedHostName()), Key.create(host)); } else { throw new IllegalStateException( String.format("EppResource with unknown type in integrity mapper: %s", resource)); } } private void mapForeignKeyIndex(ForeignKeyIndex fki) { Key> fkiKey = Key.>create(fki); @SuppressWarnings("cast") EppResource resource = verifyExistence(fkiKey, fki.getReference()); if (resource != null) { // TODO(user): Traverse the chain of pointers to old FKIs instead once they are written. if (isAtOrAfter(fki.getDeletionTime(), resource.getDeletionTime())) { integrity().check( fki.getForeignKey().equals(resource.getForeignKey()), fkiKey, Key.create(resource), "Foreign key index points to EppResource with different foreign key"); } } if (fki instanceof ForeignKeyDomainIndex) { getContext().incrementCounter("domain foreign key indexes"); emit(MapperKey.create(EntityKind.DOMAIN, fki.getForeignKey()), fkiKey); } else if (fki instanceof ForeignKeyContactIndex) { getContext().incrementCounter("contact foreign key indexes"); emit(MapperKey.create(EntityKind.CONTACT, fki.getForeignKey()), fkiKey); } else if (fki instanceof ForeignKeyHostIndex) { getContext().incrementCounter("host foreign key indexes"); emit(MapperKey.create(EntityKind.HOST, fki.getForeignKey()), fkiKey); } else { throw new IllegalStateException( String.format("Foreign key index is of unknown type: %s", fki)); } } private void mapDomainApplicationIndex(DomainApplicationIndex dai) { getContext().incrementCounter("domain application indexes"); Key daiKey = Key.create(dai); for (Ref ref : dai.getReferences()) { DomainApplication application = verifyExistence(daiKey, ref); if (application != null) { integrity().check( dai.getFullyQualifiedDomainName().equals(application.getFullyQualifiedDomainName()), daiKey, Key.create(application), "Domain application index points to application with different domain name"); } emit( MapperKey.create(EntityKind.APPLICATION, dai.getFullyQualifiedDomainName()), daiKey); } } private void mapEppResourceIndex(EppResourceIndex eri) { Key eriKey = Key.create(eri); String eriRepoId = Key.create(eri.getId()).getName(); integrity().check( eriRepoId.equals(eri.getReference().getKey().getName()), eriKey, eri.getReference().getKey(), "EPP resource index id does not match repoId of reference"); verifyExistence(eriKey, eri.getReference()); emit(MapperKey.create(EntityKind.EPP_RESOURCE, eriRepoId), eriKey); getContext().incrementCounter("EPP resource indexes to " + eri.getKind()); } private void verifyExistence(Key source, Set> targets) { Set> missingEntityKeys = Sets.difference(targets, ofy().load().keys(targets).keySet()); integrity().checkOneToMany( missingEntityKeys.isEmpty(), source, targets, "Target entity does not exist"); } @Nullable private E verifyExistence(Key source, @Nullable Ref target) { if (target == null) { return null; } return verifyExistence(source, target.getKey()); } @Nullable private E verifyExistence(Key source, @Nullable Key target) { if (target == null) { return null; } E entity = ofy().load().key(target).now(); integrity().check(entity != null, source, target, "Target entity does not exist"); return entity; } private static ImmutableSet> refsToKeys(Iterable> refs) { return FluentIterable .from(refs) .transform(new Function, Key>() { @Override public Key apply(Ref ref) { return ref.getKey(); }}) .toSet(); } } /** Reducer that checks integrity of foreign key entities. */ public static class VerifyEntityIntegrityReducer extends Reducer, Void> { private static final long serialVersionUID = -151271247606894783L; private final DateTime scanTime; private transient VerifyEntityIntegrityStreamer integrityStreamer; // The integrityStreamer field must be marked as transient so that instances of the Reducer // class can be serialized by the MapReduce framework. Thus, every time is used, lazily // construct it if it doesn't exist yet. private VerifyEntityIntegrityStreamer integrity() { if (integrityStreamer == null) { integrityStreamer = component.verifyEntityIntegrityStreamerFactory().create(scanTime); } return integrityStreamer; } public VerifyEntityIntegrityReducer(DateTime scanTime) { this.scanTime = scanTime; } @SuppressWarnings("unchecked") @Override public void reduce(MapperKey mapperKey, ReducerInput> keys) { try { reduceKeys(mapperKey, keys); } catch (Throwable e) { // Log and swallow so that the mapreduce doesn't abort on first error. logger.severefmt( e, "Exception while checking foreign key integrity constraints for: %s", mapperKey); } } private void reduceKeys( MapperKey mapperKey, ReducerInput> keys) { getContext().incrementCounter("reduced resources " + mapperKey.kind); switch (mapperKey.kind) { case EPP_RESOURCE: checkEppResourceIndexes(keys, mapperKey.id); break; case APPLICATION: checkIndexes( keys, mapperKey.id, KIND_DOMAIN_BASE_RESOURCE, KIND_DOMAIN_APPLICATION_INDEX, false); break; case CONTACT: checkIndexes(keys, mapperKey.id, KIND_CONTACT_RESOURCE, KIND_CONTACT_INDEX, true); break; case DOMAIN: checkIndexes( keys, mapperKey.id, KIND_DOMAIN_BASE_RESOURCE, KIND_DOMAIN_INDEX, true); break; case HOST: checkIndexes(keys, mapperKey.id, KIND_HOST_RESOURCE, KIND_HOST_INDEX, true); break; default: throw new IllegalStateException( String.format("Unknown type of foreign key %s", mapperKey.kind)); } } @SuppressWarnings("unchecked") private void checkEppResourceIndexes( Iterator> keys, String repoId) { List> resources = new ArrayList<>(); List> eppResourceIndexes = new ArrayList<>(); while (keys.hasNext()) { Key key = keys.next(); String kind = key.getKind(); if (kind.equals(KIND_EPPRESOURCE_INDEX)) { eppResourceIndexes.add((Key) key); } else if (kind.equals(KIND_DOMAIN_BASE_RESOURCE) || kind.equals(KIND_CONTACT_RESOURCE) || kind.equals(KIND_HOST_RESOURCE)) { resources.add((Key) key); } else { throw new IllegalStateException( String.format( "While verifying EppResourceIndexes for repoId %s, found key of unknown type: %s", repoId, key)); } } // This is a checkState and not an integrity check because the Datastore schema ensures that // there can't be multiple EppResources with the same repoId. checkState( resources.size() == 1, String.format("Found more than one EppResource for repoId %s: %s", repoId, resources)); if (integrity().check( !eppResourceIndexes.isEmpty(), null, getOnlyElement(resources), "Missing EPP resource index for EPP resource")) { integrity().checkManyToOne( eppResourceIndexes.size() == 1, eppResourceIndexes, getOnlyElement(resources), "Duplicate EPP resource indexes pointing to same resource"); } } @SuppressWarnings("unchecked") private void checkIndexes( Iterator> keys, String foreignKey, String resourceKind, String foreignKeyIndexKind, boolean thereCanBeOnlyOne) { List> resources = new ArrayList<>(); List> foreignKeyIndexes = new ArrayList<>(); while (keys.hasNext()) { Key key = keys.next(); if (key.getKind().equals(resourceKind)) { resources.add((Key) key); } else if (key.getKind().equals(foreignKeyIndexKind)) { foreignKeyIndexes.add((Key) key); } else { throw new IllegalStateException( String.format( "While processing links to foreign key %s of type %s, found unknown key: %s", foreignKey, resourceKind, key)); } } // This is a checkState and not an integrity check because it should truly be impossible to // have multiple foreign key indexes for the same foreign key because of the Datastore schema. checkState( foreignKeyIndexes.size() <= 1, String.format( "Found more than one foreign key index for %s: %s", foreignKey, foreignKeyIndexes)); integrity().check( !foreignKeyIndexes.isEmpty(), foreignKey, resourceKind, "Missing foreign key index for EppResource"); // Skip the case where no resources were found because entity exceptions are already thrown in // the mapper in invalid situations where FKIs point to non-existent entities. if (thereCanBeOnlyOne && !resources.isEmpty()) { verifyOnlyOneActiveResource(resources, getOnlyElement(foreignKeyIndexes)); } } private void verifyOnlyOneActiveResource( List> resources, Key fkiKey) { DateTime oldestActive = END_OF_TIME; DateTime mostRecentInactive = START_OF_TIME; Key mostRecentInactiveKey = null; List> activeResources = new ArrayList<>(); Map, R> allResources = ofy().load().keys(resources); ForeignKeyIndex fki = (ForeignKeyIndex) ofy().load().key(fkiKey).now(); for (Map.Entry, R> entry : allResources.entrySet()) { R resource = entry.getValue(); if (isActive(resource, scanTime)) { activeResources.add(entry.getKey()); oldestActive = earliestOf(oldestActive, resource.getCreationTime()); } else { if (resource.getDeletionTime().isAfter(mostRecentInactive)) { mostRecentInactive = resource.getDeletionTime(); mostRecentInactiveKey = entry.getKey(); } } } if (activeResources.isEmpty()) { integrity().check( fki.getDeletionTime().isEqual(mostRecentInactive), fkiKey, mostRecentInactiveKey, "Foreign key index deletion time not equal to that of most recently deleted resource"); } else { integrity().checkOneToMany( activeResources.size() == 1, fkiKey, activeResources, "Multiple active EppResources with same foreign key"); integrity().check( fki.getDeletionTime().isEqual(END_OF_TIME), fkiKey, null, "Foreign key index has deletion time but active resource exists"); integrity().check( isBeforeOrAt(mostRecentInactive, oldestActive), fkiKey, mostRecentInactiveKey, "Found inactive resource deleted more recently than when active resource was created"); } } } }