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);
}
}
}