mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
16 hours ago 37b9647aa845308648fa1450cc1a763f9bb52c94
opendj-embedded/src/main/java/org/openidentityplatform/opendj/embedded/EmbeddedOpenDJ.java
@@ -51,10 +51,30 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
public class EmbeddedOpenDJ implements Runnable, Closeable {
    private static final String JAR_SCHEMA_DIRECTORY = "opendj/config/schema/";
    private static final String ARCHIVE_NAME = "opendj.zip";
    /**
     * How long the deletion of the instance directory is retried when there is no point in
     * waiting for the full {@link Config#getDeleteTimeout() configured timeout}: from the
     * shutdown hook, where a longer wait only makes Control-C look hung, and from the
     * constructor, where an initialization failure has to be reported promptly. What is still
     * locked when this expires is deleted on JVM exit anyway.
     */
    private static final long SHORT_DELETE_TIMEOUT_MS = 1_000L;
    private static final long INITIAL_DELETE_RETRY_DELAY_MS = 50L;
    private static final long MAX_DELETE_RETRY_DELAY_MS = 500L;
    /** Upper bound on the number of leftover paths reported when the deletion fails. */
    private static final int MAX_REPORTED_REMAINING_PATHS = 20;
    final static Logger logger = LoggerFactory.getLogger(EmbeddedOpenDJ.class.getName());
    final EmbeddedDirectoryServer server;
@@ -62,6 +82,10 @@
    private final File instanceDirectory;
    private final File rootDirectory;
    private final Thread shutdownHook;
    /** Written under {@code this}, read without locking so that a running {@link #close()} blocks nothing. */
    private volatile boolean closed;
    public EmbeddedOpenDJ() {
        this(new Config());
@@ -82,7 +106,10 @@
            // deleted on close().
            instanceDirectory = Files.createTempDirectory("opendj").toFile();
            File rootDirectory = new File(instanceDirectory, "opendj");
            rootDirectory.mkdir();
            if (!rootDirectory.mkdir()) {
                // the parent has just been created, so this only fails on a real filesystem error
                throw new IOException("Cannot create the server root directory " + rootDirectory);
            }
            logger.info("OpenDJ server root: {}", rootDirectory);
            File configDirectory = new File(rootDirectory, "config");
@@ -100,8 +127,15 @@
                    System.out,
                    System.err);
            copyFilesFromJar(Collections.singletonList("opendj.zip"),"embedded-opendj/",rootDirectory);
            server.extractArchiveForSetup(new File(rootDirectory,"opendj.zip"));
            copyFilesFromJar(Collections.singletonList(ARCHIVE_NAME),"embedded-opendj/",rootDirectory);
            final File archive = new File(rootDirectory, ARCHIVE_NAME);
            server.extractArchiveForSetup(archive);
            // The archive is only needed for the extraction above. Keeping it would leave a
            // full copy of the distribution in the temporary directory and one more file to
            // delete on close().
            if (!archive.delete()) {
                logger.warn("Cannot delete {} after extracting it", archive);
            }
            server.setup(
                    SetupParameters.setupParams()
@@ -121,32 +155,35 @@
            this.rootDirectory = rootDirectory;
        }catch (Exception e) {
            logger.error("Error initializing OpenDJ");
            FileUtils.deleteQuietly(instanceDirectory);
            deleteInstanceDirectory(instanceDirectory, shortDeleteTimeout(config));
            throw new RuntimeException(e);
        }
        Runtime.getRuntime().addShutdownHook(new Thread(this::close));
        shutdownHook = new Thread(this::close, "EmbeddedOpenDJ shutdown hook");
        Runtime.getRuntime().addShutdownHook(shutdownHook);
    }
    /**
     * Returns the server root directory of this embedded instance.
     *
     * @return the server root directory
     * @throws IllegalStateException
     *             If this instance has been closed, and the directory therefore deleted.
     */
    public File getServerRootDirectory() {
        checkNotClosed();
        return rootDirectory;
    }
    @Override
    public void run() {
        checkNotClosed();
        try {
            final DN baseDN = DN.valueOf(config.getBaseDN());
            try {
                ManagementContext config = server.getConfiguration();
                BackendCfgClient userRoot = config.getRootConfiguration().getBackend("userRoot");
            try (ManagementContext managementContext = server.getConfiguration()) {
                BackendCfgClient userRoot = managementContext.getRootConfiguration().getBackend("userRoot");
                userRoot.setBaseDN((Collections.singletonList(baseDN)));
                userRoot.setEnabled(true);
                userRoot.commit();
                config.close();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
@@ -163,18 +200,167 @@
        }
    }
    /**
     * Stops this instance, if it is still running, and deletes its temporary directory.
     * <p>
     * This method is idempotent: it is also registered as a JVM shutdown hook.
     * <p>
     * When the server cannot be stopped, this instance stays open and keeps its shutdown hook
     * registered, so that a later call - or the hook at JVM exit - retries the stop.
     */
    @Override
    public void close()  {
    public synchronized void close()  {
        if (closed) {
            return;
        }
        final boolean fromShutdownHook = Thread.currentThread() == shutdownHook;
        if (server.isRunning()) {
            try {
                logger.info("Shutting down OpenDJ ...");
                server.stop(this.getClass().getName(), LocalizableMessage.raw("Stopped after receiving Control-C"));
            }catch (Throwable e) {
                logger.error("Error stopping OpenDJ", e);
                if (!fromShutdownHook) {
                    // The server is still running: deleting its directory now would destroy a
                    // live installation. Leave this instance open, with its shutdown hook still
                    // registered, so that the stop can be retried.
                    return;
                }
                // The JVM is going down and there will be no later attempt, so the temporary
                // directory is removed even though the server has not stopped cleanly.
            }
        }
        // close() is also registered as a shutdown hook, so deletion must stay idempotent
        FileUtils.deleteQuietly(instanceDirectory);
        closed = true;
        unregisterShutdownHook();
        deleteInstanceDirectory(instanceDirectory,
                fromShutdownHook ? shortDeleteTimeout(config) : config.getDeleteTimeout());
    }
    private void checkNotClosed() {
        if (closed) {
            throw new IllegalStateException("this embedded OpenDJ instance is closed");
        }
    }
    private static long shortDeleteTimeout(Config config) {
        return Math.min(config.getDeleteTimeout(), SHORT_DELETE_TIMEOUT_MS);
    }
    private void unregisterShutdownHook() {
        try {
            Runtime.getRuntime().removeShutdownHook(shutdownHook);
        } catch (IllegalStateException e) {
            // close() was reached from the shutdown hook itself: nothing to unregister
        }
    }
    /**
     * Deletes the temporary directory of an instance, retrying for a bounded period and
     * falling back to a deletion on JVM exit.
     * <p>
     * Deleting once is not enough. On Windows a file cannot be deleted while a handle to it
     * is still open, and {@code server.stop()} does not wait for the server threads to
     * terminate: an embedded server runs them as daemon threads, which the shutdown monitor
     * of the directory server ignores. Some handles are therefore released shortly after
     * {@code stop()} has returned, and a single best-effort deletion loses that race and
     * silently leaks the whole directory, backend data included. Retrying is a way to wait
     * for those handles to be released - a single attempt already removes everything that is
     * not in use at that moment.
     *
     * @param directory
     *            the directory to delete, may be {@code null} when the instance failed to
     *            initialize before creating it
     * @param timeoutMs
     *            how long the deletion is retried, in milliseconds
     */
    static void deleteInstanceDirectory(File directory, long timeoutMs) {
        deleteInstanceDirectory(directory, timeoutMs, FileUtils::deleteQuietly);
    }
    /**
     * Implements {@link #deleteInstanceDirectory(File, long)} with an injectable deletion, so
     * that tests can drive the retries without depending on a genuinely undeletable file.
     *
     * @param deleteAttempt
     *            performs one deletion attempt and reports whether the directory is gone
     */
    static void deleteInstanceDirectory(File directory, long timeoutMs, Predicate<File> deleteAttempt) {
        if (directory == null || !directory.exists()) {
            return;
        }
        final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
        long retryDelay = INITIAL_DELETE_RETRY_DELAY_MS;
        while (true) {
            // The second test covers a directory removed by someone else in the meantime:
            // deleteQuietly() reports a failure for a directory that is already gone.
            if (deleteAttempt.test(directory) || !directory.exists()) {
                return;
            }
            if (System.nanoTime() >= deadline) {
                logger.warn("Cannot delete {} within {} ms, some files are still in use. "
                        + "They are now scheduled for deletion on JVM exit:{}",
                        directory, timeoutMs, remainingPaths(directory));
                break;
            }
            try {
                Thread.sleep(retryDelay);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                logger.warn("Interrupted while deleting {}. What is left is now scheduled "
                        + "for deletion on JVM exit:{}", directory, remainingPaths(directory));
                break;
            }
            retryDelay = Math.min(retryDelay * 2, MAX_DELETE_RETRY_DELAY_MS);
        }
        deleteTreeOnExit(directory);
    }
    /**
     * Describes what is left in the given directory: one path per line, at most
     * {@link #MAX_REPORTED_REMAINING_PATHS} of them, followed by the number of paths left out.
     */
    static String remainingPaths(File directory) {
        final List<String> reported = new ArrayList<>();
        final int total = collectRemainingPaths(directory, reported);
        final StringBuilder description = new StringBuilder();
        for (String path : reported) {
            description.append("\n  ").append(path);
        }
        if (total > reported.size()) {
            description.append("\n  ... and ").append(total - reported.size()).append(" more");
        }
        return description.toString();
    }
    /**
     * Adds at most {@link #MAX_REPORTED_REMAINING_PATHS} paths of the given tree to
     * {@code reported} and returns how many paths it holds in total.
     */
    private static int collectRemainingPaths(File file, List<String> reported) {
        final File[] children = file.listFiles();
        if (children == null || children.length == 0) {
            if (reported.size() < MAX_REPORTED_REMAINING_PATHS) {
                reported.add(file.getPath());
            }
            return 1;
        }
        int total = 0;
        for (File child : children) {
            total += collectRemainingPaths(child, reported);
        }
        return total;
    }
    private static void deleteTreeOnExit(File file) {
        // File.deleteOnExit() deletes in reverse order of registration, so a directory has to
        // be registered before its content for the content to be removed first.
        file.deleteOnExit();
        final File[] children = file.listFiles();
        if (children != null) {
            for (File child : children) {
                deleteTreeOnExit(child);
            }
        }
    }
    private void copyFilesFromJar(List<String> jarFiles, String jarDirectory, File outputDirectory) throws IOException{
@@ -183,65 +369,80 @@
            final String resourcePath = !jarFile.contains("/")
                    ? "/"+jarDirectory + jarFile
                    : jarFile;
            InputStream in = new File(jarFile).exists()
            try (InputStream in = new File(jarFile).exists()
                    ? Files.newInputStream(new File(jarFile).toPath())
                    : MemoryBackend.class.getResourceAsStream(resourcePath);
            if (in == null) {
                throw new IOException("cannot find " + resourcePath);
                    : MemoryBackend.class.getResourceAsStream(resourcePath)) {
                if (in == null) {
                    throw new IOException("cannot find " + resourcePath);
                }
                FileUtils.copyInputStreamToFile(in, outputFile);
            }
            FileUtils.copyInputStreamToFile(in, outputFile);
            in.close();
        }
    }
    /**
     * Imports the LDIF read from the given stream.
     * <p>
     * The stream is closed by this method, whether the import succeeds or not.
     *
     * @param inputStream
     *            the LDIF to import
     * @throws IllegalStateException
     *             If this instance has been closed.
     */
    public void importData(InputStream inputStream) throws EmbeddedDirectoryServerException, IOException {
        checkNotClosed();
        logger.info("start import ldif from stream");
        EntryReader reader;
        try {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
            reader = new LDIFEntryReader(bufferedReader);
        } catch (Exception e) {
            logger.error("import ldif : {}", e, e);
            throw e;
        }
        org.forgerock.opendj.ldap.Entry  entryBefore;
        final Connection connection = server.getInternalConnection();
        long recordCount = 0;
        while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) {
            recordCount++;
            try {
                connection.add(entryBefore);
                logger.info("import ldif : {}",entryBefore.getName());
            }catch (LdapException e) {
                logger.error("import ldif : {} {}",entryBefore.getName(),e.toString());
        try (EntryReader reader = new LDIFEntryReader(new BufferedReader(new InputStreamReader(inputStream)));
             Connection connection = server.getInternalConnection()) {
            while (reader.hasNext() && (entryBefore = reader.readEntry()) != null) {
                recordCount++;
                try {
                    connection.add(entryBefore);
                    logger.info("import ldif : {}",entryBefore.getName());
                }catch (LdapException e) {
                    logger.error("import ldif : {} {}",entryBefore.getName(),e.toString());
                }
            }
        }
        if(recordCount == 0) {
            logger.error("no records were imported, check file contents and permissions");
            throw new RuntimeException("no records were imported");
        }
        reader.close();
        connection.close();
    }
    /**
     * Writes the entries below the given base DN to the given stream, as LDIF.
     * <p>
     * The stream is flushed and closed by this method, whether the export succeeds or not.
     *
     * @param baseDN
     *            the base DN of the subtree to export
     * @param out
     *            where the LDIF is written
     * @throws IllegalStateException
     *             If this instance has been closed.
     */
    public void getData(String baseDN, OutputStream out) throws IOException, EmbeddedDirectoryServerException {
        LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out);
        final Connection connection = server.getInternalConnection();
        ConnectionEntryReader reader = connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)");
        while(reader.hasNext()) {
            if (!reader.isReference()) {
                SearchResultEntry se = reader.readEntry();
                if (!skipEntry(se)) {
                    ldifWriter.writeEntry(se);
                    logger.info("export {}", se.toString());
        checkNotClosed();
        // resources are closed in reverse order, so the writer is flushed and closed last
        try (LDIFEntryWriter ldifWriter = new LDIFEntryWriter(out);
             Connection connection = server.getInternalConnection();
             ConnectionEntryReader reader =
                     connection.search(baseDN, SearchScope.WHOLE_SUBTREE, "(objectClass=*)")) {
            while (reader.hasNext()) {
                if (!reader.isReference()) {
                    SearchResultEntry se = reader.readEntry();
                    if (!skipEntry(se)) {
                        ldifWriter.writeEntry(se);
                        logger.info("export {}", se.toString());
                    }
                }
            }
        }
        reader.close();
        ldifWriter.close();
        connection.close();
    }
    private boolean skipEntry(SearchResultEntry se) {