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 &gt; 1023 and &lt; 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}