-
Notifications
You must be signed in to change notification settings - Fork 0
fix(java): support Cloud SQL socket DATABASE_URL on Cloud Run #393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,11 @@ | |||||||||||||||
|
|
||||||||||||||||
| import com.zaxxer.hikari.HikariConfig; | ||||||||||||||||
| import com.zaxxer.hikari.HikariDataSource; | ||||||||||||||||
| import java.net.URI; | ||||||||||||||||
| import java.net.URLDecoder; | ||||||||||||||||
| import java.nio.charset.StandardCharsets; | ||||||||||||||||
| import java.util.LinkedHashSet; | ||||||||||||||||
| import java.util.Set; | ||||||||||||||||
| import javax.sql.DataSource; | ||||||||||||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||||||||||||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||||||||||||||||
|
|
@@ -20,6 +25,7 @@ | |||||||||||||||
| */ | ||||||||||||||||
| @Configuration | ||||||||||||||||
| @Conditional(OnDatabaseUrlCondition.class) | ||||||||||||||||
| @SuppressWarnings("PMD.GodClass") | ||||||||||||||||
| public class DataSourceConfig { | ||||||||||||||||
|
|
||||||||||||||||
| @Value("${SPRING_DATASOURCE_URL:}") | ||||||||||||||||
|
|
@@ -40,6 +46,12 @@ public class DataSourceConfig { | |||||||||||||||
| @Value("${spring.datasource.driver-class-name:org.postgresql.Driver}") | ||||||||||||||||
| private String driverClassName; | ||||||||||||||||
|
|
||||||||||||||||
| @Value("${K_SERVICE:}") | ||||||||||||||||
| private String cloudRunService; | ||||||||||||||||
|
|
||||||||||||||||
| @Value("${K_REVISION:}") | ||||||||||||||||
| private String cloudRunRevision; | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Creates a HikariConfig bean configured from application.properties. | ||||||||||||||||
| * | ||||||||||||||||
|
|
@@ -49,12 +61,14 @@ public class DataSourceConfig { | |||||||||||||||
| @ConfigurationProperties(prefix = "spring.datasource.hikari") | ||||||||||||||||
| public HikariConfig hikariConfig() { | ||||||||||||||||
| HikariConfig config = new HikariConfig(); | ||||||||||||||||
| String jdbcUrl = resolveJdbcUrl(); | ||||||||||||||||
|
|
||||||||||||||||
| // Set core JDBC properties | ||||||||||||||||
| config.setJdbcUrl(resolveJdbcUrl()); | ||||||||||||||||
| config.setJdbcUrl(jdbcUrl); | ||||||||||||||||
| config.setUsername(username); | ||||||||||||||||
| config.setPassword(password); | ||||||||||||||||
| config.setDriverClassName(driverClassName); | ||||||||||||||||
| configureCloudSqlProperties(config, jdbcUrl); | ||||||||||||||||
|
|
||||||||||||||||
| return config; | ||||||||||||||||
| } | ||||||||||||||||
|
|
@@ -72,19 +86,186 @@ private String resolveJdbcUrl() { | |||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private String normalizeDatabaseUrl(String url) { | ||||||||||||||||
| if (url.startsWith("postgresql://")) { | ||||||||||||||||
| return "jdbc:" + url; | ||||||||||||||||
| if (url.startsWith("jdbc:postgresql://")) { | ||||||||||||||||
| return url; | ||||||||||||||||
| } | ||||||||||||||||
| if (url.startsWith("postgres://")) { | ||||||||||||||||
| return "jdbc:postgresql://" + url.substring("postgres://".length()); | ||||||||||||||||
| if (url.startsWith("postgresql://") || url.startsWith("postgres://")) { | ||||||||||||||||
| return toJdbcPostgresUrl(url); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return url; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private String toJdbcPostgresUrl(String url) { | ||||||||||||||||
| String normalized = | ||||||||||||||||
| url.startsWith("postgres://") | ||||||||||||||||
| ? "postgresql://" + url.substring("postgres://".length()) | ||||||||||||||||
| : url; | ||||||||||||||||
| URI uri = URI.create(normalized); | ||||||||||||||||
|
|
||||||||||||||||
| String host = isNotBlank(uri.getHost()) ? uri.getHost() : "localhost"; | ||||||||||||||||
|
|
||||||||||||||||
| StringBuilder jdbcUrl = new StringBuilder("jdbc:postgresql://"); | ||||||||||||||||
| jdbcUrl.append(host); | ||||||||||||||||
|
|
||||||||||||||||
| if (uri.getPort() != -1) { | ||||||||||||||||
| jdbcUrl.append(':').append(uri.getPort()); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (isNotBlank(uri.getRawPath())) { | ||||||||||||||||
| jdbcUrl.append(uri.getRawPath()); | ||||||||||||||||
| } else { | ||||||||||||||||
| jdbcUrl.append('/'); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| String query = | ||||||||||||||||
| appendCredentialsIfNeeded(uri.getRawQuery(), extractRawUserInfo(uri, normalized)); | ||||||||||||||||
| if (isNotBlank(query)) { | ||||||||||||||||
| jdbcUrl.append('?').append(query); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return jdbcUrl.toString(); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) | ||||||||||||||||
| private String appendCredentialsIfNeeded(String rawQuery, String rawUserInfo) { | ||||||||||||||||
| if (!isNotBlank(rawUserInfo)) { | ||||||||||||||||
| return rawQuery; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| Set<String> existingKeys = queryKeys(rawQuery); | ||||||||||||||||
| String[] userInfoParts = rawUserInfo.split(":", 2); | ||||||||||||||||
| String user = userInfoParts.length > 0 ? userInfoParts[0] : ""; | ||||||||||||||||
| String password = userInfoParts.length > 1 ? userInfoParts[1] : ""; | ||||||||||||||||
|
|
||||||||||||||||
| StringBuilder query = new StringBuilder(rawQuery == null ? "" : rawQuery); | ||||||||||||||||
| if (!existingKeys.contains("user") && isNotBlank(user)) { | ||||||||||||||||
| appendQueryParam(query, "user", user); | ||||||||||||||||
| } | ||||||||||||||||
| if (!existingKeys.contains("password") && isNotBlank(password)) { | ||||||||||||||||
| appendQueryParam(query, "password", password); | ||||||||||||||||
|
Comment on lines
+137
to
+146
|
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return query.length() == 0 ? null : query.toString(); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private String extractRawUserInfo(URI uri, String rawUrl) { | ||||||||||||||||
| if (isNotBlank(uri.getRawUserInfo())) { | ||||||||||||||||
| return uri.getRawUserInfo(); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| int schemeSeparator = rawUrl.indexOf("://"); | ||||||||||||||||
| int authorityStart = schemeSeparator >= 0 ? schemeSeparator + 3 : 0; | ||||||||||||||||
| int pathStart = rawUrl.indexOf('/', authorityStart); | ||||||||||||||||
| int userInfoEnd = rawUrl.indexOf('@', authorityStart); | ||||||||||||||||
|
|
||||||||||||||||
| if (userInfoEnd > authorityStart && (pathStart == -1 || userInfoEnd < pathStart)) { | ||||||||||||||||
| return rawUrl.substring(authorityStart, userInfoEnd); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private Set<String> queryKeys(String rawQuery) { | ||||||||||||||||
| Set<String> keys = new LinkedHashSet<>(); | ||||||||||||||||
| if (!isNotBlank(rawQuery)) { | ||||||||||||||||
| return keys; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| for (String part : rawQuery.split("&")) { | ||||||||||||||||
| if (!isNotBlank(part)) { | ||||||||||||||||
| continue; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| int separator = part.indexOf('='); | ||||||||||||||||
| String key = separator >= 0 ? part.substring(0, separator) : part; | ||||||||||||||||
| if (isNotBlank(key)) { | ||||||||||||||||
| keys.add(key); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return keys; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private void appendQueryParam(StringBuilder query, String key, String value) { | ||||||||||||||||
| if (query.length() > 0) { | ||||||||||||||||
| query.append('&'); | ||||||||||||||||
| } | ||||||||||||||||
| query.append(key).append('=').append(value); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private boolean isNotBlank(String value) { | ||||||||||||||||
| return value != null && !value.isBlank(); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private void configureCloudSqlProperties(HikariConfig config, String jdbcUrl) { | ||||||||||||||||
| String instanceUnixSocket = extractUnixSocketPath(jdbcUrl); | ||||||||||||||||
| if (!isNotBlank(instanceUnixSocket)) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| config.addDataSourceProperty("socketFactory", "com.google.cloud.sql.postgres.SocketFactory"); | ||||||||||||||||
| config.addDataSourceProperty("unixSocketPath", instanceUnixSocket); | ||||||||||||||||
|
|
||||||||||||||||
| if (isCloudRun()) { | ||||||||||||||||
| config.addDataSourceProperty("cloudSqlRefreshStrategy", "lazy"); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private String extractUnixSocketPath(String jdbcUrl) { | ||||||||||||||||
| String rawQuery = extractRawQuery(jdbcUrl); | ||||||||||||||||
| if (!isNotBlank(rawQuery)) { | ||||||||||||||||
| return null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| String host = extractQueryParam(rawQuery, "host"); | ||||||||||||||||
| if (isNotBlank(host) && decodeQueryValue(host).startsWith("/cloudsql/")) { | ||||||||||||||||
| return decodeQueryValue(host); | ||||||||||||||||
|
Comment on lines
+222
to
+223
|
||||||||||||||||
| if (isNotBlank(host) && decodeQueryValue(host).startsWith("/cloudsql/")) { | |
| return decodeQueryValue(host); | |
| if (isNotBlank(host)) { | |
| String decodedHost = decodeQueryValue(host); | |
| if (decodedHost.startsWith("/cloudsql/")) { | |
| return decodedHost; | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,8 @@ void hikariConfig_ShouldUseSpringDatasourceUrlAsIs() throws Exception { | |
| setField(config, "username", "user"); | ||
| setField(config, "password", "pass"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", ""); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
|
|
@@ -38,6 +40,8 @@ void hikariConfig_ShouldNormalizeDatabaseUrlPostgresqlScheme() throws Exception | |
| setField(config, "username", "user"); | ||
| setField(config, "password", "pass"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", ""); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
|
|
@@ -54,12 +58,71 @@ void hikariConfig_ShouldNormalizeDatabaseUrlPostgresScheme() throws Exception { | |
| setField(config, "username", "user"); | ||
| setField(config, "password", "pass"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", ""); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
| assertThat(result.getJdbcUrl()).isEqualTo("jdbc:postgresql://localhost:5432/lamp"); | ||
| } | ||
|
|
||
| @Test | ||
| void hikariConfig_ShouldNormalizeCloudSqlSocketStyleDatabaseUrl() throws Exception { | ||
| DataSourceConfig config = new DataSourceConfig(); | ||
|
|
||
| setField(config, "springDatasourceUrl", ""); | ||
| setField( | ||
| config, | ||
| "databaseUrl", | ||
| "postgresql://postgres:redacted-password@/lamp-control?" | ||
| + "host=/cloudsql/project-id:region:instance-id&connect_timeout=5"); | ||
| setField(config, "fallbackJdbcUrl", "jdbc:postgresql://localhost:5432/fallback"); | ||
| setField(config, "username", "ignored"); | ||
| setField(config, "password", "ignored"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", ""); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
| assertThat(result.getJdbcUrl()) | ||
| .isEqualTo( | ||
| "jdbc:postgresql://localhost/lamp-control" | ||
| + "?host=/cloudsql/project-id:region:instance-id" | ||
| + "&connect_timeout=5" | ||
| + "&user=postgres" | ||
| + "&password=redacted-password"); | ||
| assertThat(result.getDataSourceProperties()) | ||
| .containsEntry("socketFactory", "com.google.cloud.sql.postgres.SocketFactory") | ||
| .containsEntry("unixSocketPath", "/cloudsql/project-id:region:instance-id") | ||
| .doesNotContainKey("cloudSqlRefreshStrategy"); | ||
| } | ||
|
Comment on lines
+69
to
+99
|
||
|
|
||
| @Test | ||
| void hikariConfig_ShouldSetCloudSqlRefreshStrategyOnCloudRunInSocketMode() throws Exception { | ||
| DataSourceConfig config = new DataSourceConfig(); | ||
|
|
||
| setField(config, "springDatasourceUrl", ""); | ||
| setField( | ||
| config, | ||
| "databaseUrl", | ||
| "postgresql://postgres:redacted-password@/lamp-control?" | ||
| + "host=/cloudsql/project-id:region:instance-id&connect_timeout=5"); | ||
| setField(config, "fallbackJdbcUrl", "jdbc:postgresql://localhost:5432/fallback"); | ||
| setField(config, "username", "ignored"); | ||
| setField(config, "password", "ignored"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", "lamp-control-service"); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
Comment on lines
+115
to
+116
|
||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
| assertThat(result.getDataSourceProperties()) | ||
| .containsEntry("socketFactory", "com.google.cloud.sql.postgres.SocketFactory") | ||
| .containsEntry("unixSocketPath", "/cloudsql/project-id:region:instance-id") | ||
| .containsEntry("cloudSqlRefreshStrategy", "lazy"); | ||
| } | ||
|
|
||
| @Test | ||
| void hikariConfig_ShouldKeepJdbcDatabaseUrlUnchanged() throws Exception { | ||
| DataSourceConfig config = new DataSourceConfig(); | ||
|
|
@@ -70,6 +133,8 @@ void hikariConfig_ShouldKeepJdbcDatabaseUrlUnchanged() throws Exception { | |
| setField(config, "username", "user"); | ||
| setField(config, "password", "pass"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", ""); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
|
|
@@ -86,6 +151,8 @@ void hikariConfig_ShouldFallbackToSpringDatasourceProperty() throws Exception { | |
| setField(config, "username", "user"); | ||
| setField(config, "password", "pass"); | ||
| setField(config, "driverClassName", "org.postgresql.Driver"); | ||
| setField(config, "cloudRunService", ""); | ||
| setField(config, "cloudRunRevision", ""); | ||
|
|
||
| HikariConfig result = config.hikariConfig(); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URI.create() method on line 104 can throw IllegalArgumentException if the URL string violates RFC 2396. There is no error handling for malformed URLs, which could cause the application to fail during startup if an invalid DATABASE_URL is provided. Consider wrapping this in a try-catch block and either providing a clear error message or falling back to a safe default behavior.