/* * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2021-2025. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ * * 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.kingsrook.qbits.documentgeneration; import java.sql.Connection; import java.sql.DriverManager; import java.util.UUID; import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData; import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; /******************************************************************************* ** Disposable synthetic PostgreSQL 17 engine on a dynamic loopback port. The ** superuser is fixture-only migration authority; native QQQ connections use a ** separate DML-only role with bounded lock_timeout. No shared volume or data. *******************************************************************************/ final class PostgresGenerationDatabase implements AutoCloseable { private static final String IMAGE = "postgres:17"; private static final String OPTIONS = "?connectTimeout=5&socketTimeout=20&options=-c%20TimeZone%3DUTC"; private final String rootPassword = UUID.randomUUID().toString(); private final String appPassword = UUID.randomUUID().toString(); private final GenericContainer container; private String schema; private String server; private RDBMSBackendMetaData backend; private final java.util.TimeZone previousZone; /*************************************************************************** ** Starts only this suite's loopback container with durable settings. ***************************************************************************/ PostgresGenerationDatabase() { this(true); } /*************************************************************************** ** Pinned durability settings come from the server command line, which no ** ALTER SYSTEM can override. An unpinned engine starts with the same values ** (the PostgreSQL defaults) and lets a test weaken them through system(). ***************************************************************************/ PostgresGenerationDatabase(boolean pinned) { // pgjdbc announces the JVM zone as the session TimeZone; qualification requires UTC. previousZone = java.util.TimeZone.getDefault(); java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")); container = new GenericContainer<>(IMAGE) .withEnv("POSTGRES_PASSWORD", rootPassword).withEnv("POSTGRES_DB", "bootstrap") .withCommand(pinned ? new String[] { "postgres", "-c", "fsync=on", "-c", "synchronous_commit=on", "-c", "full_page_writes=on", "-c", "max_connections=60" } : new String[] { "postgres", "-c", "max_connections=60" }) .withExposedPorts(5432) .withCreateContainerCmdModifier(command -> command.getHostConfig().withMemory(768L * 1024 * 1024).withNanoCPUs(2_000_000_000L) .withPortBindings(FixtureEndpoint.publishedBinding(5432))) .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\s", 2).withStartupTimeout(java.time.Duration.ofMinutes(2))); try { container.start(); refreshEndpoint(); try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement()) { statement.execute("CREATE ROLE generation_app LOGIN PASSWORD '" + appPassword + "' NOSUPERUSER NOCREATEDB NOCREATEROLE"); statement.execute("ALTER ROLE generation_app SET lock_timeout='5s'"); } } catch(Exception failure) { try { close(); } catch(RuntimeException cleanup) { failure.addSuppressed(cleanup); } throw new IllegalStateException("PostgreSQL fixture failed to start", failure); } } /*************************************************************************** ** Every inherited test receives a fresh database and the DML-only identity. ***************************************************************************/ RDBMSBackendMetaData database(String name) throws Exception { schema = "generation_" + UUID.randomUUID().toString().replace("-", ""); try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement()) { statement.execute("CREATE DATABASE " + schema); statement.execute("REVOKE ALL ON DATABASE " + schema + " FROM PUBLIC"); statement.execute("GRANT CONNECT ON DATABASE " + schema + " TO generation_app"); } try(var connection = connection(); var statement = connection.createStatement()) { statement.execute("GRANT USAGE ON SCHEMA public TO generation_app"); statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO generation_app"); statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO generation_app"); } backend = new PostgreSQLBackendMetaData(); backend.setName(name); backend.setDatabaseName(schema); backend.setUsername("generation_app"); backend.setPassword(appPassword); backend.setJdbcUrl(server + schema + OPTIONS); return (backend); } /*************************************************************************** ** Separate owned fixture connection for migrations/assertions/fault injection. ***************************************************************************/ Connection connection() throws Exception { return (DriverManager.getConnection(server + schema + OPTIONS, "postgres", rootPassword)); } /*************************************************************************** ** Translate only the known fixture-specific H2 statements, never application SQL. ***************************************************************************/ void sql(String source) throws Exception { if(source.equals("INSERT INTO document_generation_item (state) SELECT 'QUEUED' FROM SYSTEM_RANGE(1,10000)")) { source = "INSERT INTO document_generation_item (state) SELECT 'QUEUED' FROM generate_series(1,10000)"; } try(var connection = connection(); var statement = connection.createStatement()) { for(String sql : source.split(";")) { if(!sql.isBlank()) { statement.execute(sql); } } } } /*************************************************************************** ** Change one reloadable server setting of an unpinned engine (ALTER SYSTEM and ** a configuration reload), then wait until a new session actually reports it. ** Fixed fixture names and values only, never application input. ***************************************************************************/ void system(String name, String value) throws Exception { ConnectionManager.closeConnectionProviders(); try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement()) { statement.execute("ALTER SYSTEM SET " + name + " = '" + value + "'"); statement.execute("SELECT pg_reload_conf()"); } long deadline = System.nanoTime() + java.time.Duration.ofSeconds(30).toNanos(); while(true) { try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement(); var row = statement.executeQuery("SELECT current_setting('" + name + "')")) { if(row.next() && value.equals(row.getString(1))) { return; } } if(System.nanoTime() >= deadline) { throw new IllegalStateException("PostgreSQL fixture did not apply " + name); } Thread.sleep(100); } } /*************************************************************************** ** Cold server restart retains only this container's data layer (committed ** data must survive; Docker may publish a different loopback port). ***************************************************************************/ void restart() throws Exception { ConnectionManager.closeConnectionProviders(); container.getDockerClient().stopContainerCmd(container.getContainerId()).withTimeout(10).exec(); container.getDockerClient().startContainerCmd(container.getContainerId()).exec(); refreshEndpoint(); backend.setJdbcUrl(server + schema + OPTIONS); long deadline = System.nanoTime() + java.time.Duration.ofSeconds(60).toNanos(); while(true) { try(var connection = connection()) { if(connection.isValid(2)) { return; } } catch(java.sql.SQLException unavailable) { if(System.nanoTime() >= deadline) { throw unavailable; } } Thread.sleep(200); } } /*************************************************************************** ** Native inspect avoids Testcontainers' stale mapped-port cache after restart. ***************************************************************************/ private void refreshEndpoint() { var address = FixtureEndpoint.of(container, 5432); server = "jdbc:postgresql://" + address.host() + ":" + address.port() + "/"; } /*************************************************************************** ** Removes only this exact disposable test container and its anonymous volumes. ***************************************************************************/ @Override public void close() { try { container.close(); } finally { java.util.TimeZone.setDefault(previousZone); } } }