diff --git a/.github/workflows/NativeTests.yaml b/.github/workflows/NativeTests.yaml index b406ac0bcc..5d012830a1 100644 --- a/.github/workflows/NativeTests.yaml +++ b/.github/workflows/NativeTests.yaml @@ -25,6 +25,7 @@ jobs: fail-fast: false matrix: it: + - alloydb-sample - bigquery-sample - bigquery - core diff --git a/.github/workflows/compatibilityCheck.yaml b/.github/workflows/compatibilityCheck.yaml index 30e5591b72..db350eb65d 100644 --- a/.github/workflows/compatibilityCheck.yaml +++ b/.github/workflows/compatibilityCheck.yaml @@ -24,6 +24,7 @@ jobs: fail-fast: false matrix: it: + - alloydb - bigquery - cloudsql - config diff --git a/.github/workflows/integrationTests.yaml b/.github/workflows/integrationTests.yaml index 56f18792c1..b686369959 100644 --- a/.github/workflows/integrationTests.yaml +++ b/.github/workflows/integrationTests.yaml @@ -27,6 +27,7 @@ jobs: fail-fast: false matrix: it: + - alloydb - bigquery - cloudsql - config diff --git a/docs/src/main/asciidoc/alloydb.adoc b/docs/src/main/asciidoc/alloydb.adoc new file mode 100644 index 0000000000..0eb3e636e9 --- /dev/null +++ b/docs/src/main/asciidoc/alloydb.adoc @@ -0,0 +1,149 @@ +[#alloydb] +== AlloyDB + +Spring Framework on Google Cloud adds integrations with +https://docs.spring.io/spring/docs/current/spring-framework-reference/html/jdbc.html[Spring JDBC], so you can run your PostgreSQL databases in https://cloud.google.com/alloydb[Google AlloyDB] using Spring JDBC. + +The AlloyDB support is provided by Spring Framework on Google Cloud in the form of a Spring Boot AlloyDB starter for PostgreSQL. +The role of the starters is to read configuration from properties and assume default settings so that user experience connecting to PostgreSQL is as simple as possible. + +=== JDBC Support +Maven and Gradle coordinates, using <>: + +[source,xml] +---- + + com.google.cloud + spring-cloud-gcp-starter-alloydb + +---- + +[source,subs="normal"] +---- +dependencies { + implementation("com.google.cloud:spring-cloud-gcp-starter-alloydb") +} +---- + +==== Prerequisites + +In order to use the Spring Boot Starters for Google AlloyDB, the Google AlloyDB API must be enabled in your Google Cloud project. + +To do that, go to the https://console.cloud.google.com/apis/library[API library page] of the Google Cloud Console, search for "AlloyDB API" and enable the option that is called "AlloyDB API" . + + +==== Spring Boot Starter for Google AlloyDB + +The Spring Boot Starters for Google AlloyDB provide an autoconfigured https://docs.oracle.com/javase/7/docs/api/javax/sql/DataSource.html[`DataSource`] object. +Coupled with Spring JDBC, it provides a +https://docs.spring.io/spring/docs/current/spring-framework-reference/html/jdbc.html#jdbc-JdbcTemplate[`JdbcTemplate`] object bean that allows for operations such as querying and modifying a database. + +[source,java] +---- +public List> listUsers() { + return jdbcTemplate.queryForList("SELECT * FROM user;"); +} +---- + +You can rely on +https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html#boot-features-connect-to-production-database[Spring Boot data source autoconfiguration] to configure a `DataSource` bean. +In other words, properties like the username, `spring.datasource.username`, and password, `spring.datasource.password` can be used. +There is also some configuration specific to Google AlloyDB (see "AlloyDB Configuration Properties" section below). + +|=== +| Property name | Description | Required | Default value +| `spring.datasource.username` | Database username | No | `postgres` +| `spring.datasource.password` | Database password | No | `null` +| `spring.datasource.driver-class-name` | JDBC driver to use. | No | `org.postgresql.Driver` +|=== + +NOTE: If you provide your own `spring.datasource.url`, it will be ignored, unless you disable AlloyDB autoconfiguration with `spring.cloud.gcp.alloydb.enabled=false`. + +===== `DataSource` creation flow + +Spring Boot starter for Google AlloyDB registers an `AlloyDbEnvironmentPostProcessor` that provides a correctly formatted `spring.datasource.url` property to the environment based on the properties mentioned above. +It also provides defaults for `spring.datasource.username` and `spring.datasource.driver-class-name`, which can be overridden. +The starter also configures credentials for the JDBC connection based on the AlloyDB properties below. + +The user properties and the properties provided by the `AlloyDbEnvironmentPostProcessor` are then used by https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html[Spring Boot] to create the `DataSource`. +You can select the type of connection pool (e.g., Tomcat, HikariCP, etc.) by https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html#boot-features-connect-to-production-database[adding their dependency to the classpath]. + +Using the created `DataSource` in conjunction with Spring JDBC provides you with a fully configured and operational `JdbcTemplate` object that you can use to interact with your AlloyDB database. +You can connect to your database with as little as a database and instance names. + +=== AlloyDB IAM database authentication + +Currently, AlloyDB supports https://cloud.google.com/alloydb/docs/manage-iam-authn[IAM database authentication]. +It allows you to connect to the database using an IAM account, rather than a predefined database username and password. +You will need to do the following to enable it: + +. In your AlloyDB instance settings, turn on the `alloydb.iam_authentication` flag. +. Add the IAM user or service account to the list of cluster users. +. In the application settings, set `spring.cloud.gcp.alloydb.enable-iam-auth` to `true`. Note that this will also set the database protocol `sslmode` to `disabled`, as it's required for IAM authentication to work. +However, it doesn't compromise the security of the communication because the connection is always encrypted. +. Set `spring.datasource.username` to the IAM user or service account created in step 2. Note that IAM user or service account still needs to be https://www.postgresql.org/docs/current/sql-grant.html[granted permissions] before modifying or querying the database. + +=== AlloyDB Configuration Properties + +|=== +| Property name | Description | Required | Default value +| `spring.cloud.gcp.alloydb.enabled` | Enables or disables AlloyDB auto configuration | No | `true` +| `spring.cloud.gcp.alloydb.database-name` | The name of the database to connect to. | Yes | +| `spring.cloud.gcp.alloydb.instance-connection-uri` | The Google AlloyDB instance connection URI. | Yes | +For example, `projects/PROJECT_ID/locations/REGION_ID/clusters/CLUSTER_ID/instances/INSTANCE_ID`. +| `spring.cloud.gcp.alloydb.ip-type` | Specifies a preferred IP type for connecting to a AlloyDB instance. (`PUBLIC`, `PRIVATE`, `PSC`) | No | `PRIVATE` +| `spring.cloud.gcp.alloydb.enable-iam-auth` | Specifies whether to enable IAM database authentication. | No | `False` +| `spring.cloud.gcp.alloydb.admin-service-endpoint` | An alternate AlloyDB API endpoint. | No | +| `spring.cloud.gcp.alloydb.quota-project` | The project ID for quota and billing. | No | +| `spring.cloud.gcp.alloydb.target-principal` | The service account to impersonate when connecting to the database and database admin API. | No | +| `spring.cloud.gcp.alloydb.delegates` | A comma-separated list of service accounts delegates. | No | +| `spring.cloud.gcp.alloydb.named-connector` | The name of the named connector. | No | +| `spring.cloud.gcp.alloydb.credentials.location` | File system path to the Google OAuth2 credentials private key file. +Used to authenticate and authorize new connections to a Google AlloyDB instance. | No +|=== + +NOTE: You need to run your application from a VM within the created VPC to connect to AlloyDB using its private IP. +To connect using Public IP, enable the AlloyDB instance's for external connections +following https://cloud.google.com/alloydb/docs/connect-public-ip[these instructions] and +add `spring.cloud.gcp.alloydb.ip-type=PUBLIC` to your `application.properties`. + +=== Troubleshooting tips + +[#connection-issues] +==== Connection issues +If you're not able to connect to a database and see an endless loop of `Connecting to AlloyDB instance [...] on IP [...]`, it's likely that exceptions are being thrown and logged at a level lower than your logger's level. +This may be the case with HikariCP, if your logger is set to INFO or higher level. + +To see what's going on in the background, you should add a `logback.xml` file to your application resources folder, that looks like this: + +[source, xml] +---- + + + + + +---- + +==== PostgreSQL: `java.net.SocketException: already connected` issue + +We found this exception to be common if your Maven project's parent is `spring-boot` version `1.5.x`, or in any other circumstance that would cause the version of the `org.postgresql:postgresql` dependency to be an older one (e.g., `9.4.1212.jre7`). + +To fix this, re-declare the dependency in its correct version. +For example, in Maven: + +[source,xml] +---- + + org.postgresql + postgresql + 42.7.3 + +---- + +=== Samples + +Available sample applications and codelabs: + +- https://github.com/GoogleCloudPlatform/spring-cloud-gcp/tree/main/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample[Spring Framework on Google Cloud AlloyDB] +- Codelab: https://codelabs.developers.google.com/create-alloydb-database-with-cloud-run-job[Creating AlloyDB database with Cloud Run Job] \ No newline at end of file diff --git a/docs/src/main/asciidoc/getting-started.adoc b/docs/src/main/asciidoc/getting-started.adoc index dbb2237d07..414d5a206d 100644 --- a/docs/src/main/asciidoc/getting-started.adoc +++ b/docs/src/main/asciidoc/getting-started.adoc @@ -128,6 +128,10 @@ Refer for the full list https://github.com/GoogleCloudPlatform/spring-cloud-gcp/ | Provides a security layer over applications deployed to Firebase | <> +| AlloyDB +| AlloyDB integrations with PostgreSQL +| <> + |=== ==== Spring Initializr @@ -186,6 +190,9 @@ https://github.com/GoogleCloudPlatform/spring-cloud-gcp/tree/main/spring-cloud-g | Cloud Security - Firebase | https://github.com/GoogleCloudPlatform/spring-cloud-gcp/tree/main/spring-cloud-gcp-samples/spring-cloud-gcp-security-firebase-sample[spring-cloud-gcp-security-firebase-sample] + +| AlloyDB +| https://github.com/GoogleCloudPlatform/spring-cloud-gcp/tree/main/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample[spring-cloud-gcp-alloydb-sample] |=== Each sample application demonstrates how to use Spring Framework on Google Cloud libraries in context and how to setup the dependencies for the project. diff --git a/docs/src/main/asciidoc/index.adoc b/docs/src/main/asciidoc/index.adoc index d136674b20..1a18c58b65 100644 --- a/docs/src/main/asciidoc/index.adoc +++ b/docs/src/main/asciidoc/index.adoc @@ -71,6 +71,8 @@ include::cloudfoundry.adoc[] include::kotlin.adoc[] +include::alloydb.adoc[] + == Configuration properties To see the list of all Google Cloud related configuration properties please check link:appendix.html[the Appendix page]. diff --git a/docs/src/main/asciidoc/sagan-index.adoc b/docs/src/main/asciidoc/sagan-index.adoc index 070116d054..944088b35e 100644 --- a/docs/src/main/asciidoc/sagan-index.adoc +++ b/docs/src/main/asciidoc/sagan-index.adoc @@ -11,6 +11,7 @@ Project features include: * Spring Data Cloud Datastore * Spring Data Reactive Repositories for Cloud Firestore * Spring Data Cloud SQL +* Spring Data AlloyDB * Google Cloud Logging & Tracing * Google Cloud Storage (Spring Resource and Spring Integration) * Google Cloud Vision API Template @@ -100,6 +101,10 @@ A sample of these artifacts are provided below. | Extracts IAP identity information from applications deployed to Firebase | `com.google.cloud:spring-cloud-gcp-starter-security-firebase` +| AlloyDB +| AlloyDB integrations with PostgreSQL +| `com.google.cloud:spring-cloud-gcp-starter-alloydb` + |=== == Code Samples diff --git a/spring-cloud-gcp-autoconfigure/pom.xml b/spring-cloud-gcp-autoconfigure/pom.xml index ce076f3e84..2234aad1d1 100644 --- a/spring-cloud-gcp-autoconfigure/pom.xml +++ b/spring-cloud-gcp-autoconfigure/pom.xml @@ -298,6 +298,12 @@ + + + com.google.cloud + alloydb-jdbc-connector + + org.springframework.cloud diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbEnvironmentPostProcessor.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbEnvironmentPostProcessor.java new file mode 100644 index 0000000000..dc98efb3ca --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbEnvironmentPostProcessor.java @@ -0,0 +1,155 @@ +/* + * 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.spring.autoconfigure.alloydb; + +import com.google.cloud.alloydb.ConnectorRegistry; +import java.util.HashMap; +import java.util.Map; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.bind.PlaceholdersResolver; +import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Provides Google AlloyDB instance connectivity through Spring JDBC by + * providing only a database and instance connection URI. + */ +public class AlloyDbEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String JDBC_URL_TEMPLATE = "jdbc:postgresql:///%s?socketFactory=com.google.cloud.alloydb.SocketFactory"; + private static final String JDBC_DRIVER_CLASS = "org.postgresql.Driver"; + private static final String DEFAULT_USERNAME = "postgres"; + + @Override + public void postProcessEnvironment( + ConfigurableEnvironment environment, SpringApplication application) { + + if (environment.getPropertySources().contains("bootstrap")) { + // Do not run in the bootstrap phase as the user configuration is not available + // yet + return; + } + + boolean isAlloyDbEnabled = Boolean.parseBoolean(environment.getProperty("spring.cloud.gcp.alloydb.enabled", "true")); + if (!isAlloyDbEnabled) { + return; + } + + String propertiesPrefix = AlloyDbProperties.class.getAnnotation(ConfigurationProperties.class).value(); + AlloyDbProperties alloyDbProperties = new Binder( + ConfigurationPropertySources.get(environment), + new NonSecretsManagerPropertiesPlaceholdersResolver(environment), + null, + null, + null) + .bind(propertiesPrefix, AlloyDbProperties.class) + .orElse(new AlloyDbProperties()); + + if (isOnClasspath("com.google.cloud.alloydb.SocketFactory") + && isOnClasspath(JDBC_DRIVER_CLASS)) { + // configure default JDBC driver and username as fallback values when not + // specified + Map fallbackMap = new HashMap<>(); + fallbackMap.put("spring.datasource.username", DEFAULT_USERNAME); + fallbackMap.put( + "spring.datasource.driver-class-name", JDBC_DRIVER_CLASS); + environment + .getPropertySources() + .addLast(new MapPropertySource("ALLOYDB_DATA_SOURCE_FALLBACK", fallbackMap)); + + // always set the spring.datasource.url property in the environment + Map primaryMap = new HashMap<>(); + primaryMap.put("spring.datasource.url", getJdbcUrl(alloyDbProperties)); + environment + .getPropertySources() + .addFirst(new MapPropertySource("ALLOYDB_DATA_SOURCE_URL", primaryMap)); + } + + // support usage metrics + ConnectorRegistry.addArtifactId( + "spring-cloud-gcp-alloydb/" + this.getClass().getPackage().getImplementationVersion()); + } + + private String getJdbcUrl(AlloyDbProperties properties) { + String jdbcUrl = String.format(JDBC_URL_TEMPLATE, properties.getDatabaseName()); + + if (StringUtils.hasText(properties.getInstanceConnectionUri())) { + jdbcUrl += "&alloydbInstanceName=" + properties.getInstanceConnectionUri(); + } + + if (StringUtils.hasText(properties.getIpType())) { + jdbcUrl += "&alloydbIpType=" + properties.getIpType(); + } + + if (properties.isEnableIamAuth()) { + jdbcUrl += "&alloydbEnableIAMAuth=true&sslmode=disable"; + } + + if (StringUtils.hasText(properties.getAdminServiceEndpoint())) { + jdbcUrl += "&alloydbAdminServiceEndpoint=" + properties.getAdminServiceEndpoint(); + } + + if (StringUtils.hasText(properties.getQuotaProject())) { + jdbcUrl += "&alloydbQuotaProject=" + properties.getQuotaProject(); + } + + if (StringUtils.hasText(properties.getTargetPrincipal())) { + jdbcUrl += "&alloydbTargetPrincipal=" + properties.getTargetPrincipal(); + } + + if (StringUtils.hasText(properties.getDelegates())) { + jdbcUrl += "&alloydbDelegates=" + properties.getDelegates(); + } + + if (StringUtils.hasText(properties.getNamedConnector())) { + jdbcUrl += "&alloydbNamedConnector=" + properties.getNamedConnector(); + } + + return jdbcUrl; + } + + private boolean isOnClasspath(String className) { + return ClassUtils.isPresent(className, null); + } + + private static class NonSecretsManagerPropertiesPlaceholdersResolver + implements PlaceholdersResolver { + + private PlaceholdersResolver resolver; + + NonSecretsManagerPropertiesPlaceholdersResolver(Environment environment) { + this.resolver = new PropertySourcesPlaceholdersResolver(environment); + } + + @Override + public Object resolvePlaceholders(Object value) { + if (value.toString().contains("sm://")) { + return value; + } else { + return resolver.resolvePlaceholders(value); + } + } + } +} \ No newline at end of file diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbProperties.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbProperties.java new file mode 100644 index 0000000000..49f629e552 --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbProperties.java @@ -0,0 +1,149 @@ +/* + * 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.spring.autoconfigure.alloydb; + +import com.google.cloud.spring.core.Credentials; +import com.google.cloud.spring.core.CredentialsSupplier; +import com.google.cloud.spring.core.GcpScope; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; + +@ConfigurationProperties("spring.cloud.gcp.alloydb") +public class AlloyDbProperties implements CredentialsSupplier { + + /** Overrides the GCP OAuth2 credentials specified in the Core module. */ + @NestedConfigurationProperty + private final Credentials credentials = new Credentials(GcpScope.CLOUD_PLATFORM.getUrl()); + + /** Overrides the GCP Project ID specified in the Core module. */ + private String projectId; + + /** Name of the database in the AlloyDB instance. */ + private String databaseName; + + /** AlloyDB instance connection URI. */ + private String instanceConnectionUri; + + /** Type of IP to be used: PRIVATE, PUBLIC, PSC. */ + private String ipType; + + /** Service account impersonation. */ + private String targetPrincipal; + + /** + * Comma-separated list of service accounts containing chained list of delegates + * required to grant the final access_token. + */ + private String delegates; + + /** Admin API Service Endpoint. */ + private String adminServiceEndpoint; + + /** GCP Project ID for quota and billing. */ + private String quotaProject; + + /** Named Connector */ + private String namedConnector; + + /** Specifies whether to enable IAM database authentication. */ + private boolean enableIamAuth; + + @Override + public Credentials getCredentials() { + return credentials; + } + + public String getProjectId() { + return projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getDatabaseName() { + return this.databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + + public String getInstanceConnectionUri() { + return this.instanceConnectionUri; + } + + public void setInstanceConnectionUri(String instanceConnectionUri) { + this.instanceConnectionUri = instanceConnectionUri; + } + + public boolean isEnableIamAuth() { + return enableIamAuth; + } + + public void setEnableIamAuth(boolean enableIamAuth) { + this.enableIamAuth = enableIamAuth; + } + + public String getIpType() { + return this.ipType; + } + + public void setIpType(String ipType) { + this.ipType = ipType; + } + + public String getTargetPrincipal() { + return this.targetPrincipal; + } + + public void setTargetPrincipal(String targetPrincipal) { + this.targetPrincipal = targetPrincipal; + } + + public String getDelegates() { + return this.delegates; + } + + public void setDelegates(String delegates) { + this.delegates = delegates; + } + + public String getAdminServiceEndpoint() { + return this.adminServiceEndpoint; + } + + public void setAdminServiceEndpoint(String adminServiceEndpoint) { + this.adminServiceEndpoint = adminServiceEndpoint; + } + + public String getQuotaProject() { + return this.quotaProject; + } + + public void setQuotaProject(String quotaProject) { + this.quotaProject = quotaProject; + } + + public String getNamedConnector() { + return this.namedConnector; + } + + public void setNamedConnector(String namedConnector) { + this.namedConnector = namedConnector; + } +} diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbRuntimeHints.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbRuntimeHints.java new file mode 100644 index 0000000000..78d5b65645 --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbRuntimeHints.java @@ -0,0 +1,38 @@ +/* + * 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.spring.autoconfigure.alloydb; + +import java.util.Arrays; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.TypeReference; + +public class AlloyDbRuntimeHints implements RuntimeHintsRegistrar { + @Override + public void registerHints( + org.springframework.aot.hint.RuntimeHints hints, ClassLoader classLoader) { + hints + .reflection() + .registerTypes( + Arrays.asList(TypeReference.of(AlloyDbProperties.class)), + hint -> + hint.withMembers( + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS, + MemberCategory.DECLARED_FIELDS)); + } +} diff --git a/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/package-info.java b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/package-info.java new file mode 100644 index 0000000000..0e5201d805 --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/alloydb/package-info.java @@ -0,0 +1,18 @@ +/* + * 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. + */ + +/** Auto-configuration for Spring Cloud GCP AlloyDB module. */ +package com.google.cloud.spring.autoconfigure.alloydb; diff --git a/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 0ed1fcdc82..635ae3bd4b 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -143,6 +143,12 @@ "type": "java.lang.Boolean", "description": "Whether to enable the Pub/Sub health indicator when used with Spring Boot Actuator.", "defaultValue": true + }, + { + "name": "spring.cloud.gcp.alloydb.enabled", + "type": "java.lang.Boolean", + "description": "Auto-configure Google Alloydb support components.", + "defaultValue": true } ] } diff --git a/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring.factories index 170b2869c6..89fa109874 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring.factories @@ -1,6 +1,7 @@ org.springframework.cloud.bootstrap.BootstrapConfiguration=\ com.google.cloud.spring.autoconfigure.config.GcpConfigBootstrapConfiguration org.springframework.boot.env.EnvironmentPostProcessor=\ +com.google.cloud.spring.autoconfigure.alloydb.AlloyDbEnvironmentPostProcessor,\ com.google.cloud.spring.autoconfigure.sql.CloudSqlEnvironmentPostProcessor,\ com.google.cloud.spring.autoconfigure.sql.R2dbcCloudSqlEnvironmentPostProcessor,\ com.google.cloud.spring.autoconfigure.secretmanager.GcpSecretManagerEnvironmentPostProcessor diff --git a/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring/aot.factories b/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring/aot.factories index a417748e88..7f060bccc7 100644 --- a/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring/aot.factories +++ b/spring-cloud-gcp-autoconfigure/src/main/resources/META-INF/spring/aot.factories @@ -1,2 +1,3 @@ org.springframework.aot.hint.RuntimeHintsRegistrar=\ - com.google.cloud.spring.autoconfigure.sql.SqlRuntimeHints \ No newline at end of file + com.google.cloud.spring.autoconfigure.sql.SqlRuntimeHints,\ + com.google.cloud.spring.autoconfigure.alloydb.AlloyDbRuntimeHints diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbEnvironmentPostProcessorTests.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbEnvironmentPostProcessorTests.java new file mode 100644 index 0000000000..69e8d860d3 --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbEnvironmentPostProcessorTests.java @@ -0,0 +1,380 @@ +/* + * 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.spring.autoconfigure.alloydb; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.api.gax.core.CredentialsProvider; +import com.google.api.gax.core.NoCredentialsProvider; +import com.zaxxer.hikari.HikariDataSource; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.PropertySource; + +class AlloyDbEnvironmentPostProcessorTests { + + private AlloyDbEnvironmentPostProcessor initializer = new AlloyDbEnvironmentPostProcessor(); + + private static final String INSTANCE_URI = "projects/test-proj/locations/us-central1/clusters/test-cluster/instances/test-instance"; + private static final String SERVICE_ENDPOINT = "googleapis.example.com:443"; + + private ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.gcp.alloydb.database-name=test-database") + .withInitializer(configurableApplicationContext -> initializer.postProcessEnvironment( + configurableApplicationContext.getEnvironment(), new SpringApplication())) + .withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class)) + .withUserConfiguration(Config.class); + + @Test + void testAlloyDbDataSource() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.datasource.password=") + .run( + context -> { + HikariDataSource dataSource = (HikariDataSource) context.getBean(DataSource.class); + assertThat(dataSource.getDriverClassName()).matches("org.postgresql.Driver"); + assertThat(dataSource.getJdbcUrl()) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + assertThat(dataSource.getUsername()).matches("postgres"); + assertThat(dataSource.getPassword()).isNull(); + }); + } + + @Test + void testAlloyDbSpringDataSourceUrlPropertyOverride() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.datasource.password=", + "spring.datasource.url=jdbc:h2:mem:none;MODE=MySQL;DB_CLOSE_ON_EXIT=FALSE") + .run( + context -> { + HikariDataSource dataSource = (HikariDataSource) context.getBean(DataSource.class); + assertThat(dataSource.getDriverClassName()).matches("org.postgresql.Driver"); + assertThat(dataSource.getJdbcUrl()) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + assertThat(dataSource.getUsername()).matches("postgres"); + assertThat(dataSource.getPassword()).isNull(); + assertThat(getSpringDatasourceDriverClassName(context)) + .matches("org.postgresql.Driver"); + assertThat(context.getEnvironment().getProperty("spring.datasource.url")) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + }); + } + + @Test + void testAlloyDbDataSourceWithIgnoredProvidedUrl() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.datasource.password=", + "spring.datasource.url=test-url") + .run( + context -> { + HikariDataSource dataSource = (HikariDataSource) context.getBean(DataSource.class); + assertThat(dataSource.getDriverClassName()).matches("org.postgresql.Driver"); + assertThat(dataSource.getJdbcUrl()) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + assertThat(dataSource.getUsername()).matches("postgres"); + assertThat(dataSource.getPassword()).isNull(); + assertThat(getSpringDatasourceDriverClassName(context)) + .matches("org.postgresql.Driver"); + }); + } + + @Test + void testUserAndPassword() { + this.contextRunner + .withPropertyValues( + "spring.datasource.username=watchmaker", + "spring.datasource.password=pass", + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI)) + .run( + context -> { + HikariDataSource dataSource = (HikariDataSource) context.getBean(DataSource.class); + assertThat(getSpringDatasourceUrl(context)) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + assertThat(dataSource.getUsername()).matches("watchmaker"); + assertThat(dataSource.getPassword()).matches("pass"); + assertThat(getSpringDatasourceDriverClassName(context)) + .matches("org.postgresql.Driver"); + }); + } + + @Test + void testInstanceConnectionUri() { + this.contextRunner + .withPropertyValues(String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI)) + .run( + context -> { + assertThat(getSpringDatasourceUrl(context)) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + assertThat(getSpringDatasourceDriverClassName(context)) + .matches("org.postgresql.Driver"); + }); + } + + @Test + void testIamAuth() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.enable-iam-auth=true") + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()) + .contains("&alloydbEnableIAMAuth=true&sslmode=disable"); + }); + } + + @Test + void testIpType() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.ip-type=PUBLIC") + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()).contains("&alloydbIpType=PUBLIC"); + }); + } + + @Test + void testAdminServiceEndpoint() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + String.format("spring.cloud.gcp.alloydb.admin-service-endpoint=%s", SERVICE_ENDPOINT)) + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()).contains(String.format("&alloydbAdminServiceEndpoint=%s", SERVICE_ENDPOINT)); + }); + } + + @Test + void testQuotaProject() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.quota-project=new-project") + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()).contains("&alloydbQuotaProject=new-project"); + }); + } + + @Test + void testTargetPrincipal() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.target-principal=IMPERSONATED_USER") + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()).contains("&alloydbTargetPrincipal=IMPERSONATED_USER"); + }); + } + + @Test + void testDelegates() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.delegates=IMPERSONATED_USER") + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()).contains("&alloydbDelegates=IMPERSONATED_USER"); + }); + } + + @Test + void testNamedConnector() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.named-connector=test-connector") + .run( + context -> { + DataSourceProperties dataSourceProperties = context.getBean(DataSourceProperties.class); + assertThat(dataSourceProperties.getUrl()).contains("&alloydbNamedConnector=test-connector"); + }); + } + + @Test + void testDataSourceProperties() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.datasource.hikari.connection-test-query=select 1", + "spring.datasource.hikari.maximum-pool-size=19") + .run( + context -> { + HikariDataSource dataSource = (HikariDataSource) context.getBean(DataSource.class); + assertThat(getSpringDatasourceUrl(context)) + .isEqualTo( + "jdbc:postgresql:///test-database?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + assertThat(getSpringDatasourceDriverClassName(context)) + .matches("org.postgresql.Driver"); + assertThat(dataSource.getMaximumPoolSize()).isEqualTo(19); + assertThat(dataSource.getConnectionTestQuery()).matches("select 1"); + }); + } + + @Test + void testSkipIfAlloyDbPropertyIsDisabled() { + new ApplicationContextRunner() + .withPropertyValues("spring.cloud.gcp.alloydb.enabled=false") + .withInitializer( + configurableApplicationContext -> + initializer.postProcessEnvironment( + configurableApplicationContext.getEnvironment(), new SpringApplication())) + .run( + context -> { + assertThat(getSpringDatasourceUrl(context)).isNull(); + }); + } + + @Test + void testSecretManagerPlaceholdersNotResolved() { + this.contextRunner + .withPropertyValues( + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.database-name=${sm://my-db}") + .run( + context -> { + assertThat( + context + .getEnvironment() + .getPropertySources() + .get("ALLOYDB_DATA_SOURCE_URL") + .getProperty("spring.datasource.url")) + .isEqualTo( + "jdbc:postgresql:///${sm://my-db}?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + }); + } + + @Test + void testEnvPlaceholdersResolved() { + this.contextRunner + .withPropertyValues( + "DB_NAME=mydb", + String.format("spring.cloud.gcp.alloydb.instance-connection-uri=%s", INSTANCE_URI), + "spring.cloud.gcp.alloydb.database-name=${DB_NAME:not_available}") + .run( + context -> { + assertThat( + context + .getEnvironment() + .getPropertySources() + .get("ALLOYDB_DATA_SOURCE_URL") + .getProperty("spring.datasource.url")) + .isEqualTo( + "jdbc:postgresql:///mydb?" + + "socketFactory=com.google.cloud.alloydb.SocketFactory" + + String.format("&alloydbInstanceName=%s", INSTANCE_URI)); + }); + } + + @Test + void testSkipOnBootstrap() { + new ApplicationContextRunner() + .withPropertyValues("spring.cloud.gcp.alloydb.databaseName=test-database") + .withInitializer( + new ApplicationContextInitializer() { + @Override + public void initialize( + ConfigurableApplicationContext configurableApplicationContext) { + // add a property source called "bootstrap" to mark it as the bootstrap phase + configurableApplicationContext + .getEnvironment() + .getPropertySources() + .addFirst( + new PropertySource("bootstrap") { + @Override + public Object getProperty(String name) { + return null; + } + }); + } + }) + .withInitializer( + configurableApplicationContext -> + initializer.postProcessEnvironment( + configurableApplicationContext.getEnvironment(), new SpringApplication())) + .run( + context -> { + assertThat(getSpringDatasourceUrl(context)).isNull(); + }); + } + + private String getSpringDatasourceUrl(ApplicationContext context) { + return context.getEnvironment().getProperty("spring.datasource.url"); + } + + private String getSpringDatasourceDriverClassName(ApplicationContext context) { + return context.getEnvironment().getProperty("spring.datasource.driver-class-name"); + } + + @Configuration + static class Config { + + @Bean + public CredentialsProvider credentialsProvider() { + return NoCredentialsProvider.create(); + } + } +} diff --git a/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbRuntimeHintsTest.java b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbRuntimeHintsTest.java new file mode 100644 index 0000000000..9c346503e6 --- /dev/null +++ b/spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/alloydb/AlloyDbRuntimeHintsTest.java @@ -0,0 +1,37 @@ +/* + * 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.spring.autoconfigure.alloydb; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.aot.hint.predicate.RuntimeHintsPredicates.reflection; + +import org.junit.jupiter.api.Test; +import org.springframework.aot.hint.RuntimeHints; + +class AlloyDbRuntimeHintsTest { + @Test + void registerAlloyDbProperties() { + RuntimeHints runtimeHints = new RuntimeHints(); + AlloyDbRuntimeHints registrar = new AlloyDbRuntimeHints(); + registrar.registerHints(runtimeHints, null); + assertThat(runtimeHints).matches(reflection().onType(AlloyDbProperties.class)); + assertThat(runtimeHints) + .matches(reflection().onField(AlloyDbProperties.class, "instanceConnectionUri")); + assertThat(runtimeHints) + .matches(reflection().onField(AlloyDbProperties.class, "databaseName")); + } +} diff --git a/spring-cloud-gcp-dependencies/pom.xml b/spring-cloud-gcp-dependencies/pom.xml index 986c34dc8d..0dcc443e74 100644 --- a/spring-cloud-gcp-dependencies/pom.xml +++ b/spring-cloud-gcp-dependencies/pom.xml @@ -38,6 +38,7 @@ 1.16.0 1.0.4.RELEASE 1.3.0 + 1.1.1 @@ -239,6 +240,18 @@ spring-cloud-spanner-spring-data-r2dbc ${project.version} + + com.google.cloud + spring-cloud-gcp-starter-alloydb + ${project.version} + + + + + com.google.cloud + alloydb-jdbc-connector + ${alloydb-jdbc-connector.version} + diff --git a/spring-cloud-gcp-samples/pom.xml b/spring-cloud-gcp-samples/pom.xml index fd85e5de66..9b926c6e63 100644 --- a/spring-cloud-gcp-samples/pom.xml +++ b/spring-cloud-gcp-samples/pom.xml @@ -85,6 +85,7 @@ spring-cloud-gcp-metrics-sample spring-cloud-gcp-kms-sample spring-cloud-spanner-r2dbc-samples + spring-cloud-gcp-alloydb-sample @@ -114,6 +115,7 @@ spring-cloud-gcp-sql-mysql-sample spring-cloud-gcp-sql-postgres-sample spring-cloud-gcp-sql-postgres-r2dbc-sample + spring-cloud-gcp-alloydb-sample @@ -145,6 +147,7 @@ true true true + true spring-cloud-gcp-ci-native CiQA04Sv0lCnHhQQIwKI0XDU+XcU6q1ryy+VuQJ6oh6VF8eJRocSLgBVx+glEafbllCrwhS8utmM9RYAC80xizA/YYyjE/roXcRDj9AGpfTS3iDKg5E= gcp-storage-resource-bucket-sample @@ -155,6 +158,8 @@ code_samples_test_db ${env.DB_PASSWORD} ${env.DB_PASSWORD} + projects/spring-cloud-gcp-ci-native/locations/us-central1/clusters/testcluster/instances/testpostgres + code_samples_test_db @@ -178,6 +183,8 @@ /tmp/gcp_integration_tests/integration_storage_sample spring-cloud-gcp-ci:us-central1:testmysql code_samples_test_db + projects/spring-cloud-gcp-ci-native/locations/us-central1/clusters/testcluster/instances/testpostgres + code_samples_test_db diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/README.adoc b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/README.adoc new file mode 100644 index 0000000000..ec3e267c7c --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/README.adoc @@ -0,0 +1,48 @@ += Spring Framework on Google Cloud AlloyDB Sample + +This code sample demonstrates how to connect to a Google Cloud AlloyDB instance using the link:../../spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb[Spring Framework on Google Cloud AlloyDB Starter]. + +You will create a cluster and primary instance, a database within the instance, populate the database and then query it. + +== Setup + +image:http://gstatic.com/cloudssh/images/open-btn.svg[link=https://ssh.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https%3A%2F%2Fgithub.com%2FGoogleCloudPlatform%2Fspring-cloud-gcp&cloudshell_open_in_editor=spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/README.adoc] + +1. Create a Google AlloyDB cluster and its primary instance following https://cloud.google.com/alloydb/docs/quickstart/integrate-cloud-run[these instructions]. + + You will be asked to set a password for the `postgres` root user; remember this value. + +2. Open the link:src/main/resources/application.properties[application.properties] file and set the following properties: +- `spring.datasource.password` - Set this to the password that you chose for the `postgres` user. +- `spring.cloud.gcp.alloydb.database-name` - Set this to the name of the database you created. +- `spring.cloud.gcp.alloydb.instance-connection-uri` - Set this to the instance URI of your AlloyDB instance. +The instance-connection-uri should be in the form: `projects/PROJECT_ID/locations/REGION_ID/clusters/CLUSTER_ID/instances/INSTANCE_ID`. ++ +For example, your instance connection name might look like: `projects/my-gcp-project/locations/us-central1/clusters/test-cluster/instances/test-instance` + ++ +If you would like to use a different user, set the `spring.datasource.username` property appropriately. + +3. https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login[If you are authenticated in the Cloud SDK], your credentials will be automatically found by the Spring Boot Starter for Google Cloud AlloyDB. ++ +Alternatively, https://console.cloud.google.com/iam-admin/serviceaccounts[create a service account from the Google Cloud Console] and download its private key. +Then, uncomment the `spring.cloud.gcp.alloydb.credentials.location` property in the link:src/main/resources/application.properties[application.properties] file and fill its value with the path to your service account private key on your local file system, prepended with `file:`. + +4. Run `$ mvn clean install` from the root directory of the project. + +== Running the application + +NOTE: You need to run the sample from a VM within the created VPC to connect to AlloyDB using its private IP. +To connect using Public IP, enable the AlloyDB instance's for external connections +following https://cloud.google.com/alloydb/docs/connect-public-ip[these instructions] and +add `spring.cloud.gcp.alloydb.ip-type=PUBLIC` to your `application.properties`. + +You can run the `AlloyDBApplication` Spring Boot app by running the following command in the same directory as this +sample (spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample): + +`$ mvn spring-boot:run` + +The database will be populated based on the link:src/main/resources/schema.sql[schema.sql] and link:src/main/resources/data.sql[data.sql] files. + +When the application is up, navigate to http://localhost:8080/getTuples in your browser, or use the `Web Preview` +button in Cloud Shell to preview the app on port 8080. This will print the contents of the `users` table. diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/pom.xml b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/pom.xml new file mode 100644 index 0000000000..23419d7a58 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/pom.xml @@ -0,0 +1,64 @@ + + + + + spring-cloud-gcp-samples + com.google.cloud + 5.1.2 + + 4.0.0 + + spring-cloud-gcp-alloydb-sample + Spring Framework on Google Cloud Code Sample - AlloyDB + + + + + + com.google.cloud + spring-cloud-gcp-dependencies + ${project.version} + pom + import + + + + + + + com.google.cloud + spring-cloud-gcp-starter-alloydb + + + org.springframework.boot + spring-boot-starter-web + + + + + org.awaitility + awaitility + 4.2.0 + test + + + junit + junit + test + + + org.springframework.boot + spring-boot-starter-test + test + + + commons-io + commons-io + 2.15.1 + test + + + + diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/java/com/example/AlloyDbApplication.java b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/java/com/example/AlloyDbApplication.java new file mode 100644 index 0000000000..515f03776e --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/java/com/example/AlloyDbApplication.java @@ -0,0 +1,29 @@ +/* + * 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.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** Sample application. */ +@SpringBootApplication +public class AlloyDbApplication { + + public static void main(String[] args) { + SpringApplication.run(AlloyDbApplication.class, args); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/java/com/example/WebController.java b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/java/com/example/WebController.java new file mode 100644 index 0000000000..ec981bb34e --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/java/com/example/WebController.java @@ -0,0 +1,41 @@ +/* + * 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.example; + +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Web app controller for sample app. */ +@RestController +public class WebController { + + private final JdbcTemplate jdbcTemplate; + + public WebController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping("/getTuples") + public List getTuples() { + return this.jdbcTemplate.queryForList("SELECT * FROM users").stream() + .map(m -> m.values().toString()) + .collect(Collectors.toList()); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/application.properties b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/application.properties new file mode 100644 index 0000000000..9ca477c342 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/application.properties @@ -0,0 +1,28 @@ +# Set to the Postgres user you want to connect to; 'postgres' is the default user. +spring.datasource.username=postgres +spring.datasource.password=[database-user-password] + +# spring.cloud.gcp.project-id= + +spring.cloud.gcp.alloydb.database-name=[database-name] + +# This value is formatted in the form: projects/PROJECT_ID/locations/REGION_ID/clusters/CLUSTER_ID/instances/INSTANCE_ID +spring.cloud.gcp.alloydb.instance-connection-uri=[instance-connection-uri] + +# The IP type options are: PRIVATE (default), PUBLIC, PSC. +# spring.cloud.gcp.alloydb.ip-type=PUBLIC +# spring.cloud.gcp.alloydb.target-principal=[target-principal] +# spring.cloud.gcp.alloydb.delegates=[delegates] +# spring.cloud.gcp.alloydb.admin-service-endpoint=[admin-service-endpoint] +# spring.cloud.gcp.alloydb.quota-project=[quota-project] +# spring.cloud.gcp.alloydb.enable-iam-auth=true +# spring.cloud.gcp.alloydb.named-connector=[named-connector] +# spring.cloud.gcp.alloydb.credentials.location=file:/ + +# So app starts despite "table already exists" errors. +spring.sql.init.continue-on-error=true +# Enforces database initialization +spring.sql.init.mode=always + +# Set the logging level +# logging.level.root=DEBUG \ No newline at end of file diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/data.sql b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/data.sql new file mode 100644 index 0000000000..ff9001a36f --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/data.sql @@ -0,0 +1,4 @@ +INSERT INTO users VALUES + ('luisao@example.com', 'Anderson', 'Silva'), + ('jonas@example.com', 'Jonas', 'Goncalves'), + ('fejsa@example.com', 'Ljubomir', 'Fejsa'); diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/schema.sql b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/schema.sql new file mode 100644 index 0000000000..1ac8ef2b4b --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + email VARCHAR(255), + first_name VARCHAR(255), + last_name VARCHAR(255), + PRIMARY KEY (email)); diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/test/java/com/example/AlloyDbSampleApplicationIntegrationTests.java b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/test/java/com/example/AlloyDbSampleApplicationIntegrationTests.java new file mode 100644 index 0000000000..cf8e9b5e78 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/test/java/com/example/AlloyDbSampleApplicationIntegrationTests.java @@ -0,0 +1,73 @@ +/* + * 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.example; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** Simple integration test to verify the AlloyDB sample application with Postgres. */ +@EnabledIfSystemProperty(named = "it.alloydb", matches = "true") +@ExtendWith(SpringExtension.class) +@SpringBootTest( + webEnvironment = WebEnvironment.RANDOM_PORT, + classes = {AlloyDbApplication.class}, + properties = { + "spring.cloud.gcp.alloydb.database-name=code_samples_test_db", + "spring.cloud.gcp.alloydb.instance-connection-uri=projects/${GCLOUD_PROJECT}/locations/us-central1/clusters/testcluster/instances/testpostgres", + "spring.datasource.username=postgres", + "spring.sql.init.continue-on-error=true", + "spring.sql.init.mode=always", + "spring.cloud.gcp.alloydb.ip-type=PUBLIC" + }) +class AlloyDbSampleApplicationIntegrationTests { + + @Autowired private TestRestTemplate testRestTemplate; + + @Autowired private JdbcTemplate jdbcTemplate; + + @AfterEach + void clearTable() { + this.jdbcTemplate.execute("DROP TABLE IF EXISTS users"); + } + + @Test + void testSqlRowsAccess() { + ResponseEntity> result = + this.testRestTemplate.exchange( + "/getTuples", HttpMethod.GET, null, new ParameterizedTypeReference>() {}); + + assertThat(result.getBody()) + .containsExactlyInAnyOrder( + "[luisao@example.com, Anderson, Silva]", + "[jonas@example.com, Jonas, Goncalves]", + "[fejsa@example.com, Ljubomir, Fejsa]"); + } +} diff --git a/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/test/resources/logback-test.xml b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..5535de3c96 --- /dev/null +++ b/spring-cloud-gcp-samples/spring-cloud-gcp-alloydb-sample/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/spring-cloud-gcp-starters/pom.xml b/spring-cloud-gcp-starters/pom.xml index 0e786da74e..62460f7554 100644 --- a/spring-cloud-gcp-starters/pom.xml +++ b/spring-cloud-gcp-starters/pom.xml @@ -42,5 +42,6 @@ spring-cloud-gcp-starter-security-firebase spring-cloud-gcp-starter-secretmanager spring-cloud-gcp-starter-kms + spring-cloud-gcp-starter-alloydb diff --git a/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb/.repo.metadata.json b/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb/.repo.metadata.json new file mode 100644 index 0000000000..8a5e766d09 --- /dev/null +++ b/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb/.repo.metadata.json @@ -0,0 +1,11 @@ +{ + "name": "spring-cloud-gcp-starter-alloydb", + "name_pretty": "Spring Cloud GCP Support for AlloyDB (PostgreSQL)", + "client_documentation": "https://spring.io/projects/spring-cloud-gcp", + "api_description": "Provides support for PostgreSQL databases in Google AlloyDB using Spring JDBC", + "release_level": "ga", + "language": "java", + "repo": "googlecloudplatform/spring-cloud-gcp", + "repo_short": "spring-cloud-gcp", + "distribution_name": "com.google.cloud:spring-cloud-gcp-starter-alloydb" +} diff --git a/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb/pom.xml b/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb/pom.xml new file mode 100644 index 0000000000..e5661e6419 --- /dev/null +++ b/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb/pom.xml @@ -0,0 +1,38 @@ + + + + spring-cloud-gcp-starters + com.google.cloud + 5.1.2 + + 4.0.0 + + spring-cloud-gcp-starter-alloydb + Spring Framework on Google Cloud Starter - AlloyDB + Starter for Google Cloud AlloyDB + https://github.com/GoogleCloudPlatform/spring-cloud-gcp/tree/main/spring-cloud-gcp-starters/spring-cloud-gcp-starter-alloydb + + + + com.google.cloud + spring-cloud-gcp-starter + + + org.springframework.boot + spring-boot-starter-jdbc + + + + + com.google.cloud + alloydb-jdbc-connector + + + org.postgresql + postgresql + + + +