Spring Boot 3.2.5

The explicitly configured usernames and passwords are not used when using the Docker Compose support:

spring:
  datasource:
    hikari:
      username: example_rw
      password: example_rw
  liquibase:
    user: example_ow
    password: example_ow

they should not be overwritten by the one configured in compose.yaml:

services:
  db:
    environment:
      POSTGRES_USER: sa
      POSTGRES_PASSWORD: sa

Logs

$ ./gradlew bootRun
...
liquibase.database : Connected to sa@jdbc:postgresql://127.0.0.1:5432/example?ApplicationName=docker-compose-datasource-test
...
com.zaxxer.hikari.HikariConfig : jdbcUrl.........................jdbc:postgresql://127.0.0.1:5432/example?ApplicationName=docker-compose-datasource-test
...
com.zaxxer.hikari.HikariConfig : schema.........................."example"
...
com.zaxxer.hikari.HikariConfig : username........................"sa"
$ docker compose logs db -f

... POSTGRES_DB from environment is created with 'sa' - correct

db-1  | 2024-05-16 09:25:45.782 UTC [47] LOG:  connection received: host=[local]
db-1  | 2024-05-16 09:25:45.783 UTC [47] LOG:  connection authorized: user=sa database=postgres application_name=psql
db-1  | 2024-05-16 09:25:45.785 UTC [47] LOG:  statement: CREATE DATABASE "example" ;

... init scripts use 'sa' - correct

db-1  | /usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/001-create-users-and-database.sh
db-1  | 2024-05-16 09:25:45.832 UTC [50] LOG:  connection received: host=[local]
db-1  | 2024-05-16 09:25:45.832 UTC [50] LOG:  connection authorized: user=sa database=example application_name=psql
db-1  | 2024-05-16 09:25:45.842 UTC [50] LOG:  statement: REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;

... liquibase uses 'sa' - should be 'example_ow'

db-1  | 2024-05-16 09:25:48.670 UTC [65] LOG:  connection received: host=192.168.65.1 port=40715
db-1  | 2024-05-16 09:25:48.751 UTC [65] LOG:  connection authenticated: identity="sa" method=scram-sha-256 (/var/lib/postgresql/data/pg_hba.conf:128)
db-1  | 2024-05-16 09:25:48.751 UTC [65] LOG:  connection authorized: user=sa database=example
...
db-1  | 2024-05-16 09:25:49.509 UTC [65] LOG:  execute <unnamed>: CREATE TABLE example.databasechangeloglock (ID INTEGER NOT NULL, LOCKED BOOLEAN NOT NULL, LOCKGRANTED TIMESTAMP WITHOUT TIME ZONE, LOCKEDBY VARCHAR(255), CONSTRAINT databasechangeloglock_pkey PRIMARY KEY (ID))

... hikari uses 'sa' - should be 'example_rw'

db-1  | 2024-05-16 09:26:38.544 UTC [32] LOG:  connection received: host=192.168.65.1 port=40792
db-1  | 2024-05-16 09:26:38.559 UTC [32] LOG:  connection authenticated: identity="sa" method=scram-sha-256 (/var/lib/postgresql/data/pg_hba.conf:128)
db-1  | 2024-05-16 09:26:38.559 UTC [32] LOG:  connection authorized: user=sa database=example
db-1  | 2024-05-16 09:26:38.562 UTC [32] LOG:  execute <unnamed>: SET extra_float_digits = 3
db-1  | 2024-05-16 09:26:38.563 UTC [32] LOG:  execute <unnamed>: SET application_name = 'docker-compose-datasource-test'
db-1  | 2024-05-16 09:26:38.564 UTC [32] LOG:  execute <unnamed>: SET SESSION search_path TO 'example'

Setup

application.yaml

spring:
  application:
    name: docker-compose-datasource-test
  datasource:
    hikari:
      schema:  example
      username: example_rw
      password: example_rw
  liquibase:
    default-schema: example
    user: example_ow
    password: example_ow
logging:
  level:
    com:
      zaxxer:
        hikari:
          HikariConfig: DEBUG
    liquibase:
      database: DEBUG
  pattern:
    console: '%c : %m%n'

