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 1541964e8..14379fcee 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 @@ -18,6 +18,7 @@ import com.google.cloud.sql.ConnectorConfig; import com.google.cloud.sql.CredentialFactory; +import com.google.cloud.sql.RefreshStrategy; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningScheduledExecutorService; import java.io.File; @@ -26,6 +27,7 @@ import java.net.Socket; import java.security.KeyPair; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; import javax.net.ssl.SSLSocket; import jnr.unixsocket.UnixSocketAddress; import jnr.unixsocket.UnixSocketChannel; @@ -154,9 +156,21 @@ ConnectionInfoCache getConnection(ConnectionConfig config) { private ConnectionInfoCache createConnectionInfo(ConnectionConfig config) { logger.debug( String.format("[%s] Connection info added to cache.", config.getCloudSqlInstance())); + if (config.getConnectorConfig().getRefreshStrategy() == RefreshStrategy.LAZY) { + // Resolve the key operation immediately. + KeyPair keyPair = null; + try { + keyPair = localKeyPair.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + return new LazyRefreshConnectionInfoCache( + config, adminApi, instanceCredentialFactory, keyPair); - return new RefreshAheadConnectionInfoCache( - config, adminApi, instanceCredentialFactory, executor, localKeyPair, minRefreshDelayMs); + } else { + return new RefreshAheadConnectionInfoCache( + config, adminApi, instanceCredentialFactory, executor, localKeyPair, minRefreshDelayMs); + } } public void close() { diff --git a/core/src/main/java/com/google/cloud/sql/core/LazyRefreshConnectionInfoCache.java b/core/src/main/java/com/google/cloud/sql/core/LazyRefreshConnectionInfoCache.java new file mode 100644 index 000000000..7b49f4739 --- /dev/null +++ b/core/src/main/java/com/google/cloud/sql/core/LazyRefreshConnectionInfoCache.java @@ -0,0 +1,79 @@ +/* + * 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 + * + * 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 com.google.cloud.sql.core; + +import com.google.cloud.sql.CredentialFactory; +import java.security.KeyPair; + +/** + * Implements the lazy refresh cache strategy, which loads the new certificate as needed during a + * request for a new connection. + */ +class LazyRefreshConnectionInfoCache implements ConnectionInfoCache { + private final ConnectionConfig config; + private final CloudSqlInstanceName instanceName; + + private final LazyRefreshStrategy refreshStrategy; + + /** + * Initializes a new Cloud SQL instance based on the given connection name using the lazy refresh + * strategy. + * + * @param config instance connection name in the format "PROJECT_ID:REGION_ID:INSTANCE_ID" + * @param connectionInfoRepository Service class for interacting with the Cloud SQL Admin API + * @param keyPair public/private key pair used to authenticate connections + */ + public LazyRefreshConnectionInfoCache( + ConnectionConfig config, + ConnectionInfoRepository connectionInfoRepository, + CredentialFactory tokenSourceFactory, + KeyPair keyPair) { + this.config = config; + this.instanceName = new CloudSqlInstanceName(config.getCloudSqlInstance()); + + AccessTokenSupplier accessTokenSupplier = + DefaultAccessTokenSupplier.newInstance(config.getAuthType(), tokenSourceFactory); + CloudSqlInstanceName instanceName = new CloudSqlInstanceName(config.getCloudSqlInstance()); + + this.refreshStrategy = + new LazyRefreshStrategy( + config.getCloudSqlInstance(), + () -> + connectionInfoRepository.getConnectionInfoSync( + instanceName, accessTokenSupplier, config.getAuthType(), keyPair)); + } + + @Override + public ConnectionMetadata getConnectionMetadata(long timeoutMs) { + return refreshStrategy.getConnectionInfo(timeoutMs).toConnectionMetadata(config, instanceName); + } + + @Override + public void forceRefresh() { + refreshStrategy.forceRefresh(); + } + + @Override + public void refreshIfExpired() { + refreshStrategy.refreshIfExpired(); + } + + @Override + public void close() { + refreshStrategy.close(); + } +} diff --git a/core/src/main/java/com/google/cloud/sql/core/LazyRefreshStrategy.java b/core/src/main/java/com/google/cloud/sql/core/LazyRefreshStrategy.java index 7d66eca07..bd8edc708 100644 --- a/core/src/main/java/com/google/cloud/sql/core/LazyRefreshStrategy.java +++ b/core/src/main/java/com/google/cloud/sql/core/LazyRefreshStrategy.java @@ -76,18 +76,18 @@ private void fetchConnectionInfo() { logger.debug(String.format("[%s] Lazy Refresh Operation: Starting refresh operation.", name)); try { this.connectionInfo = this.refreshOperation.get(); + logger.debug( + String.format( + "[%s] Lazy Refresh Operation: Completed refresh with new certificate " + + "expiration at %s.", + name, connectionInfo.getExpiration().toString())); + } catch (TerminalException e) { logger.debug(String.format("[%s] Lazy Refresh Operation: Failed! No retry.", name), e); throw e; } catch (Exception e) { throw new RuntimeException(String.format("[%s] Refresh Operation: Failed!", name), e); } - - logger.debug( - String.format( - "[%s] Lazy Refresh Operation: Completed refresh with new certificate " - + "expiration at %s.", - name, connectionInfo.getExpiration().toString())); } } diff --git a/core/src/test/java/com/google/cloud/sql/core/LazyRefreshConnectionInfoCacheTest.java b/core/src/test/java/com/google/cloud/sql/core/LazyRefreshConnectionInfoCacheTest.java new file mode 100644 index 000000000..6598c98ea --- /dev/null +++ b/core/src/test/java/com/google/cloud/sql/core/LazyRefreshConnectionInfoCacheTest.java @@ -0,0 +1,64 @@ +/* + * 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 + * + * 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 com.google.cloud.sql.core; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import java.security.KeyPair; +import java.util.concurrent.ExecutionException; +import org.junit.Before; +import org.junit.Test; + +public class LazyRefreshConnectionInfoCacheTest { + private ListenableFuture keyPairFuture; + private final StubCredentialFactory stubCredentialFactory = + new StubCredentialFactory("my-token", System.currentTimeMillis() + 3600L); + + @Before + public void setup() throws Exception { + MockAdminApi mockAdminApi = new MockAdminApi(); + this.keyPairFuture = Futures.immediateFuture(mockAdminApi.getClientKeyPair()); + } + + @Test + public void testCloudSqlInstanceDataLazyStrategyRetrievedSuccessfully() + throws ExecutionException, InterruptedException { + KeyPair kp = keyPairFuture.get(); + TestDataSupplier instanceDataSupplier = new TestDataSupplier(false); + + // initialize connectionInfoCache after mocks are set up + LazyRefreshConnectionInfoCache connectionInfoCache = + new LazyRefreshConnectionInfoCache( + new ConnectionConfig.Builder().withCloudSqlInstance("project:region:instance").build(), + instanceDataSupplier, + stubCredentialFactory, + kp); + + ConnectionMetadata gotMetadata = connectionInfoCache.getConnectionMetadata(300); + ConnectionMetadata gotMetadata2 = connectionInfoCache.getConnectionMetadata(300); + + // Assert that the underlying ConnectionInfo was retrieved exactly once. + assertThat(instanceDataSupplier.counter.get()).isEqualTo(1); + + // Assert that the ConnectionInfo fields are added to ConnectionMetadata + assertThat(gotMetadata.getKeyManagerFactory()) + .isSameInstanceAs(instanceDataSupplier.response.getSslData().getKeyManagerFactory()); + assertThat(gotMetadata.getKeyManagerFactory()) + .isSameInstanceAs(gotMetadata2.getKeyManagerFactory()); + } +} diff --git a/core/src/test/java/com/google/cloud/sql/core/TestDataSupplier.java b/core/src/test/java/com/google/cloud/sql/core/TestDataSupplier.java index 8a88079c9..e8781492a 100644 --- a/core/src/test/java/com/google/cloud/sql/core/TestDataSupplier.java +++ b/core/src/test/java/com/google/cloud/sql/core/TestDataSupplier.java @@ -98,6 +98,6 @@ public ConnectionInfo getConnectionInfoSync( throw new RuntimeException("Flaky"); } successCounter.incrementAndGet(); - return null; + return response; } } diff --git a/docs/configuration.md b/docs/configuration.md index 7de0c7aaa..858db4763 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -273,20 +273,22 @@ registered with `ConnectorRegistry.register()`. These properties configure the connector which loads Cloud SQL instance configuration using the Cloud SQL Admin API. -| JDBC Connection Property | R2DBC Property Name | Description | Example | -|-------------------------------|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| cloudSqlTargetPrincipal | TARGET_PRINCIPAL | The service account to impersonate when connecting to the database and database admin API. | `db-user@my-project.iam.gserviceaccount.com` | -| cloudSqlDelegates | DELEGATES | A comma-separated list of service accounts delegates. See [Delegated Service Account Impersonation](jdbc.md#delegated-service-account-impersonation) | `application@my-project.iam.gserviceaccount.com,services@my-project.iam.gserviceaccount.com` | -| cloudSqlGoogleCredentialsPath | GOOGLE_CREDENTIALS_PATH | A file path to a JSON file containing a GoogleCredentials oauth token. | `/home/alice/secrets/my-credentials.json` | -| cloudSqlAdminRootUrl | ADMIN_ROOT_URL | An alternate root url for the Cloud SQL admin API. Must end in '/' See [rootUrl](java-api-root-url) | `https://googleapis.example.com/` | -| cloudSqlAdminServicePath | ADMIN_SERVICE_PATH | An alternate path to the SQL Admin API endpoint. Must not begin with '/'. Must end with '/'. See [servicePath](java-api-service-path) | `sqladmin/v1beta1/` | -| cloudSqlAdminQuotaProject | ADMIN_QUOTA_PROJECT | A project ID for quota and billing. See [Quota Project][quota-project] | `my-project` | -| cloudSqlUniverseDomain | UNIVERSE_DOMAIN | A universe domain for the TPC environment (default is googleapis.com). See [TPC][tpc] | test-universe.test +| JDBC Connection Property | R2DBC Property Name | Description | Example | +|-------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| cloudSqlTargetPrincipal | TARGET_PRINCIPAL | The service account to impersonate when connecting to the database and database admin API. | `db-user@my-project.iam.gserviceaccount.com` | +| cloudSqlDelegates | DELEGATES | A comma-separated list of service accounts delegates. See [Delegated Service Account Impersonation](jdbc.md#delegated-service-account-impersonation) | `application@my-project.iam.gserviceaccount.com,services@my-project.iam.gserviceaccount.com` | +| cloudSqlGoogleCredentialsPath | GOOGLE_CREDENTIALS_PATH | A file path to a JSON file containing a GoogleCredentials oauth token. | `/home/alice/secrets/my-credentials.json` | +| cloudSqlAdminRootUrl | ADMIN_ROOT_URL | An alternate root url for the Cloud SQL admin API. Must end in '/' See [rootUrl](java-api-root-url) | `https://googleapis.example.com/` | +| cloudSqlAdminServicePath | ADMIN_SERVICE_PATH | An alternate path to the SQL Admin API endpoint. Must not begin with '/'. Must end with '/'. See [servicePath](java-api-service-path) | `sqladmin/v1beta1/` | +| cloudSqlAdminQuotaProject | ADMIN_QUOTA_PROJECT | A project ID for quota and billing. See [Quota Project][quota-project] | `my-project` | +| cloudSqlUniverseDomain | UNIVERSE_DOMAIN | A universe domain for the TPC environment (default is googleapis.com). See [TPC][tpc] | test-universe.test | +| cloudSqlRefreshStrategy | REFRESH_STRATEGY | The strategy used to refresh the Google Cloud SQL authentication tokens. Valid values: `background` - refresh credentials using a background thread, `lazy` - refresh credentials during connection attempts. [Refresh Strategy][refresh-strategy] | `lazy` | [java-api-root-url]: https://github.com/googleapis/google-api-java-client/blob/main/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java#L49 [java-api-service-path]: https://github.com/googleapis/google-api-java-client/blob/main/google-api-client/src/main/java/com/google/api/client/googleapis/services/AbstractGoogleClient.java#L52 [quota-project]: jdbc.md#quota-project [tpc]: jdbc.md#trusted-partner-cloud-tpc-support +[refresh-strategy]: jdbc.md#refresh-strategy ### Connection Configuration Properties diff --git a/docs/jdbc.md b/docs/jdbc.md index c0ad7b221..439da25a8 100644 --- a/docs/jdbc.md +++ b/docs/jdbc.md @@ -545,6 +545,24 @@ Properties connProps = new Properties(); connProps.setProperty("cloudSqlUniverseDomain", "test-universe.test"); ``` + +### Refresh Strategy for Serverless Compute + +When the connector runs in Cloud Run, App Engine Flex, or other serverless +compute platforms, the connector should be configured to use the `lazy` refresh +strategy instead of the default `background` strategy. + +Cloud Run, Flex, and other serverless compute platforms throttle application CPU +in a way that interferes with the default `background` strategy used to refresh +the client certificate and authentication token. + +#### Example + +```java +Properties connProps = new Properties(); +connProps.setProperty("cloudSqlRefreshStrategy", "lazy"); +``` + ## Configuration Reference - See [Configuration Reference](configuration.md)