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