compose.yaml

services:
  db:
    image: postgres:16.3-alpine3.19
    restart: always
    ports:
      - '5432:5432'
    command: ["postgres", "-c", "log_statement=all", "-c", "log_connections=true"]
    environment:
      POSTGRES_USER: sa
      POSTGRES_PASSWORD: sa
      POSTGRES_DB: example
    volumes:
      - ./docker/db/init/001-create-users-and-database.sh:/docker-entrypoint-initdb.d/001-create-users-and-database.sh
      - ./docker/db/init/002-create-schema.sh:/docker-entrypoint-initdb.d/002-create-schema.sh
    labels:
      org.springframework.boot.jdbc.parameters: 'ApplicationName=docker-compose-datasource-test'

docker/db/init/001-create-users-and-database.sh

#!/usr/bin/env bash

set -Eeu -o pipefail -o posix

readonly example_admin_pw='example_admin'
readonly example_ow_pw='example_ow'
readonly example_rw_pw='example_rw'
readonly example_ro_pw='example_ro'

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
  REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;
  GRANT ALL PRIVILEGES ON DATABASE postgres TO $POSTGRES_USER;

  CREATE USER example_admin WITH LOGIN REPLICATION PASSWORD '$example_admin_pw';
  CREATE USER example_ow WITH LOGIN PASSWORD '$example_ow_pw';
  CREATE USER example_rw WITH LOGIN PASSWORD '$example_rw_pw';
  CREATE USER example_ro WITH LOGIN PASSWORD '$example_ro_pw';

  CREATE DATABASE tmp;

  \c tmp

  DROP DATABASE IF EXISTS example;

  CREATE DATABASE example WITH OWNER example_admin TEMPLATE template0
    ENCODING UTF8 LC_COLLATE 'de_DE.UTF8' LC_CTYPE 'de_DE.UTF8';

  \c example

  DROP DATABASE IF EXISTS tmp;

  DROP SCHEMA IF EXISTS public;

  REVOKE ALL ON DATABASE example FROM PUBLIC;

  GRANT ALL ON DATABASE example TO $POSTGRES_USER;
  GRANT ALL ON DATABASE example TO example_admin;

  GRANT CONNECT,TEMPORARY ON DATABASE example TO example_ow;
  GRANT CONNECT,TEMPORARY ON DATABASE example TO example_rw;
  GRANT CONNECT ON DATABASE example TO example_ro;
EOSQL

docker/db/init/002-create-schema.sh

#!/usr/bin/env bash

set -Eeu -o pipefail -o posix

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
  CREATE SCHEMA IF NOT EXISTS example AUTHORIZATION example_ow;

  REVOKE ALL ON SCHEMA example FROM PUBLIC;

  GRANT ALL ON SCHEMA example TO $POSTGRES_USER;
  GRANT ALL ON SCHEMA example TO example_admin;

  GRANT ALL ON SCHEMA example TO example_ow;
  ALTER ROLE example_ow IN DATABASE example SET search_path = 'example';

  GRANT pg_read_all_data, pg_write_all_data TO example_rw;
  ALTER ROLE example_rw IN DATABASE example SET search_path = 'example';

  GRANT pg_read_all_data TO example_ro;
  ALTER ROLE example_ro IN DATABASE example SET search_path = 'example';
EOSQL

src/main/resources/db/changelog/db.changelog-master.yaml

databaseChangeLog:
  - changeSet:
      id: INIT-1-1
      logicalFilePath: INIT-1
      author: sdavids
      changes:
        - tagDatabase:
            tag: INIT-1

I have not verified but I suspect that all spring.datasource.*.username and spring.datasource.*.password properties are affected.

Comment From: wilkinsona

When you're using the Docker Compose support, the auto-configured DataSource is created using the service connection details that comes from any SQL database service. These details are the JDBC URL, the username, and the password. They intentionally take precedence over the JDBC URL, username, and password configured in application.yaml as those details won't, typically, allow a connection to the Docker Compose-managed database to be established.

