From 3bd264a570b3af90845f907a4f901e7624ec1b50 Mon Sep 17 00:00:00 2001 From: Jonathan Hess Date: Mon, 5 Aug 2024 13:28:52 -0600 Subject: [PATCH] chore: Use domain name from JDBC URL for Postgres and Mysql, Part of #2043. When connecting to a Cloud SQL database using a domain name like `db.example.com`, you may now set the domain name in the JDBC URL. For example, you can use a URL like "jdbc:mysql://db.example.com/my-schema?socketFactory=socketFactory=com.google.cloud.sql.mysql.SocketFactory" The socket factory will detect "db.example.com" and look up the TXT record to resolve the instance name. See #2043 for the whole feature definition. --- .../com/google/cloud/sql/ConnectorConfig.java | 9 +- .../cloud/sql/InstanceNameResolver.java | 5 - .../cloud/sql/core/ConnectionConfig.java | 3 +- .../com/google/cloud/sql/core/Connector.java | 38 ++++++- .../google/cloud/sql/ConnectorConfigTest.java | 3 +- .../google/cloud/sql/core/ConnectorTest.java | 44 ++++++++ .../cloud/sql/mariadb/SocketFactory.java | 4 +- .../google/cloud/sql/mysql/SocketFactory.java | 2 +- ...JdbcMysqlJ8DomainNameIntegrationTests.java | 104 ++++++++++++++++++ .../mysql/JdbcMysqlJ8IntegrationTests.java | 2 +- .../cloud/sql/postgres/SocketFactory.java | 7 +- ...dbcPostgresDomainNameIntegrationTests.java | 104 ++++++++++++++++++ .../JdbcPostgresIntegrationTests.java | 2 +- 13 files changed, 308 insertions(+), 19 deletions(-) delete mode 100644 core/src/main/java/com/google/cloud/sql/InstanceNameResolver.java create mode 100644 jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8DomainNameIntegrationTests.java create mode 100644 jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresDomainNameIntegrationTests.java diff --git a/core/src/main/java/com/google/cloud/sql/ConnectorConfig.java b/core/src/main/java/com/google/cloud/sql/ConnectorConfig.java index 30c5e8fe5..d2751539b 100644 --- a/core/src/main/java/com/google/cloud/sql/ConnectorConfig.java +++ b/core/src/main/java/com/google/cloud/sql/ConnectorConfig.java @@ -34,7 +34,7 @@ public class ConnectorConfig { private final String adminRootUrl; private final String adminServicePath; private final Supplier googleCredentialsSupplier; - private final Function instanceNameResolver; + private final Function instanceNameResolver; private final GoogleCredentials googleCredentials; private final String googleCredentialsPath; private final String adminQuotaProject; @@ -53,7 +53,7 @@ private ConnectorConfig( String adminQuotaProject, String universeDomain, RefreshStrategy refreshStrategy, - Function instanceNameResolver) { + Function instanceNameResolver) { this.targetPrincipal = targetPrincipal; this.delegates = delegates; this.adminRootUrl = adminRootUrl; @@ -162,7 +162,7 @@ public static class Builder { private String adminQuotaProject; private String universeDomain; private RefreshStrategy refreshStrategy = RefreshStrategy.BACKGROUND; - private Function instanceNameResolver; + private Function instanceNameResolver; public Builder withTargetPrincipal(String targetPrincipal) { this.targetPrincipal = targetPrincipal; @@ -214,7 +214,8 @@ public Builder withRefreshStrategy(RefreshStrategy refreshStrategy) { this.refreshStrategy = refreshStrategy; return this; } - public Builder withInstanceNameResolver(Function instanceNameResolver) { + + public Builder withInstanceNameResolver(Function instanceNameResolver) { this.instanceNameResolver = instanceNameResolver; return this; } diff --git a/core/src/main/java/com/google/cloud/sql/InstanceNameResolver.java b/core/src/main/java/com/google/cloud/sql/InstanceNameResolver.java deleted file mode 100644 index 2b009a8f6..000000000 --- a/core/src/main/java/com/google/cloud/sql/InstanceNameResolver.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.google.cloud.sql; - -public interface InstanceNameResolver { - String resolve(String name); -} diff --git a/core/src/main/java/com/google/cloud/sql/core/ConnectionConfig.java b/core/src/main/java/com/google/cloud/sql/core/ConnectionConfig.java index 1d913753d..9199c47b3 100644 --- a/core/src/main/java/com/google/cloud/sql/core/ConnectionConfig.java +++ b/core/src/main/java/com/google/cloud/sql/core/ConnectionConfig.java @@ -327,7 +327,8 @@ public Builder withIpTypes(List ipTypes) { this.ipTypes = ipTypes; return this; } - /** Set domainName as. */ + + /** Set domainName. */ public Builder withDomainName(String domainName) { this.domainName = domainName; return this; diff --git a/core/src/main/java/com/google/cloud/sql/core/Connector.java b/core/src/main/java/com/google/cloud/sql/core/Connector.java index 7b8a774a1..7d75ee63b 100644 --- a/core/src/main/java/com/google/cloud/sql/core/Connector.java +++ b/core/src/main/java/com/google/cloud/sql/core/Connector.java @@ -19,7 +19,6 @@ import com.google.cloud.sql.ConnectorConfig; import com.google.cloud.sql.CredentialFactory; import com.google.cloud.sql.RefreshStrategy; -import com.google.common.base.Strings; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningScheduledExecutorService; import java.io.File; @@ -142,9 +141,11 @@ Socket connect(ConnectionConfig config, long timeoutMs) throws IOException { } } - ConnectionInfoCache getConnection(ConnectionConfig config) { + ConnectionInfoCache getConnection(final ConnectionConfig config) { + final ConnectionConfig updatedConfig = resolveConnectionName(config); + ConnectionInfoCache instance = - instances.computeIfAbsent(config, k -> createConnectionInfo(config)); + instances.computeIfAbsent(updatedConfig, k -> createConnectionInfo(updatedConfig)); // If the client certificate has expired (as when the computer goes to // sleep, and the refresh cycle cannot run), force a refresh immediately. @@ -156,6 +157,37 @@ ConnectionInfoCache getConnection(ConnectionConfig config) { return instance; } + private ConnectionConfig resolveConnectionName(ConnectionConfig config) { + // If domainName is not set, return the original configuration unmodified. + if (config.getDomainName() == null || config.getDomainName().isEmpty()) { + return config; + } + + // If both domainName and cloudSqlInstance are set, ignore the domain name. Return a new + // configuration with domainName set to null. + if (config.getCloudSqlInstance() != null && !config.getCloudSqlInstance().isEmpty()) { + return config.withDomainName(null); + } + + // If only domainName is set, resolve the domain name. + try { + final String unresolvedName = config.getDomainName(); + final Function resolver = + config.getConnectorConfig().getInstanceNameResolver(); + if (resolver != null) { + return config.withCloudSqlInstance(resolver.apply(unresolvedName)); + } else { + throw new IllegalStateException( + "Can't resolve domain " + unresolvedName + ". ConnectorConfig.resolver is not set."); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + String.format( + "Cloud SQL connection name is invalid: \"%s\"", config.getCloudSqlInstance()), + e); + } + } + private ConnectionInfoCache createConnectionInfo(ConnectionConfig config) { logger.debug( String.format("[%s] Connection info added to cache.", config.getCloudSqlInstance())); diff --git a/core/src/test/java/com/google/cloud/sql/ConnectorConfigTest.java b/core/src/test/java/com/google/cloud/sql/ConnectorConfigTest.java index 30052235c..e1779904e 100644 --- a/core/src/test/java/com/google/cloud/sql/ConnectorConfigTest.java +++ b/core/src/test/java/com/google/cloud/sql/ConnectorConfigTest.java @@ -426,7 +426,8 @@ public void testHashCode() { wantGoogleCredentialsPath, wantAdminQuotaProject, null, // universeDomain - wantRefreshStrategy // refreshStrategy + wantRefreshStrategy, // refreshStrategy + null // instanceNameResolver )); } } diff --git a/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java b/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java index 80c9485d4..9879bad04 100644 --- a/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java +++ b/core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java @@ -131,6 +131,50 @@ public void create_successfulPrivateConnection() throws IOException, Interrupted assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE); } + @Test + public void create_successfulPublicConnectionWithDomainName() + throws IOException, InterruptedException { + FakeSslServer sslServer = new FakeSslServer(); + ConnectionConfig config = + new ConnectionConfig.Builder() + .withDomainName("db.example.com") + .withIpTypes("PRIMARY") + .withConnectorConfig( + new ConnectorConfig.Builder() + .withInstanceNameResolver((domainName) -> "myProject:myRegion:myInstance") + .build()) + .build(); + + int port = sslServer.start(PUBLIC_IP); + + Connector connector = newConnector(config.getConnectorConfig(), port); + + Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS); + + assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE); + } + + @Test + public void create_throwsErrorForDomainNameWithNoResolver() + throws IOException, InterruptedException { + // The server TLS certificate matches myProject:myRegion:myInstance + FakeSslServer sslServer = new FakeSslServer(); + ConnectionConfig config = + new ConnectionConfig.Builder() + .withDomainName("db.example.com") + .withIpTypes("PRIMARY") + .build(); + + int port = sslServer.start(PUBLIC_IP); + + Connector connector = newConnector(config.getConnectorConfig(), port); + IllegalStateException ex = + assertThrows( + IllegalStateException.class, () -> connector.connect(config, TEST_MAX_REFRESH_MS)); + + assertThat(ex).hasMessageThat().contains("ConnectorConfig.resolver is not set"); + } + @Test public void create_successfulPublicConnection() throws IOException, InterruptedException { FakeSslServer sslServer = new FakeSslServer(); diff --git a/jdbc/mariadb/src/main/java/com/google/cloud/sql/mariadb/SocketFactory.java b/jdbc/mariadb/src/main/java/com/google/cloud/sql/mariadb/SocketFactory.java index 0926b937d..6a75778b3 100644 --- a/jdbc/mariadb/src/main/java/com/google/cloud/sql/mariadb/SocketFactory.java +++ b/jdbc/mariadb/src/main/java/com/google/cloud/sql/mariadb/SocketFactory.java @@ -37,6 +37,7 @@ public class SocketFactory extends ConfigurableSocketFactory { } private Configuration conf; + private String host; public SocketFactory() {} @@ -44,13 +45,14 @@ public SocketFactory() {} public void setConfiguration(Configuration conf, String host) { // Ignore the hostname this.conf = conf; + this.host = host; } @Override public Socket createSocket() throws IOException { try { return InternalConnectorRegistry.getInstance() - .connect(ConnectionConfig.fromConnectionProperties(conf.nonMappedOptions())); + .connect(ConnectionConfig.fromConnectionProperties(conf.nonMappedOptions(), host)); } catch (InterruptedException e) { throw new RuntimeException(e); } diff --git a/jdbc/mysql-j-8/src/main/java/com/google/cloud/sql/mysql/SocketFactory.java b/jdbc/mysql-j-8/src/main/java/com/google/cloud/sql/mysql/SocketFactory.java index 03d36f507..9f55ad5be 100644 --- a/jdbc/mysql-j-8/src/main/java/com/google/cloud/sql/mysql/SocketFactory.java +++ b/jdbc/mysql-j-8/src/main/java/com/google/cloud/sql/mysql/SocketFactory.java @@ -60,7 +60,7 @@ public T connect( T socket = (T) InternalConnectorRegistry.getInstance() - .connect(ConnectionConfig.fromConnectionProperties(props)); + .connect(ConnectionConfig.fromConnectionProperties(props, host)); return socket; } diff --git a/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8DomainNameIntegrationTests.java b/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8DomainNameIntegrationTests.java new file mode 100644 index 000000000..b31f6de45 --- /dev/null +++ b/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8DomainNameIntegrationTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Google LLC + * + * 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 + * + * https://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 com.google.cloud.sql.mysql; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import com.google.cloud.sql.ConnectorConfig; +import com.google.cloud.sql.ConnectorRegistry; +import com.google.common.collect.ImmutableList; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class JdbcMysqlJ8DomainNameIntegrationTests { + + private static final String CONNECTION_NAME = System.getenv("MYSQL_CONNECTION_NAME"); + private static final String DB_NAME = System.getenv("MYSQL_DB"); + private static final String DB_USER = System.getenv("MYSQL_USER"); + private static final String DB_PASSWORD = System.getenv("MYSQL_PASS"); + private static final ImmutableList requiredEnvVars = + ImmutableList.of("MYSQL_USER", "MYSQL_PASS", "MYSQL_DB", "MYSQL_CONNECTION_NAME"); + @Rule public Timeout globalTimeout = new Timeout(80, TimeUnit.SECONDS); + private HikariDataSource connectionPool; + + @BeforeClass + public static void checkEnvVars() { + // Check that required env vars are set + requiredEnvVars.forEach( + (varName) -> + assertWithMessage( + String.format( + "Environment variable '%s' must be set to perform these tests.", varName)) + .that(System.getenv(varName)) + .isNotEmpty()); + } + + @Before + public void setUpPool() throws SQLException { + // Set up URL parameters + String jdbcURL = String.format("jdbc:mysql://db.example.com/%s", DB_NAME); + Properties connProps = new Properties(); + connProps.setProperty("user", DB_USER); + connProps.setProperty("password", DB_PASSWORD); + connProps.setProperty("socketFactory", "com.google.cloud.sql.mysql.SocketFactory"); + + // Register a resolver that resolves `db.example.com` to the connection name + connProps.setProperty("cloudSqlNamedConnector", "resolver-test"); + ConnectorRegistry.register( + "resolver-test", + new ConnectorConfig.Builder() + .withInstanceNameResolver((n) -> "db.example.com".equals(n) ? CONNECTION_NAME : null) + .build()); + + // Initialize connection pool + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(jdbcURL); + config.setDataSourceProperties(connProps); + config.setConnectionTimeout(10000); // 10s + + this.connectionPool = new HikariDataSource(config); + } + + @Test + public void pooledConnectionTest() throws SQLException { + + List rows = new ArrayList<>(); + try (Connection conn = connectionPool.getConnection()) { + try (PreparedStatement selectStmt = conn.prepareStatement("SELECT NOW() as TS")) { + ResultSet rs = selectStmt.executeQuery(); + while (rs.next()) { + rows.add(rs.getTimestamp("TS")); + } + } + } + assertThat(rows.size()).isEqualTo(1); + } +} diff --git a/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8IntegrationTests.java b/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8IntegrationTests.java index 06565646a..f7fe254d6 100644 --- a/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8IntegrationTests.java +++ b/jdbc/mysql-j-8/src/test/java/com/google/cloud/sql/mysql/JdbcMysqlJ8IntegrationTests.java @@ -62,7 +62,7 @@ public static void checkEnvVars() { @Before public void setUpPool() throws SQLException { // Set up URL parameters - String jdbcURL = String.format("jdbc:mysql:///%s", DB_NAME); + String jdbcURL = String.format("jdbc:mysql://db.example.com/%s", DB_NAME); Properties connProps = new Properties(); connProps.setProperty("user", DB_USER); connProps.setProperty("password", DB_PASSWORD); diff --git a/jdbc/postgres/src/main/java/com/google/cloud/sql/postgres/SocketFactory.java b/jdbc/postgres/src/main/java/com/google/cloud/sql/postgres/SocketFactory.java index 6fac4bc46..8585b08e4 100644 --- a/jdbc/postgres/src/main/java/com/google/cloud/sql/postgres/SocketFactory.java +++ b/jdbc/postgres/src/main/java/com/google/cloud/sql/postgres/SocketFactory.java @@ -38,6 +38,9 @@ public class SocketFactory extends javax.net.SocketFactory { private static final String DEPRECATED_SOCKET_ARG = "SocketFactoryArg"; private static final String POSTGRES_SUFFIX = "/.s.PGSQL.5432"; + /** The connection property containing the hostname from the JDBC url. */ + private static final String POSTGRES_HOST_PROP = "PGHOST"; + private final Properties props; static { @@ -78,7 +81,9 @@ private static Properties createDefaultProperties(String instanceName) { public Socket createSocket() throws IOException { try { return InternalConnectorRegistry.getInstance() - .connect(ConnectionConfig.fromConnectionProperties(props)); + .connect( + ConnectionConfig.fromConnectionProperties( + props, props.getProperty(POSTGRES_HOST_PROP))); } catch (InterruptedException e) { throw new RuntimeException(e); } diff --git a/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresDomainNameIntegrationTests.java b/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresDomainNameIntegrationTests.java new file mode 100644 index 000000000..981ee7f90 --- /dev/null +++ b/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresDomainNameIntegrationTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Google LLC + * + * 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 + * + * https://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 com.google.cloud.sql.postgres; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import com.google.cloud.sql.ConnectorConfig; +import com.google.cloud.sql.ConnectorRegistry; +import com.google.common.collect.ImmutableList; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class JdbcPostgresDomainNameIntegrationTests { + + private static final String CONNECTION_NAME = System.getenv("POSTGRES_CONNECTION_NAME"); + private static final String DB_NAME = System.getenv("POSTGRES_DB"); + private static final String DB_USER = System.getenv("POSTGRES_USER"); + private static final String DB_PASSWORD = System.getenv("POSTGRES_PASS"); + private static final ImmutableList requiredEnvVars = + ImmutableList.of("POSTGRES_USER", "POSTGRES_PASS", "POSTGRES_DB", "POSTGRES_CONNECTION_NAME"); + @Rule public Timeout globalTimeout = new Timeout(80, TimeUnit.SECONDS); + + private HikariDataSource connectionPool; + + @BeforeClass + public static void checkEnvVars() { + // Check that required env vars are set + requiredEnvVars.forEach( + (varName) -> + assertWithMessage( + String.format( + "Environment variable '%s' must be set to perform these tests.", varName)) + .that(System.getenv(varName)) + .isNotEmpty()); + } + + @Before + public void setUpPool() throws SQLException { + // Set up URL parameters + String jdbcURL = String.format("jdbc:postgresql://db.example.com/%s", DB_NAME); + Properties connProps = new Properties(); + connProps.setProperty("user", DB_USER); + connProps.setProperty("password", DB_PASSWORD); + connProps.setProperty("socketFactory", "com.google.cloud.sql.postgres.SocketFactory"); + connProps.setProperty("cloudSqlNamedConnector", "resolver-test"); + + ConnectorRegistry.register( + "resolver-test", + new ConnectorConfig.Builder() + .withInstanceNameResolver((n) -> "db.example.com".equals(n) ? CONNECTION_NAME : null) + .build()); + + // Initialize connection pool + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(jdbcURL); + config.setDataSourceProperties(connProps); + config.setConnectionTimeout(10000); // 10s + + this.connectionPool = new HikariDataSource(config); + } + + @Test + public void pooledConnectionTest() throws SQLException { + + List rows = new ArrayList<>(); + try (Connection conn = connectionPool.getConnection()) { + try (PreparedStatement selectStmt = conn.prepareStatement("SELECT NOW() as TS")) { + ResultSet rs = selectStmt.executeQuery(); + while (rs.next()) { + rows.add(rs.getTimestamp("TS")); + } + } + } + assertThat(rows.size()).isEqualTo(1); + } +} diff --git a/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresIntegrationTests.java b/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresIntegrationTests.java index a5d07b0f9..ef7d3f4df 100644 --- a/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresIntegrationTests.java +++ b/jdbc/postgres/src/test/java/com/google/cloud/sql/postgres/JdbcPostgresIntegrationTests.java @@ -63,7 +63,7 @@ public static void checkEnvVars() { @Before public void setUpPool() throws SQLException { // Set up URL parameters - String jdbcURL = String.format("jdbc:postgresql:///%s", DB_NAME); + String jdbcURL = String.format("jdbc:postgresql://db.example.com/%s", DB_NAME); Properties connProps = new Properties(); connProps.setProperty("user", DB_USER); connProps.setProperty("password", DB_PASSWORD);