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 = "13"; 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 private boolean verifyReady() throws IOException, SQLException { 485 // check TCP connection 486 final InetAddress localhost = InetAddress.getLoopbackAddress(); 487 try (Socket sock = new Socket()) { 488 sock.setSoTimeout((int) Duration.ofMillis(500).toMillis()); 489 sock.connect(new InetSocketAddress(localhost, port), (int) Duration.ofMillis(500).toMillis()); 490 } catch (ConnectException e) { 491 return false; 492 } 493 494 // check JDBC connection 495 try (Connection c = createDefaultDataSource().getConnection(); 496 Statement s = c.createStatement(); 497 ResultSet rs = s.executeQuery("SELECT 1")) { 498 checkState(rs.next(), "expecting single row"); 499 checkState(rs.getInt(1) == 1, "expecting 1 as result"); 500 checkState(!rs.next(), "expecting single row"); 501 return true; 502 } 503 } 504 505 private Thread newCloserThread() { 506 final Thread closeThread = new Thread(() -> { 507 try { 508 this.close(); 509 } catch (IOException e) { 510 logger.trace("while closing instance:", e); 511 } 512 }); 513 514 closeThread.setName("pg-closer"); 515 return closeThread; 516 } 517 518 /** 519 * Closing an {@link EmbeddedPostgres} instance shuts down the connected database instance. 520 */ 521 @Override 522 public void close() throws IOException { 523 if (closed.getAndSet(true)) { 524 return; 525 } 526 527 try { 528 stopDatabase(this.dataDirectory); 529 } catch (final Exception e) { 530 logger.error("could not stop pg:", e); 531 } 532 533 unlock(); 534 535 if (removeDataOnShutdown) { 536 try { 537 EmbeddedUtil.rmdirs(dataDirectory); 538 } catch (Exception e) { 539 logger.error(format("Could not clean up directory %s:", dataDirectory.getAbsolutePath()), e); 540 } 541 } else { 542 logger.debug(format("preserved data directory %s", dataDirectory.getAbsolutePath())); 543 } 544 } 545 546 @VisibleForTesting 547 File getDataDirectory() { 548 return dataDirectory; 549 } 550 551 @VisibleForTesting 552 Map<String, String> getLocaleConfiguration() { 553 return localeConfiguration; 554 } 555 556 557 private void cleanOldDataDirectories(File parentDirectory) { 558 final File[] children = parentDirectory.listFiles(); 559 if (children == null) { 560 return; 561 } 562 for (final File dir : children) { 563 if (!dir.isDirectory()) { 564 continue; 565 } 566 567 // only ever touch known data directories. 568 if (!dir.getName().startsWith(DATA_DIRECTORY_PREFIX)) { 569 continue; 570 } 571 572 // only touch data directories that hold a lock file. 573 final File lockFile = new File(dir, LOCK_FILE_NAME); 574 if (!lockFile.exists()) { 575 continue; 576 } 577 578 // file must have a minimum age. This can not be the same check as 579 // the exists b/c non-existent files return 0 (epoch) as lastModified so 580 // they are considered "ancient". 581 if (System.currentTimeMillis() - lockFile.lastModified() < MINIMUM_AGE_IN_MS) { 582 continue; 583 } 584 585 try (FileChannel fileChannel = FileChannel.open(lockFile.toPath(), CREATE, WRITE, TRUNCATE_EXISTING, DELETE_ON_CLOSE); 586 FileLock lock = fileChannel.tryLock()) { 587 if (lock != null) { 588 logger.debug(format("found stale data directory %s", dir)); 589 if (new File(dir, "postmaster.pid").exists()) { 590 try { 591 stopDatabase(dir); 592 logger.debug("shutting down orphaned database!"); 593 } catch (Exception e) { 594 logger.warn(format("failed to orphaned database in %s:", dir), e); 595 } 596 } 597 EmbeddedUtil.rmdirs(dir); 598 } 599 } catch (final OverlappingFileLockException e) { 600 // The directory belongs to another instance in this VM. 601 logger.trace("while cleaning old data directories:", e); 602 } catch (final IOException e) { 603 logger.warn("while cleaning old data directories:", e); 604 } 605 } 606 } 607 608 private String pgBin(String binaryName) { 609 final String extension = EmbeddedUtil.IS_OS_WINDOWS ? ".exe" : ""; 610 return new File(this.postgresInstallDirectory, "bin/" + binaryName + extension).getPath(); 611 } 612 613 private Process spawn(@Nullable String processName, List<String> commandAndArgs, 614 StreamCapture logCapture) 615 throws IOException { 616 final ProcessBuilder builder = new ProcessBuilder(commandAndArgs); 617 builder.redirectErrorStream(true); 618 builder.redirectError(errorRedirector); 619 builder.redirectOutput(outputRedirector); 620 final Process process = builder.start(); 621 622 if (outputRedirector == Redirect.PIPE) { 623 processName = processName != null ? processName : process.info().command().map(EmbeddedUtil::getFileBaseName).orElse("<unknown>"); 624 String name = format("%s (%d)", processName, process.pid()); 625 logCapture.accept(name, process.getInputStream()); 626 } 627 return process; 628 } 629 630 private Stopwatch system(List<String> commandAndArgs, StreamCapture logCapture) throws IOException { 631 checkArgument(!commandAndArgs.isEmpty(), "No commandAndArgs given!"); 632 String prefix = EmbeddedUtil.getFileBaseName(commandAndArgs.get(0)); 633 634 Stopwatch watch = Stopwatch.createStarted(); 635 try { 636 Process process = spawn(prefix, commandAndArgs, logCapture); 637 if (process.waitFor() != 0) { 638 if (errorRedirector == Redirect.PIPE) { 639 try (InputStreamReader errorReader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) { 640 throw new IOException(format("Process '%s' failed%n%s", Joiner.on(" ").join(commandAndArgs), CharStreams.toString(errorReader))); 641 } 642 } else { 643 throw new IOException(format("Process '%s' failed", 644 Joiner.on(" ").join(commandAndArgs))); 645 } 646 } 647 } catch (InterruptedException e) { 648 Thread.currentThread().interrupt(); 649 } 650 651 return watch; 652 } 653 654 /** 655 * Creates a new {@link EmbeddedPostgres} instance and starts it. 656 */ 657 public static class Builder { 658 659 private File installationBaseDirectory = null; 660 private File dataDirectory = null; 661 662 private final Map<String, String> serverConfiguration = new HashMap<>(); 663 private final Map<String, String> localeConfiguration = new HashMap<>(); 664 private boolean removeDataOnShutdown = true; 665 private int port = 0; 666 private String serverVersion = DEFAULT_POSTGRES_VERSION; 667 private final Map<String, String> connectionProperties = new HashMap<>(); 668 private NativeBinaryManager nativeBinaryManager = null; 669 private Duration serverStartupWait = DEFAULT_PG_STARTUP_WAIT; 670 671 private ProcessBuilder.Redirect errorRedirector = ProcessBuilder.Redirect.PIPE; 672 private ProcessBuilder.Redirect outputRedirector = ProcessBuilder.Redirect.PIPE; 673 674 private final boolean bootInstance; 675 676 private Builder(boolean bootInstance) { 677 this.bootInstance = bootInstance; 678 } 679 680 Builder() { 681 this(true); 682 } 683 684 /** 685 * Apply a set of defaults to the database server: 686 * <ul> 687 * <li>timezone: UTC</li> 688 * <li>synchronous_commit: off</li> 689 * <li>max_connections: 300</li> 690 * </ul> 691 * 692 * @return The builder itself. 693 */ 694 @Nonnull 695 public Builder withDefaults() { 696 serverConfiguration.put("timezone", "UTC"); 697 serverConfiguration.put("synchronous_commit", "off"); 698 serverConfiguration.put("max_connections", "300"); 699 return this; 700 } 701 702 /** 703 * Sets the time that the builder will wait for the PostgreSQL server instance to start. Default is 10 seconds. 704 * 705 * @param serverStartupWait Startup wait time. Must not be null or negative. 706 * @return The builder itself. 707 */ 708 @Nonnull 709 public Builder setServerStartupWait(@Nonnull Duration serverStartupWait) { 710 checkNotNull(serverStartupWait, "serverStartupWait is null"); 711 checkArgument(!serverStartupWait.isNegative(), "Negative durations are not permitted."); 712 713 this.serverStartupWait = serverStartupWait; 714 return this; 715 } 716 717 /** 718 * Whether to remove the data directory on server shutdown. If true, the contents of the data directory are deleted when the {@link EmbeddedPostgres} 719 * instance is closed. Default is true. 720 * 721 * @param removeDataOnShutdown True removes the contents of the data directory on shutdown. 722 * @return The builder itself. 723 */ 724 @Nonnull 725 public Builder setRemoveDataOnShutdown(boolean removeDataOnShutdown) { 726 this.removeDataOnShutdown = removeDataOnShutdown; 727 return this; 728 } 729 730 /** 731 * Explicitly set the location of the data directory. Default is using a managed directory. 732 * 733 * @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 734 * writing. If the directory does not exist then the current user must be able to create it for reading and writing. 735 * @return The builder itself. 736 */ 737 @Nonnull 738 public Builder setDataDirectory(@Nonnull Path dataDirectory) { 739 checkNotNull(dataDirectory, "dataDirectory is null"); 740 return setDataDirectory(dataDirectory.toFile()); 741 } 742 743 /** 744 * Explicitly set the location of the data directory. Default is using a managed directory. 745 * 746 * @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 747 * writing. If the directory does not exist then the current user must be able to create it for reading and writing. 748 * @return The builder itself. 749 */ 750 @Nonnull 751 public Builder setDataDirectory(@Nonnull String dataDirectory) { 752 checkNotNull(dataDirectory, "dataDirectory is null"); 753 return setDataDirectory(new File(dataDirectory)); 754 } 755 756 /** 757 * Explicitly set the location of the data directory. Default is using a managed directory. 758 * 759 * @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 760 * writing. If the directory does not exist then the current user must be able to create it for reading and writing. 761 * @return The builder itself. 762 */ 763 @Nonnull 764 public Builder setDataDirectory(@Nonnull File dataDirectory) { 765 this.dataDirectory = checkNotNull(dataDirectory, "dataDirectory is null"); 766 return this; 767 } 768 769 /** 770 * Adds a server configuration parameter. All parameters are passed to the PostgreSQL server a startup using the <code>postgres</code> command. 771 * <p> 772 * Values and their function are specific to the PostgreSQL version selected. 773 * <p> 774 * See <a href="https://www.postgresql.org/docs/13/runtime-config.html">the PostgreSQL runtime configuration</a> for more information. 775 * 776 * @param key Configuration parameter name. Must not be null. 777 * @param value Configuration parameter value. Must not be null. 778 * @return The builder itself. 779 */ 780 @Nonnull 781 public Builder addServerConfiguration(@Nonnull String key, @Nonnull String value) { 782 checkNotNull(key, "key is null"); 783 checkNotNull(value, "value is null"); 784 this.serverConfiguration.put(key, value); 785 return this; 786 } 787 788 /** 789 * Adds a configuration parameters for the <code>initdb</code> command. The <code>initdb</code> command is used to create the PostgreSQL server. 790 * <p> 791 * Each value is added as a command line parameter to the command. 792 * <p> 793 * See the <a href="https://www.postgresql.org/docs/13/app-initdb.html">PostgreSQL initdb documentation</a> for an overview of possible values. 794 * 795 * @param key initdb parameter name. Must not be null. 796 * @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 797 * a equal sign and a value assigned. 798 * @return The builder itself. 799 * @since 3.0 800 */ 801 @Nonnull 802 public Builder addInitDbConfiguration(@Nonnull String key, @Nonnull String value) { 803 checkNotNull(key, "key is null"); 804 checkNotNull(value, "value is null"); 805 this.localeConfiguration.put(key, value); 806 return this; 807 } 808 809 /** 810 * Adds a connection property. These properties are set on every connection handed out by the data source. See 811 * <a href="https://jdbc.postgresql.org/documentation/head/connect.html#connection-parameters">the 812 * PostgreSQL JDBC driver documentation</a> for possible values. 813 * 814 * @param key connection property name. Must not be null. 815 * @param value connection property value. Must not be null. 816 * @return The builder itself. 817 */ 818 @Nonnull 819 public Builder addConnectionProperty(@Nonnull String key, @Nonnull String value) { 820 checkNotNull(key, "key is null"); 821 checkNotNull(value, "value is null"); 822 this.connectionProperties.put(key, value); 823 return this; 824 } 825 826 /** 827 * Sets the directory where the PostgreSQL distribution is unpacked. Setting the installation base directory resets the {@link NativeBinaryManager} used 828 * to locate the postgres installation back to the default (which is to download the zonky.io Postgres archive and unpack it in the installation 829 * directory. The default is using a managed directory. 830 * 831 * @param installationBaseDirectory The directory to unpack the postgres distribution. The current user must be able to create and write this directory. 832 * Must not be null. 833 * @return The builder itself. 834 * @since 3.0 835 */ 836 @Nonnull 837 public Builder setInstallationBaseDirectory(@Nonnull File installationBaseDirectory) { 838 checkNotNull(installationBaseDirectory, "installationBaseDirectory is null"); 839 this.installationBaseDirectory = installationBaseDirectory; 840 this.nativeBinaryManager = null; 841 return this; 842 } 843 844 /** 845 * 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 846 * available TCP port. 847 * 848 * @param port The port to use. Must be > 1023 and < 65536. 849 * @return The builder itself. 850 */ 851 @Nonnull 852 public Builder setPort(int port) { 853 checkState(port > 1_023 && port < 65_535, "Port %s is not within 1024..65535", port); 854 this.port = port; 855 return this; 856 } 857 858 /** 859 * 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 860 * Maven artifacts. The value is ignored if {@link Builder#setNativeBinaryManager(NativeBinaryManager)} is called. 861 * <p> 862 * Not every PostgreSQL version is supported by pg-embedded. Some older versions lack the necessary options for the command line parameters and will 863 * fail at startup. Currently, every version 10 or newer should be working. 864 * 865 * @param serverVersion A partial or full version. Valid values are e.g. "12" or "11.3". 866 * @return The builder itself. 867 * @since 3.0 868 */ 869 @Nonnull 870 public Builder setServerVersion(@Nonnull String serverVersion) { 871 this.serverVersion = checkNotNull(serverVersion, "serverVersion is null"); 872 873 return this; 874 } 875 876 /** 877 * Set a {@link ProcessBuilder.Redirect} instance to receive stderr output from the spawned processes. 878 * 879 * @param errorRedirector a {@link ProcessBuilder.Redirect} instance. Must not be null. 880 * @return The builder itself. 881 */ 882 @Nonnull 883 public Builder setErrorRedirector(@Nonnull ProcessBuilder.Redirect errorRedirector) { 884 this.errorRedirector = checkNotNull(errorRedirector, "errorRedirector is null"); 885 return this; 886 } 887 888 /** 889 * Set a {@link ProcessBuilder.Redirect} instance to receive stdout output from the spawned processes. 890 * 891 * @param outputRedirector a {@link ProcessBuilder.Redirect} instance. Must not be null. 892 * @return The builder itself. 893 */ 894 @Nonnull 895 public Builder setOutputRedirector(@Nonnull ProcessBuilder.Redirect outputRedirector) { 896 this.outputRedirector = checkNotNull(outputRedirector, "outputRedirector is null"); 897 return this; 898 } 899 900 /** 901 * Sets the {@link NativeBinaryManager} that provides the location of the postgres installation. Explicitly setting a binary manager overrides the 902 * installation base directory location set with {@link Builder#setInstallationBaseDirectory(File)} as this is only used by the default binary manager. 903 * Calling {@link Builder#setInstallationBaseDirectory(File)} after this method undoes setting the binary manager. 904 * 905 * @param nativeBinaryManager A {@link NativeBinaryManager} implementation. Must not be null. 906 * @return The builder itself. 907 * @since 3.0 908 */ 909 @Nonnull 910 public Builder setNativeBinaryManager(@Nonnull NativeBinaryManager nativeBinaryManager) { 911 this.nativeBinaryManager = checkNotNull(nativeBinaryManager, "nativeBinaryManager is null"); 912 return this; 913 } 914 915 /** 916 * 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 917 * will use the locally installed binaries for starting and stopping. Calling this method sets a binary manager, so it overrides 918 * {@link Builder#setNativeBinaryManager(NativeBinaryManager)}. Calling this method makes the builder ignore the 919 * {@link Builder#setInstallationBaseDirectory(File)} setting. 920 * 921 * @param directory A local directory that contains a standard PostgreSQL installation. The directory must exist and read and executable. 922 * @return The builder itself. 923 * @since 3.0 924 */ 925 @Nonnull 926 public Builder useLocalPostgresInstallation(@Nonnull File directory) { 927 checkNotNull(directory, "directory is null"); 928 checkState(directory.exists() && directory.isDirectory(), "'%s' either does not exist or is not a directory!", directory); 929 return setNativeBinaryManager(() -> directory); 930 } 931 932 /** 933 * Creates and boots a new {@link EmbeddedPostgres} instance. 934 * 935 * @return A {@link EmbeddedPostgres} instance representing a started PostgreSQL server. 936 * @throws IOException If the server could not be installed or started. 937 */ 938 @Nonnull 939 public EmbeddedPostgres build() throws IOException { 940 // Builder Id 941 final String instanceId = EmbeddedUtil.randomAlphaNumeric(16); 942 943 int port = this.port != 0 ? this.port : EmbeddedUtil.allocatePort(); 944 945 // installation root if nothing has been set by the user. 946 final File parentDirectory = EmbeddedUtil.getWorkingDirectory(); 947 948 NativeBinaryManager nativeBinaryManager = this.nativeBinaryManager; 949 if (nativeBinaryManager == null) { 950 final String serverVersion = System.getProperty("pg-embedded.postgres-version", this.serverVersion); 951 nativeBinaryManager = new TarXzCompressedBinaryManager(new ZonkyIOPostgresLocator(serverVersion)); 952 } 953 954 // Use the parent directory if no installation directory set. 955 File installationBaseDirectory = Objects.requireNonNullElse(this.installationBaseDirectory, parentDirectory); 956 nativeBinaryManager.setInstallationBaseDirectory(installationBaseDirectory); 957 958 // this is where the binary manager actually places the unpackaged postgres installation. 959 final File postgresInstallDirectory = nativeBinaryManager.getLocation(); 960 961 File dataDirectory = this.dataDirectory; 962 if (dataDirectory == null) { 963 dataDirectory = new File(parentDirectory, DATA_DIRECTORY_PREFIX + instanceId); 964 } 965 966 EmbeddedPostgres embeddedPostgres = new EmbeddedPostgres(instanceId, postgresInstallDirectory, dataDirectory, 967 removeDataOnShutdown, serverConfiguration, localeConfiguration, connectionProperties, 968 port, errorRedirector, outputRedirector, 969 serverStartupWait); 970 971 embeddedPostgres.cleanOldDataDirectories(parentDirectory); 972 973 // for version checking (calling getPostgresVersion(), the instance does not need to run 974 // this is a special case to make the version check run faster for unit test selection. 975 if (bootInstance) { 976 embeddedPostgres.boot(); 977 } 978 979 return embeddedPostgres; 980 } 981 } 982}