Can you please describe what you're trying to achieve here? I think I might be able to reverse engineer it from the scripts and configuration that you have shared, but a description directly from you will be considerably more accurate.

Comment From: sdavids

The setup above in words:

Postgres instance; admin super user 'sa'

Each bounded context gets its own database and super user; database 'example`, super user 'example_admin'.

Each bounded context creates one or more schemas in its database and uses a dedicated admin user for each schema—all created database objects will be owned by the admin user; schema 'example`, admin user 'example_ow'

Two additional users: One with read-only permissions and one with read-write (but not create) permissions; user 'example_ro' and user 'example_rw'.

Each bounded context will be provisioned by Liquibase with the corresponding admin user; admin user 'example_ow'.

The bounded context's application uses the read-write user; user 'example_rw'.

Other stuff (Reporting/QA/etc.) uses the read-only user; user 'example_ro'.


The basic premise is: The development setup should be as close as possible to production.

As it is now, everything is done with the super user 'sa'—therefore no permission checks are performed because 'sa' has all permissions.

Therefore, if you forget to setup permissions correctly in the Liquibase migration scripts the local development setup will work.

Once you deploy to production it might not work because you set the permissions incorrectly (or forgot to set them up altogether), i.e. missing GRANT statements.


TL;DR

By using the super user one cannot test if the database roles and permissions are set up correctly.

Comment From: sdavids

At first, I tried replicating our setup with Using Testcontainers at Development Time but unfortunately the Testcontainers Postgres support does not allow multiple init scripts running against different databases:

https://github.com/testcontainers/testcontainers-java/issues/8634

Comment From: wilkinsona

Thanks for the additional details. While cumbersome, I think you can achieve what you want with a custom connection details factory:

package com.example;

import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
import org.springframework.boot.docker.compose.core.RunningService;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
import org.springframework.boot.docker.compose.service.connection.jdbc.JdbcUrlBuilder;
import org.springframework.core.Ordered;

class CustomUsernameAndPasswordPostgresJdbcDockerComposeConnectionDetailsFactory
        extends DockerComposeConnectionDetailsFactory<JdbcConnectionDetails> implements Ordered {

    protected CustomUsernameAndPasswordPostgresJdbcDockerComposeConnectionDetailsFactory() {
        super("postgres");
    }

    @Override
    protected JdbcConnectionDetails getDockerComposeConnectionDetails(DockerComposeConnectionSource source) {
        return new PostgresJdbcDockerComposeConnectionDetails(source.getRunningService());
    }

    static class PostgresJdbcDockerComposeConnectionDetails extends DockerComposeConnectionDetails
            implements JdbcConnectionDetails {

        private static final JdbcUrlBuilder jdbcUrlBuilder = new JdbcUrlBuilder("postgresql", 5432);

        private final String jdbcUrl;

        PostgresJdbcDockerComposeConnectionDetails(RunningService service) {
            super(service);
            this.jdbcUrl = jdbcUrlBuilder.build(service, "example");
        }

        @Override
        public String getUsername() {
            return "example_rw";
        }

        @Override
        public String getPassword() {
            return "example_rw";
        }

        @Override
        public String getJdbcUrl() {
            return this.jdbcUrl;
        }

    }

    @Override
    public int getOrder() {
        return 0;
    }

}

Registered in META-INF/spring.factories under the org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory key, this should give you complete control of mapping from the running Docker Compose service to the JDBC connection details. You could do similar for Liquibase by implementing another factory that produces LiquibaseConnectionDetails.

Note that ConnectionDetailsFactory implementations don't have access to the Environment so you'd have to either hardcode the username and password or provide them via some other means for now at least.

Comment From: sdavids

I will try it later today, thanks …

Comment From: sdavids

I tried your suggestion above:

java.lang.IllegalStateException: Duplicate connection details supplied for org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails
    at org.springframework.util.Assert.state(Assert.java:97) ~[spring-core-6.1.6.jar:6.1.6]
    at org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories.getConnectionDetails(ConnectionDetailsFactories.java:84) ~[spring-boot-autoconfigure-3.2.5.jar:3.2.5]
    at org.springframework.boot.docker.compose.service.connection.DockerComposeServiceConnectionsApplicationListener.registerConnectionDetails(DockerComposeServiceConnectionsApplicationListener.java:68) ~[spring-boot-docker-compose-3.2.5.jar:3.2.5]

