diff --git a/java/google/registry/reporting/ActivityReportingQueryBuilder.java b/java/google/registry/reporting/ActivityReportingQueryBuilder.java
index 159a25cb3..61a6e5231 100644
--- a/java/google/registry/reporting/ActivityReportingQueryBuilder.java
+++ b/java/google/registry/reporting/ActivityReportingQueryBuilder.java
@@ -36,12 +36,12 @@ import org.joda.time.format.DateTimeFormatter;
public final class ActivityReportingQueryBuilder implements QueryBuilder {
// Names for intermediary tables for overall activity reporting query.
- static final String ACTIVITY_REPORT_AGGREGATION = "activity_report_aggregation";
- static final String MONTHLY_LOGS = "monthly_logs";
static final String REGISTRAR_OPERATING_STATUS = "registrar_operating_status";
static final String DNS_COUNTS = "dns_counts";
+ static final String MONTHLY_LOGS = "monthly_logs";
static final String EPP_METRICS = "epp_metrics";
static final String WHOIS_COUNTS = "whois_counts";
+ static final String ACTIVITY_REPORT_AGGREGATION = "activity_report_aggregation";
@Inject @Config("projectId") String projectId;
@Inject @Parameter(IcannReportingModule.PARAM_YEAR_MONTH) String yearMonth;
@@ -81,7 +81,6 @@ public final class ActivityReportingQueryBuilder implements QueryBuilder {
.build();
queriesBuilder.put(getTableName(REGISTRAR_OPERATING_STATUS), operationalRegistrarsQuery);
- // TODO(b/62626209): Make this use the CloudDNS counts instead.
String dnsCountsQuery =
SqlTemplate.create(getQueryFromFile("dns_counts.sql")).build();
queriesBuilder.put(getTableName(DNS_COUNTS), dnsCountsQuery);
@@ -135,6 +134,7 @@ public final class ActivityReportingQueryBuilder implements QueryBuilder {
return queriesBuilder.build();
}
+
/** Returns the table name of the query, suffixed with the yearMonth in _YYYYMM format. */
private String getTableName(String queryName) {
return String.format("%s_%s", queryName, yearMonth.replace("-", ""));
@@ -149,4 +149,3 @@ public final class ActivityReportingQueryBuilder implements QueryBuilder {
return Resources.getResource(ActivityReportingQueryBuilder.class, "sql/" + filename);
}
}
-
diff --git a/java/google/registry/reporting/IcannHttpReporter.java b/java/google/registry/reporting/IcannHttpReporter.java
index ea7842d6d..fca16bea9 100644
--- a/java/google/registry/reporting/IcannHttpReporter.java
+++ b/java/google/registry/reporting/IcannHttpReporter.java
@@ -14,7 +14,9 @@
package google.registry.reporting;
+import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.net.MediaType.CSV_UTF_8;
+import static google.registry.model.registry.Registries.assertTldExists;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.api.client.http.ByteArrayContent;
@@ -23,6 +25,8 @@ import com.google.api.client.http.HttpHeaders;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
+import com.google.common.base.Ascii;
+import com.google.common.base.Splitter;
import com.google.common.io.ByteStreams;
import google.registry.config.RegistryConfig.Config;
import google.registry.keyring.api.KeyModule.Key;
@@ -35,16 +39,22 @@ import google.registry.xjc.iirdea.XjcIirdeaResult;
import google.registry.xml.XmlException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.util.List;
import javax.inject.Inject;
+import org.joda.time.YearMonth;
+import org.joda.time.format.DateTimeFormat;
/**
* Class that uploads a CSV file to ICANN's endpoint via an HTTP PUT call.
*
- *
It uses basic authorization credentials as specified in the "Registry Interfaces" draft.
+ *
It uses basic authorization credentials as specified in the "Registry Interfaces" draft.
+ *
+ *
Note that there's a lot of hard-coded logic extracting parameters from the report filenames.
+ * These are safe, as long as they follow the tld-reportType-yearMonth.csv filename format.
*
* @see IcannReportingUploadAction
- * @see
- * ICANN Reporting Specification
+ * @see ICANN
+ * Reporting Specification
*/
public class IcannHttpReporter {
@@ -57,29 +67,24 @@ public class IcannHttpReporter {
@Inject IcannHttpReporter() {}
/** Uploads {@code reportBytes} to ICANN. */
- public void send(
- byte[] reportBytes,
- String tld,
- String yearMonth,
- ReportType reportType) throws XmlException, IOException {
- GenericUrl uploadUrl = new GenericUrl(makeUrl(tld, yearMonth, reportType));
+ public void send(byte[] reportBytes, String reportFilename) throws XmlException, IOException {
+ validateReportFilename(reportFilename);
+ GenericUrl uploadUrl = new GenericUrl(makeUrl(reportFilename));
HttpRequest request =
httpTransport
.createRequestFactory()
.buildPutRequest(uploadUrl, new ByteArrayContent(CSV_UTF_8.toString(), reportBytes));
HttpHeaders headers = request.getHeaders();
- headers.setBasicAuthentication(tld + "_ry", password);
+ headers.setBasicAuthentication(getTld(reportFilename) + "_ry", password);
headers.setContentType(CSV_UTF_8.toString());
request.setHeaders(headers);
request.setFollowRedirects(false);
HttpResponse response = null;
logger.infofmt(
- "Sending %s report to %s with content length %s",
- reportType,
- uploadUrl.toString(),
- request.getContent().getLength());
+ "Sending report to %s with content length %s",
+ uploadUrl.toString(), request.getContent().getLength());
try {
response = request.execute();
byte[] content;
@@ -117,9 +122,31 @@ public class IcannHttpReporter {
return result;
}
- private String makeUrl(String tld, String yearMonth, ReportType reportType) {
- String urlPrefix = getUrlPrefix(reportType);
- return String.format("%s/%s/%s", urlPrefix, tld, yearMonth);
+ /** Verifies a given report filename matches the pattern tld-reportType-yyyyMM.csv. */
+ private void validateReportFilename(String filename) {
+ checkArgument(
+ filename.matches("[a-z0-9.\\-]+-((activity)|(transactions))-[0-9]{6}\\.csv"),
+ "Expected file format: tld-reportType-yyyyMM.csv, got %s instead",
+ filename);
+ assertTldExists(getTld(filename));
+ }
+
+ private String getTld(String filename) {
+ // Extract the TLD, up to second-to-last hyphen in the filename (works with international TLDs)
+ return filename.substring(0, filename.lastIndexOf('-', filename.lastIndexOf('-') - 1));
+ }
+
+ private String makeUrl(String filename) {
+ // Filename is in the format tld-reportType-yearMonth.csv
+ String tld = getTld(filename);
+ // Remove the tld- prefix and csv suffix
+ String remainder = filename.substring(tld.length() + 1, filename.length() - 4);
+ List elements = Splitter.on('-').splitToList(remainder);
+ ReportType reportType = ReportType.valueOf(Ascii.toUpperCase(elements.get(0)));
+ // Re-add hyphen between year and month, because ICANN is inconsistent between filename and URL
+ String yearMonth =
+ YearMonth.parse(elements.get(1), DateTimeFormat.forPattern("yyyyMM")).toString("yyyy-MM");
+ return String.format("%s/%s/%s", getUrlPrefix(reportType), tld, yearMonth);
}
private String getUrlPrefix(ReportType reportType) {
@@ -131,7 +158,7 @@ public class IcannHttpReporter {
default:
throw new IllegalStateException(
String.format(
- "Received invalid reportType! Expected ACTIVITY or TRANSACTIONS, got %s.",
+ "Received invalid reportTypes! Expected ACTIVITY or TRANSACTIONS, got %s.",
reportType));
}
}
diff --git a/java/google/registry/reporting/IcannReportingModule.java b/java/google/registry/reporting/IcannReportingModule.java
index 8ee0c6ca0..77aa5370e 100644
--- a/java/google/registry/reporting/IcannReportingModule.java
+++ b/java/google/registry/reporting/IcannReportingModule.java
@@ -14,22 +14,24 @@
package google.registry.reporting;
-import static google.registry.request.RequestParameters.extractEnumParameter;
+import static google.registry.request.RequestParameters.extractOptionalEnumParameter;
import static google.registry.request.RequestParameters.extractOptionalParameter;
-import static google.registry.request.RequestParameters.extractRequiredParameter;
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.MoreExecutors;
import dagger.Module;
import dagger.Provides;
import google.registry.bigquery.BigqueryConnection;
+import google.registry.request.HttpException.BadRequestException;
import google.registry.request.Parameter;
+import google.registry.util.Clock;
import java.util.Optional;
-import java.util.concurrent.Executors;
import javax.servlet.http.HttpServletRequest;
import org.joda.time.Duration;
+import org.joda.time.format.DateTimeFormat;
/** Module for dependencies required by ICANN monthly transactions/activity reporting. */
@Module
@@ -41,43 +43,78 @@ public final class IcannReportingModule {
ACTIVITY
}
+ static final String PARAM_OPTIONAL_YEAR_MONTH = "yearMonthOptional";
static final String PARAM_YEAR_MONTH = "yearMonth";
- static final String PARAM_REPORT_TYPE = "reportType";
+ static final String PARAM_OPTIONAL_SUBDIR = "subdirOptional";
static final String PARAM_SUBDIR = "subdir";
+ static final String PARAM_REPORT_TYPE = "reportType";
static final String ICANN_REPORTING_DATA_SET = "icann_reporting";
static final String DATASTORE_EXPORT_DATA_SET = "latest_datastore_export";
- private static final String BIGQUERY_SCOPE = "https://www.googleapis.com/auth/bigquery";
+ static final String MANIFEST_FILE_NAME = "MANIFEST.txt";
+ private static final String DEFAULT_SUBDIR = "icann/monthly";
+ private static final String BIGQUERY_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
+ /** Extracts an optional yearMonth in yyyy-MM format from the request. */
+ @Provides
+ @Parameter(PARAM_OPTIONAL_YEAR_MONTH)
+ static Optional provideYearMonthOptional(HttpServletRequest req) {
+ return extractOptionalParameter(req, PARAM_YEAR_MONTH);
+ }
+
+ /** Provides the yearMonth in yyyy-MM format, defaults to one month prior to run time. */
@Provides
@Parameter(PARAM_YEAR_MONTH)
- static String provideYearMonth(HttpServletRequest req) {
- return extractRequiredParameter(req, PARAM_YEAR_MONTH);
+ static String provideYearMonth(
+ @Parameter(PARAM_OPTIONAL_YEAR_MONTH) Optional yearMonthOptional, Clock clock) {
+ String yearMonth =
+ yearMonthOptional.orElse(
+ DateTimeFormat.forPattern("yyyy-MM").print(clock.nowUtc().minusMonths(1)));
+ if (!yearMonth.matches("[0-9]{4}-[0-9]{2}")) {
+ throw new BadRequestException(
+ String.format("yearMonth must be in yyyy-MM format, got %s instead", yearMonth));
+ }
+ return yearMonth;
}
+ /** Provides an optional subdirectory to store/upload reports to, extracted from the request. */
@Provides
- @Parameter(PARAM_REPORT_TYPE)
- static ReportType provideReportType(HttpServletRequest req) {
- return extractEnumParameter(req, ReportType.class, PARAM_REPORT_TYPE);
- }
-
- @Provides
- @Parameter(PARAM_SUBDIR)
- static Optional provideSubdir(HttpServletRequest req) {
+ @Parameter(PARAM_OPTIONAL_SUBDIR)
+ static Optional provideSubdirOptional(HttpServletRequest req) {
return extractOptionalParameter(req, PARAM_SUBDIR);
}
+ /** Provides the subdirectory to store/upload reports to, defaults to icann/monthly/yearMonth. */
@Provides
- static QueryBuilder provideQueryBuilder(
- @Parameter(PARAM_REPORT_TYPE) ReportType reportType,
- ActivityReportingQueryBuilder activityBuilder,
- TransactionsReportingQueryBuilder transactionsBuilder) {
- return reportType == ReportType.ACTIVITY ? activityBuilder : transactionsBuilder;
+ @Parameter(PARAM_SUBDIR)
+ static String provideSubdir(
+ @Parameter(PARAM_OPTIONAL_SUBDIR) Optional subdirOptional,
+ @Parameter(PARAM_YEAR_MONTH) String yearMonth) {
+ String subdir = subdirOptional.orElse(String.format("%s/%s", DEFAULT_SUBDIR, yearMonth));
+ if (subdir.startsWith("/") || subdir.endsWith("/")) {
+ throw new BadRequestException(
+ String.format("subdir must not start or end with a \"/\", got %s instead.", subdir));
+ }
+ return subdir;
+ }
+
+ /** Provides an optional reportType to store/upload reports to, extracted from the request. */
+ @Provides
+ static Optional provideReportTypeOptional(HttpServletRequest req) {
+ return extractOptionalEnumParameter(req, ReportType.class, PARAM_REPORT_TYPE);
+ }
+
+ /** Provides a list of reportTypes specified. If absent, we default to both report types. */
+ @Provides
+ @Parameter(PARAM_REPORT_TYPE)
+ static ImmutableList provideReportTypes(Optional reportTypeOptional) {
+ return reportTypeOptional.map(ImmutableList::of)
+ .orElseGet(() -> ImmutableList.of(ReportType.ACTIVITY, ReportType.TRANSACTIONS));
}
/**
* Constructs a BigqueryConnection with default settings.
*
- * We use Bigquery to generate activity reports via large aggregate SQL queries.
+ *
We use Bigquery to generate ICANN monthly reports via large aggregate SQL queries.
*
* @see ActivityReportingQueryBuilder
* @see google.registry.tools.BigqueryParameters for justifications of defaults.
@@ -87,13 +124,14 @@ public final class IcannReportingModule {
try {
GoogleCredential credential = GoogleCredential
.getApplicationDefault(transport, new JacksonFactory());
- BigqueryConnection connection = new BigqueryConnection.Builder()
- .setExecutorService(Executors.newFixedThreadPool(20))
- .setCredential(credential.createScoped(ImmutableList.of(BIGQUERY_SCOPE)))
- .setDatasetId(ICANN_REPORTING_DATA_SET)
- .setOverwrite(true)
- .setPollInterval(Duration.standardSeconds(1))
- .build();
+ BigqueryConnection connection =
+ new BigqueryConnection.Builder()
+ .setExecutorService(MoreExecutors.newDirectExecutorService())
+ .setCredential(credential.createScoped(ImmutableList.of(BIGQUERY_SCOPE)))
+ .setDatasetId(ICANN_REPORTING_DATA_SET)
+ .setOverwrite(true)
+ .setPollInterval(Duration.standardSeconds(1))
+ .build();
connection.initialize();
return connection;
} catch (Throwable e) {
@@ -101,3 +139,4 @@ public final class IcannReportingModule {
}
}
}
+
diff --git a/java/google/registry/reporting/IcannReportingStager.java b/java/google/registry/reporting/IcannReportingStager.java
new file mode 100644
index 000000000..04ab07ec0
--- /dev/null
+++ b/java/google/registry/reporting/IcannReportingStager.java
@@ -0,0 +1,264 @@
+// 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.reporting;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static google.registry.reporting.IcannReportingModule.MANIFEST_FILE_NAME;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.api.services.bigquery.model.TableFieldSchema;
+import com.google.appengine.tools.cloudstorage.GcsFilename;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableCollection;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.ListMultimap;
+import google.registry.bigquery.BigqueryConnection;
+import google.registry.bigquery.BigqueryUtils.TableType;
+import google.registry.config.RegistryConfig.Config;
+import google.registry.gcs.GcsUtils;
+import google.registry.reporting.IcannReportingModule.ReportType;
+import google.registry.request.Parameter;
+import google.registry.util.FormattingLogger;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+
+/**
+ * Class containing methods for staging ICANN monthly reports on GCS.
+ *
+ *
The main entrypoint is stageReports, which generates a given type of reports.
+ */
+public class IcannReportingStager {
+
+ private static final FormattingLogger logger = FormattingLogger.getLoggerForCallerClass();
+
+ @Inject @Config("icannReportingBucket") String reportingBucket;
+ @Inject @Parameter(IcannReportingModule.PARAM_YEAR_MONTH) String yearMonth;
+
+ @Inject
+ @Parameter(IcannReportingModule.PARAM_SUBDIR)
+ String subdir;
+
+ @Inject ActivityReportingQueryBuilder activityQueryBuilder;
+ @Inject TransactionsReportingQueryBuilder transactionsQueryBuilder;
+ @Inject GcsUtils gcsUtils;
+ @Inject BigqueryConnection bigquery;
+
+ @Inject
+ IcannReportingStager() {}
+
+ /**
+ * Creates and stores reports of a given type on GCS.
+ *
+ *
This is factored out to facilitate choosing which reports to upload,
+ */
+ ImmutableList stageReports(ReportType reportType) throws Exception {
+ QueryBuilder queryBuilder =
+ (reportType == ReportType.ACTIVITY) ? activityQueryBuilder : transactionsQueryBuilder;
+
+
+ ImmutableMap viewQueryMap = queryBuilder.getViewQueryMap();
+ // Generate intermediary views
+ for (Entry entry : viewQueryMap.entrySet()) {
+ createIntermediaryTableView(entry.getKey(), entry.getValue(), reportType);
+ }
+
+ // Get an in-memory table of the aggregate query's result
+ ImmutableTable reportTable =
+ bigquery.queryToLocalTableSync(queryBuilder.getReportQuery());
+
+ // Get report headers from the table schema and convert into CSV format
+ String headerRow = constructRow(getHeaders(reportTable.columnKeySet()));
+ logger.infofmt("Headers: %s", headerRow);
+
+ return (reportType == ReportType.ACTIVITY)
+ ? stageActivityReports(headerRow, reportTable.rowMap().values())
+ : stageTransactionsReports(headerRow, reportTable.rowMap().values());
+ }
+
+ private void createIntermediaryTableView(String queryName, String query, ReportType reportType)
+ throws ExecutionException, InterruptedException {
+ // Later views depend on the results of earlier ones, so query everything synchronously
+ logger.infofmt("Generating intermediary view %s", queryName);
+ bigquery.query(
+ query,
+ bigquery.buildDestinationTable(queryName)
+ .description(String.format(
+ "An intermediary view to generate %s reports for this month.", reportType))
+ .type(TableType.VIEW)
+ .build()
+ ).get();
+ }
+
+ private Iterable getHeaders(ImmutableSet fields) {
+ return fields
+ .stream()
+ .map((schema) -> schema.getName().replace('_', '-'))
+ .collect(ImmutableList.toImmutableList());
+ }
+
+ /** Creates and stores activity reports on GCS, returns a list of files stored. */
+ private ImmutableList stageActivityReports(
+ String headerRow, ImmutableCollection