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