https://github.com/spring-projects/spring-boot/blob/95145b23ec31c2b2a797434ef29fae7170b0a5b4/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/service/connection/ConnectionDetailsFactories.java#L75-L92

src/main/java/com.example.CustomPostgresJdbcDockerComposeConnectionDetailsFactory

package com.example;

import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
import org.springframework.boot.docker.compose.core.RunningService;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
import org.springframework.boot.docker.compose.service.connection.jdbc.JdbcUrlBuilder;
import org.springframework.core.Ordered;

class CustomPostgresJdbcDockerComposeConnectionDetailsFactory
    extends DockerComposeConnectionDetailsFactory<JdbcConnectionDetails> implements Ordered {

  CustomPostgresJdbcDockerComposeConnectionDetailsFactory() {
    super("postgres");
  }

  @Override
  public int getOrder() {
    return 0;
  }

  @Override
  protected JdbcConnectionDetails getDockerComposeConnectionDetails(
      DockerComposeConnectionSource source) {

    return new PostgresJdbcDockerComposeConnectionDetails(source.getRunningService());
  }

  static class PostgresJdbcDockerComposeConnectionDetails
      extends DockerComposeConnectionDetailsFactory.DockerComposeConnectionDetails
      implements JdbcConnectionDetails {

    private final String jdbcUrl;

    PostgresJdbcDockerComposeConnectionDetails(RunningService service) {
      super(service);
      this.jdbcUrl = new JdbcUrlBuilder("postgresql", 5432).build(service, "example");
    }

    @Override
    public String getUsername() {
      return "example_rw";
    }

    @Override
    public String getPassword() {
      return "example_rw";
    }

    @Override
    public String getJdbcUrl() {
      return this.jdbcUrl;
    }
  }
}

src/main/resources/META-INF/spring.factories

org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
com.example.CustomPostgresJdbcDockerComposeConnectionDetailsFactory

The current registration algorithm does not support Ordered.

Comment From: wilkinsona

Sorry, I'd incorrectly recalled that the first factory would win, hence it implementing Ordered.

You can get PostgresJdbcDockerComposeConnectionDetailsFactory to ignore your db service by hiding the fact that it's Postgres. This will then allow the custom factory to take control. First, add a org.springframework.boot.service-connection label to the service with a value that's anything other than postgres, say custom-postgres. Then update the constructor of CustomPostgresJdbcDockerComposeConnectionDetailsFactory to pass that value to its super constructor:

    protected CustomUsernameAndPasswordPostgresJdbcDockerComposeConnectionDetailsFactory() {
        super("custom-postgres");
    }

This should ensure that only the custom factory is used for your db service.

Comment From: sdavids

src/main/java/com/example/CustomDockerComposeConnectionDetailsFactory.java

package com.example;

import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
import org.springframework.boot.docker.compose.core.RunningService;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
import org.springframework.boot.docker.compose.service.connection.jdbc.JdbcUrlBuilder;

class CustomDockerComposeConnectionDetailsFactory
    extends DockerComposeConnectionDetailsFactory<JdbcConnectionDetails> {

  CustomDockerComposeConnectionDetailsFactory() {
    super("custom-postgres");
  }

  @Override
  protected JdbcConnectionDetails getDockerComposeConnectionDetails(
      DockerComposeConnectionSource source) {
    return new CustomDockerComposeConnectionDetails(source.getRunningService());
  }

  static class CustomDockerComposeConnectionDetails extends DockerComposeConnectionDetails
      implements JdbcConnectionDetails {

    private final String jdbcUrl;

    CustomDockerComposeConnectionDetails(RunningService service) {
      super(service);
      this.jdbcUrl = new JdbcUrlBuilder("postgresql", 5432).build(service, "example");
    }

    @Override
    public String getUsername() {
      return "example_rw";
    }

    @Override
    public String getPassword() {
      return "example_rw";
    }

    @Override
    public String getJdbcUrl() {
      return jdbcUrl;
    }
  }
}

