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