diff --git a/java/google/registry/dns/writer/clouddns/CloudDnsWriterModule.java b/java/google/registry/dns/writer/clouddns/CloudDnsWriterModule.java index aed305223..53ef924fe 100644 --- a/java/google/registry/dns/writer/clouddns/CloudDnsWriterModule.java +++ b/java/google/registry/dns/writer/clouddns/CloudDnsWriterModule.java @@ -61,6 +61,14 @@ public final class CloudDnsWriterModule { return writer; } + // TODO(b/71607306): Remove once large zone resigning is tested + @Provides + @IntoMap + @StringKey(MultiplyingCloudDnsWriter.NAME) + static DnsWriter provideMultiplyingWriter(MultiplyingCloudDnsWriter writer) { + return writer; + } + @Provides @IntoSet @Named("dnsWriterNames") @@ -68,6 +76,14 @@ public final class CloudDnsWriterModule { return CloudDnsWriter.NAME; } + // TODO(b/71607306): Remove once large zone resigning is tested + @Provides + @IntoSet + @Named("dnsWriterNames") + static String provideMultiplyingWriterName() { + return MultiplyingCloudDnsWriter.NAME; + } + @Provides @Named("cloudDns") static RateLimiter provideRateLimiter() { diff --git a/java/google/registry/dns/writer/clouddns/MultiplyingCloudDnsWriter.java b/java/google/registry/dns/writer/clouddns/MultiplyingCloudDnsWriter.java new file mode 100644 index 000000000..44dac91a4 --- /dev/null +++ b/java/google/registry/dns/writer/clouddns/MultiplyingCloudDnsWriter.java @@ -0,0 +1,464 @@ +// Copyright 2017 The Nomulus 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.dns.writer.clouddns; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static google.registry.model.EppResourceUtils.loadByForeignKey; + +import com.google.api.client.googleapis.json.GoogleJsonError.ErrorInfo; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.services.dns.Dns; +import com.google.api.services.dns.model.Change; +import com.google.api.services.dns.model.ResourceRecordSet; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.google.common.net.InternetDomainName; +import com.google.common.util.concurrent.RateLimiter; +import google.registry.config.RegistryConfig.Config; +import google.registry.dns.writer.BaseDnsWriter; +import google.registry.dns.writer.DnsWriter; +import google.registry.dns.writer.DnsWriterZone; +import google.registry.model.domain.DomainResource; +import google.registry.model.domain.secdns.DelegationSignerData; +import google.registry.model.host.HostResource; +import google.registry.model.registry.Registries; +import google.registry.util.Clock; +import google.registry.util.Concurrent; +import google.registry.util.FormattingLogger; +import google.registry.util.Retrier; +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Named; +import org.joda.time.Duration; + +/** + * {@link DnsWriter} implementation that artificially multiplies the number of domains in a dns. + * + * This is a temporary writer, that should be removed once large zone resigning has been tested. + * + * It acts just like {@link CloudDnsWriter}, but in addition to registering the requested domain + * with Cloud DNS, it registers 9 more copies of the domain. + * + * The result is a 10x larger zone on the Cloud DNS side than on the nomulus side. + * + * The reason for this is a combination of the following requirements: + * (a) we want to test how Cloud DNS handles large zones (1M+ domain), and we want to find out ASAP + * so they have time to fix any problems we find, and + * (b) we currently found problems in nomulus preventing us from creating domains at a fast enough + * rate to actually have a 1M domain zone. We managed to get up to 300k. + * + * So this temporary Writer will allow us to have a 1M domain zone in Cloud DNS while only creating + * 100k domains in nomulus. + * + * TODO(b/71607306): Remove once large zone resigning is tested + * + * @see Google Cloud DNS Documentation + */ +public class MultiplyingCloudDnsWriter extends BaseDnsWriter { + + /** + * The name of the pricing engine, as used in {@code Registry.dnsWriter}. Remember to change + * the value on affected Registry objects to prevent runtime failures. + */ + public static final String NAME = "MultiplyingCloudDnsWriter"; + + private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass(); + private static final ImmutableSet RETRYABLE_EXCEPTION_REASONS = + ImmutableSet.of("preconditionFailed", "notFound", "alreadyExists"); + + private final Clock clock; + private final RateLimiter rateLimiter; + private final int numThreads; + // TODO(shikhman): This uses @Named("transientFailureRetries") which may not be tuned for this + // application. + private final Retrier retrier; + private final Duration defaultATtl; + private final Duration defaultNsTtl; + private final Duration defaultDsTtl; + private final String projectId; + private final String zoneName; + private final Dns dnsConnection; + private final HashMap> desiredRecords = new HashMap<>(); + + @Inject + MultiplyingCloudDnsWriter( + Dns dnsConnection, + @Config("projectId") String projectId, + @DnsWriterZone String zoneName, + @Config("dnsDefaultATtl") Duration defaultATtl, + @Config("dnsDefaultNsTtl") Duration defaultNsTtl, + @Config("dnsDefaultDsTtl") Duration defaultDsTtl, + @Named("cloudDns") RateLimiter rateLimiter, + @Named("cloudDnsNumThreads") int numThreads, + Clock clock, + Retrier retrier) { + this.dnsConnection = dnsConnection; + this.projectId = projectId; + this.zoneName = zoneName.replace('.', '-'); + this.defaultATtl = defaultATtl; + this.defaultNsTtl = defaultNsTtl; + this.defaultDsTtl = defaultDsTtl; + this.rateLimiter = rateLimiter; + this.clock = clock; + this.retrier = retrier; + this.numThreads = numThreads; + } + + /** Publish the domain and all subordinate hosts. */ + @Override + public void publishDomain(String domainName) { + // Load the target domain. Note that it can be null if this domain was just deleted. + Optional domainResource = + Optional.ofNullable(loadByForeignKey(DomainResource.class, domainName, clock.nowUtc())); + + // Canonicalize name + Set absoluteDomainNames = multiplyAbsoluteName(getAbsoluteHostName(domainName)); + + // Return early if no DNS records should be published. + // desiredRecordsBuilder is populated with an empty set to indicate that all existing records + // should be deleted. + if (!domainResource.isPresent() || !domainResource.get().shouldPublishToDns()) { + absoluteDomainNames.forEach( + absoluteDomainName -> desiredRecords.put(absoluteDomainName, ImmutableSet.of())); + return; + } + + for (String absoluteDomainName : absoluteDomainNames) { + ImmutableSet.Builder domainRecords = new ImmutableSet.Builder<>(); + + // Construct DS records (if any). + Set dsData = domainResource.get().getDsData(); + if (!dsData.isEmpty()) { + HashSet dsRrData = new HashSet<>(); + for (DelegationSignerData ds : dsData) { + dsRrData.add(ds.toRrData()); + } + + if (!dsRrData.isEmpty()) { + domainRecords.add( + new ResourceRecordSet() + .setName(absoluteDomainName) + .setTtl((int) defaultDsTtl.getStandardSeconds()) + .setType("DS") + .setKind("dns#resourceRecordSet") + .setRrdatas(ImmutableList.copyOf(dsRrData))); + } + } + + // Construct NS records (if any). + Set nameserverData = domainResource.get().loadNameserverFullyQualifiedHostNames(); + if (!nameserverData.isEmpty()) { + HashSet nsRrData = new HashSet<>(); + for (String hostName : nameserverData) { + nsRrData.add(getAbsoluteHostName(hostName)); + + // Construct glue records for subordinate NS hostnames (if any) + if (hostName.endsWith(domainName)) { + publishSubordinateHost(hostName); + } + } + + if (!nsRrData.isEmpty()) { + domainRecords.add( + new ResourceRecordSet() + .setName(absoluteDomainName) + .setTtl((int) defaultNsTtl.getStandardSeconds()) + .setType("NS") + .setKind("dns#resourceRecordSet") + .setRrdatas(ImmutableList.copyOf(nsRrData))); + } + } + + desiredRecords.put(absoluteDomainName, domainRecords.build()); + logger.finefmt( + "Will write %s records for domain %s", domainRecords.build().size(), absoluteDomainName); + } + } + + private void publishSubordinateHost(String hostName) { + logger.infofmt("Publishing glue records for %s", hostName); + // Canonicalize name + String absoluteHostName = getAbsoluteHostName(hostName); + + // Load the target host. Note that it can be null if this host was just deleted. + // desiredRecords is populated with an empty set to indicate that all existing records + // should be deleted. + Optional host = + Optional.ofNullable(loadByForeignKey(HostResource.class, hostName, clock.nowUtc())); + + // Return early if the host is deleted. + if (!host.isPresent()) { + desiredRecords.put(absoluteHostName, ImmutableSet.of()); + return; + } + + ImmutableSet.Builder domainRecords = new ImmutableSet.Builder<>(); + + // Construct A and AAAA records (if any). + HashSet aRrData = new HashSet<>(); + HashSet aaaaRrData = new HashSet<>(); + for (InetAddress ip : host.get().getInetAddresses()) { + if (ip instanceof Inet4Address) { + aRrData.add(ip.toString()); + } else { + checkArgument(ip instanceof Inet6Address); + aaaaRrData.add(ip.toString()); + } + } + + if (!aRrData.isEmpty()) { + domainRecords.add( + new ResourceRecordSet() + .setName(absoluteHostName) + .setTtl((int) defaultATtl.getStandardSeconds()) + .setType("A") + .setKind("dns#resourceRecordSet") + .setRrdatas(ImmutableList.copyOf(aRrData))); + } + + if (!aaaaRrData.isEmpty()) { + domainRecords.add( + new ResourceRecordSet() + .setName(absoluteHostName) + .setTtl((int) defaultATtl.getStandardSeconds()) + .setType("AAAA") + .setKind("dns#resourceRecordSet") + .setRrdatas(ImmutableList.copyOf(aaaaRrData))); + } + + desiredRecords.put(absoluteHostName, domainRecords.build()); + } + + /** + * Publish A/AAAA records to Cloud DNS. + * + *