src/main/resources/META-INF/spring.factories

org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
com.example.CustomDockerComposeConnectionDetailsFactory

compose.yaml

services:
  db:
...
    labels:
      org.springframework.boot.service-connection: 'custom-postgres'

The setup above works.

But once Liquibase is in the mix we are back at square one.

src/main/java/com/example/CustomLiquibaseConnectionDetailsFactory.java

package com.example;

import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
import org.springframework.boot.autoconfigure.liquibase.LiquibaseConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;

class CustomLiquibaseConnectionDetailsFactory
    implements ConnectionDetailsFactory<JdbcConnectionDetails, LiquibaseConnectionDetails> {

  CustomLiquibaseConnectionDetailsFactory() {}

  @Override
  public LiquibaseConnectionDetails getConnectionDetails(JdbcConnectionDetails input) {
    return new MyLiquibaseConnectionDetails(input);
  }

  static class MyLiquibaseConnectionDetails implements LiquibaseConnectionDetails {

    private final String jdbcUrl;
    private final String driverClassName;

    public MyLiquibaseConnectionDetails(JdbcConnectionDetails input) {
      jdbcUrl = input.getJdbcUrl();
      driverClassName = input.getDriverClassName();
    }

    @Override
    public String getUsername() {
      return "example_ow";
    }

    @Override
    public String getPassword() {
      return "example_ow";
    }

    @Override
    public String getJdbcUrl() {
      return jdbcUrl;
    }

    @Override
    public String getDriverClassName() {
      return driverClassName;
    }
  }
}

src/main/resources/META-INF/spring.factories

org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
com.example.CustomDockerComposeConnectionDetailsFactory,\
com.example.CustomLiquibaseConnectionDetailsFactory

java.lang.IllegalStateException: Duplicate connection details supplied for org.springframework.boot.autoconfigure.liquibase.LiquibaseConnectionDetails
    at org.springframework.util.Assert.state(Assert.java:97) ~[spring-core-6.1.6.jar:6.1.6]
    at org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories.getConnectionDetails(ConnectionDetailsFactories.java:84) ~[spring-boot-autoconfigure-3.2.5.jar:3.2.5]

I also tried:

src/main/java/com/example/CustomLiquibaseConnectionDetailsFactory2.java

package com.example;

import org.springframework.boot.autoconfigure.liquibase.LiquibaseConnectionDetails;
import org.springframework.boot.docker.compose.core.RunningService;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionDetailsFactory;
import org.springframework.boot.docker.compose.service.connection.DockerComposeConnectionSource;
import org.springframework.boot.docker.compose.service.connection.jdbc.JdbcUrlBuilder;

class CustomLiquibaseConnectionDetailsFactory2
    extends DockerComposeConnectionDetailsFactory<LiquibaseConnectionDetails> {

  CustomLiquibaseConnectionDetailsFactory2() {
    super("custom-postgres");
  }

  @Override
  protected LiquibaseConnectionDetails getDockerComposeConnectionDetails(
      DockerComposeConnectionSource source) {
    return new CustomLiquibaseConnectionDetails(source.getRunningService());
  }

  private static class CustomLiquibaseConnectionDetails implements LiquibaseConnectionDetails {

    private final String jdbcUrl;

    CustomLiquibaseConnectionDetails(RunningService service) {
      this.jdbcUrl = new JdbcUrlBuilder("postgresql", 5432).build(service, "example");
    }

    @Override
    public String getUsername() {
      return "example_ow";
    }

    @Override
    public String getPassword() {
      return "example_ow";
    }

    @Override
    public String getJdbcUrl() {
      return jdbcUrl;
    }
  }
}

src/main/resources/META-INF/spring.factories

org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory=\
com.example.CustomDockerComposeConnectionDetailsFactory,\
com.example.CustomLiquibaseConnectionDetailsFactory2

src/main/resources/application.yaml

spring:
  main:
    allow-bean-definition-overriding: true

