/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2024-2026 3A Systems LLC. */ package org.openidentityplatform.opendj.embedded; import org.apache.commons.io.FileUtils; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.opendj.config.client.ManagementContext; import org.forgerock.opendj.ldap.Connection; import org.forgerock.opendj.ldap.DN; import org.forgerock.opendj.ldap.LdapException; import org.forgerock.opendj.ldap.SearchScope; import org.forgerock.opendj.ldap.responses.SearchResultEntry; import org.forgerock.opendj.ldif.ConnectionEntryReader; import org.forgerock.opendj.ldif.EntryReader; import org.forgerock.opendj.ldif.LDIFEntryReader; import org.forgerock.opendj.ldif.LDIFEntryWriter; import org.forgerock.opendj.server.config.client.BackendCfgClient; import org.forgerock.opendj.server.embedded.ConfigParameters; import org.forgerock.opendj.server.embedded.ConnectionParameters; import org.forgerock.opendj.server.embedded.EmbeddedDirectoryServer; import org.forgerock.opendj.server.embedded.EmbeddedDirectoryServerException; import org.forgerock.opendj.server.embedded.SetupParameters; import org.forgerock.opendj.server.embedded.UpgradeParameters; import org.opends.server.backends.MemoryBackend; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Paths; 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; final Config config; 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()); } public EmbeddedOpenDJ(Config config) { logger.info("Create embedded OpenDJ instance: {}", config); this.config = config; File instanceDirectory = null; try { // Create a fresh per-instance parent directory, only accessible by the // current user, instead of the fixed shared {java.io.tmpdir}/opendj // directory. The server root inside it must be named "opendj" because // setup from an archive requires the server root directory to match the // root directory contained in the archive. The whole directory is // deleted on close(). instanceDirectory = Files.createTempDirectory("opendj").toFile(); File rootDirectory = new File(instanceDirectory, "opendj"); 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"); File schemaDirectory = new File(configDirectory, "schema"); server = EmbeddedDirectoryServer.manageEmbeddedDirectoryServer( ConfigParameters.configParams() .serverRootDirectory(rootDirectory.getPath()) .configurationFile(Paths.get(rootDirectory.getPath(), "config", "config.ldif").toString()), ConnectionParameters.connectionParams() .hostName("localhost") .ldapPort(config.getPort()) .adminPort(config.getAdminPort()) .bindDn("cn=Directory Manager") .bindPassword(config.getAdminPassword()), System.out, System.err); 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() .baseDn(config.getBaseDN()) .backendType(config.getBackendType()) .jmxPort(config.getJmxPort()) ); List schemaFiles = new ArrayList<>(); if (config.getLdifSchema() != null) { schemaFiles.add(config.getLdifSchema()); } copyFilesFromJar(schemaFiles, JAR_SCHEMA_DIRECTORY, schemaDirectory); this.instanceDirectory = instanceDirectory; this.rootDirectory = rootDirectory; }catch (Exception e) { logger.error("Error initializing OpenDJ"); deleteInstanceDirectory(instanceDirectory, shortDeleteTimeout(config)); throw new RuntimeException(e); } 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 managementContext = server.getConfiguration()) { BackendCfgClient userRoot = managementContext.getRootConfiguration().getBackend("userRoot"); userRoot.setBaseDN((Collections.singletonList(baseDN))); userRoot.setEnabled(true); userRoot.commit(); } catch (Exception e) { throw new RuntimeException(e); } logger.info("Check upgrade OpenDJ ..."); server.upgrade(UpgradeParameters.upgradeParams().isIgnoreErrors(false)); logger.info("Start OpenDJ ..."); server.start(); } catch (Exception e) { logger.error("Error starting OpenDJ", e); throw new RuntimeException(e); } } /** * Stops this instance, if it is still running, and deletes its temporary directory. *

* This method is idempotent: it is also registered as a JVM shutdown hook. *

* 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 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. } } 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. *

* 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 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 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 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 jarFiles, String jarDirectory, File outputDirectory) throws IOException{ for(String jarFile : jarFiles) { File outputFile = new File(outputDirectory, new File(jarFile).getName()); final String resourcePath = !jarFile.contains("/") ? "/"+jarDirectory + jarFile : jarFile; 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); } FileUtils.copyInputStreamToFile(in, outputFile); } } } /** * Imports the LDIF read from the given stream. *

* 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"); org.forgerock.opendj.ldap.Entry entryBefore; long recordCount = 0; 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"); } } /** * Writes the entries below the given base DN to the given stream, as LDIF. *

* 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 { 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()); } } } } } private boolean skipEntry(SearchResultEntry se) { for (String skip : config.getSkipSet()) { if (se.getName().toString().toLowerCase().contains(skip)){ logger.trace("ignore export {}", se); return true; } } return false; } public boolean isRunning() { return server != null && server.isRunning(); } }