001/* 002 * Licensed under the Apache License, Version 2.0 (the "License"); 003 * you may not use this file except in compliance with the License. 004 * You may obtain a copy of the License at 005 * 006 * http://www.apache.org/licenses/LICENSE-2.0 007 * 008 * Unless required by applicable law or agreed to in writing, software 009 * distributed under the License is distributed on an "AS IS" BASIS, 010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 011 * See the License for the specific language governing permissions and 012 * limitations under the License. 013 */ 014 015package de.softwareforge.testing.postgres.embedded; 016 017import static com.google.common.base.Preconditions.checkArgument; 018import static com.google.common.base.Preconditions.checkNotNull; 019import static com.google.common.base.Preconditions.checkState; 020import static de.softwareforge.testing.postgres.embedded.DatabaseInfo.PG_DEFAULT_DB; 021import static de.softwareforge.testing.postgres.embedded.DatabaseInfo.PG_DEFAULT_USER; 022import static de.softwareforge.testing.postgres.embedded.EmbeddedUtil.formatDuration; 023import static java.lang.String.format; 024import static java.nio.file.StandardOpenOption.CREATE; 025import static java.nio.file.StandardOpenOption.DELETE_ON_CLOSE; 026import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; 027import static java.nio.file.StandardOpenOption.WRITE; 028 029import de.softwareforge.testing.postgres.embedded.ProcessOutputLogger.StreamCapture; 030 031import jakarta.annotation.Nonnull; 032import jakarta.annotation.Nullable; 033import java.io.File; 034import java.io.IOException; 035import java.io.InputStreamReader; 036import java.lang.ProcessBuilder.Redirect; 037import java.net.ConnectException; 038import java.net.InetAddress; 039import java.net.InetSocketAddress; 040import java.net.Socket; 041import java.nio.channels.FileChannel; 042import java.nio.channels.FileLock; 043import java.nio.channels.OverlappingFileLockException; 044import java.nio.charset.StandardCharsets; 045import java.nio.file.Files; 046import java.nio.file.Path; 047import java.sql.Connection; 048import java.sql.ResultSet; 049import java.sql.SQLException; 050import java.sql.Statement; 051import java.time.Duration; 052import java.util.HashMap; 053import java.util.List; 054import java.util.Map; 055import java.util.Map.Entry; 056import java.util.Objects; 057import java.util.concurrent.ExecutionException; 058import java.util.concurrent.TimeUnit; 059import java.util.concurrent.atomic.AtomicBoolean; 060import javax.sql.DataSource; 061 062import com.google.common.annotations.VisibleForTesting; 063import com.google.common.base.Joiner; 064import com.google.common.base.Stopwatch; 065import com.google.common.collect.ImmutableList; 066import com.google.common.collect.ImmutableMap; 067import com.google.common.io.CharStreams; 068import com.google.common.io.Closeables; 069import org.postgresql.ds.PGSimpleDataSource; 070import org.slf4j.Logger; 071import org.slf4j.LoggerFactory; 072 073/** 074 * Manages an embedded PostgreSQL server instance. 075 */ 076public final class EmbeddedPostgres implements AutoCloseable { 077 078 /** 079 * The version of postgres used if no specific version has been given. 080 */ 081 public static final String DEFAULT_POSTGRES_VERSION = "15"; 082 083 static final String[] LOCALHOST_SERVER_NAMES = {"localhost"}; 084 085 private static final String PG_TEMPLATE_DB = "template1"; 086 087 @VisibleForTesting 088 static final Duration DEFAULT_PG_STARTUP_WAIT = Duration.ofSeconds(10); 089 090 // folders need to be at least 10 minutes old to be considered for deletion. 091 private static final long MINIMUM_AGE_IN_MS = Duration.ofMinutes(10).toMillis(); 092 093 // prefix for data folders in the parent that might be deleted 094 private static final String DATA_DIRECTORY_PREFIX = "data-"; 095 096 private static final String PG_STOP_MODE = "fast"; 097 private static final String PG_STOP_WAIT_SECONDS = "5"; 098 static final String LOCK_FILE_NAME = "epg-lock"; 099 100 @SuppressWarnings("PMD.ProperLogger") 101 private final Logger logger; 102 103 private final String instanceId; 104 private final File postgresInstallDirectory; 105 private final File dataDirectory; 106 107 private final Duration serverStartupWait; 108 private final int port; 109 private final AtomicBoolean started = new AtomicBoolean(); 110 private final AtomicBoolean closed = new AtomicBoolean(); 111 112 private final ImmutableMap<String, String> serverConfiguration; 113 private final ImmutableMap<String, String> localeConfiguration; 114 private final ImmutableMap<String, String> connectionProperties; 115 116 private final File lockFile; 117 private volatile FileChannel lockChannel; 118 private volatile FileLock lock; 119 120 private final boolean removeDataOnShutdown; 121 122 private final ProcessBuilder.Redirect errorRedirector; 123 private final ProcessBuilder.Redirect outputRedirector; 124 private final ProcessOutputLogger pgServerLogger; 125 126 127 /** 128 * Returns an instance that has been started and configured. The {@link Builder#withDefaults()} configuration has been applied. 129 */ 130 @Nonnull 131 public static EmbeddedPostgres defaultInstance() throws IOException { 132 return builderWithDefaults().build(); 133 } 134 135 /** 136 * Returns a builder with default {@link Builder#withDefaults()} configuration already applied. 137 */ 138 @Nonnull 139 public static EmbeddedPostgres.Builder builderWithDefaults() { 140 return new Builder().withDefaults(); 141 } 142 143 /** 144 * This returns an {@link EmbeddedPostgres} instance that can be solely used for version checking. It has not been booted and will not work for any other 145 * things but executing {@link EmbeddedPostgres#getPostgresVersion()}. This is a performance optimization for code that needs to do version checking and 146 * does not want to pay the penalty of spinning up and shutting down an instance. 147 * 148 * @return An unstarted {@link EmbeddedPostgres} instance. 149 * @throws IOException Could not create the instance. 150 * @since 4.1 151 */ 152 public static EmbeddedPostgres forVersionCheck() throws IOException { 153 return new Builder(false).build(); 154 } 155 156 /** 157 * Returns a new {@link Builder}. 158 */ 159 @Nonnull 160 public static EmbeddedPostgres.Builder builder() { 161 return new Builder(); 162 } 163 164 private EmbeddedPostgres( 165 final String instanceId, 166 final File postgresInstallDirectory, 167 final File dataDirectory, 168 final boolean removeDataOnShutdown, 169 final Map<String, String> serverConfiguration, 170 final Map<String, String> localeConfiguration, 171 final Map<String, String> connectionProperties, 172 final int port, 173 final ProcessBuilder.Redirect errorRedirector, 174 final ProcessBuilder.Redirect outputRedirector, 175 final Duration serverStartupWait) { 176 177 this.instanceId = checkNotNull(instanceId, "instanceId is null"); 178 179 this.logger = LoggerFactory.getLogger(toString()); 180 this.pgServerLogger = new ProcessOutputLogger(logger); 181 182 this.postgresInstallDirectory = checkNotNull(postgresInstallDirectory, "postgresInstallDirectory is null"); 183 this.dataDirectory = checkNotNull(dataDirectory, "dataDirectory is null"); 184 185 this.removeDataOnShutdown = removeDataOnShutdown; 186 187 this.serverConfiguration = ImmutableMap.copyOf(checkNotNull(serverConfiguration, "serverConfiguration is null")); 188 this.localeConfiguration = ImmutableMap.copyOf(checkNotNull(localeConfiguration, "localeConfiguration is null")); 189 this.connectionProperties = ImmutableMap.copyOf(checkNotNull(connectionProperties, "connectionProperties is null")); 190 191 this.port = port; 192 193 this.errorRedirector = checkNotNull(errorRedirector, "errorRedirector is null"); 194 this.outputRedirector = checkNotNull(outputRedirector, "outputRedirector is null"); 195 196 this.serverStartupWait = checkNotNull(serverStartupWait, "serverStartupWait is null"); 197 this.lockFile = new File(this.dataDirectory, LOCK_FILE_NAME); 198 199 logger.debug(format("data dir is %s, install dir is %s", this.dataDirectory, this.postgresInstallDirectory)); 200 } 201 202 /** 203 * Creates a {@link DataSource} object that connects to the standard system database. 204 * <p> 205 * The standard system database is the <code>template1</code> database. 206 * <p> 207 * Any modification to this database will be propagated to any new database that is created with <code>CREATE DATABASE...</code> unless another database is 208 * explicitly named as the template.. 209 */ 210 @Nonnull 211 public DataSource createTemplateDataSource() throws SQLException { 212 checkState(started.get(), "instance has not been started!"); 213 214 return createDataSource(PG_DEFAULT_USER, PG_TEMPLATE_DB, getPort(), getConnectionProperties()); 215 } 216 217 /** 218 * Creates a {@link DataSource} object that connects to the default database. 219 * <p> 220 * The default database is the <code>postgres</code> database. 221 */ 222 @Nonnull 223 public DataSource createDefaultDataSource() throws SQLException { 224 checkState(started.get(), "instance has not been started!"); 225 226 return createDataSource(PG_DEFAULT_USER, PG_DEFAULT_DB, getPort(), getConnectionProperties()); 227 } 228 229 /** 230 * Creates a {@link DataSource} with a specific user and database name. 231 * <p> 232 * Creating the DataSource does <b>not</b> create the database or the user itself. This must be done by the calling code (e.g. with a 233 * {@link EmbeddedPostgresPreparer}). 234 */ 235 @Nonnull 236 public DataSource createDataSource(@Nonnull String user, @Nonnull String databaseName) throws SQLException { 237 checkState(started.get(), "instance has not been started!"); 238 239 return createDataSource(user, databaseName, getPort(), getConnectionProperties()); 240 } 241 242 static DataSource createDataSource(String user, String databaseName, int port, Map<String, String> connectionProperties) throws SQLException { 243 checkNotNull(user, "user is null"); 244 checkNotNull(databaseName, "databaseName is null"); 245 checkNotNull(connectionProperties, "connectionProperties is null"); 246 247 final PGSimpleDataSource ds = new PGSimpleDataSource(); 248 249 ds.setServerNames(LOCALHOST_SERVER_NAMES); 250 ds.setPortNumbers(new int[]{port}); 251 ds.setDatabaseName(databaseName); 252 ds.setUser(user); 253 254 for (final Entry<String, String> entry : connectionProperties.entrySet()) { 255 ds.setProperty(entry.getKey(), entry.getValue()); 256 } 257 258 return ds; 259 } 260 261 /** 262 * Returns the network (TCP) port for the PostgreSQL server instance. 263 */ 264 public int getPort() { 265 checkState(started.get(), "instance has not been started!"); 266 267 return port; 268 } 269 270 /** 271 * Returns the connection properties for the PostgreSQL server instance. 272 */ 273 @Nonnull 274 ImmutableMap<String, String> getConnectionProperties() { 275 checkState(started.get(), "instance has not been started!"); 276 277 return connectionProperties; 278 } 279 280 /** 281 * Returns the instance id for the PostgreSQL server instance. This id is an alphanumeric string that can be used to differentiate between multiple embedded 282 * PostgreSQL server instances. 283 */ 284 @Nonnull 285 public String instanceId() { 286 checkState(started.get(), "instance has not been started!"); 287 288 return instanceId; 289 } 290 291 /** 292 * Return the version of the PostgreSQL installation that is used by this instance. 293 * 294 * @return A string representing the Postgres version as described in the <a href="https://www.postgresql.org/support/versioning/">Postgres versioning 295 * policy</a>. 296 * @since 4.1 297 */ 298 public String getPostgresVersion() throws IOException { 299 300 StringBuilder sb = new StringBuilder(); 301 StreamCapture logCapture = pgServerLogger.captureStreamAsConsumer(sb::append); 302 303 List<String> commandAndArgs = ImmutableList.of(pgBin("pg_ctl"), "--version"); 304 final Stopwatch watch = system(commandAndArgs, logCapture); 305 306 String version = "unknown"; 307 308 try { 309 logCapture.getCompletion().get(); 310 final String s = sb.toString(); 311 checkState(s.startsWith("pg_ctl "), "Response %s does not match 'pg_ctl'", sb); 312 version = s.substring(s.lastIndexOf(' ')).trim(); 313 314 } catch (ExecutionException e) { 315 throw new IOException(format("Process '%s' failed%n", Joiner.on(" ").join(commandAndArgs)), e); 316 } catch (InterruptedException e) { 317 Thread.currentThread().interrupt(); 318 } 319 320 logger.debug(format("postgres version check completed in %s", formatDuration(watch.elapsed()))); 321 return version; 322 } 323 324 @Override 325 public String toString() { 326 return this.getClass().getName() + "$" + this.instanceId; 327 } 328 329 @Override 330 public boolean equals(Object o) { 331 if (this == o) { 332 return true; 333 } 334 if (o == null || getClass() != o.getClass()) { 335 return false; 336 } 337 EmbeddedPostgres that = (EmbeddedPostgres) o; 338 return instanceId.equals(that.instanceId); 339 } 340 341 @Override 342 public int hashCode() { 343 return Objects.hash(instanceId); 344 } 345 346 // internal methods 347 DatabaseInfo createDefaultDatabaseInfo() { 348 return DatabaseInfo.builder().port(getPort()).connectionProperties(getConnectionProperties()).build(); 349 } 350 351 352 private void boot() throws IOException { 353 EmbeddedUtil.ensureDirectory(this.dataDirectory); 354 355 if (this.removeDataOnShutdown || !new File(this.dataDirectory, "postgresql.conf").exists()) { 356 initDatabase(); 357 } 358 359 lock(); 360 361 startDatabase(); 362 } 363 364 365 private synchronized void lock() throws IOException { 366 this.lockChannel = FileChannel.open(this.lockFile.toPath(), CREATE, WRITE, TRUNCATE_EXISTING); 367 this.lock = this.lockChannel.tryLock(); 368 checkState(lock != null, "could not lock %s", lockFile); 369 } 370 371 private synchronized void unlock() throws IOException { 372 if (lock != null) { 373 lock.release(); 374 } 375 Closeables.close(this.lockChannel, true); 376 Files.deleteIfExists(this.lockFile.toPath()); 377 } 378 379 private void initDatabase() throws IOException { 380 ImmutableList.Builder<String> commandBuilder = ImmutableList.builder(); 381 commandBuilder.add(pgBin("initdb")) 382 .addAll(createInitDbOptions()) 383 .add("-A", "trust", 384 "-U", PG_DEFAULT_USER, 385 "-D", this.dataDirectory.getPath(), 386 "-E", "UTF-8"); 387 final Stopwatch watch = system(commandBuilder.build(), pgServerLogger.captureStreamAsLog()); 388 logger.debug(format("initdb completed in %s", formatDuration(watch.elapsed()))); 389 } 390 391 private void startDatabase() throws IOException { 392 checkState(!started.getAndSet(true), "database already started!"); 393 394 final ImmutableList.Builder<String> commandBuilder = ImmutableList.builder(); 395 commandBuilder.add(pgBin("pg_ctl"), 396 "-D", this.dataDirectory.getPath(), 397 "-o", String.join(" ", createInitOptions()), 398 "start" 399 ); 400 401 final Stopwatch watch = Stopwatch.createStarted(); 402 final Process postmaster = spawn("pg", commandBuilder.build(), pgServerLogger.captureStreamAsLog()); 403 404 logger.info(format("started as pid %d on port %d", postmaster.pid(), port)); 405 logger.debug(format("Waiting up to %s for server startup to finish", formatDuration(serverStartupWait))); 406 407 Runtime.getRuntime().addShutdownHook(newCloserThread()); 408 409 checkState(waitForServerStartup(), "Could not start PostgreSQL server, interrupted?"); 410 logger.debug(format("startup complete in %s", formatDuration(watch.elapsed()))); 411 } 412 413 private void stopDatabase(File dataDirectory) throws IOException { 414 if (started.get()) { 415 final ImmutableList.Builder<String> commandBuilder = ImmutableList.builder(); 416 commandBuilder.add(pgBin("pg_ctl"), 417 "-D", dataDirectory.getPath(), 418 "stop", 419 "-m", PG_STOP_MODE, 420 "-t", PG_STOP_WAIT_SECONDS, "-w"); 421 422 final Stopwatch watch = system(commandBuilder.build(), pgServerLogger.captureStreamAsLog()); 423 logger.debug(format("shutdown complete in %s", formatDuration(watch.elapsed()))); 424 } 425 pgServerLogger.close(); 426 } 427 428 private List<String> createInitOptions() { 429 final ImmutableList.Builder<String> initOptions = ImmutableList.builder(); 430 initOptions.add( 431 "-p", Integer.toString(port), 432 "-F"); 433 434 serverConfiguration.forEach((key, value) -> { 435 initOptions.add("-c"); 436 if (!value.isEmpty()) { 437 initOptions.add(key + "=" + value); 438 } else { 439 initOptions.add(key + "=true"); 440 } 441 }); 442 443 return initOptions.build(); 444 } 445 446 @VisibleForTesting 447 List<String> createInitDbOptions() { 448 final ImmutableList.Builder<String> localeOptions = ImmutableList.builder(); 449 450 localeConfiguration.forEach((key, value) -> { 451 if (!value.isEmpty()) { 452 localeOptions.add("--" + key + "=" + value); 453 } else { 454 localeOptions.add("--" + key); 455 } 456 }); 457 return localeOptions.build(); 458 } 459 460 private boolean waitForServerStartup() throws IOException { 461 Throwable lastCause = null; 462 final long start = System.nanoTime(); 463 final long maxWaitNs = TimeUnit.NANOSECONDS.convert(serverStartupWait.toMillis(), TimeUnit.MILLISECONDS); 464 while (System.nanoTime() - start < maxWaitNs) { 465 try { 466 if (verifyReady()) { 467 return true; 468 } 469 } catch (final SQLException e) { 470 lastCause = e; 471 logger.trace("while waiting for server startup:", e); 472 } 473 474 try { 475 Thread.sleep(100); 476 } catch (final InterruptedException e) { 477 Thread.currentThread().interrupt(); 478 return false; 479 } 480 } 481 throw new IOException("Gave up waiting for server to start after " + serverStartupWait.toMillis() + "ms", lastCause); 482 } 483 484 @SuppressWarnings("PMD.CheckResultSet") // see https://github.com/pmd/pmd/issues/5209 / https://github.com/pmd/pmd/issues/5031 485 private boolean verifyReady() throws IOException, SQLException { 486 // check TCP connection 487 final InetAddress localhost = InetAddress.getLoopbackAddress(); 488 try (Socket sock = new Socket()) { 489 sock.setSoTimeout((int) Duration.ofMillis(500).toMillis()); 490 sock.connect(new InetSocketAddress(localhost, port), (int) Duration.ofMillis(500).toMillis()); 491 } catch (ConnectException e) { 492 return false; 493 } 494 495 // check JDBC connection 496 try (Connection c = createDefaultDataSource().getConnection(); 497 Statement s = c.createStatement(); 498 ResultSet rs = s.executeQuery("SELECT 1")) { 499 checkState(rs.next(), "expecting single row"); 500 checkState(rs.getInt(1) == 1, "expecting 1 as result"); 501 checkState(!rs.next(), "expecting single row"); 502 return true; 503 } 504 } 505 506 private Thread newCloserThread() { 507 final Thread closeThread = new Thread(() -> { 508 try { 509 this.close(); 510 } catch (IOException e) { 511 logger.trace("while closing instance:", e); 512 } 513 }); 514 515 closeThread.setName("pg-closer"); 516 return closeThread; 517 } 518 519 /** 520 * Closing an {@link EmbeddedPostgres} instance shuts down the connected database instance. 521 */ 522 @Override 523 public void close() throws IOException { 524 if (closed.getAndSet(true)) { 525 return; 526 } 527 528 try { 529 stopDatabase(this.dataDirectory); 530 } catch (final Exception e) { 531 logger.error("could not stop pg:", e); 532 } 533 534 unlock(); 535 536 if (removeDataOnShutdown) { 537 try { 538 EmbeddedUtil.rmdirs(dataDirectory); 539 } catch (Exception e) { 540 logger.error(format("Could not clean up directory %s:", dataDirectory.getAbsolutePath()), e); 541 } 542 } else { 543 logger.debug(format("preserved data directory %s", dataDirectory.getAbsolutePath())); 544 } 545 } 546 547 @VisibleForTesting 548 File getDataDirectory() { 549 return dataDirectory; 550 } 551 552 @VisibleForTesting 553 Map<String, String> getLocaleConfiguration() { 554 return localeConfiguration; 555 } 556 557 558 private void cleanOldDataDirectories(File parentDirectory) { 559 final File[] children = parentDirectory.listFiles(); 560 if (children == null) { 561 return; 562 } 563 for (final File dir : children) { 564 if (!dir.isDirectory()) { 565 continue; 566 } 567 568 // only ever touch known data directories. 569 if (!dir.getName().startsWith(DATA_DIRECTORY_PREFIX)) { 570 continue; 571 } 572 573 // only touch data directories that hold a lock file. 574 final File lockFile = new File(dir, LOCK_FILE_NAME); 575 if (!lockFile.exists()) { 576 continue; 577 } 578 579 // file must have a minimum age. This can not be the same check as 580 // the exists b/c non-existent files return 0 (epoch) as lastModified so 581 // they are considered "ancient". 582 if (System.currentTimeMillis() - lockFile.lastModified() < MINIMUM_AGE_IN_MS) { 583 continue; 584 } 585 586 try (FileChannel fileChannel = FileChannel.open(lockFile.toPath(), CREATE, WRITE, TRUNCATE_EXISTING, DELETE_ON_CLOSE); 587 FileLock lock = fileChannel.tryLock()) { 588 if (lock != null) { 589 logger.debug(format("found stale data directory %s", dir)); 590 if (new File(dir, "postmaster.pid").exists()) { 591 try { 592 stopDatabase(dir); 593 logger.debug("shutting down orphaned database!"); 594 } catch (Exception e) { 595 logger.warn(format("failed to orphaned database in %s:", dir), e); 596 } 597 } 598 EmbeddedUtil.rmdirs(dir); 599 } 600 } catch (final OverlappingFileLockException e) { 601 // The directory belongs to another instance in this VM. 602 logger.trace("while cleaning old data directories:", e); 603 } catch (final IOException e) { 604 logger.warn("while cleaning old data directories:", e); 605 } 606 } 607 } 608 609 private String pgBin(String binaryName) { 610 final String extension = EmbeddedUtil.IS_OS_WINDOWS ? ".exe" : ""; 611 return new File(this.postgresInstallDirectory, "bin/" + binaryName + extension).getPath(); 612 } 613 614 private Process spawn(@Nullable String processName, List<String> commandAndArgs, 615 StreamCapture logCapture) 616 throws IOException { 617 final ProcessBuilder builder = new ProcessBuilder(commandAndArgs); 618 builder.redirectErrorStream(true); 619 builder.redirectError(errorRedirector); 620 builder.redirectOutput(outputRedirector); 621 final Process process = builder.start(); 622 623 if (outputRedirector == Redirect.PIPE) { 624 processName = processName != null ? processName : process.info().command().map(EmbeddedUtil::getFileBaseName).orElse("<unknown>"); 625 String name = format("%s (%d)", processName, process.pid()); 626 logCapture.accept(name, process.getInputStream()); 627 } 628 return process; 629 } 630 631 private Stopwatch system(List<String> commandAndArgs, StreamCapture logCapture) throws IOException { 632 checkArgument(!commandAndArgs.isEmpty(), "No commandAndArgs given!"); 633 String prefix = EmbeddedUtil.getFileBaseName(commandAndArgs.get(0)); 634 635 Stopwatch watch = Stopwatch.createStarted(); 636 try { 637 Process process = spawn(prefix, commandAndArgs, logCapture); 638 if (process.waitFor() != 0) { 639 if (errorRedirector == Redirect.PIPE) { 640 try (InputStreamReader errorReader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) { 641 throw new IOException(format("Process '%s' failed%n%s", Joiner.on(" ").join(commandAndArgs), CharStreams.toString(errorReader))); 642 } 643 } else { 644 throw new IOException(format("Process '%s' failed", 645 Joiner.on(" ").join(commandAndArgs))); 646 } 647 } 648 } catch (InterruptedException e) { 649 Thread.currentThread().interrupt(); 650 } 651 652 return watch; 653 } 654 655 /** 656 * Creates a new {@link EmbeddedPostgres} instance and starts it. 657 */ 658 public static class Builder { 659 660 private File installationBaseDirectory = null; 661 private File dataDirectory = null; 662 663 private final Map<String, String> serverConfiguration = new HashMap<>(); 664 private final Map<String, String> localeConfiguration = new HashMap<>(); 665 private boolean removeDataOnShutdown = true; 666 private int port = 0; 667 private String serverVersion = DEFAULT_POSTGRES_VERSION; 668 private final Map<String, String> connectionProperties = new HashMap<>(); 669 private NativeBinaryManager nativeBinaryManager = null; 670 private Duration serverStartupWait = DEFAULT_PG_STARTUP_WAIT; 671 672 private ProcessBuilder.Redirect errorRedirector = ProcessBuilder.Redirect.PIPE; 673 private ProcessBuilder.Redirect outputRedirector = ProcessBuilder.Redirect.PIPE; 674 675 private final boolean bootInstance; 676 677 private Builder(boolean bootInstance) { 678 this.bootInstance = bootInstance; 679 } 680 681 Builder() { 682 this(true); 683 } 684 685 /** 686 * Apply a set of defaults to the database server: 687 * <ul> 688 * <li>timezone: UTC</li> 689 * <li>synchronous_commit: off</li> 690 * <li>max_connections: 300</li> 691 * </ul> 692 * 693 * @return The builder itself. 694 */ 695 @Nonnull 696 public Builder withDefaults() { 697 serverConfiguration.put("timezone", "UTC"); 698 serverConfiguration.put("synchronous_commit", "off"); 699 serverConfiguration.put("max_connections", "300"); 700 return this; 701 } 702 703 /** 704 * Sets the time that the builder will wait for the PostgreSQL server instance to start. Default is 10 seconds. 705 * 706 * @param serverStartupWait Startup wait time. Must not be null or negative. 707 * @return The builder itself. 708 */ 709 @Nonnull 710 public Builder setServerStartupWait(@Nonnull Duration serverStartupWait) { 711 checkNotNull(serverStartupWait, "serverStartupWait is null"); 712 checkArgument(!serverStartupWait.isNegative(), "Negative durations are not permitted."); 713 714 this.serverStartupWait = serverStartupWait; 715 return this; 716 } 717 718 /** 719 * Whether to remove the data directory on server shutdown. If true, the contents of the data directory are deleted when the {@link EmbeddedPostgres} 720 * instance is closed. Default is true. 721 * 722 * @param removeDataOnShutdown True removes the contents of the data directory on shutdown. 723 * @return The builder itself. 724 */ 725 @Nonnull 726 public Builder setRemoveDataOnShutdown(boolean removeDataOnShutdown) { 727 this.removeDataOnShutdown = removeDataOnShutdown; 728 return this; 729 } 730 731 /** 732 * Explicitly set the location of the data directory. Default is using a managed directory. 733 * 734 * @param dataDirectory The directory to use. Must not be null. If it exists, the current user must be able to access the directory for reading and 735 * writing. If the directory does not exist then the current user must be able to create it for reading and writing. 736 * @return The builder itself. 737 */ 738 @Nonnull 739 public Builder setDataDirectory(@Nonnull Path dataDirectory) { 740 checkNotNull(dataDirectory, "dataDirectory is null"); 741 return setDataDirectory(dataDirectory.toFile()); 742 } 743 744 /** 745 * Explicitly set the location of the data directory. Default is using a managed directory. 746 * 747 * @param dataDirectory The directory to use. Must not be null. If it exists, the current user must be able to access the directory for reading and 748 * writing. If the directory does not exist then the current user must be able to create it for reading and writing. 749 * @return The builder itself. 750 */ 751 @Nonnull 752 public Builder setDataDirectory(@Nonnull String dataDirectory) { 753 checkNotNull(dataDirectory, "dataDirectory is null"); 754 return setDataDirectory(new File(dataDirectory)); 755 } 756 757 /** 758 * Explicitly set the location of the data directory. Default is using a managed directory. 759 * 760 * @param dataDirectory The directory to use. Must not be null. If it exists, the current user must be able to access the directory for reading and 761 * writing. If the directory does not exist then the current user must be able to create it for reading and writing. 762 * @return The builder itself. 763 */ 764 @Nonnull 765 public Builder setDataDirectory(@Nonnull File dataDirectory) { 766 this.dataDirectory = checkNotNull(dataDirectory, "dataDirectory is null"); 767 return this; 768 } 769 770 /** 771 * Adds a server configuration parameter. All parameters are passed to the PostgreSQL server a startup using the <code>postgres</code> command. 772 * <p> 773 * Values and their function are specific to the PostgreSQL version selected. 774 * <p> 775 * See <a href="https://www.postgresql.org/docs/13/runtime-config.html">the PostgreSQL runtime configuration</a> for more information. 776 * 777 * @param key Configuration parameter name. Must not be null. 778 * @param value Configuration parameter value. Must not be null. 779 * @return The builder itself. 780 */ 781 @Nonnull 782 public Builder addServerConfiguration(@Nonnull String key, @Nonnull String value) { 783 checkNotNull(key, "key is null"); 784 checkNotNull(value, "value is null"); 785 this.serverConfiguration.put(key, value); 786 return this; 787 } 788 789 /** 790 * Adds a configuration parameters for the <code>initdb</code> command. The <code>initdb</code> command is used to create the PostgreSQL server. 791 * <p> 792 * Each value is added as a command line parameter to the command. 793 * <p> 794 * See the <a href="https://www.postgresql.org/docs/13/app-initdb.html">PostgreSQL initdb documentation</a> for an overview of possible values. 795 * 796 * @param key initdb parameter name. Must not be null. 797 * @param value initdb parameter value. Must not be null. When the empty string is used as the value, the resulting command line parameter will not have 798 * a equal sign and a value assigned. 799 * @return The builder itself. 800 * @since 3.0 801 */ 802 @Nonnull 803 public Builder addInitDbConfiguration(@Nonnull String key, @Nonnull String value) { 804 checkNotNull(key, "key is null"); 805 checkNotNull(value, "value is null"); 806 this.localeConfiguration.put(key, value); 807 return this; 808 } 809 810 /** 811 * Adds a connection property. These properties are set on every connection handed out by the data source. See 812 * <a href="https://jdbc.postgresql.org/documentation/head/connect.html#connection-parameters">the 813 * PostgreSQL JDBC driver documentation</a> for possible values. 814 * 815 * @param key connection property name. Must not be null. 816 * @param value connection property value. Must not be null. 817 * @return The builder itself. 818 */ 819 @Nonnull 820 public Builder addConnectionProperty(@Nonnull String key, @Nonnull String value) { 821 checkNotNull(key, "key is null"); 822 checkNotNull(value, "value is null"); 823 this.connectionProperties.put(key, value); 824 return this; 825 } 826 827 /** 828 * Sets the directory where the PostgreSQL distribution is unpacked. Setting the installation base directory resets the {@link NativeBinaryManager} used 829 * to locate the postgres installation back to the default (which is to download the zonky.io Postgres archive and unpack it in the installation 830 * directory. The default is using a managed directory. 831 * 832 * @param installationBaseDirectory The directory to unpack the postgres distribution. The current user must be able to create and write this directory. 833 * Must not be null. 834 * @return The builder itself. 835 * @since 3.0 836 */ 837 @Nonnull 838 public Builder setInstallationBaseDirectory(@Nonnull File installationBaseDirectory) { 839 checkNotNull(installationBaseDirectory, "installationBaseDirectory is null"); 840 this.installationBaseDirectory = installationBaseDirectory; 841 this.nativeBinaryManager = null; 842 return this; 843 } 844 845 /** 846 * Explicitly set the TCP port for the PostgreSQL server. If the port is not available, starting the server will fail. Default is to find and use an 847 * available TCP port. 848 * 849 * @param port The port to use. Must be > 1023 and < 65536. 850 * @return The builder itself. 851 */ 852 @Nonnull 853 public Builder setPort(int port) { 854 checkState(port > 1_023 && port < 65_535, "Port %s is not within 1024..65535", port); 855 this.port = port; 856 return this; 857 } 858 859 /** 860 * Set the version of the PostgreSQL server. This value is passed to the default binary manager which will try to resolve this version from existing 861 * Maven artifacts. The value is ignored if {@link Builder#setNativeBinaryManager(NativeBinaryManager)} is called. 862 * <p> 863 * Not every PostgreSQL version is supported by pg-embedded. Some older versions lack the necessary options for the command line parameters and will 864 * fail at startup. Currently, every version 10 or newer should be working. 865 * 866 * @param serverVersion A partial or full version. Valid values are e.g. "12" or "11.3". 867 * @return The builder itself. 868 * @since 3.0 869 */ 870 @Nonnull 871 public Builder setServerVersion(@Nonnull String serverVersion) { 872 this.serverVersion = checkNotNull(serverVersion, "serverVersion is null"); 873 874 return this; 875 } 876 877 /** 878 * Set a {@link ProcessBuilder.Redirect} instance to receive stderr output from the spawned processes. 879 * 880 * @param errorRedirector a {@link ProcessBuilder.Redirect} instance. Must not be null. 881 * @return The builder itself. 882 */ 883 @Nonnull 884 public Builder setErrorRedirector(@Nonnull ProcessBuilder.Redirect errorRedirector) { 885 this.errorRedirector = checkNotNull(errorRedirector, "errorRedirector is null"); 886 return this; 887 } 888 889 /** 890 * Set a {@link ProcessBuilder.Redirect} instance to receive stdout output from the spawned processes. 891 * 892 * @param outputRedirector a {@link ProcessBuilder.Redirect} instance. Must not be null. 893 * @return The builder itself. 894 */ 895 @Nonnull 896 public Builder setOutputRedirector(@Nonnull ProcessBuilder.Redirect outputRedirector) { 897 this.outputRedirector = checkNotNull(outputRedirector, "outputRedirector is null"); 898 return this; 899 } 900 901 /** 902 * Sets the {@link NativeBinaryManager} that provides the location of the postgres installation. Explicitly setting a binary manager overrides the 903 * installation base directory location set with {@link Builder#setInstallationBaseDirectory(File)} as this is only used by the default binary manager. 904 * Calling {@link Builder#setInstallationBaseDirectory(File)} after this method undoes setting the binary manager. 905 * 906 * @param nativeBinaryManager A {@link NativeBinaryManager} implementation. Must not be null. 907 * @return The builder itself. 908 * @since 3.0 909 */ 910 @Nonnull 911 public Builder setNativeBinaryManager(@Nonnull NativeBinaryManager nativeBinaryManager) { 912 this.nativeBinaryManager = checkNotNull(nativeBinaryManager, "nativeBinaryManager is null"); 913 return this; 914 } 915 916 /** 917 * Use a locally installed PostgreSQL server for tests. The tests will still spin up a new instance and locate the data in the data directory but it 918 * will use the locally installed binaries for starting and stopping. Calling this method sets a binary manager, so it overrides 919 * {@link Builder#setNativeBinaryManager(NativeBinaryManager)}. Calling this method makes the builder ignore the 920 * {@link Builder#setInstallationBaseDirectory(File)} setting. 921 * 922 * @param directory A local directory that contains a standard PostgreSQL installation. The directory must exist and read and executable. 923 * @return The builder itself. 924 * @since 3.0 925 */ 926 @Nonnull 927 public Builder useLocalPostgresInstallation(@Nonnull File directory) { 928 checkNotNull(directory, "directory is null"); 929 checkState(directory.exists() && directory.isDirectory(), "'%s' either does not exist or is not a directory!", directory); 930 return setNativeBinaryManager(() -> directory); 931 } 932 933 /** 934 * Creates and boots a new {@link EmbeddedPostgres} instance. 935 * 936 * @return A {@link EmbeddedPostgres} instance representing a started PostgreSQL server. 937 * @throws IOException If the server could not be installed or started. 938 */ 939 @Nonnull 940 public EmbeddedPostgres build() throws IOException { 941 // Builder Id 942 final String instanceId = EmbeddedUtil.randomAlphaNumeric(16); 943 944 int port = this.port != 0 ? this.port : EmbeddedUtil.allocatePort(); 945 946 // installation root if nothing has been set by the user. 947 final File parentDirectory = EmbeddedUtil.getWorkingDirectory(); 948 949 NativeBinaryManager nativeBinaryManager = this.nativeBinaryManager; 950 if (nativeBinaryManager == null) { 951 final String serverVersion = System.getProperty("pg-embedded.postgres-version", this.serverVersion); 952 nativeBinaryManager = new TarXzCompressedBinaryManager(new ZonkyIOPostgresLocator(serverVersion)); 953 } 954 955 // Use the parent directory if no installation directory set. 956 File installationBaseDirectory = Objects.requireNonNullElse(this.installationBaseDirectory, parentDirectory); 957 nativeBinaryManager.setInstallationBaseDirectory(installationBaseDirectory); 958 959 // this is where the binary manager actually places the unpackaged postgres installation. 960 final File postgresInstallDirectory = nativeBinaryManager.getLocation(); 961 962 File dataDirectory = this.dataDirectory; 963 if (dataDirectory == null) { 964 dataDirectory = new File(parentDirectory, DATA_DIRECTORY_PREFIX + instanceId); 965 } 966 967 EmbeddedPostgres embeddedPostgres = new EmbeddedPostgres(instanceId, postgresInstallDirectory, dataDirectory, 968 removeDataOnShutdown, serverConfiguration, localeConfiguration, connectionProperties, 969 port, errorRedirector, outputRedirector, 970 serverStartupWait); 971 972 embeddedPostgres.cleanOldDataDirectories(parentDirectory); 973 974 // for version checking (calling getPostgresVersion(), the instance does not need to run 975 // this is a special case to make the version check run faster for unit test selection. 976 if (bootInstance) { 977 embeddedPostgres.boot(); 978 } 979 980 return embeddedPostgres; 981 } 982 } 983}