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