The application starts.

The result map contains both custom entries

https://github.com/spring-projects/spring-boot/blob/95145b23ec31c2b2a797434ef29fae7170b0a5b4/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/service/connection/ConnectionDetailsFactories.java#L91

result = {LinkedHashMap@4873}  size = 2
 {Class@4893} "interface org.springframework.boot.autoconfigure.liquibase.LiquibaseConnectionDetails" -> {CustomLiquibaseConnectionDetailsFactory2$CustomLiquibaseConnectionDetails@4906} 
 {Class@4898} "interface org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails" -> {CustomDockerComposeConnectionDetailsFactory$CustomDockerComposeConnectionDetails@4907} 

Unfortunately, the bean injected into the liquibase bean is not our custom one but the default one:

https://github.com/spring-projects/spring-boot/blob/df578d56160af186627d7b75d2f5fa6293eb8684/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/liquibase/LiquibaseAutoConfiguration.java#L99-L101

connectionDetails = {JdbcAdaptingLiquibaseConnectionDetailsFactory$1@6852}

Implementing Ordered did not help either.

Comment From: sdavids

Should I author a PR adding Ordered support?

https://github.com/spring-projects/spring-boot/blob/95145b23ec31c2b2a797434ef29fae7170b0a5b4/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/service/connection/ConnectionDetailsFactories.java#L83-L85

Basically if previous != null then check for Ordered and use the one with the lowest priority—if tied throw "duplicate" exception

Comment From: wilkinsona

Thanks for the offer. That's definitely room for improvement here but I'm not yet sure what we should do. Ideally, you wouldn't have to mess around for connection details factories at all to do what you want. We'll discuss it as team and try to figure out what we want to do here.

Comment From: wilkinsona

This won't help with the Liquibase side of things, but for Postgres things can be improved slightly by having the custom JdbcConnectionDetails implement EnvironmentAware. It can then retrieve properties from the environment for the username and password:

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }

    @Override
    public String getUsername() {
      return this.environment.getProperty("spring.datasource.username");
    }

    @Override
    public String getPassword() {
        return this.environment.getProperty("spring.datasource.password");
    }

Comment From: wilkinsona

I've experimented a bit in this branch. With no need for a custom connection details factory, it lets you have a compose file like this:

services:
  database:
    image: postgres:16.3-alpine3.19'
    ports:
      - '5432'
    environment:
      - 'POSTGRES_USER=sa'
      - 'POSTGRES_PASSWORD=sa'
      - 'POSTGRES_DB=mydatabase'
    labels:
      # $$ is required to disable Docker Compose's own interpolation
      # https://docs.docker.com/compose/compose-file/12-interpolation/
      - org.springframework.boot.jdbc.username=$${spring.datasource.username}
      - org.springframework.boot.jdbc.password=$${spring.datasource.password}

This is very much an experiment and, with this particular approach, I dislike how broad the changes would be to expand support across other service types or even just across the other factories for JdbcConnectionDetails. Putting that aside for now, I quite like the end result from an external perspective. I haven't yet looked at the Liquibase side of things.

Comment From: sdavids

Just one question about this approach:

Where/how do you specify the desired spring profile?

One could have fine-grained profiles containing only the credentials and then run the application with several profiles.

So instead of -Dspring.profiles.active=dev you might have -Dspring.profiles.active=dev,jpa_dev,liquibase_dev,kafka_dev,something_else_dev.

Comment From: wilkinsona

You'd specify the profiles as you normally would when starting the app. As usual, the profiles would influence the application properties and YAML files that are loaded into the Spring environment. The placeholders in the label values are then resolved against the environment.

Comment From: sdavids

Two alternative ideas:

Label org.springframework.boot.service.use-environment-configuration or something similarly named.

If true the Docker Compose support will use the appropriate config from the Spring environment for the annotated service.

It would not be as explicit though.


Label org.springframework.boot.service.configuration-precedence

with values like compose-only, spring-only, compose-first, spring-first.

Comment From: wilkinsona

Thanks for suggestion.