Cloud DNS has no API for glue -- A/AAAA records are automatically matched to their + * corresponding NS records to serve glue. + */ + @Override + public void publishHost(String hostName) { + // Get the superordinate domain name of the host. + InternetDomainName host = InternetDomainName.from(hostName); + Optional tld = Registries.findTldForName(host); + + // Host not managed by our registry, no need to update DNS. + if (!tld.isPresent()) { + logger.severefmt("publishHost called for invalid host %s", hostName); + return; + } + + // Extract the superordinate domain name. The TLD and host may have several dots so this + // must calculate a sublist. + ImmutableList hostParts = host.parts(); + ImmutableList tldParts = tld.get().parts(); + ImmutableList domainParts = + hostParts.subList(hostParts.size() - tldParts.size() - 1, hostParts.size()); + String domain = Joiner.on(".").join(domainParts); + + // Refresh the superordinate domain, since we shouldn't be publishing glue records if we are not + // authoritative for the superordinate domain. + publishDomain(domain); + } + + /** + * Sync changes in a zone requested by publishDomain and publishHost to Cloud DNS. + * + *

The zone for the TLD must exist first in Cloud DNS and must be DNSSEC enabled. + * + *

The relevant resource records (including those of all subordinate hosts) will be retrieved + * and the operation will be retried until the state of the retrieved zone data matches the + * representation built via this writer. + */ + @Override + protected void commitUnchecked() { + ImmutableMap> desiredRecordsCopy = + ImmutableMap.copyOf(desiredRecords); + retrier.callWithRetry(() -> mutateZone(desiredRecordsCopy), ZoneStateException.class); + logger.info("Wrote to Cloud DNS"); + } + + /** + * Returns the glue records for in-bailiwick nameservers for the given domain+records. + */ + private Stream filterGlueRecords(String domainName, Stream records) { + return records + .filter(record -> record.getType().equals("NS")) + .flatMap(record -> record.getRrdatas().stream()) + .filter(hostName -> hostName.endsWith(domainName) && !hostName.equals(domainName)); + } + + /** + * Mutate the zone with the provided {@code desiredRecords}. + */ + @VisibleForTesting + void mutateZone(ImmutableMap> desiredRecords) { + // Fetch all existing records for names that this writer is trying to modify + ImmutableSet.Builder flattenedExistingRecords = new ImmutableSet.Builder<>(); + + // First, fetch the records for the given domains + Map> domainRecords = + getResourceRecordsForDomains(desiredRecords.keySet()); + + // add the records to the list of exiting records + domainRecords.values().forEach(flattenedExistingRecords::addAll); + + // Get the glue record host names from the given records + ImmutableSet hostsToRead = + domainRecords + .entrySet() + .stream() + .flatMap(entry -> filterGlueRecords(entry.getKey(), entry.getValue().stream())) + .collect(toImmutableSet()); + + // Then fetch and add the records for these hosts + getResourceRecordsForDomains(hostsToRead).values().forEach(flattenedExistingRecords::addAll); + + // Flatten the desired records into one set. + ImmutableSet.Builder flattenedDesiredRecords = new ImmutableSet.Builder<>(); + desiredRecords.values().forEach(flattenedDesiredRecords::addAll); + + // Delete all existing records and add back the desired records + updateResourceRecords(flattenedDesiredRecords.build(), flattenedExistingRecords.build()); + } + + /** + * Fetch the {@link ResourceRecordSet}s for the given domain names under this zone. + * + *

The provided domain should be in absolute form. + */ + private Map> getResourceRecordsForDomains( + Set domainNames) { + logger.finefmt("Fetching records for %s", domainNames); + // As per Concurrent.transform() - if numThreads or domainNames.size() < 2, it will not use + // threading. + return ImmutableMap.copyOf( + Concurrent.transform( + domainNames, + numThreads, + domainName -> + new SimpleImmutableEntry<>(domainName, getResourceRecordsForDomain(domainName)))); + } + + /** + * Fetch the {@link ResourceRecordSet}s for the given domain name under this zone. + * + *

The provided domain should be in absolute form. + */ + private List getResourceRecordsForDomain(String domainName) { + // TODO(b/70217860): do we want to use a retrier here? + try { + Dns.ResourceRecordSets.List listRecordsRequest = + dnsConnection.resourceRecordSets().list(projectId, zoneName).setName(domainName); + + rateLimiter.acquire(); + return listRecordsRequest.execute().getRrsets(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Update {@link ResourceRecordSet}s under this zone. + * + *

This call should be used in conjunction with {@link #getResourceRecordsForDomains} in a + * get-and-set retry loop. + * + *

See {@link "https://cloud.google.com/dns/troubleshooting"} for a list of errors produced by + * the Google Cloud DNS API. + * + * @throws ZoneStateException if the operation could not be completely successfully because the + * records to delete do not exist, already exist or have been modified with different + * attributes since being queried. + */ + private void updateResourceRecords( + ImmutableSet additions, ImmutableSet deletions) { + // Find records that are both in additions and deletions, so we can remove them from both before + // requesting the change. This is mostly for optimization reasons - not doing so doesn't affect + // the result. + ImmutableSet intersection = + Sets.intersection(additions, deletions).immutableCopy(); + logger.infofmt( + "There are %s common items out of the %s items in 'additions' and %s items in 'deletions'", + intersection.size(), additions.size(), deletions.size()); + // Exit early if we have nothing to update - dnsConnection doesn't work on empty changes + if (additions.equals(deletions)) { + logger.infofmt("Returning early because additions is the same as deletions"); + return; + } + Change change = + new Change() + .setAdditions(ImmutableList.copyOf(Sets.difference(additions, intersection))) + .setDeletions(ImmutableList.copyOf(Sets.difference(deletions, intersection))); + + rateLimiter.acquire(); + try { + dnsConnection.changes().create(projectId, zoneName, change).execute(); + } catch (GoogleJsonResponseException e) { + List errors = e.getDetails().getErrors(); + // We did something really wrong here, just give up and re-throw + if (errors.size() > 1) { + throw new RuntimeException(e); + } + String errorReason = errors.get(0).getReason(); + + if (RETRYABLE_EXCEPTION_REASONS.contains(errorReason)) { + throw new ZoneStateException(errorReason); + } else { + throw new RuntimeException(e); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the presentation format ending in a dot used for an absolute hostname. + * + * @param hostName the fully qualified hostname + */ + private static String getAbsoluteHostName(String hostName) { + return hostName.endsWith(".") ? hostName : hostName + "."; + } + + private static ImmutableSet multiplyAbsoluteName(String absoluteName) { + return IntStream.range(0, 10) + .mapToObj(i -> i == 0 ? absoluteName : String.format("%d-%s", i, absoluteName)) + .collect(ImmutableSet.toImmutableSet()); + } + + /** Zone state on Cloud DNS does not match the expected state. */ + static class ZoneStateException extends RuntimeException { + public ZoneStateException(String reason) { + super("Zone state on Cloud DNS does not match the expected state: " + reason); + } + } +}