I'm not sure that Spring Boot's Docker Compose support should be that tightly coupled to the configuration properties that are defined in spring-boot-autoconfigure. Just for the case of a DataSource's username, it would require the Docker Compose support to know about spring.datasource.username, spring.datasource.hikari.username, spring.datasource.dbcp2.username, etc.

There's also the possibility that the JdbcConnectionDetails bean has been defined using some other properties. The Docker Compose support would then have no way of knowing what properties to use when overriding it with the JDBC URL from the compose-managed container.

While it may make the compose YAML slightly more verbose, I think it will be better to configure the use of properties explicitly rather than trying to do it automatically. That also allows people who want to hardcode the custom credentials rather than using their application's properties to achieve their goal too.

Comment From: serandel

Spring Boot 3.3.4

I could workaround Liquibase with a custom AutoConfiguration.

By default, by getting the ConnectionDetails with a custom factory, as seen in this thread, the application used the values from the application property files instead of creating one with the superuser credentials from the Docker container env variables.

But then, LiquibaseAutoConfiguration would get a JdbcAdaptingLiquibaseConnectionDetailsFactory.LiquibaseConnectionDetails, that took my custom JdbcConnectionDetails and just wrapped them to use in Liquibase as well.

As I wanted a different Liquibase user, I did this...

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

example.CustomLiquibaseAutoConfiguration

src/main/kotlin/example/CustomLiquibaseAutoConfiguration.kt

@AutoConfiguration(before = [LiquibaseAutoConfiguration::class])
@ConditionalOnClass(
    LiquibaseAutoConfiguration::class
)
class CustomLiquibaseAutoConfiguration {
    @Bean
    @Primary
    fun customLiquibaseConnectionDetails(): LiquibaseConnectionDetails = CustomLiquibaseConnectionDetails()

    private class CustomLiquibaseConnectionDetails :
        LiquibaseConnectionDetails, EnvironmentAware {
        private lateinit var environment: Environment

        override fun setEnvironment(environment: Environment) {
            this.environment = environment
        }

        override fun getUsername() = environment.getProperty("spring.liquibase.user")

        override fun getPassword() = environment.getProperty("spring.liquibase.password")

        override fun getJdbcUrl() = environment.getProperty("spring.datasource.url")
    }
}

Now the Liquibase auto configuration gets these connection details and I can enjoy different DB users and passwords for the Postgres superuser, my app main datasource and Liquibase.

Comment From: zartc

I too have encountered a similar problem when attempting to include spring-boot-docker-compose in my application.

I don't understand why, as stated by wilkinsona, spring-boot-docker-compose intentionally prefer to use database connection details comming from the SQL database service in the docker-compose.yml file, instead of those configured in the application.yml file!

Because of that, I had a lot of pain making Liquibase operates on my database of choice and my schema of choice instead of the default "postgres/public" (I had to avoid setting the POSTGRES_DB environment variable to prevent Postgres from creating the database itself before my scripts have had a chance to run).

I seems to me that the database connection details coming from the SQL database service in the docker-compose.yml are somewhat limited in scope, because 1) it can only provide one username/password pair (for the admin), not multiple users/roles, and 2) because there is no way to change the schema name to operate on (always default to public).

This implementation choice is not the wisest one IMHO, because 1) if I wanted to have this behavior (i.e. always work as administrator on the default public schema), I could simply remove the data source configuration from the application.yml file and rely on the one created in memory by spring-boot-docker-compose and 2) because I naturally expect SpringBoot to use and respect the configurations carefully crafted in the application.yml file. If I've taken the time and effort to configure different datasources for the app and for Liquibase, it's obviously because I expect the application to respect these configurations.

I like spring-boot-docker-compose's feature, which consists of starting my infrastructure services, if needed, before starting the application, but I don't like that it overrides/ignore my configurations.

Due to this, spring-boot-docker-compose is more of a pain than a gain, for me at the moment. I can easily create a taskfile or a justfile or a makefile to start my services without pain. And the Dynamic port mapping isn't such a significant issue that it justifies ignoring the whole datasource (and liquibase) configurations in the application.yml file.

regards.